
TanStack Query + Next.js에서 발생하는 작지만 치명적인 실수
💡 TL;DR
prefetchQuery, dehydrate, HydrationBoundary, useSuspenseQuery까지 다 썼는데도 클라이언트에서 동일한 API가 또 호출된다면, 서버 컴포넌트에서 QueryClient를 새로 생성하고 있는지 확인하세요. getQueryClient()를 사용하지 않으면 hydration이 깨지고 브라우저에서 새 요청이 발생합니다.
TanStack Query v5와 Next.js를 함께 사용하며 꽤 답답한 상황을 겪었습니다.
• prefetchQuery()로 서버에서 미리 데이터 불러오고
• dehydrate()로 캐시를 직렬화하고
• <HydrationBoundary>로 감싸고
• 클라이언트에서는 useSuspenseQuery() 사용
모든 걸 했는데도 브라우저가 같은 API를 또 호출하는 거예요.
문제는 내가 잘못 쓴 코드 한 줄이었습니다.
공식 문서도 수십 번 읽었고, HydrationBoundary의 위치나 쿼리 키를 의심했지만 문제는 QueryClient 인스턴스를 새로 만들고 있었다는 점이었습니다.
🔍 아래 코드는 TanStack 공식 문서를 기반으로 프로젝트에 맞게 살짝 수정한 예제입니다.
// /queries/get-query-client.ts
import {
QueryClient,
defaultShouldDehydrateQuery,
isServer,
} from '@tanstack/react-query';
function makeQueryClient() {
return new QueryClient({
defaultOptions: {
queries: {
staleTime: 60 * 1000, // 1 minute
},
dehydrate: {
// include pending queries in dehydration
shouldDehydrateQuery: (query) =>
defaultShouldDehydrateQuery(query) ||
query.state.status === 'pending',
},
},
});
}
let browserQueryClient: QueryClient | undefined = undefined;
export function getQueryClient() {
if (isServer) {
// Server: always make a new query client
return makeQueryClient();
} else {
// Browser: make a new query client if we don't already have one
// This is very important, so we don't re-make a new client if React
// suspends during the initial render. This may not be needed if we
// have a suspense boundary BELOW the creation of the query client
if (!browserQueryClient) browserQueryClient = makeQueryClient();
return browserQueryClient;
}
}
코드 한 줄 때문에 hydration이 깨졌습니다.
서버에서 미리 불러온 캐시를 전혀 공유하지 않는 새로운 인스턴스를 만들었기 때문에, 브라우저는 빈 캐시를 보고 fetchFn을 다시 실행했습니다.
import {
dehydrate,
QueryClient,
HydrationBoundary,
QueryClient,
queryOptions,
} from '@tanstack/react-query';
const queryClient = new QueryClient();
queryClient.prefetchQuery({
queryKey: ['todos'],
queryFn: fetchTodos,
})
return (
<HydrationBoundary state={dehydrate(queryClient)}>
<Content />
</HydrationBoundary>
);
import {
dehydrate,
HydrationBoundary,
QueryClient,
queryOptions,
} from '@tanstack/react-query';
import { getQueryClient } from '@/queries/get-query-client';
const queryClient = getQueryClient();
queryClient.prefetchQuery({
queryKey: ['todos'],
queryFn: fetchTodos,
})
return (
<HydrationBoundary state={dehydrate(queryClient)}>
<Content />
</HydrationBoundary>
);
getQueryClient()를 사용하자 모든 문제가 해결됐습니다.
브라우저에서도 캐시를 정상적으로 인식하고, 불필요한 API 호출 없이 화면이 즉시 렌더링됐습니다.
| 공유하지 않을 때 | 공유할 때 |
|---|---|
| 새로운 인스턴스, 빈 캐시 | 동일한 인스턴스, 캐시 공유됨 |
useSuspenseQuery → 다시 호출 | useSuspenseQuery → 캐시 사용 |
| 네트워크 낭비, 로딩 깜빡임 | 빠른 렌더링, UX 향상 |
이 문제의 원인은 정말 사소한 실수였지만,
성능과 UX엔 큰 영향을 주었습니다.
Next.js + TanStack Query를 함께 사용하면서
예상치 못한 refetch 문제가 발생했다면,
당신도 QueryClient를 잘못 만들고 있을 가능성이 있습니다.
저처럼 며칠씩 고생하지 마시고,
한 번 더 getQueryClient()를 확인해보세요 😊