TanStack Query HydrationBoundary 사용법: 서버 데이터와 클라이언트 캐시 연결

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

HydrationBoundary로 서버에서 준비한 Query 데이터를 브라우저 캐시에 어떻게 전달하나요?

HydrationBoundary는 미리 준비한 Query 데이터를 브라우저가 이어받도록 연결합니다. 준비용 QueryClient의 상태를 dehydrate로 꺼내 전달하고, 받는 쪽은 같은 queryKey로 읽습니다.

적용 조건·범위: 실행 환경은 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 서버는 필요하지 않습니다. 요청 함수는 이 파일 안에 정의된 비동기 모형입니다.

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

이 기술만 따로 연습하기

기대 결과: 준비된 제목 / 후속 요청 0이 즉시 표시됩니다. 준비용 QueryClient를 clear해도 전달된 snapshot과 소비하는 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 {
  QueryClient,
  QueryClientProvider,
  useQuery,
  HydrationBoundary,
  dehydrate
} from '@tanstack/react-query';

const client = new QueryClient({
  defaultOptions: { queries: { retry: false, refetchOnWindowFocus: false } },
});

// 서버가 보낸 것과 같은 직렬화 가능한 캐시 상태를 별도로 만듭니다.
const prepared = new QueryClient();
prepared.setQueryData(['hydrated-title'], { title: '준비된 제목' });
const transferred = JSON.parse(JSON.stringify(dehydrate(prepared)));
prepared.clear();
let requests = 0;
function Reader() {
  const query = useQuery({
    queryKey: ['hydrated-title'],
    queryFn: async () => { requests += 1; return { title: '다시 조회한 제목' }; },
    staleTime: 60_000,
  });
  return <p>{query.data?.title ?? '초기 대기'} / 후속 요청 {requests}</p>;
}
function Demo() {
  return (
    <HydrationBoundary state={transferred}>
      <Reader />
    </HydrationBoundary>
  );
}

export default function App() {
  return (
    <QueryClientProvider client={client}>
      <Demo />
    </QueryClientProvider>
  );
}
npm run dev
npm run build

자가 확인·변형: useQuery 키를 다른 값으로 바꾸면 준비된 데이터를 못 찾고 다시 조회합니다. 이 코드는 브라우저 안에서 전달 경로만 분리한 모델입니다. 실제 Next.js HTML SSR과 요청별 격리는 하단 예제로 추가 확인합니다.

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

전체 흐름

Server Component
→ QueryClient 생성
→ queryClient.query()
→ 서버 cache 준비
→ dehydrate(queryClient)
→ HydrationBoundary
→ Client Component
→ useQuery
→ 브라우저 QueryClient cache

서버와 브라우저가 같은 QueryClient 인스턴스를 공유하는 것은 아닙니다.

dehydrated state를 전달해 같은 데이터에서 시작하도록 연결합니다.

dehydrate

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


const state =
  dehydrate(queryClient);

dehydrate는 QueryClient의 cache 상태를 전달 가능한 형태로 만듭니다.

기본적인 SSR 예제에서는 성공한 query를 서버에서 준비한 뒤 dehydrate합니다.

HydrationBoundary

import {
  HydrationBoundary,
  dehydrate,
} from '@tanstack/react-query';


return (
  <HydrationBoundary
    state={dehydrate(queryClient)}
  >
    <TaskListClient />
  </HydrationBoundary>
);

HydrationBoundary 아래의 Client Component가 같은 queryKey를 사용하면 서버에서 준비한 데이터를 브라우저 cache에 이어 받을 수 있습니다.

클라이언트 useQuery

'use client';


import {
  useQuery,
} from '@tanstack/react-query';
import {
  getTasks,
} from '../lib/tasks-api';
import {
  taskKeys,
} from '../lib/task-keys';


export default function TaskListClient() {
  const query = useQuery({
    queryKey:
      taskKeys.list('all'),
    queryFn: ({ signal }) =>
      getTasks(signal),
    staleTime: 60_000,
  });


  if (query.isPending) {
    return <p>불러오는 중입니다.</p>;
  }


  return (
    <ul>
      {query.data.map((task) => (
        <li key={task.id}>
          {task.title}
        </li>
      ))}
    </ul>
  );
}

서버와 클라이언트 queryKey가 정확히 같아야 합니다.

QueryClientProvider도 필요합니다

HydrationBoundary는 브라우저 QueryClient에 상태를 hydrate합니다.

따라서 앱 상위에는 QueryClientProvider가 있어야 합니다.

QueryClientProvider
└─ Server route output
   └─ HydrationBoundary
      └─ TaskListClient

아래 독립 예제는 QueryClientProvider와 소비할 QueryClient까지 함께 제공합니다. Next.js 앱에 적용할 때에는 상위 Client Provider 안에 HydrationBoundary가 들어가는 같은 관계를 유지합니다.

서버 page 전체 예제

import {
  dehydrate,
  HydrationBoundary,
  QueryClient,
} from '@tanstack/react-query';
import {
  taskKeys,
} from '../lib/task-keys';
import {
  getTasksForServer,
} from '../server/tasks';
import TaskListClient
  from './TaskListClient';


export default async function TasksPage() {
  const queryClient =
    new QueryClient({
      defaultOptions: {
        queries: {
          staleTime: 60_000,
        },
      },
    });


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


  return (
    <main>
      <h1>TaskFlow</h1>


      <HydrationBoundary
        state={
          dehydrate(
            queryClient,
          )
        }
      >
        <TaskListClient />
      </HydrationBoundary>
    </main>
  );
}

왜 staleTime을 함께 확인하는가

서버에서 방금 받은 데이터라도 클라이언트 query의 staleTime이 0이면 hydrate 직후 stale로 판단해 백그라운드 요청이 발생할 수 있습니다.

이것은 hydration 실패가 아니라 재검증 정책일 수 있습니다.

staleTime: 60_000

SSR로 전달한 데이터를 일정 시간 fresh로 보고 싶다면 서버와 클라이언트 query 정책을 맞춥니다.

initialData와 Hydration 차이

간단한 화면에서는 Server Component 데이터 값을 props로 넘기고 useQuery initialData를 사용할 수도 있습니다.

Hydration 방식은 query cache 자체를 서버에서 준비하므로 여러 query를 일관된 queryKey 체계로 연결할 때 유리합니다.

직렬화 가능한 데이터

서버에서 브라우저로 전달할 query data는 전송 가능한 형태인지 확인합니다.

복잡한 class instance나 특수 타입을 그대로 전달하는 구조라면 serialize/deserialize 정책이 필요할 수 있습니다.

TaskFlow의 Task처럼 일반 객체·문자열·boolean 위주라면 단순합니다.

학습 완료 체크리스트

  • dehydrate가 서버 Query cache를 전달 가능한 상태로 만든다는 것을 이해했습니다.
  • HydrationBoundary로 Client Component를 감쌀 수 있습니다.
  • 서버와 브라우저 QueryClient가 별도 인스턴스임을 이해했습니다.
  • 서버/클라이언트 queryKey를 일치시킬 수 있습니다.
  • hydrate 직후 refetch와 hydration 실패를 구분할 수 있습니다.

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

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

/tasks의 서버 query cache를 브라우저 TaskListClient로 전달합니다.

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

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

TaskFlow 프로젝트 적용

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

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

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

시작본에서 직접 바꾸기

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

  1. app/(app)/tasks/page.tsx — 기존 파일의 같은 위치를 갱신합니다.

변경 이유와 결과: dehydrate와 HydrationBoundary로 서버 목록 snapshot을 같은 목록 키에 전달합니다. fresh snapshot이면 첫 클라이언트 요청을 재사용합니다.

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

app/(app)/tasks/page.tsx 전체 파일

export const dynamic = 'force-dynamic';
import { QueryClient, dehydrate, HydrationBoundary } from '@tanstack/react-query';
import { listTasks } from '@/app/lib/store';
import { taskKeys } from '@/app/lib/task-keys';
import TaskDashboard from '@/app/tasks/TaskDashboard';
export default async function TasksPage() {
  const client = new QueryClient();
  await client.prefetchQuery({ queryKey: taskKeys.list('all'), queryFn: async () => listTasks('demo'), staleTime: 30_000 });
  return <HydrationBoundary state={dehydrate(client)}><TaskDashboard /></HydrationBoundary>;
}

완료 확인

  1. 시작본은 직전 완료본과 동일한 ZIP입니다. 압축을 푼 뒤 npm ci로 잠금 파일의 의존성을 설치합니다.
  2. npm run dev 실행 후 http://localhost:3000/tasks를 엽니다.
  3. dehydrate와 HydrationBoundary로 서버 목록 snapshot을 같은 목록 키에 전달합니다. fresh snapshot이면 첫 클라이언트 요청을 재사용합니다.
  4. npm run typecheck와 npm run build가 성공하면 다음 프로젝트 단계로 갑니다.

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

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

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

이전 프로젝트 단계: TanStack Query SSR prefetchQuery: Server Component에서 초기 데이터 준비하기. 위 시작본 ZIP 전체를 내려받으면 이전 글을 다시 조립하지 않고 이 단계부터 실행할 수 있습니다.

서버 page에서 dehydrate한 상태를 HydrationBoundary로 전달하고 TaskListClient의 키와 반환 shape를 맞춥니다. Provider도 함께 유지합니다.

이전 체크포인트

query-step-12-ssr-prefetch

이번 목표

/tasks의 서버 query cache를 브라우저 TaskListClient로 전달합니다.

변경 파일

app/tasks/page.tsx
app/tasks/TaskListClient.tsx
app/providers.tsx

Provider는 기존 구조를 유지하고 page.tsx에 HydrationBoundary를 추가합니다.

Before

서버 QueryClient에 데이터는 있지만
Client useQuery는 별도 cache로 시작

After

Server query()
→ dehydrate
→ HydrationBoundary
→ Client useQuery
→ 동일 queryKey cache 재사용

실행 확인

  1. /tasks를 새로고침합니다.
  2. 서버 렌더 결과가 바로 표시되는지 확인합니다.
  3. Client Component가 별도 초기 pending 화면으로 크게 깜빡이지 않는지 확인합니다.
  4. React Query Devtools에서 [‘tasks’, ‘list’, …] cache를 확인합니다.
  5. staleTime에 따른 후속 refetch를 구분합니다.
npm run build

현재 확장 단계

✓ 1~12. Query·mutation·서버 prefetch
→ 13. HydrationBoundary
○ 14. Hydration 오류 진단
○ 15. Next.js cache 경계

완료 체크포인트

query-step-13-hydration

다음 학습

다음 글에서는 QueryClient 생성 위치, queryKey 불일치, Provider와 HydrationBoundary 위치 때문에 hydration이 깨지거나 중복 요청처럼 보이는 문제를 진단합니다.

다음 프로젝트 단계: TanStack Query Hydration 오류 해결: QueryClient 설정 기준

공식 문서

프로젝트 적용 후 확인: hydration 뒤 같은 데이터를 다시 요청하나요?

관찰 절차 /tasks 페이지 소스의 초기 목록을 확인하고 Devtools에서 같은 queryKey의 캐시 데이터 및 dataUpdatedAt을 봅니다. staleTime 60초와 0을 각각 적용해 최초 브라우저 GET 차이를 Network로 비교합니다.

예상 결과 60초 fresh 설정이면 즉시 재요청이 줄어들 수 있고 0이면 hydration 이후 백그라운드 재검증이 발생할 수 있습니다.

오류 점검 초기 데이터가 비면 Provider와 HydrationBoundary 범위, 서버/클라이언트 queryKey 일치, 직렬화 가능한 응답인지 확인합니다.

다음 학습 14회차에서 QueryClient 생명주기와 hydration 오류를 점검합니다.

기존 체크포인트 검증 범위 전체 체크포인트의 빌드·상태 동작은 로컬 검증을 통과했습니다. 실제 브라우저 시각 확인은 별도로 진행해야 합니다.

글 소개·학습 준비·전체 목차
HydrationBoundary로 서버 데이터와 브라우저 캐시를 연결하는 과정을 표현한 표지

TanStack Query · 13 / 15 · 고급 학습 · HydrationBoundary

서버와 브라우저는 별도 QueryClient를 사용합니다. 준비한 상태를 dehydrate하고 HydrationBoundary로 전달한 뒤 같은 queryKey의 useQuery가 읽게 합니다. 아래 독립 모형에서 준비용 client를 비운 뒤에도 전달된 제목을 읽는지 확인합니다.

TanStack Query에서는 서버 QueryClient를 dehydrate한 뒤 HydrationBoundary에 전달해 클라이언트 useQuery가 같은 queryKey의 데이터를 즉시 사용할 수 있게 합니다.

이번 글에서 배우는 내용

  • dehydrate
  • HydrationBoundary
  • 서버 QueryClient와 브라우저 QueryClient의 차이
  • 동일 queryKey 재사용
  • 초기 HTML과 client cache 연결
  • staleTime과 즉시 refetch
  • Next.js App Router 구조

목차

이 글이 도움이 되었나요?

조회 중

읽음 기록

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

이 글을 쓴 해비

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

새 글 받아보기

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

RSS 피드 구독하기

댓글 남기기