카카오 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
https://fe-developers.kakaoent.com/2023/230330-frontend-solid/