DIP 원칙을 통해 요구사항 변화에 유연하게 대응한 경험, feat: TanStack Query
실제 경험은 TanStack Query를 이용하지만, 이 글에서는 각색하여 다룬다.
배경
최근 UI는 유지한 채, 페이지네이션 API를 교체해야할 일이 있었다.
이전 API는 데이터를 생성일순으로 정렬하여 페이지네이션 했었지만, 교체할 API는 개인화 추천 시스템이 적용된 데이터를 제공하였다. 이로 인해 두 API의 스펙이 상이했다.
그래서 FE에서는 관련 로직을 변경해야 했지만, UI는 그대로 유지해야했다.
이런 문제 속에서 DIP 원칙을 적용하여 어떻게 변화에 대응 했는지에 대한 경험을 이야기한다.
변경 이전의 컴포넌트
export function SomethingComponent() {
const { data, hasNextPage, isFetchingNextPage, fetchNextPage } =
useFetchSomethings();
return (
<InfiniteScroll
onEndReached={fetchNextPage}
...
>
<Layout>
{data.pages.map((page) => {
return page.somethings.map((something) => {
return <SomethingCard key={something.id} {...something} />;
});
})}
</Layout>
</InfiniteScroll>
);
}
이 컴포넌트는 데이터를 무한 스크롤 방식으로 불러오는 컴포넌트이다. 그리고 이 컴포넌트는 useFetchSomethings라는 커스텀 훅에 직접 의존하고 있다.
컴포넌트에게 추상화 제공하기
그리고 이제 나는 위에서 언급한 것처럼 사용자에게 개인화된 데이터를 제공하기 위해 useFetchSomethings를 useFetchPersonalizedSomethings로 교체해야 했다.
두 hook의 내부 구현은 완전히 다를 것이다.
여기서 주목할 점은 <SomethingComponent>는 hook의 내부 구현에는 전혀 관심이 없다는 것이다. 컴포넌트는 오직 아래와 같은 인터페이스만 알면 된다.
interface FetchSomethings {
data: Something[];
hasNextPage: boolean;
isFetchingNextPage: boolean;
fetchNextPage: () => void;
}
function useFetchSomethings(): FetchSomethings { ... }
function useFetchPersonalizedSomethings(): FetchSomethings { ... }
즉, <SomethingComponent>가 FetchSomethings라는 추상화에 의존하는 결과를 반환하는 hook에 의존하게 하면, useFetchSomethings를 useFetchPersonalizedSomethings로 부품 교체 하듯이 교체하면 된다는 것이다.
실제 예시: 구현체 전환하기
interface FetchSomethings {
data: Something[];
hasNextPage: boolean;
isFetchingNextPage: boolean;
fetchNextPage: () => void;
}
// 조회순 API 구현
function useFetchSomethings(): FetchSomethings {
const [data, setData] = useState<Something[]>([]);
...
const fetchNextPage = () => {
somethingService.fetchSomethings(page).then((data) => {
...
});
...
}
const hasNextPage = page < totalPages;
return {
data,
hasNextPage,
isFetchingNextPage,
fetchNextPage,
}
}
// 개인화된 API 구현
function useFetchPersonalizedSomethings(): FetchSomethings {
const [data, setData] = useState<Something[]>([]);
...
const fetchNextPage = () => {
somethingService.fetchPersonalizedSeeds(nextEndpoint).then((data) => {
...
})
...
const nextEndpoint: string | null = ((
continuationToken: string | null
) => {
if (continuationToken) {
return `/api/somethings?continuationToken=${continuationToken}`
}
return null
})(continuationToken);
}
const hasNextPage = !!nextEndpoint;
return {
data,
hasNextPage,
isFetchingNextPage,
fetchNextPage,
}
}
위 두 구현체는 내부적으로 완전히 다른 방식으로 동작하지만, 동일한 인터페이스를 제공한다. 덕분에 컴포넌트는 어떤 구현체를 사용하든 변경할 필요가 없이, 아래와 같이 직접 의존할 hook만 교체해주면 된다.
export function SomethingComponent() {
// const { data, hasNextPage, isFetchingNextPage, fetchNextPage } = useFetchSomethings();
const { data, hasNextPage, isFetchingNextPage, fetchNextPage } = useFetchPersonalizedSomethings();
return (
<InfiniteScroll
onEndReached={fetchNextPage}
...
>
<Layout>
{data.pages.map((page) => {
return page.somethings.map((something) => {
return <SomethingCard key={something.id} {...something} />;
});
})}
</Layout>
</InfiniteScroll>
);
}
