
글의 주제를 설명하려고 만든 일러스트예요. 실제 제품 화면이나 정확한 구성도는 아니에요.
예제는 Next.js 15 App Router와 TanStack Query v5를 사용해요.
App Router에서 서버가 데이터를 미리 받아도, 화면에서 같은 데이터를 조회하는 코드는 클라이언트에 남아요. 서버의 캐시를 어떻게 넘길지와 브라우저에서 그 캐시를 얼마나 오래 쓸지를 함께 정해야 했어요.
QueryClient를 만드는 코드는 공유하되 인스턴스까지 공유하지는 않았어요. 서버 요청마다 새 캐시를 만들고, 브라우저에서는 렌더가 반복돼도 같은 캐시를 쓰게 했어요.
서버 요청은 나누고 브라우저 캐시는 남겨요
// query-client.ts
import {
QueryClient,
defaultShouldDehydrateQuery,
isServer,
} from '@tanstack/react-query';
function makeQueryClient() {
return new QueryClient({
defaultOptions: {
queries: {
staleTime: 60_000,
},
dehydrate: {
shouldDehydrateQuery: (query) =>
defaultShouldDehydrateQuery(query) ||
query.state.status === 'pending',
},
},
});
}
let browserQueryClient: QueryClient | undefined;
export function getQueryClient() {
if (isServer) {
return makeQueryClient();
}
if (!browserQueryClient) {
browserQueryClient = makeQueryClient();
}
return browserQueryClient;
}getQueryClient는 서버에서 호출할 때마다 makeQueryClient로 새 인스턴스를 돌려줘요. 한 요청에서 조회한 데이터가 다음 요청의 캐시에 남지 않게 하는 거예요. 브라우저 쪽 인스턴스는 모듈에 보관해 Provider가 다시 렌더돼도 유지해요.
서버 여부는 TanStack Query의 isServer로 구분했어요. typeof로 직접 검사한다면 비교 대상도 문자열이어야 해요. typeof window !== undefined라고 쓰면 서버에서도 참이 되므로, typeof window === 'undefined'처럼 비교해야 해요.
Provider에는 브라우저에서 쓸 인스턴스를 연결해요
'use client';
import { QueryClientProvider } from '@tanstack/react-query';
import { getQueryClient } from './query-client';
export function QueryProvider({ children }: { children: React.ReactNode }) {
const queryClient = getQueryClient();
return (
<QueryClientProvider client={queryClient}>
{children}
</QueryClientProvider>
);
}Provider는 클라이언트 컴포넌트에 두고 getQueryClient가 돌려준 인스턴스를 넘겼어요. layout이 이 Provider로 children을 감싸도, children으로 들어오는 서버 컴포넌트까지 클라이언트 컴포넌트가 되지는 않아요. 캐시를 구독하는 자리에 Provider를 두면서 서버 렌더링도 그대로 쓸 수 있어요.
서버와 클라이언트가 같은 조회를 가리키게 해요
import { queryOptions } from '@tanstack/react-query';
export const categoryOptions = queryOptions({
queryKey: ['categories'],
queryFn: async () => {
const response = await fetch(`${process.env.API_URL}/categories`);
if (!response.ok) throw new Error('카테고리를 불러오지 못했습니다.');
return (await response.json()) as Category[];
},
});서버에서 prefetch할 때와 화면에서 구독할 때 query key를 따로 적으면, 같은 데이터를 요청하면서도 서로 다른 캐시를 볼 수 있어요. queryOptions 안에 key와 queryFn을 함께 두고 양쪽에서 가져다 쓰게 했어요. 카테고리 조회의 key나 요청 방식이 바뀌면 이 정의를 고쳐요.
서로 기다릴 이유가 없는 조회는 함께 시작해요
import { dehydrate, HydrationBoundary } from '@tanstack/react-query';
export default async function HomePage() {
const queryClient = getQueryClient();
await Promise.all([
queryClient.prefetchQuery(categoryOptions),
queryClient.prefetchQuery(integrationOptions),
]);
return (
<HydrationBoundary state={dehydrate(queryClient)}>
<HomePageView />
</HydrationBoundary>
);
}카테고리와 연동 목록은 서로의 결과가 필요하지 않아서 Promise.all로 함께 기다렸어요. 조회가 끝나면 dehydrate로 캐시를 꺼내 HydrationBoundary에 넘겨요. 이제 하위 화면이 같은 key로 구독하면 서버가 받아 둔 데이터를 찾을 수 있어요.
이 예제처럼 완료된 조회만 보낼 때는 기본 dehydration 정책으로도 충분해요. 앞의 QueryClient 설정에서 pending 상태를 추가한 것은, prefetch가 끝나기를 기다리지 않고 Suspense로 스트리밍하는 화면도 지원하려는 설정이에요.
화면에서는 넘겨받은 캐시를 구독해요
'use client';
import { useSuspenseQuery } from '@tanstack/react-query';
export function HomePageView() {
const { data } = useSuspenseQuery(integrationOptions);
return <IntegrationList items={data} />;
}HomePageView는 서버에서 썼던 integrationOptions로 useSuspenseQuery를 호출해요. key가 같으니 첫 렌더에서는 HydrationBoundary로 넘긴 데이터를 읽어요. 이후 invalidate나 refetch가 필요하면 브라우저의 QueryClient가 계속 처리해요. 서버가 한 번 조회했다는 이유로 별도의 상태에 데이터를 복사할 필요는 없어요.
기다릴지 스트리밍할지는 조회마다 달라요
초기 HTML에 필요한 데이터는 위 예제처럼 prefetchQuery를 await해요. 다만 v5의 prefetchQuery는 실패를 호출부로 던지지 않으므로, 서버에서 오류를 받아 처리해야 하는 조회에는 fetchQuery를 써야 해요.
화면을 먼저 보내도 되는 조회는 pending dehydration을 켜고, 데이터를 쓰는 컴포넌트를 Suspense 안에 둘 수 있어요. 그 컴포넌트가 useSuspenseQuery로 구독하면 준비된 데이터를 이어서 받아요. QueryClient를 언제 만들지는 공통으로 정했지만, 응답을 기다리는 방식까지 모든 화면에 같게 적용하지는 않았어요.


