TanStack Query SSR prefetchQuery: Server Component에서 초기 데이터 준비하기

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

서버에서 Query 데이터를 준비할 때 QueryClient를 요청마다 만들어야 하나요?

서버 Query 데이터는 요청마다 새 QueryClient에 준비해 사용자 간 캐시가 섞이지 않도록 합니다. 고정 버전의 Node 예제로 A·B 요청 분리를 확인하며, 이 준비 단계만으로 브라우저 hydration까지 수행되는 것은 아닙니다.

적용 조건·범위: Node.js 24와 @tanstack/react-query 5.104.0 기준입니다. queryClient.query가 추가되기 전의 이전 v5 패치에서는 이 API를 사용할 수 없습니다. 별도 연습 폴더의 server-cache.mjs 전체 코드이며 실제 데이터베이스나 Route Handler는 필요하지 않습니다.

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

이 기술만 따로 연습하기

Node.js 24와 @tanstack/react-query 5.104.0 기준입니다. queryClient.query가 추가되기 전의 이전 v5 패치에서는 이 API를 사용할 수 없습니다. 별도 연습 폴더의 server-cache.mjs 전체 코드이며 실제 데이터베이스나 Route Handler는 필요하지 않습니다.

npm install @tanstack/react-query@5.104.0
node server-cache.mjs
import {
  QueryClient
} from '@tanstack/react-query';

async function prepareRequest(user) {
  const client = new QueryClient();
  await client.query({
    queryKey: ['items'],
    queryFn: async () => [{ id: 1, title: `${user}의 작업` }],
    staleTime: 60_000,
  });
  const value = client.getQueryData(['items']);
  client.clear();
  return value;
}
console.log(await prepareRequest('A'));
console.log(await prepareRequest('B'));

기대 결과: A의 작업과 B의 작업이 각각 별도 결과로 출력됩니다. 같은 queryKey라도 요청마다 QueryClient가 달라 서로 값을 섞지 않습니다.

자가 확인·변형: 이 Node 예제는 서버 cache 준비만 검증합니다. HTML 렌더링이나 브라우저 hydration을 수행하지 않습니다. 서버 전역 client를 재사용하고 freshness 때문에 이전 값을 재사용하는 잘못된 설계와 비교해 보세요.

검증 기록: 위 Node 코드를 실행하여 A/B 결과가 각각 분리되는 것을 확인했습니다.

현재 API 변화

이전 v5 예제에서 자주 보이는 코드:

await queryClient.prefetchQuery({
  queryKey: ['tasks'],
  queryFn: getTasks,
});

현재 최신 문서 권장:

await queryClient.query({
  queryKey: ['tasks'],
  queryFn: getTasks,
});

prefetch 목적으로 결과값을 직접 사용하지 않아도 cache에는 결과가 저장됩니다.

오류를 페이지에 전파하지 않고 prefetch 실패만 무시하고 싶다면 프로젝트 정책에 따라 catch를 명시할 수 있습니다.

Server Component에서 QueryClient 생성

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


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


  // ...
}

서버에서 사용하는 QueryClient를 module 전역 singleton으로 만들어 모든 사용자 요청이 공유하게 하면 안 됩니다.

// 피하는 편이 좋은 서버 패턴
const queryClient =
  new QueryClient();

요청별 데이터가 같은 cache 객체에 섞일 수 있기 때문입니다.

서버 queryFn

Server Component에서는 자신의 Route Handler를 브라우저처럼 상대 URL fetch로 다시 호출하기보다, Route Handler가 사용하는 서버 데이터 계층을 직접 재사용하는 구조가 더 단순할 수 있습니다.

예를 들어 기존 Route Handler와 Server Component가 같은 서버 함수를 공유합니다.

// server/tasks.ts
import 'server-only';


export async function getTasksForServer():
  Promise<Task[]> {
  return taskRepository.list();
}

프로젝트의 실제 DB, Firebase, Supabase, ORM 구조에 맞게 서버 데이터 계층을 연결합니다.

queryClient.query 실행

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

이 시점에 서버 QueryClient cache에는 작업 목록이 들어 있습니다.

staleTime을 0보다 크게 두는 이유

서버에서 방금 가져온 데이터를 Hydration한 직후 클라이언트 useQuery가 곧바로 stale로 판단하면 브라우저에서 즉시 다시 요청할 수 있습니다.

SSR/Hydration 예제에서는 적절한 staleTime을 설정해 방금 준비한 데이터를 잠시 fresh로 보는 패턴이 일반적입니다.

new QueryClient({
  defaultOptions: {
    queries: {
      staleTime:
        60 * 1000,
    },
  },
});

정확한 시간은 데이터 특성에 따라 조정합니다.

서버와 클라이언트 queryKey 일치

서버:

queryKey:
  taskKeys.list('all')

클라이언트:

useQuery({
  queryKey:
    taskKeys.list('all'),
  queryFn: getTasks,
});

키가 다르면 서버에서 준비한 cache를 클라이언트 query가 재사용하지 못합니다.

아직 Hydration은 하지 않습니다

이번 회차의 목표는 서버 QueryClient에 데이터를 준비하는 것까지입니다.

다음 회차에서 다음 흐름을 완성합니다.

Server QueryClient
→ query()
→ cache 준비
→ dehydrate()
→ HydrationBoundary
→ Client QueryClient
→ useQuery

학습 완료 체크리스트

  • 최신 문서의 API 권고와 제공된 예제의 설치 버전 범위를 구분할 수 있습니다.
  • queryClient.query로 서버 cache를 준비할 수 있습니다.
  • 서버 QueryClient를 요청 간 전역 공유하면 안 되는 이유를 설명할 수 있습니다.
  • 서버와 클라이언트에서 같은 queryKey를 사용할 수 있습니다.
  • Hydration 전 단계의 역할을 이해했습니다.

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

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

TaskFlow /tasks Server Component에서 작업 목록을 서버 QueryClient에 준비합니다.

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

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

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 — 기존 파일의 같은 위치를 갱신합니다.

변경 이유와 결과: Server Component에서 요청마다 새 QueryClient를 만들고 공통 목록 키를 prefetch합니다. 이 단계는 아직 HydrationBoundary를 연결하지 않아 클라이언트 요청이 별도로 남는 중간 완료본입니다.

완료본의 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 <><p>서버 prefetch 완료: {client.getQueryState(taskKeys.list('all'))?.status}</p><TaskDashboard /></>;
}

완료 확인

  1. 시작본은 직전 완료본과 동일한 ZIP입니다. 압축을 푼 뒤 npm ci로 잠금 파일의 의존성을 설치합니다.
  2. npm run dev 실행 후 http://localhost:3000/tasks를 엽니다.
  3. Server Component에서 요청마다 새 QueryClient를 만들고 공통 목록 키를 prefetch합니다. 이 단계는 아직 HydrationBoundary를 연결하지 않아 클라이언트 요청이 별도로 남는 중간 완료본입니다.
  4. npm run typecheck와 npm run build가 성공하면 다음 프로젝트 단계로 갑니다.

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

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

연속 학습의 이전 글: TanStack Query 수동 캐시 정규화: 목록·상세 동기화와 선택 기준. 위 시작본 ZIP 전체를 내려받으면 이전 글을 다시 조립하지 않고 이 단계부터 실행할 수 있습니다.

app/tasks/page.tsx의 요청별 QueryClient에 서버 데이터 함수를 연결합니다. 실제 DB 함수는 아래 부분 코드의 프로젝트 의존성이며 독립 예제의 고정 배열과 바꾸어 이해하지 않습니다.

이전 체크포인트

query-step-11-normalization

이번 목표

TaskFlow /tasks Server Component에서 작업 목록을 서버 QueryClient에 준비합니다.

전체 파일·코드 보기

app/tasks/page.tsx

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


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>
      <p>
        서버 Query cache 준비 완료
      </p>
    </main>
  );
}

이번 단계에서는 cache를 브라우저에 아직 전달하지 않기 때문에 Client TaskList 연결은 다음 회차에서 완성합니다.

현재 확장 단계

✓ 1~11. 조회·mutation·cache 설계
→ 12. Server Component prefetch
○ 13. dehydrate / HydrationBoundary
○ 14. Hydration 오류 해결
○ 15. Next.js cache 경계

완료 체크포인트

query-step-12-ssr-prefetch

다음 학습

다음 글에서는 서버 QueryClient를 dehydrate하고 HydrationBoundary로 감싸 클라이언트 useQuery가 같은 cache 상태에서 시작하도록 연결합니다.

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

공식 문서

프로젝트 적용 후 확인: 서버 캐시 준비와 브라우저 전달을 구분했나요?

관찰 절차 /tasks를 새로 열고 제공된 app/(app)/tasks/page.tsx의 ‘서버 prefetch 완료’ 상태를 확인합니다. 서버는 listTasks를 직접 사용하므로 서버에서 GET /api/tasks가 반드시 발생한다고 가정하지 않습니다. 아직 전달되지 않은 Client 목록의 조회 요청은 브라우저 Network에서 따로 확인합니다.

예상 결과 서버 캐시 준비가 성공하면 완료 상태가 success로 표시됩니다. 이 단계는 그 상태를 Client 목록에 hydrate하지 않아 브라우저 조회가 별도로 남습니다. 서버 prefetch 사실만으로 초기 HTML 목록 전달이나 hydration 성공을 판정하지 않습니다.

오류 점검 서버 준비가 실패하면 queryFn과 서버 데이터 계층을 확인합니다. 브라우저 전달은 다음 단계의 dehydrate·HydrationBoundary 범위와 동일 키를 확인할 별도 과제입니다. 서버 QueryClient를 요청 간 공유하지 않습니다.

다음 학습 13회차에서 HydrationBoundary로 서버 캐시를 넘깁니다.

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

글 소개·학습 준비·전체 목차
TanStack Query SSR prefetchQuery로 서버 데이터를 미리 준비하는 과정을 표현한 표지

TanStack Query · 12 / 15 · 고급 학습 · Server Component prefetch

서버 데이터는 요청마다 만든 QueryClient에 미리 준비합니다. 이 준비만으로 브라우저 QueryClient에 전달되지는 않으며 dehydrate와 HydrationBoundary가 별도로 필요합니다. 아래 Node 예제로 A/B 요청의 캐시 분리를 확인하고, 하단에서는 전달 전 단계의 서버 상태를 관찰합니다.

2026-10-12에 확인한 최신 공식 문서는 prefetchQuery를 deprecated로 표시하고 query(options)를 권장합니다. 아래 Node 독립 예제는 원문에 고정된 @tanstack/react-query 5.104.0의 query를 사용하며, 누적 시작·완료 코드는 기존 prefetchQuery를 보존합니다. 제공된 package-lock과 코드의 설치 버전을 기준으로 실행하고 최신 문서의 API를 그대로 섞지 않습니다.

이번 글에서 배우는 내용

  • Server Component에서 QueryClient 생성
  • queryClient.query로 서버 prefetch
  • prefetchQuery가 deprecated된 이유
  • 서버 QueryClient를 요청 간 공유하면 안 되는 이유
  • staleTime 설정
  • dehydrate 준비
  • 클라이언트 useQuery와 같은 queryKey 사용

목차

이 글이 도움이 되었나요?

조회 중

읽음 기록

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

이 글을 쓴 해비

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

새 글 받아보기

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

RSS 피드 구독하기

댓글 남기기