Next.js App Router에서 HydrationBoundary로 SSR 데이터 그리드의 초기 flicker와 이중 패칭을 구조적으로 없애기
Next.js App Router로 프로젝트를 전환하고 나서, 데이터 그리드 초기 로딩에서 예상치 못한 문제 두 개를 동시에 맞닥뜨린 적이 있습니다. 하나는 페이지가 열리는 순간 스피너가 반짝하고 나타났다 사라지는 초기 로딩 깜빡임(flicker)이었고, 다른 하나는 서버에서 분명히 데이터를 가져왔는데 클라이언트 마운트 직후 같은 API를 다시 호출하는 이중 패칭이었습니다. 네트워크 탭을 열어보고 "어, 왜 두 번 불러오지?"라고 중얼거렸던 기억이 납니다.
Server Component에서 prefetchQuery로 데이터를 미리 가져오고, dehydrate로 직렬화한 뒤, HydrationBoundary로 클라이언트 캐시에 주입하면 이 두 문제를 구조적으로 한 번에 해결할 수 있습니다. 이 패턴 하나로 첫 렌더부터 데이터가 즉시 존재하고, 불필요한 네트워크 요청도 사라집니다. 설정은 한 번이지만 이후 어떤 컴포넌트를 추가해도 동일한 방식으로 적용됩니다.
대상은 App Router 기반 프로젝트를 운영 중이거나 도입을 검토 중인 프론트엔드 개발자입니다. Server Component와 Client Component 경계에서 데이터를 어떻게 넘겨야 할지 아직 감이 잡히지 않는다면, 이 글이 그 감을 잡는 데 도움이 됩니다. 코드 예시는 TanStack Query v5 기준입니다. v4에서 마이그레이션 중이라면 공식 마이그레이션 가이드를 함께 참고하시면 좋습니다.
핵심 개념
두 가지 문제가 동시에 생기는 이유
Server Component가 서버에서 데이터를 가져왔더라도, 그 데이터를 props로 Client Component에 넘기는 순간 TanStack Query는 그 데이터의 존재를 모릅니다. useQuery가 마운트될 때 캐시에 아무것도 없으니 isLoading: true 상태로 시작하고, 곧바로 동일 엔드포인트에 네트워크 요청을 날립니다. 이것이 이중 패칭입니다. 그리고 그 짧은 순간, 화면에는 스피너나 스켈레톤이 반짝하고 나타났다 사라집니다. 이것이 flicker입니다.
문제의 근원은 단순합니다. 서버의 데이터와 클라이언트의 쿼리 캐시가 연결되어 있지 않은 것입니다.
하이브리드 패턴의 작동 원리
TanStack Query v5는 이 연결 문제를 dehydrate와 HydrationBoundary로 해결합니다.
Server Component
↓ queryClient.prefetchQuery() ← 서버에서 데이터 패칭 및 캐시에 저장
↓ dehydrate(queryClient) ← 캐시를 JSON 직렬화 가능한 스냅샷으로 변환
↓ <HydrationBoundary state={dehydratedState}>
↓ Client Component
↓ useQuery(동일한 쿼리 키) ← 캐시 히트 → 네트워크 요청 없음서버에서 만들어진 캐시 스냅샷은 Next.js의 RSC payload(React Server Components 스트리밍 형식)에 직렬화되어 HTML과 함께 브라우저로 전달됩니다. 브라우저에서는 HydrationBoundary가 이 스냅샷을 클라이언트의 QueryClient에 병합합니다. 이후 useQuery가 동일한 키로 호출되면 이미 캐시에 데이터가 있으므로 isLoading이 false로 시작하고, 네트워크 요청도 발생하지 않습니다.
dehydrate / hydrate: 서버의 상태를 JSON으로 "탈수(dehydrate)"해 전달하고, 클라이언트에서 다시 "수화(hydrate)"해 살려내는 비유에서 온 용어입니다. Redux나 Zustand의 SSR 직렬화와 개념적으로 같습니다.
staleTime이 핵심 변수인 이유
prefetchQuery로 캐시에 데이터를 담았더라도, staleTime이 0(기본값)이면 클라이언트 마운트 직후 데이터가 즉시 "오래됨(stale)"으로 판정됩니다. TanStack Query는 stale 상태의 데이터를 자동으로 백그라운드에서 재요청하기 때문에, 결국 이중 패칭이 다시 발생합니다.
staleTime을 서버 렌더링 왕복 시간 이상으로 설정하는 것이 이 패턴의 필수 조건입니다. 서버 렌더링 왕복에 통상 수백 ms가 걸리므로 최소 30초, 일반적으로 60초를 권장합니다. 실시간성이 강한 데이터라면 짧게 잡되, 그 트레이드오프(이중 패칭 재발 가능성)를 인식하고 결정하는 것이 좋습니다.
실전 적용
이제 실제 코드로 들어가 보겠습니다. 세 단계로 구성되며, 순서대로 의존 관계가 형성됩니다.
Step 1: 요청 격리 — getQueryClient 만들기
서버 환경에서 QueryClient를 잘못 생성하면 여러 사용자의 요청 사이에 인스턴스가 공유되어 데이터가 오염됩니다. 저도 처음에 모듈 최상단에 new QueryClient()를 두었다가, 한 사용자의 캐시가 다른 사용자에게 노출되는 상황을 겪었습니다. React 18의 cache()를 사용하면 요청 단위로 격리된 인스턴스를 안전하게 만들 수 있습니다.
// lib/query-client.ts
import { QueryClient } from '@tanstack/react-query'
import { cache } from 'react'
export const getQueryClient = cache(
() =>
new QueryClient({
defaultOptions: {
queries: {
staleTime: 60 * 1000, // 60초: 클라이언트 마운트 직후 재요청 방지
},
},
})
)| 포인트 | 설명 |
|---|---|
cache() 래핑 |
동일 HTTP 요청 내에서 같은 인스턴스 반환. 요청 간 공유 없음 |
staleTime: 60 * 1000 |
60초 동안 데이터를 fresh로 유지. 마운트 후 백그라운드 재요청 차단 |
| 모듈 레벨 싱글턴 금지 | new QueryClient()를 모듈 최상단에 두면 서버 재시작 전까지 공유됨 |
React
cache(): React 18에서 도입된 서버 전용 메모이제이션 헬퍼로, 동일한 요청 수명 주기 안에서 같은 함수에 대해 반환값을 캐싱합니다. Next.js의fetch기반 중복 제거와 비슷한 역할이지만,fetch가 아닌 임의 함수에 적용할 수 있다는 차이가 있습니다. 브라우저에서는 동작하지 않으며, Server Component 전용입니다.
클라이언트 사이드 QueryClientProvider는 별도 파일에 분리하는 것이 일반적입니다. 'use client' 지시어가 필요하기 때문에 Server Component와 함께 쓸 수 없습니다.
// app/providers.tsx
'use client'
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
import { useState } from 'react'
export function Providers({ children }: { children: React.ReactNode }) {
const [queryClient] = useState(
() =>
new QueryClient({
defaultOptions: {
queries: {
staleTime: 60 * 1000, // 서버와 동일하게 맞추기
},
},
})
)
return (
<QueryClientProvider client={queryClient}>
{children}
</QueryClientProvider>
)
}// app/layout.tsx
import { Providers } from './providers'
export default function RootLayout({
children,
}: {
children: React.ReactNode
}) {
return (
<html lang="ko">
<body>
<Providers>{children}</Providers>
</body>
</html>
)
}Step 2: 서버에서 prefetch — 캐시를 채워 내려보내기
Server Component는 async 함수이므로 await로 데이터를 가져오고, dehydrate로 직렬화한 뒤 HydrationBoundary로 감싸서 자식에게 전달합니다.
코드를 복사해서 바로 실행하려면 fetchGridData의 시그니처를 알아야 하니, 먼저 API 함수와 타입을 정의합니다. 이 예시는 인증이 필요 없는 공개 API를 전제로 합니다. 인증 쿠키나 토큰이 필요한 경우 서버 컴포넌트에서 cookies()나 headers()를 읽어 fetch 요청에 포함해야 합니다.
// lib/api.ts
type GridRow = {
id: string
name: string
value: string
}
export type GridData = {
rows: GridRow[]
totalCount: number
}
export async function fetchGridData(params: {
page: number
pageSize: number
}): Promise<GridData> {
const res = await fetch(
`https://api.example.com/grid?page=${params.page}&pageSize=${params.pageSize}`
)
if (!res.ok) throw new Error('Failed to fetch grid data')
return res.json()
}쿼리 키를 서버와 클라이언트에서 다르게 쓰면 캐시 히트가 발생하지 않습니다. 키 생성 함수를 공통 파일로 분리해 두면 이 실수를 미연에 방지할 수 있습니다.
// lib/query-keys.ts
export const gridKeys = {
all: ['grid-data'] as const,
list: (params: { page: number; pageSize: number }) =>
[...gridKeys.all, params] as const,
}이제 Server Component에서 prefetch를 적용합니다.
// app/dashboard/page.tsx
import { dehydrate, HydrationBoundary } from '@tanstack/react-query'
import { getQueryClient } from '@/lib/query-client'
import { fetchGridData } from '@/lib/api'
import { gridKeys } from '@/lib/query-keys'
import { DataGrid } from './DataGrid'
export default async function DashboardPage() {
const queryClient = getQueryClient()
await queryClient.prefetchQuery({
queryKey: gridKeys.list({ page: 1, pageSize: 50 }),
queryFn: () => fetchGridData({ page: 1, pageSize: 50 }),
})
return (
<HydrationBoundary state={dehydrate(queryClient)}>
<DataGrid />
</HydrationBoundary>
)
}HydrationBoundary는 직렬화된 캐시 스냅샷을 자식 트리 안의 QueryClient에 병합합니다. DataGrid 안에서 동일한 키로 useQuery를 호출하면 이미 캐시에 데이터가 있으므로, 로딩 상태 없이 즉시 렌더링됩니다.
Step 3: Client Component에서 캐시 히트로 시작하기
// app/dashboard/DataGrid.tsx
'use client'
import { useQuery, keepPreviousData } from '@tanstack/react-query'
import { fetchGridData } from '@/lib/api'
import { gridKeys } from '@/lib/query-keys'
import { useState } from 'react'
export function DataGrid() {
const [page, setPage] = useState(1)
const { data, isFetching, isError, error } = useQuery({
queryKey: gridKeys.list({ page, pageSize: 50 }),
queryFn: () => fetchGridData({ page, pageSize: 50 }),
placeholderData: keepPreviousData, // 페이지 전환 시 이전 데이터 유지
// staleTime은 QueryClient 기본값(60초) 상속
})
// 에러 처리 — 실제 프로덕션에서는 에러 바운더리나 toast 알림을 함께 사용하는 것을 권장
if (isError) return <div>데이터를 불러오지 못했습니다: {error.message}</div>
return (
<div>
{isFetching && <span aria-live="polite">백그라운드 갱신 중...</span>}
<table>
<thead>
<tr>
<th>이름</th>
<th>값</th>
</tr>
</thead>
<tbody>
{data?.rows.map((row) => (
<tr key={row.id}>
<td>{row.name}</td>
<td>{row.value}</td>
</tr>
))}
</tbody>
</table>
<button onClick={() => setPage((p) => p - 1)} disabled={page === 1}>
이전
</button>
<button onClick={() => setPage((p) => p + 1)}>다음</button>
</div>
)
}| 코드 포인트 | 동작 |
|---|---|
page: 1 초기 상태 |
서버에서 prefetch한 키와 일치 → 캐시 히트, 로딩 없음 |
page: 2 이상 |
캐시 미스 → 클라이언트 패칭 활성화 |
keepPreviousData |
페이지 전환 중 이전 데이터 유지, 레이아웃 시프트 방지 |
isFetching |
백그라운드 재검증 시에만 true. 초기 렌더는 항상 false |
gridKeys.list(...) |
공통 키 팩토리로 서버/클라이언트 키 불일치 방지 |
장단점 분석
장점
이 패턴을 적용하고 나서 가장 크게 체감한 건 prop drilling이 사라진 것이었습니다. 이전에는 서버에서 가져온 데이터를 props로 계속 내려보내야 했는데, HydrationBoundary 하위라면 트리 어디서든 동일한 키로 캐시를 꺼낼 수 있습니다. 그리고 첫 렌더부터 data가 즉시 존재하니 스피너 깜빡임 자체가 구조적으로 없어집니다.
| 항목 | 내용 |
|---|---|
| 초기 flicker 제거 | 첫 렌더 시 data가 즉시 존재. isLoading: true 상태 건너뜀 |
| 이중 패칭 제거 | staleTime 내에는 캐시 히트로 처리. 네트워크 요청 없음 |
| SEO 및 초기 LCP 개선 | 서버에서 HTML을 완성해 전달하므로 콘텐츠가 즉시 보임 |
| prop drilling 불필요 | HydrationBoundary 하위 어디서나 동일 키로 캐시 공유 |
| 이후 인터랙션은 클라이언트 패칭 | 페이지네이션, 필터 변경 시 자연스럽게 클라이언트 주도로 전환 |
단점 및 주의사항
| 항목 | 설명 | 대응 방안 |
|---|---|---|
staleTime 미설정 |
기본값 0이면 마운트 직후 재요청 발생 |
QueryClient 기본값 또는 쿼리별 staleTime 명시 |
| 쿼리 키 불일치 | 서버/클라이언트 키 구조가 다르면 캐시 미스 | 키 팩토리 함수를 공통 모듈에서 관리 |
| Suspense 중첩 순서 | Suspense 안에 HydrationBoundary를 넣으면 fallback이 먼저 렌더링될 수 있음 |
HydrationBoundary를 Suspense 바깥에 배치 |
| 서버 메모리 | 대용량 데이터 prefetch 시 직렬화 크기 증가 | 첫 페이지만 prefetch하고 나머지는 클라이언트 패칭으로 분리 |
| 인증 API | 서버에서 쿠키/토큰을 전달해야 하는 엔드포인트에 주의 | cookies()나 headers()로 인증 정보를 fetch에 포함 |
HydrationBoundary vs Suspense:
HydrationBoundary는 이미 완성된 데이터를 클라이언트 캐시에 주입하는 역할이고,Suspense는 아직 준비되지 않은 데이터를 기다리는 역할입니다. 목적이 다르기 때문에,Suspense안에HydrationBoundary를 넣으면 hydration이 완료되기 전에 fallback이 먼저 보일 수 있습니다.
실무에서 가장 흔한 실수
-
staleTime을 설정하지 않아 이중 패칭이 계속 발생하는 경우입니다.HydrationBoundary를 적용했는데 여전히 네트워크 탭에 두 번의 요청이 보인다면,staleTime을 먼저 확인해보시면 됩니다. 저도 이 실수를 가장 늦게 발견했는데, "HydrationBoundary까지 붙였는데 왜 또 요청이 가지?"라고 한참 헤맸던 기억이 납니다. -
서버와 클라이언트의 쿼리 키 구조가 미묘하게 다른 경우입니다. 서버에서
['grid-data', { page: 1 }]로 저장하고 클라이언트에서['grid-data', 1]로 읽으면 캐시 히트가 발생하지 않습니다.gridKeys.list(...)처럼 키 팩토리 함수를 공통 파일에 두면 이 문제를 구조적으로 막을 수 있습니다. -
QueryClientProvider를 Server Component에서 직접 사용하려는 시도입니다.QueryClientProvider는 클라이언트 컨텍스트가 필요하므로'use client'파일 안에 두어야 합니다. 보통app/providers.tsx에 분리하고app/layout.tsx에서 import하는 구조가 깔끔합니다. Step 1의 코드가 그 예시입니다.
마치며
prefetchQuery + dehydrate + HydrationBoundary 조합은 App Router 환경에서 초기 flicker와 이중 패칭을 구조적으로 해결하는 현시점 가장 안정적인 패턴입니다. 설정은 한 번이고, 이후 컴포넌트를 추가할 때마다 같은 방식을 적용할 수 있습니다.
아래 순서로 시작해보시면 빠르게 감을 잡을 수 있습니다.
lib/query-client.ts와app/providers.tsx를 만들어getQueryClient와QueryClientProvider를 분리합니다. 기존 프로젝트에 이미QueryClient가 있다면 그 생성 로직을 이 파일로 옮기는 것부터 시작하면 됩니다.- 데이터를 가장 먼저 필요로 하는 Server Component(
page.tsx)에prefetchQuery와HydrationBoundary를 추가합니다. 브라우저 네트워크 탭에서 클라이언트 마운트 후 동일 API 호출이 사라지는 걸 직접 확인할 수 있습니다. - 페이지네이션이 있는 그리드라면
keepPreviousData옵션을 함께 적용합니다. 페이지 전환 시 빈 화면 대신 이전 데이터가 유지되면서 훨씬 자연스러운 사용자 경험을 제공합니다.
참고 자료
공식
- Server Rendering & Hydration | TanStack Query 공식 문서
- Advanced Server Rendering | TanStack Query 공식 문서
- Prefetching & Router Integration | TanStack Query 공식 문서
- Next.js App Prefetching Example | TanStack Query
- Next.js Suspense Streaming Example | TanStack Query v5
- Data Fetching Patterns and Best Practices | Next.js 공식 문서
- Set up with React Server Components | tRPC 공식 문서
참고
- Building a Fully Hydrated SSR App with Next.js App Router and TanStack Query | Medium
- Stop the Duplicate Refetch | Medium
- Integrate TanStack Query with Next.js App Router 2025 | storieasy
- Advanced Server Rendering with Next.js App Router | DEV Community
- Using TanStack Query with Next.js | LogRocket Blog