먼저 읽기: TanStack Query staleTime gcTime 차이: 캐시 시간 기준 · TanStack Query queryKey 설계 기준: 배열 키로 캐시 구분하기 · TanStack Query Provider와 첫 useQuery 상태 처리 실습 가이드
이 글에서 정리하는 내용
TanStack Query Hydration 오류는 서버에서 prefetch한 스냅샷이 클라이언트 QueryClient에 올바르게 복원되지 않을 때 자주 생깁니다. TanStack Query v5 공식 기준으로 QueryClientProvider, 서버 prefetch, dehydrate, HydrationBoundary, queryKey 일치, staleTime 설정을 한 흐름으로 확인해야 합니다.
HydrationBoundary 문제를 고친 뒤에도 데이터가 갱신되지 않는다면 TanStack Query 화면 갱신 문제 해결에서 query invalidation과 캐시 갱신 흐름을 이어서 점검하세요.
React HTML hydration과 Query 캐시 hydration은 다른 문제
React hydration은 서버 HTML에 브라우저의 React 트리를 연결하는 과정이고, Query hydration은 별도 QueryClient에 서버 데이터 스냅샷을 복원하는 과정입니다. 두 환경이 같은 메모리 인스턴스를 공유하는 것이 아닙니다. 잘못된 중첩 태그, 렌더 중 Date.now·난수·locale 차이, 서버에서 없는 window 값도 HTML 불일치를 만들 수 있습니다. 캐시 키가 틀린 경우에는 데이터 재요청만 나타나고 React hydration 경고는 없을 수도 있습니다.
요청마다 만드는 서버 QueryClient를 모듈 전역 singleton으로 바꾸면 사용자별 캐시가 요청 사이에 섞일 수 있습니다. 브라우저 client는 앱 생명주기 동안 재사용하되 로그인 사용자 전환 시 민감한 캐시를 정리하는 정책이 필요합니다. Client Component라는 이름도 브라우저에서만 실행된다는 뜻은 아닙니다. 최초 서버 렌더에도 참여할 수 있습니다.
staleTime은 hydration 완료 시각부터 새로 세지 않습니다. 서버의 dataUpdatedAt을 유지하므로 prefetch 후 오래 지났다면 브라우저에서 곧바로 refetch하는 것이 정상입니다. 짧은 staleTime을 무조건 늘리기 전에 HTML/CDN 캐시의 나이, 서버 시계, 현재 key와 옵션을 확인합니다. 이미 더 최신인 브라우저 캐시가 있으면 오래된 dehydrated 데이터로 덮어쓰지 않는지도 진단합니다.
기본 dehydrate는 성공한 query를 포함합니다. prefetchQuery는 오류를 호출자에게 throw하지 않으므로 try/catch만 붙여 “서버 조회 성공”을 판단하지 않습니다. 페이지 자체의 실패 정책이 필요하면 fetchQuery를 await하고 오류를 처리하거나 getQueryState로 실패 상태를 검사합니다. pending query 전달은 별도의 streaming 구성과 shouldDehydrateQuery 정책이 필요한 심화 범위입니다. staleTime을 조정한다고 pending Promise의 rejection이 해결되지는 않습니다.
dehydrate 결과는 곧 JSON 문자열이라는 뜻이 아닙니다. Date·Error·undefined 등 변환이 필요한 값은 프레임워크 직렬화 정책을 따라야 합니다. 인증 토큰이나 서버 전용 비밀 값을 캐시에 넣어 클라이언트로 보내지 않습니다. 사용자 데이터가 든 JSON을 HTML script에 수동 삽입하지 말고 프레임워크의 안전한 직렬화 경로를 사용하세요. 이 예제는 문자열·배열·객체만 가진 fixture를 JSON 왕복으로 검사합니다.
내 증상이 이거면 여기부터 보세요

Next.js에서 TanStack Query를 붙이면 서버에서 데이터를 미리 가져오고 클라이언트에서 이어받는 구조가 됩니다. 이때 QueryClient를 매 렌더마다 새로 만들거나 queryKey가 조금만 달라져도 서버 캐시가 클라이언트에서 이어지지 않습니다.
| 증상 | 실제 에러 메시지 | 먼저 볼 위치 | 바로 해볼 조치 | 이동할 섹션 |
|---|---|---|---|---|
| Provider 오류 | No QueryClient set |
QueryClientProvider | 앱 루트 Provider 확인 | 핵심 수정 코드 |
| 서버/클라이언트 내용 불일치 | Hydration failed |
HydrationBoundary | dehydrate state 전달 확인 | 핵심 수정 코드 |
| prefetch는 했는데 다시 요청 | query refetches |
queryKey | 서버/클라이언트 queryKey 일치 | 왜 생기는가 |
| pending query가 깨짐 | dehydrated as pending |
비동기 에러 처리 | pending 전달 정책과 서버 오류 확인 | 예외 케이스 |
Hydration 오류는 콘솔 문구만 복사하기보다 서버와 클라이언트의 queryKey, dehydrate 결과 전달 여부, QueryClientProvider 위치를 함께 기록해야 합니다. 이 정보가 있어야 React HTML 불일치와 TanStack Query 캐시 연결 실패를 구분할 수 있습니다.
Hydration failed because the initial UI does not match what was rendered on the server.
No QueryClient set, use QueryClientProvider to set one
A query that was dehydrated as pending ended up rejecting
Text content does not match server-rendered HTML
먼저 적용할 핵심 수정 코드
다음 Next.js App Router 코드는 적용 구조를 설명하는 참고용입니다. 아래 ZIP은 Next.js 앱이 아니라 캐시 전달 실험이며 실제 Next.js 서버 실행을 검증하지 않았습니다. PostList는 같은 [“posts”] key의 useQuery를 사용하는 클라이언트 컴포넌트여야 하며, Provider는 app/layout의 children을 감쌉니다.
원인 설명을 오래 읽기 전에 아래 설정부터 현재 코드와 대조해보세요. Provider에서 QueryClient를 안정적으로 만들고, 서버 컴포넌트에서 prefetchQuery 후 dehydrate 결과를 HydrationBoundary에 넘깁니다. 중요한 것은 오류를 덮는 옵션을 추가하는 것이 아니라, 실행 환경과 설정 파일이 같은 기준으로 동작하게 만드는 것입니다.
클라이언트 Provider 설정
"use client"
import { QueryClient, QueryClientProvider, isServer } from "@tanstack/react-query"
function makeQueryClient() {
return new QueryClient({
defaultOptions: {
queries: {
staleTime: 60 * 1000,
},
},
})
}
let browserQueryClient: QueryClient | undefined
function getQueryClient() {
if (isServer) return makeQueryClient()
if (!browserQueryClient) browserQueryClient = makeQueryClient()
return browserQueryClient
}
export function QueryProvider({ children }: { children: React.ReactNode }) {
const queryClient = getQueryClient()
return (
<QueryClientProvider client={queryClient}>
{children}
</QueryClientProvider>
)
}
클라이언트에서 QueryClient가 렌더마다 새로 만들어지지 않도록 브라우저용 QueryClient를 재사용합니다. TanStack Query의 Next.js App Router 예시는 서버에서는 요청마다 새 QueryClient를 만들고, 브라우저에서는 이미 만든 QueryClient를 재사용하는 패턴을 제시합니다.
서버 prefetch와 HydrationBoundary
import { HydrationBoundary, QueryClient, dehydrate } from "@tanstack/react-query"
async function getPosts() {
return [{ id: 1, title: "서버 fixture 문서" }]
}
export default async function Page() {
const queryClient = new QueryClient()
await queryClient.prefetchQuery({
queryKey: ["posts"],
queryFn: getPosts,
})
return (
<HydrationBoundary state={dehydrate(queryClient)}>
<PostList />
</HydrationBoundary>
)
}
서버에서 prefetch한 queryKey와 클라이언트 useQuery의 queryKey가 같아야 캐시가 이어집니다.
왜 이런 오류가 생기는가
Hydration은 서버에서 만든 HTML과 클라이언트가 처음 그린 화면을 맞추는 과정입니다. TanStack Query 데이터가 서버와 클라이언트에서 다르게 들어오면 React는 같은 화면이라고 판단하지 못합니다.
QueryClient는 캐시 저장소입니다. 클라이언트 컴포넌트가 렌더될 때마다 QueryClient를 새로 만들면 방금 받은 캐시를 스스로 버리는 구조가 됩니다.
HydrationBoundary는 서버에서 dehydrate한 캐시를 클라이언트 영역으로 넘기는 경계입니다. 공식 문서 기준으로 HydrationBoundary는 useQueryClient가 반환하는 QueryClient에 이전에 dehydrated 된 state를 병합합니다. Provider만 있다고 자동으로 서버 prefetch 결과가 연결되는 것은 아닙니다.
실제 작업에서 점검하는 순서
먼저 오류를 No QueryClient set, Hydration failed, 불필요한 재요청 중 하나로 나눕니다. Provider 누락은 클라이언트 트리, hydration 실패는 HTML 일치와 서버 상태 전달, 재요청은 queryKey와 staleTime 순서로 확인합니다.
두 번째로 한 번에 여러 설정을 바꾸지 않습니다. TanStack Query Hydration 오류를 해결하다 보면 관련 파일을 전부 고치고 싶어지지만, 그러면 어떤 변경이 실제 해결책이었는지 알기 어렵습니다. 핵심 설정 하나를 바꾸고 검증 명령을 실행한 뒤 다음 설정으로 넘어가야 합니다.
저장소에는 QueryClient 생성 함수, 앱 루트 Provider, 서버의 prefetchQuery와 HydrationBoundary를 한 흐름으로 남깁니다. 서버와 브라우저가 같은 queryKey 팩토리를 사용하면 팀원이 기능을 추가해도 캐시 키가 어긋날 가능성이 줄어듭니다.
그래도 안 될 때 볼 예외 케이스
수정 뒤에도 hydration 경고가 남으면 브라우저 캐시보다 QueryClient 생성 시점부터 확인합니다. 클라이언트 렌더마다 새 인스턴스가 생기지 않는지, pending query를 전달하도록 별도 설정했는지와 그 Promise가 왜 거부됐는지, React 개발 모드의 이중 실행에서 값이 달라지지 않는지 차례로 봅니다.
다음에 같은 문제를 줄이는 체크리스트

TanStack Query Hydration 문제는 Provider, QueryClient, HydrationBoundary, queryKey가 한 세트로 맞아야 줄어듭니다. 한 군데만 고치면 해결된 것처럼 보이다가 다른 페이지에서 같은 문제가 반복될 수 있습니다.
결국 TanStack Query Hydration 오류는 한 줄짜리 우회 코드보다 확인 순서가 중요합니다. 에러 문구를 단계별로 나누고, 설정 파일과 실행 명령을 같은 기준으로 맞추면 같은 문제를 훨씬 짧게 끝낼 수 있습니다. 공식 기준은 Advanced Server Rendering, Server Rendering & Hydration, Hydration API, QueryClientProvider 문서에서 확인했습니다.
과정 마무리 실습
목록을 조회하고 항목 변경 후 관련 queryKey의 데이터를 갱신하세요.
완료 기준: 로딩·오류·빈 목록·성공 및 변경 후 재조회 결과를 각각 확인합니다.
이어서 공부할 과정: 테스트 첫 글 · Next.js 첫 글
서버 요청별 캐시를 만들고 스냅샷 복원을 검증하는 실험
npm test로 요청 격리와 전달 계약을 먼저 검사합니다. 선택적으로 npm run dev 후 A/B 요청 준비 버튼을 누르면 해당 snapshot key와 문서 제목이 보입니다. 버튼은 서버 HTTP 요청이 아닌 브라우저 내부 prepareRequest 호출입니다.
Node.js 22.12 이상(또는 20.19 이상)에서 압축을 푼 폴더로 이동합니다. 외부 API 키는 필요 없습니다. fixture 값은 새로고침·프로세스 종료 후 보존되지 않습니다.
npm install
npm test
npm run build
npm run dev
HTML 진입점·CSS·JavaScript 파일을 분리했습니다. 본문의 기존 API 코드가 설명하는 실제 서버와 ZIP의 메모리 fixture는 다른 실행 범위입니다.
App.jsx
실습 파일
파일 경로를 확인하고 같은 프로젝트 안에 저장하세요. 이미지 등 소스 목록에 없는 파일과 실행 안내는 실습 ZIP에 포함되어 있습니다.
전체 코드
App.jsx
import React, { useState } from 'react';
import {
QueryClientProvider,
HydrationBoundary,
useQuery,
} from '@tanstack/react-query';
import { makeClient, prepareRequest, profileOptions } from './hydration.js';
const browserClient = makeClient();
function Profile({ user }) {
const query = useQuery(profileOptions(user));
return <p>{query.data?.title ?? '조회 중'}</p>;
}
export default function App() {
const [state, setState] = useState(null);
const [user, setUser] = useState('A');
async function run(nextUser) {
setState(await prepareRequest(nextUser));
setUser(nextUser);
}
return (
<QueryClientProvider client={browserClient}>
<main>
<h1>캐시 전달 실험</h1>
<p>
이 화면은 브라우저에서 서버 단계를 모사합니다. 실제 SSR HTML은 생성하지
않습니다.
</p>
<button onClick={() => run('A')}>요청 A 준비</button>
<button onClick={() => run('B')}>요청 B 준비</button>
{state && (
<HydrationBoundary state={state}>
<Profile user={user} />
<pre>
{JSON.stringify(
state.queries.map((q) => q.queryKey),
null,
2,
)}
</pre>
</HydrationBoundary>
)}
</main>
</QueryClientProvider>
);
}
hydration.js
import { QueryClient, dehydrate, hydrate } from '@tanstack/react-query';
export function makeClient() {
return new QueryClient({
defaultOptions: { queries: { staleTime: 60000, gcTime: Infinity, retry: false } },
});
}
export function profileOptions(user) {
return {
queryKey: ['profile', user],
queryFn: async () => ({ user, title: `${user}의 문서` }),
};
}
export async function prepareRequest(user) {
const client = makeClient();
try {
await client.prefetchQuery(profileOptions(user));
return dehydrate(client);
} finally {
client.clear();
}
}
export function restore(state) {
const client = makeClient();
hydrate(client, state);
return client;
}
hydration.test.js
import { expect, test, vi } from 'vitest';
import { dehydrate, hydrate } from '@tanstack/react-query';
import { makeClient, prepareRequest, restore, profileOptions } from './hydration.js';
test('concurrent request clients expose only their own user', async () => {
const [a, b] = await Promise.all([prepareRequest('A'), prepareRequest('B')]);
expect(a.queries.map((q) => q.queryKey)).toEqual([['profile', 'A']]);
expect(b.queries.map((q) => q.queryKey)).toEqual([['profile', 'B']]);
const client = restore(a);
expect(client.getQueryData(['profile', 'B'])).toBeUndefined();
client.clear();
});
test('JSON roundtrip keeps fresh data; a wrong key triggers another query', async () => {
const state = JSON.parse(JSON.stringify(await prepareRequest('A')));
const client = restore(state);
const queryFn = vi.fn(async () => ({ title: 'read again' }));
await client.fetchQuery({ ...profileOptions('A'), queryFn });
expect(queryFn).not.toHaveBeenCalled();
await client.fetchQuery({ queryKey: ['profile', 1], queryFn });
expect(queryFn).toHaveBeenCalledTimes(1);
client.clear();
});
test('freshness starts at server dataUpdatedAt, not hydration time', async () => {
const state = await prepareRequest('A');
state.queries[0].state.dataUpdatedAt = Date.now() - 61000;
const client = restore(state);
const queryFn = vi.fn(async () => ({ title: 'fresh' }));
await client.fetchQuery({ ...profileOptions('A'), queryFn });
expect(queryFn).toHaveBeenCalledOnce();
client.clear();
});
test('failed prefetch does not throw and default dehydration excludes it', async () => {
const client = makeClient();
await expect(
client.prefetchQuery({
queryKey: ['broken'],
queryFn: async () => {
throw Error('fail');
},
}),
).resolves.toBeUndefined();
expect(dehydrate(client).queries).toHaveLength(0);
expect(client.getQueryState(['broken']).status).toBe('error');
client.clear();
});
test('newer existing cache survives older hydrated snapshot', async () => {
const state = await prepareRequest('A');
const client = makeClient();
client.setQueryData(
['profile', 'A'],
{ title: 'newer' },
{ updatedAt: state.queries[0].state.dataUpdatedAt + 1000 },
);
hydrate(client, state);
expect(client.getQueryData(['profile', 'A']).title).toBe('newer');
client.clear();
});
index.html
<!doctype html>
<html lang="ko">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Query 실습</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/main.jsx"></script>
</body>
</html>
main.jsx
import React from 'react';
import { createRoot } from 'react-dom/client';
import App from './App.jsx';
import './style.css';
createRoot(document.getElementById('root')).render(<App />);
package.json
{
"name": "query-lab-4573",
"private": true,
"version": "1.0.0",
"type": "module",
"scripts": {
"dev": "vite",
"build": "vite build",
"test": "vitest run"
},
"dependencies": {
"@tanstack/react-query": "5.102.8",
"react": "19.3.0",
"react-dom": "19.3.0"
},
"devDependencies": {
"vite": "8.3.0",
"vitest": "4.1.11",
"jsdom": "30.0.1",
"@testing-library/react": "16.3.3"
}
}
style.css
body {
margin: 0;
color: #222;
background: #fff;
font-family: system-ui, sans-serif;
}
main {
max-width: 760px;
margin: 40px auto;
padding: 24px;
}
button,
select {
padding: 10px;
margin: 6px;
border: 1px solid #888;
background: #f5f5f5;
color: #222;
}
button:disabled {
color: #777;
}
li {
padding: 8px;
border-bottom: 1px solid #ddd;
}
pre {
padding: 16px;
background: #eee;
overflow: auto;
}
hydration.js
hydration.test.js
index.html
main.jsx
package.json
style.css
검증 결과와 이 실습의 경계
Vitest Node 환경 5개 테스트: 동시 요청 A/B 캐시 격리, JSON 전달 후 fresh 데이터 재사용과 잘못된 key 요청, 서버 갱신 시각 기준 freshness, 실패 prefetch의 기본 제외, 더 최신 캐시 보존. Vite production build도 통과했습니다.
실제 브라우저나 HTTP API를 실행한 결과는 아닙니다. SSR 글의 Node 검증은 캐시 격리·dehydrate/hydrate 계약이며 실제 Next.js 서버 HTML·streaming·브라우저 hydration 일치를 검증하지 않습니다.
v5 공식 문서 확인
2026-09-12 공식 문서와 설치된 @tanstack/react-query 5.102.8의 실행 결과를 대조했습니다. 문서의 latest 예시는 이후 바뀔 수 있으므로 ZIP의 고정 버전과 함께 확인하세요.
이 글이 도움이 되었나요?
TanStack Query 학습 순서
필수 11개 · 전체 12개
읽음 기록 관리
전체 과정 목차 (12개)
- 필수 길잡이 · TanStack Query vs Zustand: 서버 상태와 클라이언트 상태 차이
- 필수 학습 · TanStack Query Provider와 첫 useQuery 상태 처리 실습 가이드
- 필수 학습 · TanStack Query queryKey 설계 기준: 배열 키로 캐시 구분하기
- 필수 학습 · TanStack Query queryFn 사용법: 데이터 요청 로직 분리하기
- 필수 학습 · TanStack Query staleTime gcTime 차이: 캐시 시간 기준
- 필수 학습 · TanStack Query useMutation 저장 실습: 실패·재시도와 목록 갱신까지
- 선택 참고 · TanStack Query 화면 갱신 문제 해결: 데이터 변경 후 UI가 바뀌지 않을 때
- 필수 학습 · TanStack Query 페이지네이션 실습: placeholderData로 이전 목록 유지하기
- 필수 학습 · TanStack Query 무한 스크롤 사용법: useInfiniteQuery로 목록 이어 불러오기
- 필수 선수 · TanStack Query Hydration 오류 해결: QueryClient 설정 기준 현재 글
- 필수 학습 · TanStack Query Todo 실습: 서버 연결·낙관적 업데이트·실패 롤백
- 필수 학습 · TanStack Query 수동 캐시 정규화: 목록·상세 동기화와 선택 기준
새 글 받아보기
RSS 리더에서 BlogFlow의 새 글을 확인할 수 있습니다.