TanStack Query – prefetchQuery를 써도 중복 fetch가 발생한다면?!

Maria Kim·2025년 5월 19일
0

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 향상

🧠 얻은 교훈

  1. 직접 new QueryClient() 하지 마세요.
  • getQueryClient() 같은 헬퍼를 항상 사용하세요.
  1. Hydration은 인스턴스 일치에 의존합니다.
  • 같은 쿼리 키라도 다른 인스턴스면 캐시를 못 씁니다.
  1. 의심 가면 import부터 점검하세요.
  • 제 경우에도 로직은 정상이었고, 문제는 단 하나의 import였습니다.
  1. 개발 중엔 네트워크 탭을 항상 확인하세요.
  • 로컬 서버 속도가 빠르면 중복 호출을 못 느낄 수 있습니다.

🙌 마무리

이 문제의 원인은 정말 사소한 실수였지만,
성능과 UX엔 큰 영향을 주었습니다.

Next.js + TanStack Query를 함께 사용하면서
예상치 못한 refetch 문제가 발생했다면,
당신도 QueryClient를 잘못 만들고 있을 가능성이 있습니다.

저처럼 며칠씩 고생하지 마시고,
한 번 더 getQueryClient()를 확인해보세요 😊

profile
Developer, who has business in mind.

0개의 댓글