TanStack Query Hydration 오류 해결: QueryClient 설정 기준

··설명 약 11분·작성: 해비·블로그 소개

TanStack Query hydration 뒤 서버 데이터를 못 찾으면 어떤 설정을 확인하나요?

서버 Query 데이터를 브라우저가 못 찾으면 QueryClient 수명·queryKey·Provider 위치·staleTime을 대조합니다. HydrationBoundary만 추가해도 서로 다른 키나 반복 생성 문제가 해결되는 것은 아닙니다.

적용 조건·범위: 서버 cache가 정상 hydrate되어 화면이 즉시 표시되더라도 클라이언트에서 stale query를 다시 검증할 수 있습니다.

본문 근거: tanstack.com/query/latest/docs/framework/react/guides/advanced-ssr · tanstack.com/query/latest/docs/framework/react/guides/ssr

이 기술만 따로 연습하기

기대 결과: 체크를 끈 채 부모 재렌더를 눌러 Reader가 재마운트되어도 fresh cache의 같은 요청 번호를 재사용합니다. 체크를 켜면 부모 재렌더마다 새 client에서 다시 요청합니다.

직접 실행: 환경 준비와 전체 파일

실행 환경은 React·TypeScript와 @tanstack/react-query 5.104.0입니다. React 환경이 없다면 React Vite 사용법 Vite 8 프로젝트 생성 실행 빌드를, 라이브러리 설치는 TanStack Query Provider와 첫 useQuery 상태 처리 실습 가이드의 설치 항목을 확인합니다. 아래는 별도 Vite 연습 앱 src/App.tsx 전체 내용입니다. src/main.tsx는 유지하며 이전 TaskFlow나 API 서버는 필요하지 않습니다. 요청 함수는 이 파일 안에 정의된 비동기 모형입니다.

import { useState } from 'react';
import {
  QueryClient,
  QueryClientProvider,
  useQuery
} from '@tanstack/react-query';

const wait = (ms = 400) => new Promise<void>((resolve) => setTimeout(resolve, ms));

let requests = 0;
function Reader() {
  const query = useQuery({
    queryKey: ['lifecycle'],
    queryFn: async () => { await wait(); return ++requests; },
    staleTime: Infinity,
  });
  return <p>요청 번호 {query.data ?? '대기'}</p>;
}
export default function App() {
  const [stable] = useState(() => new QueryClient());
  const [broken, setBroken] = useState(false);
  const [render, setRender] = useState(0);
  const current = broken ? new QueryClient() : stable;
  return (
    <main>
      <label><input type="checkbox" checked={broken} onChange={(event) => setBroken(event.target.checked)} />매번 새 client</label>
      <button onClick={() => setRender((value) => value + 1)}>부모 재렌더 {render}</button>
      <QueryClientProvider client={current}>
        <Reader key={render} />
      </QueryClientProvider>
    </main>
  );
}
npm run dev
npm run build

자가 확인·변형: key={render}는 화면 이동처럼 Reader를 다시 마운트하기 위한 실험 장치입니다. 실제 앱에 매번 바뀌는 key를 넣으라는 권장이 아닙니다. 서버 요청별 client 분리와 브라우저 client 보존이 서로 다른 조건임을 정리하세요.

기존 검증 기록: 위 전체 코드는 타입검사·빌드와 jsdom 동작 검증을 통과했습니다. 실제 브라우저 화면과 누적 Next.js 프로젝트 통합 실행은 별도 확인 대상입니다.

문제 1. 서버 QueryClient를 전역 공유

다음 코드를 서버 요청이 함께 사용하는 module 전역 cache처럼 두는 것은 피하는 편이 좋습니다.

const queryClient =
  new QueryClient();

SSR 데이터가 사용자별일 수 있다면 요청 간 cache가 섞이는 위험이 있습니다.

서버에서는 새 QueryClient를 만들도록 구성합니다.

문제 2. 브라우저 QueryClient가 계속 새로 생성

반대로 브라우저에서는 렌더마다 QueryClient가 바뀌면 기존 cache가 사라질 수 있습니다.

function Providers({
  children,
}: {
  children: React.ReactNode;
}) {
  const queryClient =
    new QueryClient();


  return (
    <QueryClientProvider
      client={queryClient}
    >
      {children}
    </QueryClientProvider>
  );
}

안정적으로 유지합니다.

const [queryClient] =
  useState(
    () => makeQueryClient(),
  );

서버/브라우저를 구분하는 getQueryClient 패턴

현재 TanStack Query의 Next.js App Router 고급 가이드에서는 서버에서는 새 client를 만들고 브라우저에서는 singleton을 재사용하는 패턴을 보여줍니다.

import {
  isServer,
  QueryClient,
} from '@tanstack/react-query';


function makeQueryClient() {
  return new QueryClient({
    defaultOptions: {
      queries: {
        staleTime: 60_000,
      },
    },
  });
}


let browserQueryClient:
  QueryClient | undefined;


export function getQueryClient() {
  if (isServer) {
    return makeQueryClient();
  }


  if (!browserQueryClient) {
    browserQueryClient =
      makeQueryClient();
  }


  return browserQueryClient;
}

프로젝트 구조에 따라 Provider에서 useState 패턴을 유지해도 되지만, Server Component prefetch와 공유하는 helper가 필요하다면 위처럼 역할을 명확히 할 수 있습니다.

문제 3. queryKey가 다름

서버:

queryKey: ['tasks']

클라이언트:

queryKey: [
  'tasks',
  'list',
  { status: 'all' },
]

이 두 query는 별도 cache입니다.

서버와 클라이언트 모두 같은 factory를 사용합니다.

taskKeys.list('all')

문제 4. HydrationBoundary 바깥에서 useQuery

HydrationBoundary
└─ A


B
└─ useQuery

B는 해당 boundary에서 전달된 dehydrated state를 적용받는 트리 밖에 있을 수 있습니다.

서버에서 준비한 query를 사용하는 Client Component를 boundary 아래에 배치합니다.

문제 5. QueryClientProvider가 없음

HydrationBoundary는 hydrate할 브라우저 QueryClient가 필요합니다.

앱 상위에서 QueryClientProvider가 실제 Client tree를 감싸는지 확인합니다.

문제 6. 즉시 refetch를 hydration 실패로 오해

기본 staleTime은 0입니다.

서버 cache가 정상 hydrate되어 화면이 즉시 표시되더라도 클라이언트에서 stale query를 다시 검증할 수 있습니다.

staleTime: 60_000

SSR 직후 재요청을 줄이고 싶다면 데이터 특성에 맞는 staleTime을 설정합니다.

Network에 GET이 한 번 더 보인다는 사실만으로 hydration 실패라고 판단하지 않습니다.

문제 7. 서버와 클라이언트 data shape가 다름

서버 queryFn:

Task[]

클라이언트 queryFn:

{ items: Task[] }

같은 queryKey로 서로 다른 data shape를 사용하면 렌더링 코드가 깨질 수 있습니다.

queryKey가 같다면 cache data contract도 같게 유지합니다.

문제 8. prefetch 오류가 숨겨짐

서버 query가 실패했는데 catch로 무조건 무시하면 dehydrate할 성공 데이터가 없을 수 있습니다.

초기 SSR 데이터가 필수라면 오류를 route error boundary로 전달할지, fallback을 보여줄지 정책을 정합니다.

진단 순서

1. 서버 query가 성공했는가
2. dehydrate 결과에 query가 있는가
3. 서버/클라이언트 queryKey가 같은가
4. Client Component가 HydrationBoundary 안에 있는가
5. QueryClientProvider가 상위에 있는가
6. 브라우저 QueryClient가 안정적으로 유지되는가
7. 서버 QueryClient가 요청 간 공유되지 않는가
8. staleTime 때문에 단순 refetch되는 것은 아닌가
9. data shape가 같은가

학습 완료 체크리스트

  • 서버와 브라우저 QueryClient의 생명주기를 구분할 수 있습니다.
  • queryKey 불일치를 진단할 수 있습니다.
  • Provider와 HydrationBoundary 위치를 점검할 수 있습니다.
  • 즉시 refetch와 hydration 실패를 구분할 수 있습니다.
  • data shape 계약을 확인할 수 있습니다.

선택 실습: 할 일 앱에 적용하기

TaskFlow는 작업을 추가·완료·삭제하는 할 일 앱입니다. 이 선택 실습에서는 서버 데이터의 조회·변경과 캐시를 관리합니다.

TaskFlow SSR/Hydration 구조를 최소 구조로 다시 점검합니다.

선택: 연재 프로젝트와 전체 자료

연재 프로젝트를 이어 만드는 분을 위한 자료입니다. 이 글의 개념과 예제만 찾으셨다면 필요한 파일과 실행 방법만 펼쳐보세요.

TaskFlow 프로젝트 적용

중간부터 시작하는 전체 실습 파일

기존 Next.js Route Handler TaskFlow의 /tasks 화면과 /api/tasks를 이어서 확장합니다. 인증 없는 demo 메모리 저장소를 사용하는 로컬 실습이며 계정·외부 DB·API 키가 필요 없습니다. MSW를 활성화하지 않습니다. 서버를 다시 시작하면 데이터가 초기화됩니다.

이 글의 시작본은 직전 프로젝트 단계 완료본 전체입니다. 이미 직전 단계를 마쳤다면 그 폴더를 이어 쓰셔도 됩니다. Node.js 24.x와 npm을 사용하며 패키지 버전은 package-lock.json에 고정했습니다.

시작본에서 직접 바꾸기

시작본을 실행한 뒤 아래 변경 파일을 순서대로 수정합니다. 완료본의 같은 경로 파일에는 import와 입력 타입을 포함한 전체 코드가 있습니다.

  1. app/lib/query-client.ts — 이전 단계에 없던 파일을 만듭니다.
  2. app/providers.tsx — 기존 파일의 같은 위치를 갱신합니다.

변경 이유와 결과: app/lib/query-client.ts에서 서버 요청용 새 인스턴스와 브라우저 재사용 인스턴스를 분리합니다. 전역 서버 QueryClient 공유를 만들지 않습니다.

완료본의 docs/changes.patch에서 시작·완료 차이를, docs/current-step.md에서 파일별 적용 위치를 확인할 수 있습니다. 아래 전체 파일을 같은 경로에 반영한 다음 실행하세요.

app/lib/query-client.ts 전체 파일

import { QueryClient } from '@tanstack/react-query';
function makeQueryClient() {
  return new QueryClient({ defaultOptions: { queries: { staleTime: 30_000, retry: false, refetchOnWindowFocus: false } } });
}
let browserClient: QueryClient | undefined;
export function getQueryClient() {
  if (typeof window === 'undefined') return makeQueryClient();
  return browserClient ??= makeQueryClient();
}

app/providers.tsx 전체 파일

'use client';
import type { ReactNode } from 'react';
import { QueryClientProvider } from '@tanstack/react-query';
import { getQueryClient } from './lib/query-client';
export default function Providers({ children }: { children: ReactNode }) {
  return <QueryClientProvider client={getQueryClient()}>{children}</QueryClientProvider>;
}

완료 확인

  1. 시작본은 직전 완료본과 동일한 ZIP입니다. 압축을 푼 뒤 npm ci로 잠금 파일의 의존성을 설치합니다.
  2. npm run dev 실행 후 http://localhost:3000/tasks를 엽니다.
  3. app/lib/query-client.ts에서 서버 요청용 새 인스턴스와 브라우저 재사용 인스턴스를 분리합니다. 전역 서버 QueryClient 공유를 만들지 않습니다.
  4. npm run typecheck와 npm run build가 성공하면 다음 프로젝트 단계로 갑니다.

원문에 있는 기존 체크포인트 검증 기록은 TypeScript 검사·프로덕션 빌드와 실제 소스를 사용한 JSDOM/상태 시나리오 검증 통과입니다. 별도 실제 브라우저 화면 검수는 이 파일 검증 범위에 포함하지 않았습니다.

아래 부분 예제는 핵심 변경을 설명합니다. 실행에 필요한 전체 파일과 보조 타입·API는 위 완료본에서 확인하세요.

누적 실습은 이 절의 프로젝트 단계 링크를 따라 진행합니다. 페이지 끝 학습 순서는 기술 주제 읽기 순서입니다.

이전 프로젝트 단계: TanStack Query HydrationBoundary 사용법: 서버 데이터와 클라이언트 캐시 연결. 위 시작본 ZIP 전체를 내려받으면 이전 글을 다시 조립하지 않고 이 단계부터 실행할 수 있습니다.

완료본은 app/lib/query-client.ts와 app/providers.tsx를 사용합니다. 아래 app/get-query-client.ts와 ./get-query-client import는 helper의 역할을 설명하는 별도 부분 예제입니다. 실제 적용은 위 완료본 전체 파일의 경로와 import를 함께 따라 서버는 요청별 생성, 브라우저는 안정적 재사용이라는 경계를 지키세요.

이전 체크포인트

query-step-13-hydration

이번 목표

TaskFlow SSR/Hydration 구조를 최소 구조로 다시 점검합니다.

QueryClient 생성 도우미: 개념과 완료본 경로

import {
  isServer,
  QueryClient,
} from '@tanstack/react-query';


function makeQueryClient() {
  return new QueryClient({
    defaultOptions: {
      queries: {
        staleTime: 60_000,
      },
    },
  });
}


let browserQueryClient:
  QueryClient | undefined;


export function getQueryClient() {
  if (isServer) {
    return makeQueryClient();
  }


  if (!browserQueryClient) {
    browserQueryClient =
      makeQueryClient();
  }


  return browserQueryClient;
}
전체 파일·코드 보기

app/providers.tsx

'use client';


import {
  QueryClientProvider,
} from '@tanstack/react-query';
import {
  getQueryClient,
} from './get-query-client';


export default function Providers({
  children,
}: {
  children: React.ReactNode;
}) {
  const queryClient =
    getQueryClient();


  return (
    <QueryClientProvider
      client={queryClient}
    >
      {children}
    </QueryClientProvider>
  );
}

/tasks 서버 쪽

const queryClient =
  getQueryClient();


await queryClient.query({
  queryKey:
    taskKeys.list('all'),
  queryFn:
    getTasksForServer,
});

서버 호출에서는 getQueryClient가 매번 새 QueryClient를 반환하고 브라우저에서는 안정된 인스턴스를 재사용합니다.

테스트

  • 서버 queryKey와 Client queryKey 문자열/객체를 비교합니다.
  • HydrationBoundary 범위를 확인합니다.
  • 새로고침과 클라이언트 이동을 각각 테스트합니다.
  • Network 요청이 왜 발생했는지 staleTime과 함께 확인합니다.
  • 로그인 사용자별 데이터라면 요청 간 cache가 섞이지 않는지 확인합니다.
npm run build

완료 체크포인트

query-step-14-hydration-fix

다음 학습

마지막 글에서는 Next.js 16의 서버 캐시 모델과 TanStack Query의 Query cache를 같은 흐름에서 비교하고, 어떤 계층이 어떤 데이터를 소유해야 하는지 최종 기준을 정합니다.

다음 프로젝트 단계: Next.js 캐시와 TanStack Query 캐시 차이: 서버·클라이언트 경계 설계

공식 문서

프로젝트 적용 후 확인: QueryClient를 다시 만들면 어떤 증상이 날까요?

관찰 절차 /tasks 목록을 로드한 뒤 Client Component 재렌더를 유발해 Devtools의 캐시가 유지되는지 관찰합니다. QueryClient를 렌더마다 새로 만드는 잘못된 예제와 안정적으로 보존한 예제의 Network GET 횟수를 비교합니다.

예상 결과 안정된 브라우저 QueryClient에서는 재렌더만으로 캐시가 초기화되지 않습니다. 서버 요청별 QueryClient는 서로 분리됩니다.

오류 점검 No QueryClient set이면 Provider 위치를, 반복 GET이면 인스턴스 생성 위치와 staleTime을, 서버 데이터 누출 우려가 있으면 요청 간 공유 여부를 확인합니다.

다음 학습 15회차에서 Next.js 서버 캐시와 브라우저 Query 캐시의 책임을 정리합니다.

기존 검증 기록 원문에는 전체 체크포인트의 빌드·상태 동작이 로컬 검증을 통과한 것으로 기록되어 있습니다. 실제 브라우저 시각 확인은 별도로 진행해야 합니다.

글 소개·학습 준비·전체 목차
TanStack Query Hydration 오류 해결: QueryClient 설정 기준

TanStack Query · 14 / 15 · 오류 해결 · Hydration

TanStack Query SSR을 적용한 뒤 “서버에서 분명 데이터를 가져왔는데 클라이언트가 다시 처음부터 요청한다”, “HydrationBoundary를 넣었는데 cache를 못 찾는다” 같은 문제가 생길 수 있습니다.

대부분은 QueryClient 생명주기, queryKey, Provider 위치, staleTime을 순서대로 확인하면 원인을 찾을 수 있습니다.

이번 글에서 배우는 내용

  • 서버 QueryClient를 요청 간 공유하면 안 되는 이유
  • 브라우저 QueryClient는 안정적으로 유지해야 하는 이유
  • queryKey 불일치
  • QueryClientProvider 위치
  • HydrationBoundary 범위
  • staleTime 0으로 인한 즉시 refetch
  • 최소 재현 진단 순서

목차

이 글이 도움이 되었나요?

조회 중

읽음 기록

읽음 — / 전체 15개 · 필수 14개

이 글을 쓴 해비

웹사이트를 제작·운영하고, 개인 서비스와 자동화·AI 도구를 만듭니다. 직접 만들며 배운 내용과 문제 해결 과정을 BlogFlow에 기록합니다.

새 글 받아보기

RSS 리더에서 BlogFlow의 새 글을 확인할 수 있습니다.

RSS 피드 구독하기

댓글 남기기