React에서의 SOLID 원칙

지렁·2024년 2월 20일

프로젝트를 만족스럽게 진행하고 pr을 올렸는데
" 단일 책임 원칙으로 변경해주세요 라는 코멘트를 받았다

단일책임원칙은 SOLID 에 해당하는 부분으로 오늘 자세히 다뤄볼 것이다!


SOLID 원칙이란

Solid 원칙: 소프트웨어의 기본 5가지 원칙을 말한다.

프로그래머가 시간이 지나도 유지 보수와 확장이 쉬운 시스템을 만들고자 할 때 이 원칙들을 함께 적용할 수 있다
SOLID 원칙들은 소프트웨어 작업에서 프로그래머가 소스 코드가 읽기 쉽고 확장하기 쉽게 될 때까지 소프트웨어 소스 코드를 리팩터링하여 코드 냄새를 제거하기 위해 적용할 수 있는 지침이다.

아래의 코드를 SOLID 5가지 원칙에 의거해서 리팩토링 해보겠다!


SRP (Single Responsibility Principle) 단일 책임 원칙

클래스는 단일 책임만 가져야 하며, 즉, 소프트웨어 사양의 한 부분에 대한 변경만 클래스 사양에 영향을 줄 수 있어야 합니다.
이 말을 쉽게하면 컴포넌트가 하나의 기능 또는 하나의 역할만을 책임져야 된다는 말이다.

예시 1)

import { useEffect, useState } from "react";
import axios from "axios";
 
export default function App() {
  const [todos, setTodos] = useState([]);
 
  useEffect(() => {
    axios.get("https://example.com/todos").then((res) => {
      setTodos(res.data);
    });
  }, []);
 
  return (
    <div className="App">
      <h1>todolist</h1>
      <ul>
        {todos.map((todo) => (
          <li key={todo.id}>{todo.text}</li>
        ))}
      </ul>
    </div>
  );
}

위의 코드는 하나의 컴포넌트가 데이터 가져오기와 렌더링을 담당하고 있다
때문에 단일 책임 원칙을 위반하고 있으며, 코드의 복잡성을 높이고 유지 보수가 어렵다. 위의 코드를 단일 책임 원칙을 적용해보자

import { useEffect, useState } from 'react';
import axios from 'axios';

//렌더링 담당
function TodoRendering({ todos }) {
    return (
        <div className="App">
            <h1>todolist</h1>
            <ul>
                {todos.map(todo => (
                    <li key={todo.id}>{todo.text}</li>
                ))}
            </ul>
        </div>
    );
}

//데이터 가져오기 담당
function TodoFetch() {
    const [todos, setTodos] = useState([]);

    useEffect(() => {
        axios.get('https://example.com/todos').then(res => {
            setTodos(res.data);
        });
    }, []);

    return <TodoRendering todos={todos} />;
}

export default function App() {
    return <TodoFetch />;
}

➡️ TodoFetch는 데이터 가져오기 역할을, TodoRendering는 렌더링 역할을 함으로써 각각의 컴포넌트의 역할을 분리해줬다👍

예시2)

export function CouponList() {
  const [coupons, setCoupons] = useState([]);

  useEffect(() => {
    getCoupons().then((res) => setCoupons(res));
  }, []);

  return (
    <div>
      {coupons.map((coupon, i) => (
        <div
          key={i}
          title={coupon.title}
          onClick={() => handleSelectCoupon(coupon)}
        />
      ))}
      <button title="취소하기" onClick={() => handleSelectCoupon(null)} />
    </div>
  );
}

useEffect, useState가 있는 부분은 hook으로 뺄 수 있다

그래서 hook으로 분리를 하면 아래와 같은 코드가 된다

즉 서버에서 데이터를 패칭 하는 역할은 useGetCoupons 훅에게 위임하고 List는 데이터를 가지고 화면을 그리는데 집중할 수 있도록 역할 분리를 해준 것
이렇게 했을 책임 분리로 인한 가독성이 올라가고 역할별 테스팅 하기도 용이해진다

function useGetCoupons() {
    const [coupons, setCoupons] = useState([]);

    useEffect(() => {
        getCoupons().then(res => setCoupons(res));
    }, []);
    return { coupons };
}

export function CouponList() {
    const { coupons } = useGetCoupons();
    return (
        <div>
            {coupons.map((coupon, i) => (
                <div key={i} title={coupon.title} onClick={() => handleSelectCoupon(coupon)} />
            ))}
            <button title="취소하기" onClick={() => handleSelectCoupon(null)} />
        </div>
    );
}

OCP (Open Closed Principle) 개방 폐쇄 원칙

확장에 대해 개방적이어야 하지만, 수정에 대해서는 폐쇄적이어야 한다.

이 말을 쉽게하면 기존 코드를 수정하지 않고도 새로운 기능을 추가하거나 확장할 수 있어야 한다는 말이다.

예시 1)

예를 들어 아래의 컴포넌트를 보자 label과 onClick 두 가지 prop을 받은 버튼 컴포넌트다. 버튼 컴포넌트는 초기에는 간단한 동작을 갖고 있을 수 있지만, 시간이 지나면서 다양한 기능을 추가 해야 할 수 있다.

import React from 'react';
 
interface ButtonProps {
  label: string;
  onClick: () => void;
}
 
function Button({ label, onClick }: ButtonProps) {
  return (
    <button onClick={onClick}>
      {label}
    </button>
  );
}
 
export default Button;
 

개방-폐쇄 원리를 따라 버튼 컴포넌트를 수정하지 않고 다양한 기능을 추가하거나 확장할 수 있는 컴포넌트로 개선해보자

import React, { ReactNode } from 'react';
 
interface ButtonProps {
  label: string;
  onClick: () => void;
  icon?: ReactNode; // 아이콘
  disabled?: boolean; // 비활성화
  className?: string; // 클래스네임
}
 
function Button({
  label,
  onClick,
  icon,
  disabled = false,
  className = '',
}: ButtonProps) {
  return (
    <button
      onClick={onClick}
      disabled={disabled}
      className={className}
    >
      {icon && <span className="icon">{icon}</span>}
      {label}
    </button>
  );
}
 
export default Button;

위의 코드는 다양한 새로운 속성을 prop으로 추가해서 코드를 수정하지 않고도 다양한 기능을 추가할 수 있다.

예시 2)

function QuantityButton({ mode, onClick }: Props) {
  return (
    <CountButton onClick={onClick}>
      {mode === 'plus' ? <AiOutlinePlus /> : <AiOutlineMinus />}
    </CountButton>
  );
}

위 코드는 아이콘이 Plus, Minus아이콘으로 한정되어 있다

즉 확장에 닫혀있다는 뜻!

위 코드에 확장성을 올리려면 아래와 같이 아이콘을 버튼 안에서 정의하는 것이 아니라 바깥에서 주입받으면 된다

function QuantityButton({ icon, onClick }: Props) {
  return <CountButton onClick={onClick}>{icon}</CountButton>;
}

이제 아이콘이 두 개로 한정되어 있는게 아니라, 바깥에서 프롭스로 넘겨받기 때문에 두개로 한정된 게 아니라 여러 가지 아이콘을 적용할 수 있다.

또한 이 컴포넌트 내부에서 아이콘을 바꿀 필요가 없으므로 확장에는 용이하고 변경에는 닫혀있는 원칙이 적용된 것이다

ISP (Interface Segregation Principle) 인터페이스 분리 원칙

인터페이스 분리 원칙이란 사용하지 않는 인터페이스에 의존하면 안 된다

interface CartProduct {
  item_no: number;
  item_name: string;
  detail_image_url: string;
  price: number;
  score: number;
  availableCoupon?: boolean;
  quantity: number;
  checked: boolean;
}

interface Props {
  product: CartProduct;
  onClick: () => void;
}

function CheckBox({ product, onClick }: Props) {
  const uniqueID = `checkbox-${product.item_no}`;
  return (
    <Block>
      <input
        id={uniqueID}
        type={'checkbox'}
        defaultChecked={product.checked}
        onClick={onClick}
      ></input>
      <label htmlFor={uniqueID}></label>
    </Block>
  );
}

밑에 체크박스 컴포넌트는 8개의 product 타입을 받아서 그중 product.item_no, product.checked 속성 두 개만 사용하고 있다
즉 사용하지 않는 인터페이스가 너무 많은 것이다

아래처럼 꼭 필요한 인터페이스만 넘겨받으면 된다

interface Props {
  item_no: number;
  checked: boolean;
  onClick: () => void;
}

function CheckBox({ item_no, onClick, checked }: Props) {
  const uniqueID = `checkbox-${item_no}`;
  return (
    <Block>
      <input
        id={uniqueID}
        type={'checkbox'}
        defaultChecked={checked}
        onClick={onClick}
      ></input>
      <label htmlFor={uniqueID}></label>
    </Block>
  );
}
profile
공부 기록 공간 🎈💻

0개의 댓글