TanStack Query v5 prefetchQuery + Next.js App Router: Render-as-You-Fetch와 스트리밍 SSR로 초기 로딩 제거하기
Next.js App Router로 전환하고 나서 한동안 이런 상황이 계속됐습니다. Server Component 덕분에 데이터 페칭이 훨씬 깔끔해졌다고 좋아했는데, 클라이언트 인터랙션이 필요한 컴포넌트에서 useQuery를 쓰는 순간 어김없이 로딩 스피너가 나타나는 겁니다. 이커머스 상품 페이지에서 유저 인사말은 50ms에 뜨는데 추천 상품 목록은 800ms짜리 API를 기다리느라 레이아웃이 덜컹거리는 그 느낌, Next.js App Router를 쓰다 보면 누구나 한 번쯤 겪어보셨을 겁니다.
이 글에서는 TanStack Query v5의 prefetchQuery와 Next.js App Router의 스트리밍 SSR을 결합해서 그 문제를 근본적으로 없애는 방법을 다룹니다. 서버에서 미리 캐시를 채운 뒤 클라이언트로 전달하는 파이프라인을 구성하면, 사용자는 로딩 상태를 거치지 않고 완성된 UI를 즉시 만날 수 있습니다. MAU 수십만 규모의 이커머스 페이지에 직접 적용하면서 LCP를 의미 있게 개선한 경험을 바탕으로, 바로 프로덕션에 써먹을 수 있는 수준으로 풀어드리겠습니다.
대상 독자는 Next.js App Router를 이미 사용 중인 프론트엔드 개발자입니다. Server Component와 Client Component의 구분, React Suspense의 기본 동작은 알고 있다고 가정하겠습니다. 예시 1, 2, 3은 순서대로 복잡도가 올라가니, 지금 당장 도입할 패턴을 고르고 싶다면 아래 "어떤 예시를 선택할까?" 안내를 먼저 보시면 좋을 것 같습니다.
핵심 개념
Fetch-on-Render vs Render-as-You-Fetch — 뭐가 다른가요?
기존 방식인 Fetch-on-Render는 컴포넌트가 마운트된 이후에 데이터 요청이 시작됩니다. 브라우저가 JS를 파싱하고, React가 컴포넌트를 렌더링하고, useEffect나 useQuery가 실행되고 나서야 비로소 네트워크 요청이 나가는 구조입니다. 그 사이에 사용자는 스피너를 봅니다.
Render-as-You-Fetch는 라우트 진입 시점, 즉 컴포넌트가 렌더링되기 전에 이미 데이터 페칭을 시작합니다. 데이터가 준비되는 속도에 맞춰 UI를 점진적으로 스트리밍할 수 있어서, 사용자는 빈 화면 대신 의미 있는 콘텐츠를 훨씬 빨리 만납니다. 저도 처음에 이게 왜 다른지 잘 와닿지 않았는데, 50ms짜리 API와 800ms짜리 API가 섞인 페이지에서 전자는 거의 즉시 보이고 후자는 스켈레톤을 보여주다가 데이터가 들어오는 걸 직접 보고 나서야 실감했습니다.
Request Waterfall: 컴포넌트 A가 렌더링된 후 데이터를 요청하고, 그 데이터로 컴포넌트 B가 렌더링되고 나서야 B의 데이터를 요청하는 식으로 순차 지연이 누적되는 현상입니다. 중첩된 컴포넌트 구조에서 심각한 성능 저하를 유발합니다.
세 가지 핵심 구성 요소
이 패턴을 이루는 부품은 세 가지입니다. 처음 접할 때 dehydrate와 HydrationBoundary가 각각 뭘 하는 건지 헷갈릴 수 있는데, 역할을 먼저 이해하면 전체 흐름이 훨씬 명확해집니다.
| 구성 요소 | 역할 | 실행 위치 |
|---|---|---|
prefetchQuery |
컴포넌트 렌더링 전, QueryClient 캐시를 미리 채움 | 서버 (Server Component) |
dehydrate |
서버 캐시를 직렬화해서 클라이언트로 전달 가능한 형태로 변환 | 서버 |
HydrationBoundary |
직렬화된 캐시를 받아 클라이언트 QueryClient에 복원 | 클라이언트 |
이 세 가지가 연결되면 아래 파이프라인이 완성됩니다.
서버 Server Component
└─ prefetchQuery() ──┐
▼
QueryClient 캐시 채워짐
│
dehydrate() 직렬화
│
스트리밍 HTML 응답 (청크 전송)
│
클라이언트 HydrationBoundary
│
캐시 복원 (재요청 없음)
│
useQuery() 즉시 데이터 반환pending 쿼리 dehydrate — v5.40.0의 게임 체인저
솔직히 이 기능이 나오기 전까지는 스트리밍의 이점을 제대로 살리기 어려웠습니다. 예전에는 prefetchQuery를 모두 await해야만 dehydrate로 클라이언트에 전달할 수 있었거든요. 결국 가장 느린 쿼리가 끝날 때까지 스트리밍 자체가 막혀버렸습니다.
TanStack Query v5.40.0부터는 pending 상태의 쿼리도 dehydrate할 수 있습니다. 서버에서 await 없이 쿼리를 시작해두면, 스트리밍 도중 클라이언트에서 resolve되는 방식으로 동작합니다. 느린 API와 빠른 API가 섞인 페이지에서 특히 효과가 큽니다.
// v5.40.0 이전 — 모든 prefetch가 끝날 때까지 스트리밍 대기
await queryClient.prefetchQuery({ queryKey: ['posts'], queryFn: fetchPosts })
await queryClient.prefetchQuery({ queryKey: ['user'], queryFn: fetchUser })
// 느린 쪽이 끝나야 HTML 전송 시작
// v5.40.0 이후 — void로 시작해두고 즉시 스트리밍
void queryClient.prefetchQuery({ queryKey: ['posts'], queryFn: fetchPosts })
void queryClient.prefetchQuery({ queryKey: ['user'], queryFn: fetchUser })
// HTML 전송 시작, 각 쿼리가 resolve될 때마다 청크로 스트리밍
void키워드: Promise를 무시하겠다는 명시적 선언입니다.await없이 그냥 호출하면 TypeScript가 "부동 Promise(floating promise)"라고 경고를 냅니다.void를 붙이면 "의도적으로 기다리지 않겠다"는 의사를 코드에 명시하는 것입니다.
실전 적용
어떤 예시를 선택할까?
| 상황 | 추천 패턴 |
|---|---|
| 특정 라우트에서 prefetch를 명시적으로 제어하고 싶은 경우 | 예시 1 (기본 패턴) |
| 한 페이지에 빠른 API와 느린 API가 섞여 있는 경우 | 예시 2 (병렬 스트리밍) |
| 라우트마다 boilerplate를 최소화하고 싶고, 실험적 패키지를 감수할 수 있는 경우 | 예시 3 (실험적 패턴) |
예시 1: 대시보드 페이지 — prefetchQuery + HydrationBoundary 기본 패턴
가장 자주 쓰이고 안정성이 검증된 패턴입니다. 대시보드, 상품 상세 페이지, 유저 프로필 등 대부분의 데이터 페칭 페이지에 적용할 수 있습니다. API 레이어부터 시작해서 전체 흐름을 보여드리겠습니다.
// lib/api.ts — 서버·클라이언트 공용 fetch 래퍼
export interface DashboardStats {
revenue: number
orderCount: number
activeUsers: number
}
export async function fetchDashboardStats(): Promise<DashboardStats> {
const res = await fetch('/api/dashboard/stats', { cache: 'no-store' })
if (!res.ok) throw new Error('Failed to fetch dashboard stats')
return res.json()
}// app/dashboard/page.tsx (Server Component)
import { dehydrate, HydrationBoundary, QueryClient } from '@tanstack/react-query'
import DashboardStats from './DashboardStats'
import { fetchDashboardStats } from '@/lib/api'
export default async function DashboardPage() {
// 요청마다 새 QueryClient 생성 — 싱글톤으로 쓰면 서버 간 데이터 누수 발생
const queryClient = new QueryClient({
defaultOptions: {
queries: {
staleTime: 60 * 1000, // 1분 — 없으면 클라이언트 마운트 즉시 리페치 발생
},
},
})
await queryClient.prefetchQuery({
queryKey: ['dashboard-stats'],
queryFn: fetchDashboardStats,
})
return (
<HydrationBoundary state={dehydrate(queryClient)}>
<DashboardStats />
</HydrationBoundary>
)
}// app/dashboard/DashboardStats.tsx (Client Component)
'use client'
import { useQuery } from '@tanstack/react-query'
import { fetchDashboardStats, type DashboardStats } from '@/lib/api'
export default function DashboardStats() {
// 서버에서 채운 캐시를 그대로 활용 — 마운트 시 네트워크 요청 없이 즉시 렌더링
const { data } = useQuery<DashboardStats>({
queryKey: ['dashboard-stats'],
queryFn: fetchDashboardStats,
})
return (
<div>
<p>매출: {data?.revenue.toLocaleString()}원</p>
<p>주문: {data?.orderCount.toLocaleString()}건</p>
</div>
)
}| 코드 포인트 | 설명 |
|---|---|
new QueryClient() 위치 |
Server Component 함수 내부에서 요청마다 새로 생성합니다. 전역 싱글톤 금지 |
staleTime: 60 * 1000 |
없으면 클라이언트 마운트 직후 즉시 리페치가 일어납니다. prefetch의 이점이 반감됩니다 |
queryKey 일치 |
서버(prefetchQuery)와 클라이언트(useQuery)의 키가 완전히 동일해야 캐시가 연결됩니다 |
HydrationBoundary 래핑 |
서버 캐시를 클라이언트 트리에 주입하는 경계. 이 안쪽 컴포넌트들이 혜택을 받습니다 |
예시 2: 병렬 스트리밍 — 느린 API에 발목 잡히지 않기
실무에서 정말 자주 맞닥뜨리는 상황입니다. 유저 인사말은 50ms, 추천 상품 목록은 800ms가 걸린다면, 예전 방식대로라면 모든 데이터가 나올 때까지 800ms를 기다려야 했습니다. void prefetchQuery 패턴으로 이 문제를 해결할 수 있습니다.
// app/shop/page.tsx (Server Component)
import { dehydrate, HydrationBoundary, QueryClient } from '@tanstack/react-query'
import { Suspense } from 'react'
import ProductList from './ProductList'
import UserGreeting from './UserGreeting'
import { ProductsSkeleton, UserSkeleton } from './skeletons'
import { fetchProducts, fetchUser } from '@/lib/api'
export default async function ShopPage() {
const queryClient = new QueryClient({
defaultOptions: { queries: { staleTime: 60 * 1000 } },
})
// await 없이 병렬로 시작 — 두 쿼리가 동시에 서버에서 페칭 시작
void queryClient.prefetchQuery({
queryKey: ['products'],
queryFn: fetchProducts, // 800ms 걸리는 느린 API
})
void queryClient.prefetchQuery({
queryKey: ['user'],
queryFn: fetchUser, // 50ms 빠른 API
})
return (
<HydrationBoundary state={dehydrate(queryClient)}>
<Suspense fallback={<UserSkeleton />}>
<UserGreeting /> {/* 50ms 후 즉시 스트리밍 */}
</Suspense>
<Suspense fallback={<ProductsSkeleton />}>
<ProductList /> {/* 800ms 후 스트리밍, 그 전까지 Skeleton 표시 */}
</Suspense>
</HydrationBoundary>
)
}// app/shop/UserGreeting.tsx (Client Component)
'use client'
import { useSuspenseQuery } from '@tanstack/react-query'
import { fetchUser, type User } from '@/lib/api'
export default function UserGreeting() {
const { data } = useSuspenseQuery<User>({
queryKey: ['user'],
queryFn: fetchUser,
})
return <p>안녕하세요, {data.name}님!</p>
}// app/shop/ProductList.tsx (Client Component)
'use client'
import { useSuspenseQuery } from '@tanstack/react-query'
import { fetchProducts, type Product } from '@/lib/api'
export default function ProductList() {
// useSuspenseQuery — 데이터 없으면 suspend, 있으면 즉시 반환
// data 타입이 항상 non-null로 추론되어 타입 단언 없이도 안전합니다
const { data } = useSuspenseQuery<Product[]>({
queryKey: ['products'],
queryFn: fetchProducts,
})
return (
<ul>
{data.map(product => (
<li key={product.id}>{product.name}</li>
))}
</ul>
)
}
useSuspenseQueryvsuseQuery:useQuery는isLoading,isError상태를 컴포넌트 내부에서 직접 처리해야 합니다.useSuspenseQuery는 데이터가 없으면 가장 가까운<Suspense>경계로 제어를 넘기고, 있으면 즉시 반환합니다.data타입도 항상 non-null로 추론되어 코드가 훨씬 깔끔해집니다. 단, 반드시 상위 트리에<Suspense>경계가 있어야 합니다.
예시 3: 실험적 패턴 — ReactQueryStreamedHydration으로 보일러플레이트 줄이기
라우트마다 prefetchQuery → dehydrate → HydrationBoundary를 반복하는 게 번거롭다면, @tanstack/react-query-next-experimental 패키지를 검토해볼 수 있습니다. useSuspenseQuery만으로 스트리밍 SSR이 동작하는 방식입니다.
중요한 점은 QueryClient를 절대 모듈 최상단 싱글톤으로 선언하면 안 된다는 것입니다. 서버 환경에서는 요청 간 데이터가 공유되는 누수가 발생합니다. 아래처럼 요청마다 새 인스턴스를 생성하는 팩토리 함수 패턴을 써야 합니다.
// lib/query-client.ts — 요청별 QueryClient 팩토리
import { QueryClient } from '@tanstack/react-query'
export function makeQueryClient() {
return new QueryClient({
defaultOptions: {
queries: {
staleTime: 60 * 1000,
},
},
})
}// app/layout.tsx
'use client'
import { ReactQueryStreamedHydration } from '@tanstack/react-query-next-experimental'
import { QueryClientProvider } from '@tanstack/react-query'
import { useState } from 'react'
import { makeQueryClient } from '@/lib/query-client'
export default function RootLayout({ children }: { children: React.ReactNode }) {
// useState로 인스턴스를 고정 — 매 렌더마다 새로 생성되지 않도록
const [queryClient] = useState(() => makeQueryClient())
return (
<QueryClientProvider client={queryClient}>
<ReactQueryStreamedHydration>
{children}
</ReactQueryStreamedHydration>
</QueryClientProvider>
)
}// 어느 Client Component에서든 — 수동 prefetch 없이 스트리밍 SSR 동작
'use client'
import { useSuspenseQuery } from '@tanstack/react-query'
import { fetchProducts, type Product } from '@/lib/api'
export function ProductList() {
const { data } = useSuspenseQuery<Product[]>({
queryKey: ['products'],
queryFn: fetchProducts,
})
return <ul>{data.map(p => <li key={p.id}>{p.name}</li>)}</ul>
}단, 이 패키지는 실험적(experimental) 단계입니다. 프로덕션 도입 전에 아래 단점 섹션의 주의사항을 꼭 확인해보시길 권장합니다.
장단점 분석
장점
| 항목 | 내용 |
|---|---|
| 초기 로딩 상태 제거 | 서버에서 캐시를 미리 채우므로 사용자에게 스피너가 노출되지 않습니다 |
| Request Waterfall 방지 | 서버 단계에서 병렬 페칭이 가능해 중첩 컴포넌트의 순차 지연을 없앱니다 |
| SEO 향상 | 완전히 렌더링된 HTML을 크롤러에 제공해 검색 노출에 유리합니다 |
| 클라이언트 캐시 재활용 | hydration 후에도 동일한 useQuery로 자동 리페치, 인터벌 갱신 등 TanStack Query 기능 전체를 유지합니다 |
| 점진적 스트리밍 | 중요 UI는 먼저 전달하고 느린 데이터는 Suspense 경계로 분리해 순차 노출합니다 |
단점 및 주의사항
| 항목 | 내용 | 적용 패턴 | 대응 방안 |
|---|---|---|---|
| 보일러플레이트 증가 | 라우트마다 QueryClient → prefetchQuery → dehydrate → HydrationBoundary 반복 |
예시 1, 2 | makeQueryClient() 헬퍼와 공통 래퍼 유틸로 추상화 |
| Hydration mismatch | 스트리밍 완료 전 컴포넌트가 unsuspend되면 서버-클라이언트 구조 불일치 오류 발생 | 예시 3만 해당 | 안정 버전 패키지에서는 await prefetchQuery 패턴 유지 |
| 클라이언트 내비게이션 한계 | 무-prefetch 방식은 초기 로딩은 해결하지만 CSR 페이지 이동 시 워터폴 재현 | 예시 3만 해당 | 중요한 CSR 전환 경로에는 prefetchQuery 명시적 사용 |
| 직렬화 제약 | dehydrate로 전달하는 데이터는 순수 직렬화 가능 객체만 허용 |
예시 1, 2, 3 | queryFn 함수 참조는 클라이언트에서 별도 등록 |
| 서버 간 데이터 누수 | QueryClient를 전역 싱글톤으로 쓰면 다른 사용자의 데이터가 섞임 |
예시 1, 2, 3 | 요청마다 makeQueryClient() 팩토리로 생성 |
실무에서 가장 체감되는 단점은 보일러플레이트보다 오히려 staleTime을 빠뜨렸을 때였습니다. 분명히 prefetch를 했는데 클라이언트에서 네트워크 탭을 보면 요청이 다시 나가고 있는 거예요. staleTime이 기본값 0이라 마운트 즉시 stale로 판단해서 리페치를 트리거한 겁니다. 설정 한 줄이 없어서 prefetch의 의미가 전혀 없어지는 상황이라 특히 주의가 필요합니다.
실무에서 가장 흔한 실수
-
QueryClient를 모듈 최상단에 싱글톤으로 선언하기 — 서버 환경에서는 요청 A의 데이터가 요청 B에 남아있을 수 있습니다. 반드시makeQueryClient()팩토리를 써서 요청마다 새 인스턴스를 만들어야 합니다. -
staleTime을 설정하지 않기 — 기본값이 0이라 클라이언트 마운트 즉시 stale로 판단하고 리페치가 일어납니다. 서버 prefetch의 이점이 완전히 사라집니다.defaultOptions에staleTime: 60 * 1000이상을 설정하는 것을 권장합니다. -
queryKey를 서버와 클라이언트에서 다르게 쓰기 —['products', userId]처럼 동적 파라미터가 포함된 키는 양쪽에서 완전히 동일해야 캐시가 연결됩니다. 키를 상수로 추출해두거나 쿼리 옵션 팩토리 함수를 만들어두는 것이 안전합니다. -
useSuspenseQuery를 쓰면서<Suspense>경계를 빠뜨리기 — 반드시 상위 트리에<Suspense>경계가 있어야 합니다. 없으면 런타임 에러가 발생하고, 에러 메시지만 봐서는 원인을 찾기 어렵습니다. 이 부분에서 공식 문서를 두세 번 정독했을 만큼 처음엔 직관적이지 않은 동작입니다.
마치며
prefetchQuery + HydrationBoundary + Suspense 스트리밍의 조합은 이제 Next.js App Router의 표준 데이터 페칭 아키텍처로 자리 잡아가고 있습니다. 특히 v5.40.0의 pending 쿼리 dehydrate 기능 덕분에 void prefetchQuery 패턴으로 API 응답 시간에 따라 UI를 자연스럽게 점진적으로 보여줄 수 있게 됐고, 사용자 경험 차이가 체감될 만큼 커졌습니다.
지금 바로 시작해볼 수 있는 3단계를 제안드립니다.
-
기존 프로젝트에서 가장 느린 페이지 하나를 골라 기본 패턴을 적용해볼 수 있습니다.
pnpm add @tanstack/react-query@^5로 v5를 설치한 뒤, Server Component에서prefetchQuery→dehydrate→HydrationBoundary순서로 코드를 추가해보시면 됩니다. 기존useQuery코드는 그대로 두어도 동작합니다. -
Suspense 경계를 데이터 의존성에 맞게 나눠보시면 좋습니다. 빠른 데이터와 느린 데이터를 각각 별도
<Suspense>로 감싸면void prefetchQuery패턴으로 병렬 스트리밍을 바로 경험할 수 있습니다. Chrome DevTools의 Network 탭에서 HTML 응답이 청크로 분할되는 것을 확인해보실 수 있고, Lighthouse나 Web Vitals(LCP, FCP)로 개선 전후를 측정해보면 수치로도 확인이 됩니다. -
makeQueryClient()팩토리를lib/query-client.ts에 공통화해두시면 이후 라우트 추가가 훨씬 가벼워집니다. 요청별 QueryClient 생성 로직을 한 곳에 모아두면, 새 라우트를 추가할 때마다 두 줄이면 충분합니다. TanStack Query 공식 문서의 Advanced Server Rendering 가이드에 예시가 잘 정리되어 있으니 참고해보시면 좋습니다.
다음 글: TanStack Query의
prefetchInfiniteQuery와 React의use()훅을 활용한 무한 스크롤 스트리밍 SSR 구현기
참고 자료
- Advanced Server Rendering | TanStack Query 공식 문서
- Performance & Request Waterfalls | TanStack Query 공식 문서
- Next.js App Router Prefetching 예시 | TanStack Query
- Next.js Suspense Streaming 예시 | TanStack Query
- The Next.js 15 Streaming Handbook | freeCodeCamp
- Next.js App Router Streaming 공식 문서 | Next.js
- Building a Fully Hydrated SSR App with Next.js App Router and TanStack Query | Medium
- Combining React Server Components with react-query | Frontend Masters Blog
- TanStack Query & Next.js 15: The Ultimate Guide (2024) | Daniel Olawoyin
- Data Fetching in 2025: Streaming, Suspense, and Deferred Fetching | Medium