지난 포스팅에서 useSuspenseQuery와 useQuery의 차이점, 그리고 Next.js에서 활용할 때 왜 바로 사용하면 안 되는지 등등을 배웠다. 그러면서 CSR적인 해결책만 내놨었는데 이번에는 Hydration API로 Server Rendering환경에서의 Next.js에서 어떻게 동작하고 사용할 수 있는지를 다뤄보도록 하겠다.
⛔ 주의사항
이 포스팅은 단순 사용에 초점을 두기보다는 흐름 및 설명을 두었으며 Next.js렌더링 동작 과정을 이해했다는 가정하에 작성을 한 것이다. 그러므로 Next.js의 렌더링 동작 과정을 모른다면 가볍게 알아보고 본다면 이해가 더 쉬울 것이다.
먼저 아래의 방식들을 이해하려면 서버 렌더링에 대해 간단하게 알아야 한다. 서버 렌더링은 사용자가 페이지를 로드하는 즉시 볼 수 있는 초기 HTML을 서버에서 생성하는 행위이다. 이는 페이지 요청 시 즉시 발생할 수 있으며(SSR), 이전 요청이 캐시 되었거나 빌드 시간에 미리 생성(SSG)할 수도 있다.
클라이언트 렌더링의 경우 페이지가 나타나기까지 3번의 과정이 필요하다.
1. |-> Markup (without content)
2. |-> JS
3. |-> Query
서버 렌더링의 경우엔 위의 과정을 아래와 같이 변환한다.
1. |-> Markup (with content AND initial data)
2. |-> JS
서버 렌더링을 통해 내용이 채워져 있고 data가 초기화되어있는 html을 생성하기 위해서는 마크업을 생성/렌더링 하기 전에 해당 데이터를 미리 가져와야(prefetch)하며, 데이터를 직렬화 가능한 형식으로 dehydrate시켜 마크업에 포함시키고 클라이언트에서는 react query 캐시로 해당 데이터를 hydrate하여 새로운 fetch를 클라이언트에서 추가적으로 할 필요가 없도록 해야 한다.
서버에서 pre-fetching 후 Props로 내려주어 useQuery 등 훅의 옵션인 initialData에 데이터를 넣어주는 방식이다.
해당 방법은 간단하긴 하지만 아래와 같은 한계점이 존재하여 대부분 아래의 Hydration을 활용한다. 한계점은 다음과 같다.
🔥 한계점
- 더 깊은 컴포넌트에서 useQuery를 호출하는 경우 해당 지점까지 initialData를 전달해야 한다.
- 여러 위치에서 동일한 쿼리를 호출하는 경우 그중 하나만 initialData를 전달하는 것은 앱이 변경될 때 문제가 발생할 수 있다. useQuery에 initialData를 가진 컴포넌트를 제거하거나 이동하는 경우 더 깊이 중첩된 useQuery는 더 이상 데이터가 없을 수도 있다. initialData를 필요로 하는 모든 쿼리에 전달하는 것은 번거롭다.
- 서버에서 쿼리가 언제 가져온 것인지 알 수 없기 때문에 dataUpdatedAt과 쿼리를 다시 가져와야 하는지 결정하는 방법은 페이지가 로드된 시점을 기준으로 한다.
- 쿼리에 대해 이미 캐시에 데이터가 있는 경우 새로운 데이터가 이전 데이터보다 최신이라도 initialData는 이를 덮어쓰지 않는다. (
getServerSideProps
가 매번 호출되어 새 데이터를 가져오지만, initialData 옵션을 사용하기 때문에 클라이언트 캐시와 데이터는 절대 업데이트되지 않는다.)
React Query에서는 dehydrate
와 hydrate
함수를 제공하여 이 과정을 간소화한다.
hydrate
는 클라이언트 측에서 직렬화된 상태를 받아 이를 React Query의 상태로 변환한다. 서버에서 미리 가져온 데이터를 클라이언트의 쿼리 캐시에 적용하여, 네트워크 요청 없이 데이터를 사용할 수 있게한다.
dehydrate
는 서버에서 불러온 데이터를 클라이언트로 전송하기 위한 직렬화 과정을 말하며, HydrationBoundary
는 이러한 직렬화된 데이터를 클라이언트에서 사용할 수 있도록 하기 위한 맥락에서 사용한다.
useQuery
를 통해 데이터를 불러오는 과정에서 HydrationBoundary
내에 의해 직렬화되었던 데이터가 활용되며, 이러한 방식을 통해 데이터를 효율적으로 관리하고 네트워크 요청을 줄일 수 있다. 이는 Next.js의 서버사이드 렌더링(SSR) 및 정적 사이트 생성(SSG)의 이점과 결합되어, 보다 빠른 페이지 로드 속도와 개선된 사용자 경험을 제공한다.
특정 데이터가 필요하거나 필요할 것으로 예상될 때, prefetching을 사용하여 미리 그 데이터를 캐시에 저장할 수 있다. 다양한 prefetching 패턴이 있다.
4번은 후술할 목차에서 다룰 것이다. prefetching의 한 가지 특별한 용도는 요청 폭포(Request Waterfalls)를 방지하는 것이다.
✨ 요청 폭포?
요청 폭포란 데이터 가져오기가 순차적으로 발생하여 성능 저하를 일으키는 상황이다.// 요청 폭포가 발생하는 컴포넌트 구조 function BlogPage({ postId }) { // 포스트 데이터를 가져옴 - 첫 번째 요청 const { data: post, isLoading: postLoading } = useQuery({ queryKey: ['post', postId], queryFn: () => fetchPost(postId) }); if (postLoading) return <Loading />; return ( <div> <PostDetails post={post} /> {/* 포스트가 로드된 후에만 Comments 컴포넌트가 렌더링됨 */} <Comments postId={postId} /> </div> ); } function Comments({ postId }) { // 포스트 렌더링 후 두 번째 요청이 시작됨 const { data: comments, isLoading } = useQuery({ queryKey: ['comments', postId], queryFn: () => fetchComments(postId) }); if (isLoading) return <Loading />; return ( <div> {comments.map(comment => ( <CommentItem key={comment.id} comment={comment} /> ))} </div> ); }
이런식으로 순차적 요청을 해버리면 총 로딩 시간은 포스트 로딩 시간 + 댓글 로딩 시간이기 때문에 성능이 저하된다.
useQuery 등으로 필요하거나 렌더링되기 전에 쿼리를 미리 가져오는 데 사용할 수 있는 비동기 메서드이다.
이러기 때문에 뒤에 후술하듯이 Next.js에서 SSR을 사용할 때 데이터를 미리 가져오는 메서드로 사용하며, 서버에서 데이터를 미리 생성한 후 이후의 fetch에서는 이미 완성된 초기 데이터를 사용한다. 초기 페이지 로드 시 필요한 데이터를 미리 서버에서 불러와 클라이언트에 전달하는 것이다.
사실상 기능은 동일하다. 다만 차이가 있다면 에러를 포함한 결과값, 데이터를 미리 가져오느냐 아니냐의 차이가 있다.
fetchQuery
는 쿼리를 가져오고 캐싱하는 데 사용할 수 있는 비동기 메서드이다.
fetchQuery
는 prefetchQuery
와 달리 실패할 경우 에러를 던지며 결과값에 대한 return을 할 수 있다. 이는 서버에서 받은 queryData를 통해 jotai의 hydrateAtom
초기화나 SSR에서의 error boundary를 이용할 때 유용하다. fetchQuery
는 실패한 경우에도 에러를 던질 수 있어 더 유연한 사용이 가능하다.
useQuery와 옵션은 대부분 비슷하지만 해당 부분들은 제외된다.
제외되는 부분 → 이들은 useQuery와 useInfiniteQuery에만 사용된다.
enabled, refetchInterval, refetchIntervalInBackground, refetchOnWindowFocus, refetchOnReconnect, refetchOnMount, notifyOnChangeProps, throwOnError, select, suspense, placeholderData
반면 prefetchQuery
는 공식문서의 설명에서도 fetchQuery
와는 다르게 여기서만 렌더링되기 전에 미리 가져온다는 설명을 볼 수 있다. prefetchQuery
를 통해 가져오는 쿼리에 대한 데이터가 이미 캐싱 되어 있으면 데이터를 가져오지 않는다. prefetchQuery
는 항상 성공한 쿼리만 dehydrate를 해준다. 반환값을 보면 Promise<TData>
가 아닌 Promise<void>
인 것을 보면 알 수 있다. 그러기 때문에 결과가 필요 없이 쿼리만 가져오고 싶다면 이 메서드를 사용해야 한다.
필자의 경우에는 useQuery API를 사용하였는데 공식문서 말로는 모든 쿼리를 항상 prefetch하는 한 useSuspenseQuery로 대체하는 것도 가능하다고 한다. useSuspenseQuery를 사용할 때 쿼리를 프리페치하는 것을 잊으면, 결과는 사용 중인 프레임워크에 따라 달라진다. 일부 경우, 데이터는 서버에서 Suspend되어 가져와지지만 클라이언트로 하이드레이션되지 않고, 클라이언트에서 다시 가져오게 된다. 이런 경우 서버와 클라이언트가 서로 다른 것을 렌더링하려고 했기 때문에 마크업 하이드레이션 불일치가 발생한다.
그러므로 웬만하면 useQuery API로 사용해보자
사용방법의 흐름은 다음과 같다.
await queryClient.prefetchQuery(...)
실행한다.await Promise.all(...)
을 사용하여 쿼리를 병렬로 가져오는 것이 좋다고 한다.dehydrate(queryClient)
를 반환한다. 이를 반환하는 정확한 구문은 프레임워크마다 다르다.<HydrationBoundary state={dehydratedState}>
로 감싼다. dehydratedState를 얻는 방법도 프레임워크마다 다르다.대학생 때 활용했던 코드는 다음과 같았다.
export default function Index({ dehydratedState }: { dehydratedState: DehydratedState }) {
const router = useRouter();
const hostNickname = decodeURIComponent(router.query.name as string);
const hostSuffixArray = useGetSuffixArray(hostNickname) as string[];
return (
<HydrationBoundary state={dehydratedState}>
<main className="bg--layout">
<div className="flex flex-col justify-center p-7 mb-20">
{// ... 생략}
</div>
<Footer />
</main>
</HydrationBoundary>
);
}
// 통계 데이터 표시 컴포넌트
function StatisticsContent({ hostNickname, hostSuffixArray }) {
const resetInfo = useUserStore.use.resetInfo();
const router = useRouter();
const { data, error, isLoading } = useQuery({
queryKey: ["host-stats"],
queryFn: useGetStatistic,
});
// 에러 처리는 useEffect으로 분리하였다.
useEffect(() => {
if (error) {
// ... 생략
}
}, [error]);
if (isLoading) return <ProgressCompo />;
return data?.data ? (
<WhiteBox className="font-Neo" isStatistic>
<StatisticForm
data={data.data}
hostNickname={hostNickname}
hostSuffixArray={hostSuffixArray}
/>
</WhiteBox>
) : null;
}
export async function getServerSideProps(context) {
const { name } = context.params;
const queryClient = new QueryClient();
try {
await queryClient.prefetchQuery({
queryKey: ["host-stats"],
queryFn: useGetStatistic,
});
return {
props: {
dehydratedState: dehydrate(queryClient),
},
};
} catch (error) {
// 서버 측 에러 처리
}
}
공식문서에 따르면 흥미로운 세부 사항은 실제로 세 개의 queryClient가 관련된다는 것이다. 프레임워크 로더는 렌더링 전에 발생하는 일종의 "프리로딩" 단계이며, 이 단계에는 프리페칭을 수행하는 자체 queryClient가 있다. 이 단계의 dehydrate된 결과는 서버 렌더링 프로세스와 클라이언트 렌더링 프로세스 모두에 전달되며, 각각은 자체 queryClient를 가진다. 이는 둘 다 동일한 데이터로 시작하여 동일한 마크업을 반환할 수 있도록 보장한다.
이…이게 무슨 소리지? 싶어서 나름 혼자서 정리를 해보았다.
SSR 프레임워크에서 React Query를 사용할 때 실제로 세 단계의 처리 과정과 세 개의 queryClient가 존재한다.
프리로딩 단계(첫 번째 queryClient)
[사용자 요청] → [서버] → [로더 함수 실행]
↓
[프리로딩용 queryClient 생성]
↓
[prefetchQuery로 데이터 로드]
↓
[dehydrate 실행]
필요한 데이터를 미리 가져오기 위하여 페이지 렌더링 전에 실행된다. 이를 통해 가져온 데이터의 스냅샷을 생성한다.(dehydratedState)
서버 렌더링 단계(두 번째 queryClient)
[dehydratedState] → [새 queryClient 생성] → [HydrationBoundary로 감싸기]
↓
[React 컴포넌트 렌더링]
↓
[HTML 생성]
브라우저로 보낼 초기 HTML 만들기 위해 실제 HTML을 생성하는 단계이다. 첫 번쨰 단계에서 가져온 데이터를 재사용하므로 refetch를 하지 않는다.
클라이언트 하이드레이션 단계(세 번째 queryClient)
[브라우저가 HTML 받음] → [dehydratedState 추출] → [새 queryClient 생성]
↓
[HydrationBoundary로 감싸기]
↓
[React 활성화]
정적 HTML을 인터랙티브 앱으로 전환하기 위해 브라우저에서 실행된다. 서버에서 가져온 동일한 데이터로 시작하며 이는 서버와 클라이언트가 일치한다.
이를 통해서 다음과 같은 특징들을 가질 수 있다.
쉽게 말해, 서버에서 데이터를 한 번 가져와서 그 "결과물"을 서버 렌더링과 클라이언트 초기화 모두에 재사용하는 구조라고 보면 된다.
React Query는 기본적으로 graceful degradation전략을 사용하는데 이는 다음을 의미한다.
queryClient.prefetchQuery(...)
는 절대 에러를 발생시키지 않는다.dehydrate(...)
는 실패한 쿼리를 제외하고 성공한 쿼리만 포함한다.하지만 중요한 콘텐츠가 누락된 경우 상황에 따라 404나 500 상태 코드로 응답하고 싶을 수도 있다. 이런 경우에는 queryClient.fetchQuery(...)
를 대신 사용해야 한다. 이 방법은 실패할 때 에러를 발생시켜 적절한 방식으로 처리할 수 있게 해주기 때문이다. 예시는 다음과 같이 작성할 수 있다.
// SSR에서의 예시
export async function getServerSideProps() {
const queryClient = new QueryClient();
try {
// 이 데이터는 반드시 있어야 함 - 없으면 404 응답
await queryClient.fetchQuery({
queryKey: ['critical-data'],
queryFn: fetchCriticalData
});
return {
props: {
dehydratedState: dehydrate(queryClient)
}
};
} catch (error) {
// 중요 데이터 로드 실패 시 404 반환
return {
notFound: true // 404 페이지 렌더링
};
}
}
어떤 이유로 재시도를 피하기 위해 실패한 쿼리를 디하이드레이트된 상태에 포함시키고 싶다면, shouldDehydrateQuery
옵션을 사용하여 기본 함수를 재정의하고 자체 로직을 구현할 수 있다. 아래는 공식문서 코드이다.
dehydrate(queryClient, {
shouldDehydrateQuery: (query) => {
// This will include all queries, including failed ones,
// but you can also implement your own logic by inspecting `query`
return true
},
})
Prefetching & Router Integration | TanStack Query React Docs
Server Rendering & Hydration | TanStack Query React Docs