서버 컴포넌트와 TanStack Query를 같이 적용하게된 스토리를 적어보고자 한다.
목표
우선 내가 구현하고자 하는 내용의 조건은 다음과 같다.
서버 컴포넌트에서 n개의 데이터를 불러와 SEO를 위해 페이지를 미리 채워 놓을것.
무한 스크롤을 이용해 데이터를 더 로드할 수 있을 것
새로고침 전까지 클라이언트에서 계속 캐싱하고 있을 것
다른 카테고리 선택시 클라이언트에서 refetching 하여 데이터를 보여줄 것
그리고 걸린 제약 조건은 다음과 같다.
데이터 fetching은 third-party 라이브러리를 사용하며, 이는 서버 사이드에서만 동작하며 클라이언트에서 호출할 수 없다.
구현 과정
1. 서버 컴포넌트에서 데이터 Prefetching
TanStack Query 공식 문서에서 가이드해주는 방법을 참고하여, 부모가 되는 서버 컴포넌트를 작성했다.
page.tsx
export default async function PostPage() {
const queryClient = new QueryClient()
// prefetch할 query를 지정한다.
await queryClient.prefetchQuery({
queryKey: ['posts'],
queryFn: () => PostService.getPostsByTag("initial"),
})
return (
<HydrationBoundary state={dehydrate(queryClient)}>
<Posts />
</HydrationBoundary>
)
}
위 코드에서 <HydrationBoundary state={dehydrate(queryClient)}>
이 부분은 서버에서 가져온 데이터를 클라이언트 컴포넌트에서 사용하기 위해 직렬화를 해주는 과정이다. 직렬화가 왜 필요한지는 여기서 설명하지 않겠다.
2. 무한 스크롤을 위한 클라이언트 컴포넌트 작성
이제 무한 스크롤을 위해 useInfiniteQuery
를 사용할 클라이언트 컴포넌트 부분을 살펴보자. prefetch한 데이터를 가져오기 위해서는 usePrefetchInfiniteQuery
훅을 사용해야 한다.
Post.tsx
export default function Posts() {
const { data, fetchNextPage, isFetchingNextPage } = await queryClient.prefetchInfiniteQuery({
queryKey: ["posts"],
queryFn: () => PostService.getPostsByTag("initial"),
initialPageParam: undefined,
});
// ...생략
return (
<div>
{data....}
</div>
)
}
첫 번째 문제: 서버 사이드 전용 함수 호출
여기서 첫 번째 문제가 발생했다. queryFn
에 들어가는 비동기 함수는 서버에서 실행되는 함수인데, 클라이언트 사이드에서 호출하니 에러가 발생했다.
이를 해결하기 위해 Next.js의 server action을 사용했다.
const getPostsAction = async (tag: string, startCursor?: string) => {
"use server";
return await BlogService.getPostsByTag(tag, startCursor);
};
위와 같이 감싸 준다음, getPostsAction
를 클라이언트 컴포넌트에 props
로 내려주거나, 파일을 따로 분리해서 export한 후, 클라이언트에서 import해서 사용해도 된다. 나는 후자의 방법을 선택했다.
const { data, fetchNextPage, isFetchingNextPage } = useInfiniteQuery({
queryKey: ["posts"],
queryFn: ({ pageParam }: { pageParam: string | undefined }) =>
getPostsAction({ startCursor: pageParam, tag }),
initialPageParam: undefined,
getNextPageParam: (lastPage, pages) => lastPage.next_cursor || undefined,
staleTime: Infinity,
gcTime: Infinity,
});
지금까지의 과정을 통해 초기 데이터도 서버에서 만들어져 초기 HTML에 포함되어 나왔고, 무한 스크롤을 통한 클라이언트에서의 데이터 페칭도 정상적으로 진행 되었다.
두 번째 문제: 캐싱 문제
근데 캐싱이 안된다..!! 다른 페이지로 이동했다 다시 돌아오면 클라이언트 사이드에서 스크롤을 통해 가져온 데이터는 사라져 있었다. 아무리 staleTime
과 gcTime
의 옵션을 변경해도 마찬가지였다.
원인은 다음과 같았다. 서버 컴포넌트(page.tsx
)가 실행 될 때마다 queryClient
를 새로 생성하기 때문에 캐시가 남아 있지 않게되고, staleTime
등의 옵션은 별 의미가 없어진다.
그럼 서버에서 생성한 queryClient
를 밖으로 빼면 되지 않나? 라고 생각할 수 있지만, 서버에서 만들어지다 보니 다른 사용자들끼리 공유가될 수 있는 문제가 있다.
해결
따라서 나는 서버에서 queryClient.prefetchQuery
쓰는 것을 포기하고 다음과 같은 방법을 적용 했다.
우선 초기 데이터를 fetching한 뒤, 데이터를 컴포넌트에
props
로 내려줬다.
async function PostPage() {
const initialPosts = await getPostsAction({ tag: "all" });
return (
<section>
<BlogPostList
initialPosts={initialPosts}
/>
</section>
);
}
전달 받은 데이터를
placeholderData
에 넣어 사용했다.
const getPlaceholderData = (): InfiniteData<
BlogPostsResultType,
string | undefined
> => {
const cache = queryClient.getQueryData<
InfiniteData<BlogPostsResultType, string | undefined>
>(["posts", "all"]);
return (
cache || {
pages: [initialPosts],
pageParams: [],
}
);
};
const { data, fetchNextPage, isFetching, isFetchingNextPage } =
useInfiniteQuery({
queryKey: ["posts", selectedTag],
queryFn: ({ pageParam }: { pageParam: string | undefined }) =>
getPostsAction({ startCursor: pageParam, tag: selectedTag }),
initialPageParam: undefined,
getNextPageParam: lastPage => lastPage?.next_cursor,
placeholderData: getPlaceholderData(),
refetchOnMount: false,
staleTime: Infinity,
gcTime: Infinity,
});
placeholderData
가 아닌 initialData
에 넣을 수도 있지만, 내 조건에서는 태그를 선택시 태그에 맞는 데이터를 보여줘야했고, 데이터 페칭시 이전 데이터를 활용하여 화면에 데이터가 사라지는 것을 막기 위해 placeholderData
를 사용했다.
참조: https://tkdodo.eu/blog/placeholder-and-initial-data-in-react-query
결론
SSR, RSC 등과 클라이언트 데이터 페칭 라이브러리인 TanStack Query를 적절하게 섞어서 사용할 수 있지만, 아직 완벽하게 사용할 수는 없는 느낌을 받았다. 서버측에서의 렌더링이 유행처럼 되가고 있으니 조만간 TanStack Query에서도 지원이 제대로 되었으면 좋겠다.