프론트엔드 개발/React

프론트에서의 SOLID를 이해해보자

방구석 코딩쟁이 2024. 3. 28. 17:27

카카오 FE 기술 블로그의 SOLID 관련 글을 읽다가 이해가 잘 안 되어서 직접 구현해보면서 이해한 바를 토대로 글을 써보고자 합니다. 
아무래도 구체적인 코드보다 큰 그림에서의 코드를 통해 설명을 해주셨기 때문에, 아직 초보인 저로써는 직접 구현하며 더 이해하고자 하였습니다.

(그리고 예시 코드보다 허접하며, 빠르게 구현 원리를 확인만 하려고 했기 때문에 최대한 단순화했습니다)

 

먼저, SOLID의 "I"와 "D"는 각각 "Interface Segregation Principle"와 "Dependency Inversion Principle"의 앞글자를 딴 것입니다.

한국어로 번역하면 인터페이스 분리 원칙과 의존성 역전 원칙이죠.
저도 많이 들어봤고, 예시도 몇 차례 보면서 눈과 머릿 속에서는 이해가 된 듯하지만 결국 실전 프로젝트에서는 지키기 어려운...
그래서 원칙인가 봅니다 

 

각각의 원칙에 대한 정의를 좀 더 구체적으로 이해해 봅시다.

OOP에서의 인터페이스 분리 원칙은 인터페이스를 사용에 맞게 분리하여, 클라이언트의 목적과 용도에 적합한 인터페이스를 제공해야 한다는 의미입니다.

 

OOP에서의 의존성 역전 원칙은 객체에서 어떤 클래스를 참조해서 사용해야 하는 경우, 그 대상의 상위 요소(추상 클래스, 인터페이스)로 참조하라는 원칙입니다. 

 

그렇다면 FE 개발을 할 때 이 두 원칙이 어떤 도움이 될까요?

본문에 따르면 인터페이스를 조합(composition)할 때, 큰 도움이 된다고 합니다. 

추가적으로, 리액트에서의 Composition을 활용하려면 다들 자연스럽게 사용하시는 `children props`을 사용하시면 됩니다.

더 자세히 알고 싶으시다면 `Compound Pattern`에 대해서 알아보시면 좋으실 것 같습니다.

 

결국 FE에서는 하나의 페이지를 만들 때는 컴포넌트들을 조합해야 하기 때문에 두 원칙들이 사용되는 경우가 많을 수 밖에 없습니다.  

🌊 그리고 관심사별로 비즈니스 로직을 Custom Hook으로 분리하여 관심사의 분리(Soc)를 이뤄내는 등의 노력들이 많이 나오는 것 같습니다.

🌊 본문에서 SRP에서의 책임은 "기능", "동작" 단위가 아닌 "액터" 단위여야 한다고 하며, 액터는 사용자/이해관계자 집단을 의미하며, 
결론적으로는 책임은 동작이나 논리가 아닌 조직간의 커뮤니케이션 영역으로 봐야 한다고 말합니다.
(다만 저는 아직 이 부분이 정확히 어떤 뜻을 전달하고자 하는지 이해하진 못하고 있습니다. 최근에 저도 기능 단위로 함수나 컴포넌트를 너무 잘게 쪼개서 오히려 유지보수, 가독성을 해치는 일이 많았었는데, 커뮤니케이션을 할 수 있는 단위로 컴포넌트를 설계하는 방안도 고려를 해보는 것이 좋을 것 같다는 생각을 하게 되었습니다.)

 


 

먼저 다음과 같은 컴포넌트가 있다고 합시다.

export default function PostsContainer() {
  const { data, isFetching, error } = useQuery({
    queryKey: ["posts"],
    queryFn: async () => {
      const response = await fetch(
        "https://jsonplaceholder.typicode.com/posts"
      );
      const data = await response.json();

      return data;
    },
    initialData: [],
  });

  if (isFetching) return <div>로딩중</div>;

  if (error) return <div>에러</div>;
  return <Posts posts={data} />;
}

 

여기서 데이터를 가져온 뒤에 `Posts` 컴포넌트에 data를 넘겨주는 컨테이너 컴포넌트입니다. 제가 평소에 짜는 코드과 매우 유사합니다. 

하지만 아키텍처 관점에서는 아쉬운 코드입니다.

 

  • Loading과 Error 처리가 Container 컴포넌트 내부에 결합되어 있습니다. API 로딩, 에러를 공통으로 처리할 수 있는 인터페이스를 만들어야 할 것 같습니다 (DIP)
  • 현재 저희 코드에서는 괜찮지만 props를 여러개 넘겨줘야 한다면 Posts 컴포넌트는 의존되는 곳이 많아지고, 테스트 코드의 작성도 어려워지게 됩니다.

데이터를 가져오는 비즈니스 로직을 분리하기 위해 커스텀 훅을 사용하고, 데이터 Fetching에 관련된 로딩/에러 처리를 한 번에 해결해줄 수 있는 `Fetcher` 컴포넌트를 만들어봅시다

// Custom Hook
export default function useFetchPost() {
  return useQuery({
    queryKey: ["posts"],
    queryFn: async () => {
      const response = await fetch(
        "https://jsonplaceholder.typicode.com/posts"
      );
      const data = await response.json();

      return data;
    },
    initialData: [],
  });
}

// Fetching 관련 로딩, 에러 처리를 해결해주는 Fetcher 컴포넌트 (상위 인터페이스를 구현해줌)
type Props = {
  query: () => ReturnType<typeof useQuery<any>> | UseQueryResult<any, Error>;
  children: JSX.Element;
};
function Fetcher({ query, children }: Props) {
  const { isFetching, error, data } = query();

  const renderChildren = () => {
    return React.Children.map(children, (child) => {
      return React.cloneElement(child, {
        posts: data,
        key: data.id,
      });
    });
  };

  if (isFetching) return <div>Loading...</div>;
  if (error) return <div>Error</div>;
  return renderChildren();
}

 

renderChildren() 로직이 궁금하시다면 링크를 클릭해보세요...!

 

이렇게 된다면 `PostsContainer` 코드가 좀 더 가독성도 높아지고, 유지보수성도 높아집니다. 만약 로딩 관련 UI를 변경하기 위해서 모든 컴포넌트를 찾아다니는 것이 아닌 `Fetcher` 컴포넌트만을 변경하면 되니깐요! 

로딩과 에러 관련 로직을 정의하는 컴포넌트를 통해 의존성을 역전시킨 셈이죠

export default function PostsContainer() {

  return (
    <Fetcher query={useFetchPost}>
      <Posts />
    </Fetcher>
  );
}

 

 

추가적으로, 현재로서는 굳이 할 필요는 없지만 children에 여러 데이터가 들어가야 한다면 컴포넌트 단위로 쪼개서 꼭 필요한 인터페이스만 받도록 해줄 수도 있습니다. 

 

본문의 예시 코드는 다음과 같습니다.

개선 전과 개선 후를 보면 필요한 데이터에 따라 컴포넌트(인터페이스)를 분리하는 것을 볼 수 있습니다. 물론 개선 전도 Compound Pattern을 통해 ISP를 지키려고 한 코드입니다.

// 개선 전
function TicketInfoContainer() {
  // ...데이터 Fetching 로직
  
  return (
    <TicketInfo>
      <TicketInfo.WaitfreeArea waitfreePeriod={waitfreePeriod} waitfreeChargedDate={waitfreeChargedDate} />
      <TicketInfo.TicketArea rentalTicketCount={rentalTicketCount} ownTicketCount={ownTicketCount} />
      <TicketInfo.CommentArea commentCount={commentCount} commentError={commentError} keywordInfo={keywordInfo}/>
    </TicketInfo>
  )
}

// 개선 후
function TicketInfoContainer() {
  const [{waitfreePeriod, waitfreeChargedDate, rentalTicketCount, ownTicketCount}, TicketInfoFetcher] = useFetcher(useTicketInfoQuery);
  const [{keywordInfo}, KeywordInfoFetcher] = useFetcher(useKeywordInfoQuery);
  const [{commentCount}, CommentInfoFetcher] = useFetcher(useCommentInfoQuery);


  return (
    <TicketInfoFetcher>
      <TicketInfo>
        <TicketInfo.WaitfreeArea waitfreePeriod={waitfreePeriod} waitfreeChargedDate={waitfreeChargedDate} />
        <TicketInfo.TicketArea rentalTicketCount={rentalTicketCount} ownTicketCount={ownTicketCount} />
        <KeywordInfoFetcher />
        <CommentInfoFetcher>
          <TicketInfo.CommentArea commentCount={commentCount} keywordInfo={keywordInfo} />
        </CommentInfoFetcher>
      </TicketInfo>
    </TicketInfoFetcher>
  )
}

 

저도 개선 후의 코드와 유사한 형태로 만들어보고자 노력했습니다.

 

export default function PostsContainer() {
  const [data, FetchComponent] = useFetcher(useFetchPost);
  console.log(data, FetchComponent);
  return (
    <FetchComponent>
      <Posts posts={data} />
    </FetchComponent>
  );
}

// useFetcher
type Props = () =>
  | UseQueryResult<any, Error>
  | DefinedUseQueryResult<any, Error>;

export const useFetcher = (useQueryHook: Props) => {
  const { isFetching, error, data } = useQueryHook();

  return [
    data,
    ({ children }: PropsWithChildren) => (
      // () => (
      <Fetcher isFetching={isFetching} error={error}>
        {children}
      </Fetcher>
    ),
  ] as const;
};

type Props = {
  isFetching: boolean;
  error: Error | null;
};

// Fetcher 컴포넌트
export default function Fetcher({
  isFetching,
  error,
  children,
}: PropsWithChildren<Props>) {

  if (isFetching) return <div>Loading...</div>;
  if (error) return <div>Error</div>;

  return children;
}

 

`useFetcher`를 통해 `useQuery`를 실행하고자 하는 커스텀 훅을 실행하여 그 결과대로, `data`와 `children`을 props로 가질 수 있는 `Fetcher`를 반환하고자 했습니다. 

 

본문에서 요구하는 바와 100% 일치하지 않을지도 모르지만 직접 구현해보니 배우는 점들이 많았던 것 같습니다. 

특히 Compound Pattern 등과 같은 패턴을 통해 ISP를, 공통 컴포넌트를 상위로 추상화시키는 과정에서 DIP를 구현할 수 있다는 점이 재미있었던 것 같습니다.

 

좀 더 많은 코드를 짜면서, 계속해서 SOLID에 대해 이해해보고자 노력해야 할 것 같습니다.

 

 

 

출처

https://inpa.tistory.com/entry/OOP-%F0%9F%92%A0-%EC%95%84%EC%A3%BC-%EC%89%BD%EA%B2%8C-%EC%9D%B4%ED%95%B4%ED%95%98%EB%8A%94-DIP-%EC%9D%98%EC%A1%B4-%EC%97%AD%EC%A0%84-%EC%9B%90%EC%B9%99

 

💠 완벽하게 이해하는 DIP (의존 역전 원칙)

의존 역전 원칙 - DIP (Dependency Inversion Principle) DIP 원칙이란 객체에서 어떤 Class를 참조해서 사용해야하는 상황이 생긴다면, 그 Class를 직접 참조하는 것이 아니라 그 대상의 상위 요소(추상 클래스

inpa.tistory.com

https://fe-developers.kakaoent.com/2023/230330-frontend-solid/

 

프론트엔드와 SOLID 원칙 | 카카오엔터테인먼트 FE 기술블로그

임성묵(steve) 판타지, 무협을 좋아하는 개발자입니다. 덕업일치를 위해 카카오페이지로의 이직을 결심했는데 인사팀의 실수로 백엔드에서 FE개발자로 전향하게 되었습니다. 인생소설로는 데로

fe-developers.kakaoent.com