React를 SOLID 원칙으로 바라보기

profile
FE Developer - 루카스
June 28, 2023
GPT 요약
Thumbnail

안녕하세요. 루카스입니다.

이번 글은 SOLID에 대한 내용을 준비했습니다. SOLID는 OOP의 5가지 설계 원칙을 말합니다. 보통 SOLID 원칙은 백엔드에서 많이 언급되는 내용이지만, 프론트엔드 측에서도 동일하게 적용할 수 있기 때문에 React 코드를 통해 SOLID 원칙을 바라보는 시간을 가져보려고 합니다.

Talk

OOP는 객체 지향 프로그래밍(Object Oriented Programming)의 약자입니다. 여담이지만 SOLID는 Robert C. Martin이 2000년대 초반에 발표한 객체 지향 프로그래밍 및 설계(OOD)에 기원합니다.

SOLID 원칙

SOLID는 다음과 같은 원칙을 말합니다.

  • SRP(Single Responsibility Principle) : 단일 책임 원칙
  • OCP(Open Closed Principle) : 개방 폐쇄 원칙
  • LSP(Liskov Substitution Principle) : 리스코프 치환 원칙
  • ISP(Interface Segregation Principle) : 인터페이스 분리 원칙
  • DIP(Dependency Inversion Principle) : 의존 역전 원칙

하나씩 살펴보겠습니다.

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

모든 클래스는 한가지 책임만을 갖는다.

SOLID 원칙의 가장 첫 번째 원칙은 SRP(Single Responsibility Principle) 입니다. 프론트엔드에서 해석한다면 컴포넌트는 한 가지 일만 수행해야 하는 것으로 생각할 수 있습니다.

const ReceptionList = () => {
  const [receptionList, setReceptionList] = useState<Reception[]>([]);
  const [loading, setLoading] = useState(false);
  const [error, setError] = useState<Error | null>(null);

  useEffect(() => {
    const fetchReceptionList = async () => {
      try {
        setLoading(true);
        const { data } = await getReceptionList();
        setReceptionList(data);
      } catch (error) {
        setError(error);
      } finally {
        setLoading(false);
      }
    };

    fetchReceptionList();
  }
  , []);

  if (loading) {
    return <div>로딩중</div>;
  }

  if (error) {
    return <div>에러</div>;
  }

  return (
    <div>
      {receptionList.map((reception) => (
        <div key={reception.id}>{reception.name}</div>
      ))}
    </div>
  );
};

ReceptionList는 접수 정보를 가져와 보여주는 컴포넌트입니다. 보시다시피 데이터를 가져오는 일데이터를 보여주는 일 두 가지 일을 동시에 수행하고 있습니다. 이는 SRP를 위배하는 코드입니다. 다음과 같이 컴포넌트를 나누어 SRP를 만족하게 할 수 있습니다.

const ReceptionList = () => {
  const [receptionList, setReceptionList] = useState<Reception[]>([]);
  const [loading, setLoading] = useState(false);
  const [error, setError] = useState<Error | null>(null);

  useEffect(() => {
    const fetchReceptionList = async () => {
      try {
        setLoading(true);
        // 데이터를 가져오는 일
        const { data } = await getReceptionList();
        setReceptionList(data);
      } catch (error) {
        setError(error);
      } finally {
        setLoading(false);
      }
    };

    fetchReceptionList();
  }
  , []);

  if (loading) {
    return <div>로딩중</div>;
  }

  if (error) {
    return <div>에러</div>;
  }

  return (
    <div>
        {/* 데이터를 보여주는 일 */}
      {receptionList.map((reception) => (
        <ReceptionInfo key={reception.id} name={reception.name} /> // reception을 보여주는 컴포넌트로 분리
      ))}
    </div>
  );
};

const ReceptionInfo = ({name}) => {
  return <div>{name}</div>
}

이렇게 분리함으로써, 컴포넌트가 하나의 역할만 수행하게 되었습니다.

넓은 의미의 SRP

코드 수준에서는 책임을 기능으로 해석해 볼 수 있었습니다. 이제는 조금 더 의미를 확장시켜, 보다 넓은 관점에서 SRP를 바라보겠습니다.

책임은 변경의 이유라고 해석할 수 있습니다. 즉, 하나의 책임을 갖는다는 것은 변경되는 이유가 같은 것을 하나로 묶어두는 것을 의미합니다.

Thumbnail

프론트엔드의 코드가 변경되는 경우는 크게 두 가지 구분할 수 있습니다.

  • 기획적인 변경(UX)
  • 디자인적인 변경(UI)

즉, UI와 UX적인 요소를 분리하여 다룬다면 넓은 의미의 SRP를 만족한다고 볼 수 있습니다. 이와 같은 해석은 다음 글을 참고해보세요. (프론트엔드와 SOLID 원칙)

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

소프트웨어의 개체(Entity)는 확장(Extention)에 열려있고 변경(Modification)에 닫혀있다.

이 원칙은 변화에 대한 대응에 관한 원칙입니다. 새로운 요구사항이 생긴 경우, 기존 코드를 변경하는 것이 아니라 새로운 개체를 생성함으로써 대응할 수 있도록 설계하는 것을 의미합니다. 이렇게 하면 기존 코드의 변경이 없으므로 유지 보수하기 좋은 코드를 작성할 수 있습니다.

그렇다면 컴포넌트 레벨에서 OCP를 만족하기 위해서는 어떻게 작성해야 할까요? 예제를 통해 확인해보겠습니다.

const SignInForm = ({ name, email, password, onSubmit, onChange }) => {
  return (
    <div>
      <h2>SignIn Form</h2>
      <form onSubmit={onSubmit}>
        <input type="text" name="name" value={name} onChange={onChange} />
        <input type="text" name="email" value={email} onChange={onChange} />
        <input
          type="password"
          name="password"
          value={password}
          onChange={onChange}
        />
        <button type="submit">Sumbit</button>
      </form>
    </div>
  );
};

name, email, password를 입력하는 로그인 페이지 form을 위와 같이 작성했습니다.

겉으로 보기에는 아무 문제가 없어 보입니다. 하지만 개발을 진행하다 보니 문의하기 form을 새롭게 만들어야 하는 상황이 발생했습니다. 문의하기 form이 name, email, content 항목을 입력 받는다고 가정할 때, name 항목과 email 항목이 겹치는 SignIn Form을 재사용 할 수 있을 것 같습니다.

우선은 SignInForm의 이름을 Form으로 바꾸고 다음과 같이 코드를 작성했습니다.

// SignInForm -> Form
const Form = ({ name, email, password, content, onSubmit, onChange }) => {
  return (
    <div>
      <h2>SignIn Form</h2>
      <form onSubmit={onSubmit}>
        <input type="text" name="name" value={name} onChange={onChange} />
        <input type="text" name="email" value={email} onChange={onChange} />
        {/* content가 있으면 문의하기 폼, 없으면 로그인 폼으로 동작 */}
        {!content && (
          <input
            type="password"
            name="password"
            value={password}
            onChange={onChange}
          />
        )}
        {content && (
          <input
            type="text"
            name="content"
            value={content}
            onChange={onChange}
          />
        )}
        <button type="submit">Sumbit</button>
      </form>
    </div>
  );
};

Form 컴포넌트는 content를 통해 form에 렌더링 될 항목을 결정하고 있습니다. 크게 문제가 없어 보이지만, 이는 OCP를 위배하는 것입니다. 기존 코드의 변경을 통해(변경에 열림) 해결하려고 했기 때문입니다. Contact form, 의사 로그인 form, 간호사 로그인 form 등 새로운 요구사항이 추가되면 기존 코드를 계속 수정하며 대응해야 합니다. 이는 기존 코드에 수 많은 분기를 유발하기 때문에 유지보수에 좋지 않은 코드가 됩니다.

React에서는 Composition이라는 개념으로 이 문제를 해결하고 있습니다.

const Form = ({ name, email, onSubmit, onChange, children }) => {
  return (
    <div>
      <h2>SignIn Form</h2>
      <form onSubmit={onSubmit}>
        <input type="text" name="name" value={name} onChange={onChange} />
        <input type="text" name="email" value={email} onChange={onChange} />
        {children}
        <button type="submit">Sumbit</button>
      </form>
    </div>
  );
};

const SignInForm = ({ children, onChange, password, ...props }) => {
  return (
    <div>
      <h2>SignIn Form</h2>
      <input
        type="password"
        name="password"
        value={password}
        onChange={onChange}
      />
      <Form {...props}>{children}</Form>
    </div>
  );
};

const QuestionnaireForm = ({ content, onChange, children, ...props }) => {
  return (
    <div>
      <h2>Questionnaire Form</h2>
      <textarea name="content" value={content} onChange={onChange} />
      <Form {...props}>{children}</Form>
    </div>
  );
};

Form을 Base로 하여, Composition 과정을 통해 SiginForm, QuetionnaireForm 을 만들었습니다. 이제는 새로운 form에 대한 요구사항이 들어와도 기존 컴포넌트인 Form을 수정하지 않고 Form을 Composition 하여 새로운 form을 만들어 대응할 수 있게 되었습니다. 즉, 확장(Composition)에 열려있고 변경(기존 코드 수정)에 닫혀있는 컴포넌트를 만들 수 있게 되었습니다.

LSP(Liskov Substitution Principle) : 리스코프 치환 원칙

서브 타입은 기반타입을 대체할 수 있다.

리스코프 치환 원칙은 클래스의 상속을 설명하는 원칙입니다. 이를 프론트엔드에서는 어떻게 해석할 수 있을까요? 좁은 범위에서는 LSP를 논할 수 없지만, 넓은 의미에서 바라보면 프론트엔드에서도 LSP를 논할 수 있습니다. 넓은 의미에서 상속은 is-a관계를 만족하면 상속관계를 만족한다고 볼 수 있고, LSP 원칙을 지켰다고 볼 수 있습니다.

예를 들어 새는 동물이다 와 같은 명제가 상속을 의미합니다. 상속 관계인 두 관계에서 새는 광합성을 한다 와 같은 이야기를 하지 않습니다. 즉, 예상하지 못한 행동을 하지 말라는 관점으로 해석할 수 있습니다.

SubmitButton 컴포넌트를 보고 onChange가 있는 것은 LSP를 위배하는 코드를 작성한 것입니다. 또한, ErrorBoundary라는 컴포넌트에서 데이터를 fetch 할거라는 생각을 하지 않습니다. 예측 가능한 컴포넌트를 구성하는 것이 곧 LSP를 만족한다고 볼 수 있습니다.

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

객체는 자신이 사용하는 메서드에만 의존해야 한다.

컴포넌트는 자신이 사용하는 메서드나 props에 의존해야 한다고 생각해볼 수 있습니다. 이 원칙은 불필요한 의존성을 제거하여 확장성 있는 컴포넌트를 작성할 수 있도록 도와줍니다. 다음의 예를 살펴 보겠습니다.

  • VideoList 컴포넌트
interface Video {
  title: string;
  coverUrl: string;
}

const VideoList = ({ items }: Video[]) => {
  return (
    <ul>
      {items.map((item) => (
        <Thumbnail key={item.title} video={item} />
      ))}
    </ul>
  );
};
  • Thumbnail 컴포넌트
interface Props {
  video: Video;
}

const Thumbnail = ({ video }: Props) => {
  return <img src={video.coverUrl} />;
};

Thumbnail 컴포넌트는 Video 객체를 props로 전달해 썸네일로 보여주고 있습니다. 하지만 위와 같은 컴포넌트 구조는 문제가 발생할 가능성이 있습니다. item 즉 Video라는 구체적인 도메인에 의존적이기 때문입니다. 또한 Thumbnail 컴포넌트는 Video 객체가 필요한 것이 아니라 coverUrl이라는 데이터만 필요합니다. LiveStream이라는 새로운 도메인이 추가된다면 Thumbnail은 재사용할 수 없는 컴포넌트가 됩니다.

interface LiveStream {
  name: string;
  previewUrl: string;
} // 다음과 같은 도메인이 추가된다면 Thumbnail 컴포넌트는 재사용할 수 없는 컴포넌트가 됩니다.

즉, Thumbnail 컴포넌트는 자신에게 필요 없는 정보까지 받고 있어 ISP를 위배하고 있다고 볼 수 있습니다.

ISP를 만족하고 확장 가능한 컴포넌트로 변경하려면 필요한 데이터만 받아야 합니다.

interface Props {
  coverUrl: string;
}

const Thumbnail = ({ coverUrl }: Props) => {
  return <img src={coverUrl} />;
};
const VideoList = ({ items }) => {
  return (
    <ul>
      {items.map((item) => {
        'coverUrl' in item ? (
          <Thumbnail coverUrl={item.coverUrl} /> // type: Video
        ) : (
          <Thumbnail coverUrl={item.previewUrl} /> // type: LiveStream
        );
      })}
    </ul>
  );
};

Thumbnail 컴포넌트가 관심있는 이미지 경로만 주입 받게 된다면 확장성 있는 컴포넌트를 작성할 수 있게 됩니다.

Tip

GraphQL은 ISP를 만족시키기 위한 좋은 도구일 수 있습니다. 컴포넌트에 필요한 데이터만 요청하여 사용하는 것이 가능하기 때문입니다.

DIP(Dependency Inversion Principle) : 의존 역전 원칙

상위 모듈은 하위 모듈과 같은 구체화 된 것에 의존하면 안된다. 추상화에 의존해야 한다.

여기서 상위 모듈은 추상화된 것을 의미하고 하위 모듈은 구체화 된 것을 의미합니다. 자바와 같은 객체 지향 언어에서는 상위 모듈을 추상 인터페이스를 의미하고 하위 모듈은 이를 구현한 구현체를 뜻합니다.

그렇다면 프론트엔드에서 DIP는 어떤 것을 의미할까요? React에서는 구체적인 상황에 의존하지 않고 추상화시켜서 처리할 수 있는 좋은 방법이 있습니다. 바로 children입니다. children을 이용하여 특정 상황을 일반화시켜 구체적인 상황에 의존하지 않게 구성할 수 있습니다. 다음의 예제를 살펴 보겠습니다.

const MemoContainer = () => {
  const { data, loading, error } = useQuery(GET_MEMO);

  if (loading) return <div>loading...</div>;
  if (error) return <div>error...:(</div>;

  return <Memo data={data} />;
};

위 코드는 메모 정보를 가져와, 로딩이나 에러에 대해 처리를 하고 Memo 컴포넌트를 렌더링하고 있습니다. 일반적으로 작성하는 코드입니다. 하지만 좀 더 들어가 보면 Memo 컴포넌트 뿐만 아니라 게시판, 프로필 모든 상황에서 loading과 error에 대해 처리해야 합니다.

이와는 반대로 Memo 컴포넌트는 사실 loading과 error에 관심이 없습니다. 그저 메모를 렌더링하는 것에만 관심이 있을 뿐입니다. 우리는 이런 상황에서 DIP에 따라 의존성을 역전해 더 좋은 컴포넌트를 작성할 수 있습니다.

const Fetcher = ({ query, children }) => {
  const { data, loading, error } = query();

  if (loading) return <Loading />;
  if (error) return <Error />;

  return children;
};

const MemoContainer = () => {
  const [memo, MemoFetcher] = useFetcher(useGetMemoQuery);

  return (
    <MemoFetcher>
      <Memo data={memo} />
    </MemoFetcher>
  );
};

Fetcher를 구현한 useFetcher를 통해 로딩이나 에러에 대한 처리를 맡기고, MemoContainer는 단순히 메모를 그리는 코드를 작성하고 있습니다. 이렇게 되면 자연스럽게 관심사 분리가 이루어지고 해당 코드는 유지 보수성이 좋은 코드가 됩니다.

정리

개발자가 작성하는 모든 프로그램은 변화합니다. 어제 작성했던 코드를 오늘 수정할 수도 있고, 빠르면 내일이나 몇 달 후에 수정할 수도 있습니다. 그러므로 개발자는 변화에 대응하기 쉬운 코드를 작성해야 합니다. 이는 백엔드, 프론트엔드 모두 동일합니다.

변화에 대응하기 쉬운 코드는 어떻게 작성할 수 있을까요?

그 방법은 바로 격리입니다. 변화율이 같은 것을 묶고 변화율이 다른 코드들을 분리하여 변화에 대한 영향을 최소화시키는 것입니다. 즉, SOLID 원칙은 변화율에 따른 격리를 도와, 변화에 대응하기 쉬운 코드를 작성할 수 있게 도와주는 원칙이라고 할 수 있습니다.

감사합니다. 🙏

Reference

profile
안녕하세요 👏
FE Developer 루카스입니다.