Step 03: TanStack Query SSR — 수동 Dehydrate/Hydrate vs 자동 관리
Step 03: TanStack Query SSR — 수동 Dehydrate/Hydrate vs 자동 관리
PM 요청 (김도연)
"준혁님, 이제 API 데이터를 TanStack Query로 관리하고 싶어요. 클라이언트에서 상태 관리도 편하고, 캐싱도 자동으로 해주니까 좋잖아요. 그런데 SSR도 같이 하려면 어떻게 해야 하나요? 서버에서 미리 데이터를 가져와서 캐시에 채워두면 클라이언트가 API를 다시 호출하지 않아도 되지 않을까요?"
시니어 멘토링 (이준혁)
"좋은 생각이야. TanStack Query(예전 이름이 React Query였지)는 SSR을 지원하는데, 핵심 개념이 Dehydrate/Hydrate야. 이게 뭔지 비유로 설명해줄게."
통조림 비유로 이해하는 Dehydrate/Hydrate
"캠핑 가본 적 있어? 산에 올라갈 때 무거운 생수통 대신 가벼운 분말 음료를 들고 가잖아. 이게 바로 Dehydrate/Hydrate의 원리야."
🥫 Dehydrate (탈수/통조림 만들기)
- 서버의 QueryClient 캐시를 순수 데이터로 변환하는 과정
- 물기를 빼서 가볍게 만드는 것처럼, 직렬화 불가능한 함수, 타이머, 콜백 등을 제거하고 순수한 데이터만 추출
- 통조림 공장에서 음식을 보존 가능한 형태로 가공하는 것
📦 Serialize (포장/배송)
- 추출한 순수 데이터를 JSON 문자열로 변환
- HTML에
<script>태그로 주입해서 브라우저로 배송 - 통조림을 상자에 담아 배송하는 것
💧 Hydrate (물 붓기/복원)
- 클라이언트에서 JSON 데이터를 파싱하여 QueryClient에 다시 채우는 과정
- 분말 음료에 물을 부어 마실 수 있게 만드는 것
- 통조림을 열어서 조리하는 것
"왜 이렇게 복잡하게 하냐고? QueryClient는 살아있는 객체거든. 내부에 타이머도 돌아가고, 옵저버 패턴으로 리액트 컴포넌트들이 구독도 하고 있어. 이런 걸 그대로 전송할 수는 없잖아. 순수 데이터만 빼내서(dehydrate) 보내고, 받는 쪽에서 다시 QueryClient에 부어(hydrate) 살려내는 거지."
전체 파이프라인 흐름
"먼저 큰 그림을 보자. 서버에서 클라이언트까지 데이터가 어떻게 흘러가는지:"
[서버 Node.js 프로세스] [브라우저]
① QueryClient 생성
const queryClient = new QueryClient()
↓
② prefetchQuery (API 호출)
await queryClient.prefetchQuery({
queryKey: ['posts'],
queryFn: fetchPosts
})
↓
[QueryClient 캐시 상태]
{
queries: Map {
"posts" => { data: [...], status: "success" }
}
}
↓
③ dehydrate(queryClient) → 순수 데이터 추출
{
queries: [
{ queryKey: ["posts"], state: { data: [...] } }
]
}
↓
④ JSON.stringify → HTML <script>에 삽입
<script>
window.__REACT_QUERY_STATE__ = {"queries":[...]}
</script>
↓
━━━━━━━━━━━━━━━━━ HTML 전송 ━━━━━━━━━━━━━━━━→
↓
⑤ JSON.parse
브라우저가 스크립트 실행
const state = window.__REACT_QUERY_STATE__
↓
⑥ new QueryClient + hydrate
HydrationBoundary가 캐시에 데이터 복원
↓
⑦ useQuery 실행
→ 캐시 히트! (API 재호출 X)
→ isLoading = false
→ 데이터 즉시 사용"이 파이프라인을 Express에서는 직접 구축해야 하고, Next.js는 많은 부분을 자동화해줘. 하나씩 보자."
Express + TanStack Query: 완전 수동 파이프라인
"Express에서는 이 통조림 공장을 처음부터 끝까지 직접 운영해야 해. 각 단계를 명시적으로 코딩하는 거지."
서버 측 Dehydrate 파이프라인
// server.js
import express from 'express';
import React from 'react';
import { renderToString } from 'react-dom/server';
import { QueryClient, QueryClientProvider, dehydrate } from '@tanstack/react-query';
import PostsPage from './react-pages/PostsPage.jsx';
const app = express();
app.get('/posts', async (req, res) => {
// ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
// 1단계: 요청별 QueryClient 생성 (격리!)
// ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
const queryClient = new QueryClient({
defaultOptions: {
queries: {
staleTime: 60 * 1000, // 1분간 fresh 상태 유지
cacheTime: 5 * 60 * 1000, // 5분간 캐시 보관
},
},
});
// ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
// 2단계: 서버에서 데이터 Prefetch
// ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
try {
// 게시글 목록 prefetch
await queryClient.prefetchQuery({
queryKey: ['posts'],
queryFn: async () => {
const response = await fetch('https://jsonplaceholder.typicode.com/posts');
if (!response.ok) throw new Error('Failed to fetch posts');
return response.json();
},
});
// 사용자 정보도 prefetch (여러 쿼리 가능!)
await queryClient.prefetchQuery({
queryKey: ['user', 1],
queryFn: async () => {
const response = await fetch('https://jsonplaceholder.typicode.com/users/1');
if (!response.ok) throw new Error('Failed to fetch user');
return response.json();
},
});
// 이 시점에 queryClient 내부 상태:
// {
// queries: Map {
// '["posts"]' => { data: [100개 posts], status: 'success', ... },
// '["user",1]' => { data: { id: 1, name: '...' }, status: 'success', ... }
// }
// }
// ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
// 3단계: Dehydrate — 캐시 상태를 순수 데이터로 추출
// ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
const dehydratedState = dehydrate(queryClient);
// dehydratedState의 실제 구조:
// {
// mutations: [],
// queries: [
// {
// queryKey: ["posts"],
// queryHash: '["posts"]',
// state: {
// data: [{ id: 1, title: "...", body: "..." }, ...],
// dataUpdatedAt: 1707500000000,
// status: "success",
// fetchStatus: "idle"
// }
// },
// {
// queryKey: ["user", 1],
// queryHash: '["user",1]',
// state: {
// data: { id: 1, name: "김영빈", email: "..." },
// dataUpdatedAt: 1707500000001,
// status: "success",
// fetchStatus: "idle"
// }
// }
// ]
// }
// ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
// 4단계: React 컴포넌트 렌더링
// ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
const html = renderToString(
React.createElement(
QueryClientProvider,
{ client: queryClient },
React.createElement(PostsPage)
)
);
// 이 시점에 html 변수에는 "<div><h1>Posts (100)</h1>...</div>" 같은 문자열이 들어있음
// 서버에서 이미 데이터가 있으니 정상적으로 렌더링됨
// ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
// 5단계: 직렬화 + XSS 방지
// ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
const serializedState = JSON.stringify(dehydratedState)
.replace(/</g, '\\u003c') // < 를 유니코드로 이스케이프
.replace(/>/g, '\\u003e') // > 를 유니코드로 이스케이프
.replace(/\u2028/g, '\\u2028') // Line separator
.replace(/\u2029/g, '\\u2029'); // Paragraph separator
// 왜 이스케이프가 필요하냐고?
// 악의적인 데이터: { title: "</script><script>alert('XSS')</script>" }
// 이스케이프 없이 주입하면: window.__STATE__ = {"title":"</script><script>alert('XSS')</script>"}
// → 스크립트가 끊기고 공격 코드가 실행됨!
// 이스케이프 후: window.__STATE__ = {"title":"\u003c/script\u003e..."}
// → 안전하게 문자열로 처리됨
// ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
// 6단계: HTML 배관 — dehydratedState를 HTML에 주입
// ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
const fullHtml = `
<!DOCTYPE html>
<html lang="ko">
<head>
<meta charset="UTF-8">
<title>Posts - Express SSR</title>
<style>
body { font-family: system-ui; max-width: 800px; margin: 0 auto; padding: 20px; }
h1 { color: #333; }
.post { border-bottom: 1px solid #eee; padding: 15px 0; }
.post h3 { margin: 0 0 10px 0; color: #0066cc; }
</style>
</head>
<body>
<div id="root">${html}</div>
<!-- 통조림 배송! -->
<script>
window.__REACT_QUERY_STATE__ = ${serializedState};
// 브라우저가 이 스크립트를 실행하면 전역 변수에 dehydratedState가 저장됨
</script>
<!-- 클라이언트 JS 번들 (여기서 hydrate 실행) -->
<script src="/public/react-bundle.js"></script>
</body>
</html>
`.trim();
res.send(fullHtml);
// ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
// 7단계: 메모리 정리 (중요!)
// ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
queryClient.clear();
// 이 요청에서 생성한 queryClient를 정리하지 않으면 메모리 누수!
// 요청이 1000개 들어오면 queryClient 1000개가 메모리에 쌓임
} catch (error) {
console.error('SSR Error:', error);
res.status(500).send('Server Error');
queryClient.clear(); // 에러 발생 시에도 정리!
}
});
app.use('/public', express.static('public'));
app.listen(3000, () => {
console.log('Express server running on http://localhost:3000');
});"여기서 중요한 포인트가 몇 가지 있어:"
왜 요청마다 새 QueryClient를 만드냐고?
// ❌ 잘못된 방법 (전역 싱글톤)
const queryClient = new QueryClient(); // 서버 시작 시 한 번만 생성
app.get('/posts', async (req, res) => {
await queryClient.prefetchQuery({ queryKey: ['posts'], ... });
// 문제 1: 사용자 A의 요청으로 채운 캐시를 사용자 B가 볼 수 있음 (보안!)
// 문제 2: 동시 요청이 들어오면 캐시가 섞임 (Race condition)
// 문제 3: 한 요청에서 에러 나면 전체 캐시가 오염됨
});
// ✅ 올바른 방법 (요청별 격리)
app.get('/posts', async (req, res) => {
const queryClient = new QueryClient(); // 매 요청마다 새로 생성
// 각 요청이 독립적인 캐시를 가짐
// 요청 끝나면 queryClient.clear()로 정리
});XSS 이스케이프는 왜 필요한가?
// 악의적인 API 응답이라고 가정
const maliciousData = {
title: '게시글 제목</script><script>document.cookie=""; alert("해킹!")</script>'
};
// 이스케이프 없이 주입하면:
// <script>
// window.__STATE__ = {"title":"게시글 제목</script><script>..."}
// </script>
// → 첫 번째 </script>에서 태그가 끊기고, 뒤의 <script>가 실행됨!
// 이스케이프 후:
// <script>
// window.__STATE__ = {"title":"게시글 제목\u003c/script\u003e\u003cscript\u003e..."}
// </script>
// → 안전하게 문자열로 처리됨클라이언트 측 Hydrate 파이프라인
// client/react-entry.jsx
import React from 'react';
import { hydrateRoot } from 'react-dom/client';
import { QueryClient, QueryClientProvider, HydrationBoundary } from '@tanstack/react-query';
import PostsPage from '../react-pages/PostsPage.jsx';
// ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
// 1단계: 서버에서 보낸 통조림 꺼내기
// ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
const dehydratedState = window.__REACT_QUERY_STATE__;
// 브라우저가 이미 <script>를 실행했기 때문에 전역 변수에서 꺼낼 수 있음
console.log('Dehydrated state:', dehydratedState);
// {
// queries: [
// { queryKey: ["posts"], state: { data: [...100 posts...], status: "success" } }
// ]
// }
// ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
// 2단계: 클라이언트 QueryClient 생성
// ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
const queryClient = new QueryClient({
defaultOptions: {
queries: {
staleTime: 60 * 1000, // 서버와 동일한 설정 유지
cacheTime: 5 * 60 * 1000,
},
},
});
// ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
// 3단계: Hydrate — 통조림에 물 붓기!
// ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
hydrateRoot(
document.getElementById('root'),
<QueryClientProvider client={queryClient}>
<HydrationBoundary state={dehydratedState}>
<PostsPage />
</HydrationBoundary>
</QueryClientProvider>
);
// HydrationBoundary가 하는 일:
// 1. dehydratedState.queries 배열을 순회
// 2. 각 쿼리를 queryClient.setQueryData()로 캐시에 주입
// 3. queryKey, data, status, dataUpdatedAt 등을 복원
//
// 내부 동작 (의사코드):
// dehydratedState.queries.forEach(query => {
// queryClient.setQueryData(query.queryKey, query.state.data);
// queryClient.setQueryState(query.queryKey, query.state);
// });
//
// 이제 queryClient 내부 상태:
// {
// queries: Map {
// '["posts"]' => { data: [...100 posts...], status: 'success', dataUpdatedAt: ... }
// }
// }"중요한 건 HydrationBoundary야. 이게 없으면 어떻게 될까?"
// ❌ HydrationBoundary 없이 직접 hydrate 시도
hydrateRoot(
document.getElementById('root'),
<QueryClientProvider client={queryClient}>
<PostsPage />
</QueryClientProvider>
);
// 문제:
// 1. PostsPage의 useQuery가 실행됨
// 2. queryClient 캐시가 비어있음 (dehydratedState를 주입하지 않았으니까)
// 3. isLoading = true, 로딩 스피너 표시
// 4. 서버에서 렌더링한 HTML(<h1>Posts (100)</h1>...)이 사라짐!
// 5. 로딩 화면으로 바뀜 (깜빡임 발생!)
// 6. API 재호출
// 7. 데이터 도착 후 다시 렌더링
//
// 결과: SSR의 의미가 없어짐. 서버에서 이미 렌더링한 내용을 버리고 다시 그림// ✅ HydrationBoundary 사용
<HydrationBoundary state={dehydratedState}>
<PostsPage />
</HydrationBoundary>
// 동작:
// 1. HydrationBoundary가 dehydratedState를 queryClient에 주입
// 2. PostsPage의 useQuery 실행
// 3. queryClient 캐시에서 데이터 발견! (캐시 히트)
// 4. isLoading = false, 데이터 즉시 반환
// 5. 서버에서 렌더링한 HTML과 동일한 내용을 렌더링
// 6. React의 hydration이 성공 (HTML 재사용)
// 7. API 재호출 없음!
//
// 결과: 깜빡임 없이 부드러운 전환, 네트워크 요청 절약컴포넌트 코드 (서버/클라이언트 공통)
// react-pages/PostsPage.jsx
import { useQuery } from '@tanstack/react-query';
export default function PostsPage() {
// ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
// useQuery는 서버/클라이언트 양쪽에서 실행됨
// ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
const { data: posts, isLoading, error } = useQuery({
queryKey: ['posts'],
queryFn: async () => {
console.log('🌐 API 호출 중...'); // 언제 호출되는지 확인용
const response = await fetch('https://jsonplaceholder.typicode.com/posts');
if (!response.ok) throw new Error('Failed to fetch');
return response.json();
},
});
// ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
// 실행 흐름 분석
// ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
// [서버 측 실행]
// 1. renderToString 중에 이 컴포넌트가 실행됨
// 2. useQuery가 queryClient 캐시를 확인
// 3. prefetchQuery로 이미 채워둔 데이터가 있음!
// 4. isLoading = false, data = [...100 posts...]
// 5. 아래 return문이 정상적으로 렌더링됨
// 6. queryFn은 실행되지 않음 (이미 캐시에 있으니까)
// [클라이언트 측 실행 - WITH Hydration]
// 1. HydrationBoundary가 dehydratedState를 캐시에 주입 (물 붓기!)
// 2. 이 컴포넌트의 useQuery 실행
// 3. queryClient 캐시에서 데이터 발견 (캐시 히트!)
// 4. isLoading = false, data = [...100 posts...]
// 5. queryFn은 실행되지 않음
// 6. staleTime(60초) 지나기 전까지 API 재호출 없음
// [클라이언트 측 실행 - WITHOUT Hydration]
// 1. queryClient 캐시가 비어있음
// 2. isLoading = true
// 3. "Loading..." 표시 (서버에서 렌더링한 HTML이 사라짐!)
// 4. queryFn 실행 → "🌐 API 호출 중..." 로그 출력
// 5. 데이터 도착 후 isLoading = false
// 6. 다시 렌더링
if (isLoading) {
return <div>Loading...</div>;
// Hydration이 제대로 됐다면 이 코드는 실행되지 않음!
// 서버에서도, 클라이언트에서도 캐시에 데이터가 있기 때문
}
if (error) {
return <div>Error: {error.message}</div>;
}
return (
<div>
<h1>게시글 목록 ({posts.length}개)</h1>
<p style={{ color: '#666', fontSize: '14px' }}>
💡 개발자 도구 Network 탭을 보세요. API 재호출이 없습니다!
</p>
<div>
{posts.slice(0, 10).map(post => (
<div key={post.id} className="post">
<h3>{post.id}. {post.title}</h3>
<p>{post.body}</p>
</div>
))}
</div>
<p style={{ marginTop: '40px', color: '#999', fontSize: '12px' }}>
* 처음 10개만 표시 중
</p>
</div>
);
}"자, 이제 Hydration이 없으면 어떻게 되는지 실험해보자:"
Hydration 유무 비교 실험
// 실험 1: Hydration 제대로 구현 (위 코드)
// 브라우저 콘솔 출력:
// (아무것도 없음)
//
// Network 탭:
// - localhost:3000/posts (Document) - 서버 요청
// - react-bundle.js (Script)
// (끝! API 요청 없음)
//
// 화면:
// 서버에서 렌더링한 HTML이 그대로 유지됨
// 깜빡임 없이 부드러운 전환
// 즉시 인터랙션 가능
// ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
// 실험 2: HydrationBoundary 제거 (안티패턴)
hydrateRoot(
document.getElementById('root'),
<QueryClientProvider client={queryClient}>
<PostsPage /> {/* HydrationBoundary 없음! */}
</QueryClientProvider>
);
// 브라우저 콘솔 출력:
// 🌐 API 호출 중...
//
// Network 탭:
// - localhost:3000/posts (Document) - 서버 요청
// - react-bundle.js (Script)
// - jsonplaceholder.typicode.com/posts (XHR) ← 불필요한 중복 요청!
//
// 화면:
// 1. 초기 HTML: <h1>게시글 목록 (100개)</h1>... (서버 렌더링 결과)
// 2. JS 로딩 후: <div>Loading...</div> (깜빡! 내용이 사라짐)
// 3. API 응답 후: <h1>게시글 목록 (100개)</h1>... (다시 렌더링)
//
// 문제점:
// - 깜빡임 (Content flickering)
// - 불필요한 네트워크 요청 (서버에서 이미 가져온 데이터를 또 가져옴)
// - 사용자 경험 저하
// - 서버 리소스 낭비 (SSR한 의미가 없음)
// ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
// 실험 3: dehydratedState 주입 실패 (HTML에 스크립트 안 넣었을 때)
// 서버에서 이 부분을 빼먹었다면:
// <script>
// window.__REACT_QUERY_STATE__ = ${serializedState};
// </script>
// 클라이언트 코드:
const dehydratedState = window.__REACT_QUERY_STATE__; // undefined!
// 브라우저 콘솔 에러:
// Warning: Expected server HTML to contain a matching <div> in <div>.
// (React hydration mismatch)
//
// 결과:
// - Hydration 실패
// - React가 전체 DOM을 다시 렌더링 (성능 저하)
// - 위 실험 2와 동일한 문제 발생Express 파이프라인 총정리
"자, 지금까지 본 Express의 Dehydrate/Hydrate 파이프라인을 정리하면:"
┌─────────────────────────────────────────────────────────────────┐
│ Express SSR 수동 배관 │
└─────────────────────────────────────────────────────────────────┘
[서버 측 - 7단계 수동 작업]
1. QueryClient 생성
├─ new QueryClient() 호출
├─ defaultOptions 설정
└─ 요청마다 새로 생성 (격리)
2. Prefetch
├─ await queryClient.prefetchQuery({ queryKey, queryFn })
├─ 필요한 모든 쿼리를 직접 나열
└─ 에러 처리 직접 구현
3. Dehydrate
├─ const dehydratedState = dehydrate(queryClient)
└─ 순수 데이터 추출
4. Serialize
├─ JSON.stringify(dehydratedState)
├─ XSS 방지 이스케이프 (<, >, \u2028, \u2029)
└─ 보안 취약점 직접 관리
5. HTML 주입
├─ <script>window.__RQ__ = ...</script> 수동 작성
├─ 변수 이름 충돌 관리
└─ CSP(Content Security Policy) 고려
6. 렌더링
├─ renderToString() 호출
├─ QueryClientProvider 수동 래핑
└─ HTML 템플릿 문자열 조립
7. 메모리 정리
├─ queryClient.clear() 호출
├─ try-catch-finally로 누수 방지
└─ 잊으면 메모리 폭탄
[클라이언트 측 - 3단계 수동 작업]
1. 전역 변수 파싱
├─ const state = window.__RQ__
├─ undefined 체크
└─ 파싱 에러 처리
2. QueryClient 생성
├─ new QueryClient() (서버와 동일한 옵션)
└─ 옵션 불일치 시 버그
3. Hydration
├─ <HydrationBoundary state={state}>
└─ 누락 시 깜빡임 발생
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
총 코드 라인 수: ~120줄
직접 관리해야 할 것:
✓ 요청별 QueryClient 격리
✓ XSS 이스케이프
✓ 메모리 누수 방지
✓ 에러 처리
✓ 보안
✓ 성능 최적화"이 모든 걸 직접 해야 해. 놓치기 쉬운 부분이 많지? 이제 Next.js가 어떻게 이걸 자동화하는지 보자."
Next.js + TanStack Query: 자동 관리의 세계
"Next.js는 App Router에서 React Server Components를 쓰니까, 서버/클라이언트 경계가 명확해. 그래서 Dehydrate/Hydrate 파이프라인이 훨씬 간결해."
Next.js 구현 (3단계면 끝)
// ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
// 1단계: QueryClient Provider 설정 (1회만)
// ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
// app/providers.tsx
'use client'; // 클라이언트 컴포넌트
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import { ReactQueryDevtools } from '@tanstack/react-query-devtools';
import { useState } from 'react';
export default function Providers({ children }: { children: React.ReactNode }) {
// useState를 쓰는 이유:
// React가 리렌더링해도 queryClient가 새로 생성되지 않도록
// 초기값 함수는 최초 1회만 실행됨
const [queryClient] = useState(() => new QueryClient({
defaultOptions: {
queries: {
staleTime: 60 * 1000,
cacheTime: 5 * 60 * 1000,
},
},
}));
return (
<QueryClientProvider client={queryClient}>
{children}
<ReactQueryDevtools initialIsOpen={false} />
</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>
);
}
// ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
// 2단계: Server Component에서 Prefetch
// ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
// app/posts/page.tsx
import { QueryClient, dehydrate, HydrationBoundary } from '@tanstack/react-query';
import PostsList from './PostsList';
// Server Component (기본값, 'use client' 없음)
export default async function PostsPage() {
// 1. QueryClient 생성 (요청별 자동 격리)
const queryClient = new QueryClient();
// 2. Prefetch
await queryClient.prefetchQuery({
queryKey: ['posts'],
queryFn: async () => {
const res = await fetch('https://jsonplaceholder.typicode.com/posts', {
// Next.js 캐싱 옵션
next: { revalidate: 60 }, // 60초마다 재검증
});
if (!res.ok) throw new Error('Failed to fetch posts');
return res.json();
},
});
// 여러 쿼리도 병렬로
await Promise.all([
queryClient.prefetchQuery({
queryKey: ['user', 1],
queryFn: async () => {
const res = await fetch('https://jsonplaceholder.typicode.com/users/1');
return res.json();
},
}),
queryClient.prefetchQuery({
queryKey: ['comments'],
queryFn: async () => {
const res = await fetch('https://jsonplaceholder.typicode.com/comments?_limit=10');
return res.json();
},
}),
]);
// 3. Dehydrate + Hydration (자동!)
return (
<HydrationBoundary state={dehydrate(queryClient)}>
<PostsList />
</HydrationBoundary>
);
// 끝!
// 여기서 Next.js가 자동으로 해주는 것:
// ✓ dehydratedState를 직렬화
// ✓ XSS 이스케이프
// ✓ HTML에 주입 (내부 메커니즘으로, window 변수 아님)
// ✓ 클라이언트에서 자동 파싱
// ✓ HydrationBoundary가 자동으로 queryClient에 주입
// ✓ 메모리 관리 (요청 끝나면 자동 정리)
}
// ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
// 3단계: Client Component에서 사용
// ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
// app/posts/PostsList.tsx
'use client'; // 클라이언트 컴포넌트 (useQuery 사용)
import { useQuery } from '@tanstack/react-query';
export default function PostsList() {
const { data: posts, isLoading } = useQuery({
queryKey: ['posts'],
queryFn: async () => {
// 이 함수는 캐시 미스 시에만 실행됨
console.log('🌐 API 호출');
const res = await fetch('https://jsonplaceholder.typicode.com/posts');
return res.json();
},
});
// 서버에서 prefetch했기 때문에:
// - 초기 렌더링 시 isLoading = false
// - data에 이미 100개의 posts가 있음
// - API 재호출 없음 (staleTime 60초 동안)
if (isLoading) return <div>Loading...</div>; // 실행 안 됨
return (
<div>
<h1>게시글 목록 ({posts?.length}개)</h1>
{posts?.slice(0, 10).map((post: any) => (
<div key={post.id}>
<h3>{post.title}</h3>
<p>{post.body}</p>
</div>
))}
</div>
);
}"보이지? Express에서 120줄로 했던 걸 Next.js는 30줄로 끝낸 거야. 차이가 뭔지 보자."
Express vs Next.js 비교표
| 단계 | Express 수동 파이프라인 | Next.js 자동 지원 | 자동화 수준 |
|---|---|---|---|
| QueryClient 생성 | new QueryClient() 수동 호출 요청별 격리 직접 관리 | Server Component마다 자동 격리 | ⭐⭐⭐ |
| Prefetch | await queryClient.prefetchQuery() | 동일 (명시적) | - |
| Dehydrate | dehydrate(queryClient) 수동 호출 | 동일 (명시적) | - |
| 직렬화 | JSON.stringify() 직접 호출 XSS 이스케이프 직접 구현 (<, >, \u2028, \u2029) | 완전 자동 Next.js 내부에서 안전하게 처리 | ⭐⭐⭐⭐⭐ |
| HTML 주입 | <script>window.__RQ__</script> 수동 작성 변수 이름 충돌 관리 CSP 고려 | 완전 자동 React Server Components 통신 채널 사용 (window 변수 사용 안 함) | ⭐⭐⭐⭐⭐ |
| 클라이언트 파싱 | window.__RQ__ 직접 접근 undefined 체크 파싱 에러 처리 | 완전 자동 HydrationBoundary가 내부적으로 처리 | ⭐⭐⭐⭐⭐ |
| Hydrate | <HydrationBoundary state={...}> 수동 연결 | 동일 (명시적) | - |
| 메모리 관리 | queryClient.clear() 수동 호출 try-finally로 누수 방지 | 완전 자동 요청 끝나면 자동 정리 | ⭐⭐⭐⭐⭐ |
| 에러 처리 | try-catch 직접 구현 | Error Boundary 자동 통합 | ⭐⭐⭐ |
| 보안 | XSS 이스케이프 직접 관리 | 프레임워크 레벨에서 보장 | ⭐⭐⭐⭐⭐ |
| 코드 라인 수 | ~120줄 | ~30줄 | 4배 감소 |
| 버그 가능성 | 높음 (7단계 수동 작업) | 낮음 (3단계만 명시) | ⭐⭐⭐⭐ |
| 학습 곡선 | 가파름 (내부 동작 이해 필요) | 완만함 (추상화 잘 됨) | ⭐⭐⭐⭐ |
Next.js가 자동화하는 내부 메커니즘
"Next.js는 어떻게 이 모든 걸 자동으로 하는 걸까? 내부를 들여다보자."
// Next.js 내부 동작 (의사코드)
// 1. Server Component 렌더링 중
async function renderServerComponent(Component) {
const reactTree = await Component(); // <HydrationBoundary state={...}> 실행
// 2. HydrationBoundary의 state prop 감지
// HydrationBoundary가 dehydratedState를 받으면:
const dehydratedStates = extractHydrationStates(reactTree);
// [
// { id: 'hydration-1', state: { queries: [...] } },
// { id: 'hydration-2', state: { queries: [...] } }
// ]
// 3. 안전한 직렬화 (XSS 자동 방지)
const serializedStates = dehydratedStates.map(item => ({
id: item.id,
state: JSON.stringify(item.state)
.replace(/</g, '\\u003c')
.replace(/>/g, '\\u003e')
.replace(/\u2028/g, '\\u2028')
.replace(/\u2029/g, '\\u2029')
// + 추가 보안 체크
}));
// 4. RSC Payload에 포함 (HTML이 아님!)
// Next.js는 React Server Components 프로토콜을 사용
// 브라우저로 전송되는 특수 포맷:
const rscPayload = {
componentTree: reactTree,
hydrationData: serializedStates,
// 기타 메타데이터
};
// 5. 클라이언트로 전송
return streamRSCPayload(rscPayload);
}
// 클라이언트 측
function hydrateFromRSCPayload(payload) {
// 1. RSC Payload 파싱 (자동)
const { componentTree, hydrationData } = parseRSCPayload(payload);
// 2. HydrationBoundary에 state 주입 (자동)
hydrationData.forEach(item => {
// 해당 HydrationBoundary를 찾아서
const boundary = findHydrationBoundary(item.id);
// queryClient에 자동 주입
boundary.hydrate(JSON.parse(item.state));
});
// 3. React hydration 실행
hydrateRoot(rootElement, componentTree);
}"핵심은 Next.js가 window 전역 변수를 안 쓴다는 거야. RSC(React Server Components) 프로토콜이라는 특수한 통신 채널을 쓰기 때문에, Express처럼 <script>window.__STATE__</script> 같은 해킹을 안 해도 돼."
dehydratedState 내부 구조 상세 분석
"Dehydrate가 정확히 뭘 추출하는지 보자:"
// QueryClient 내부 구조 (실제)
class QueryClient {
private queryCache: QueryCache;
private mutationCache: MutationCache;
private defaultOptions: DefaultOptions;
// ... 기타 많은 내부 상태
}
class QueryCache {
private queries: Map<string, Query>;
// queries: Map {
// '["posts"]' => Query {
// queryKey: ["posts"],
// queryHash: '["posts"]',
// state: QueryState {
// data: [...100 posts...],
// dataUpdatedAt: 1707500000000,
// error: undefined,
// errorUpdatedAt: 0,
// fetchStatus: "idle",
// status: "success"
// },
// observers: [QueryObserver, ...], // 리액트 컴포넌트들
// gcTimeout: Timeout { ... }, // 가비지 컬렉션 타이머
// retryer: Retryer { ... }, // 재시도 로직
// // ... 직렬화 불가능한 객체들
// }
// }
}// dehydrate() 함수가 하는 일
function dehydrate(client: QueryClient): DehydratedState {
return {
mutations: dehydrateMutations(client),
queries: dehydrateQueries(client),
};
}
function dehydrateQueries(client: QueryClient) {
const queries = client.getQueryCache().getAll();
return queries
.filter(query => {
// 조건: 성공한 쿼리만, 또는 에러를 직렬화하고 싶은 경우
return query.state.status === 'success' ||
(query.state.status === 'error' && query.options.dehydrate?.shouldDehydrateError);
})
.map(query => ({
// ✅ 직렬화 가능한 것만 추출
queryKey: query.queryKey,
queryHash: query.queryHash,
state: {
data: query.state.data, // 실제 데이터
dataUpdatedAt: query.state.dataUpdatedAt, // 타임스탬프
status: query.state.status,
fetchStatus: query.state.fetchStatus,
error: query.state.error, // Error 객체는 특별 처리
},
// ❌ 제외되는 것들
// observers: [...] → 제외 (리액트 컴포넌트 참조)
// gcTimeout: Timeout → 제외 (타이머)
// retryer: Retryer → 제외 (함수 포함)
// queryFn: () => fetch(...) → 제외 (함수)
}));
}"결과물은 이렇게 생겨:"
{
"mutations": [],
"queries": [
{
"queryKey": ["posts"],
"queryHash": "[\"posts\"]",
"state": {
"data": [
{
"userId": 1,
"id": 1,
"title": "sunt aut facere repellat provident occaecati excepturi optio reprehenderit",
"body": "quia et suscipit\nsuscipit recusandae consequuntur..."
},
...99개 더
],
"dataUpdatedAt": 1707500000000,
"error": null,
"errorUpdatedAt": 0,
"fetchStatus": "idle",
"status": "success"
}
},
{
"queryKey": ["user", 1],
"queryHash": "[\"user\",1]",
"state": {
"data": {
"id": 1,
"name": "Leanne Graham",
"username": "Bret",
"email": "Sincere@april.biz",
"address": {
"street": "Kulas Light",
"suite": "Apt. 556",
"city": "Gwenborough",
"zipcode": "92998-3874",
"geo": {
"lat": "-37.3159",
"lng": "81.1496"
}
}
},
"dataUpdatedAt": 1707500000100,
"error": null,
"errorUpdatedAt": 0,
"fetchStatus": "idle",
"status": "success"
}
}
]
}"이 순수 데이터를 받아서 클라이언트의 QueryClient에 다시 채우는 게 Hydrate야:"
// hydrate() 함수 (의사코드)
function hydrate(client: QueryClient, dehydratedState: DehydratedState) {
// 1. 각 쿼리를 순회
dehydratedState.queries.forEach(dehydratedQuery => {
// 2. QueryClient에 데이터 주입
client.setQueryData(
dehydratedQuery.queryKey,
dehydratedQuery.state.data
);
// 3. 메타데이터도 복원
client.setQueryState(dehydratedQuery.queryKey, {
data: dehydratedQuery.state.data,
dataUpdatedAt: dehydratedQuery.state.dataUpdatedAt,
status: dehydratedQuery.state.status,
fetchStatus: dehydratedQuery.state.fetchStatus,
error: dehydratedQuery.state.error,
});
// 4. 이제 useQuery가 실행되면 캐시에서 바로 찾음!
});
// Mutation도 동일하게 처리
dehydratedState.mutations.forEach(dehydratedMutation => {
// ...
});
}실전 시나리오: 사용자별 데이터 Prefetch
"실무에서는 이렇게 쓰지:"
// Next.js 예제: 인증된 사용자의 대시보드
// app/dashboard/page.tsx
import { QueryClient, dehydrate, HydrationBoundary } from '@tanstack/react-query';
import { cookies } from 'next/headers';
import DashboardContent from './DashboardContent';
export default async function DashboardPage() {
// 1. 쿠키에서 인증 토큰 가져오기
const cookieStore = cookies();
const token = cookieStore.get('auth_token')?.value;
if (!token) {
redirect('/login');
}
const queryClient = new QueryClient();
// 2. 사용자별 데이터 Prefetch
await Promise.all([
// 사용자 프로필
queryClient.prefetchQuery({
queryKey: ['user', 'profile'],
queryFn: async () => {
const res = await fetch('https://api.example.com/user/profile', {
headers: { Authorization: `Bearer ${token}` },
});
return res.json();
},
}),
// 최근 알림
queryClient.prefetchQuery({
queryKey: ['notifications', { unread: true }],
queryFn: async () => {
const res = await fetch('https://api.example.com/notifications?unread=true', {
headers: { Authorization: `Bearer ${token}` },
});
return res.json();
},
}),
// 대시보드 통계
queryClient.prefetchQuery({
queryKey: ['dashboard', 'stats'],
queryFn: async () => {
const res = await fetch('https://api.example.com/dashboard/stats', {
headers: { Authorization: `Bearer ${token}` },
});
return res.json();
},
}),
]);
// 3. Dehydrate + Hydrate (자동!)
return (
<HydrationBoundary state={dehydrate(queryClient)}>
<DashboardContent />
</HydrationBoundary>
);
}
// app/dashboard/DashboardContent.tsx
'use client';
import { useQuery } from '@tanstack/react-query';
export default function DashboardContent() {
// 서버에서 이미 prefetch한 데이터 사용
const { data: profile } = useQuery({ queryKey: ['user', 'profile'] });
const { data: notifications } = useQuery({ queryKey: ['notifications', { unread: true }] });
const { data: stats } = useQuery({ queryKey: ['dashboard', 'stats'] });
// 모두 즉시 사용 가능! isLoading = false
return (
<div>
<h1>안녕하세요, {profile?.name}님!</h1>
<div>읽지 않은 알림: {notifications?.length}개</div>
<div>오늘의 방문자: {stats?.visitors}명</div>
</div>
);
}"Express에서 같은 걸 하려면?"
// Express: 인증 토큰을 어떻게 prefetch에 전달할까?
app.get('/dashboard', authenticateMiddleware, async (req, res) => {
const queryClient = new QueryClient();
// 문제: prefetchQuery의 queryFn에서 req.user에 어떻게 접근?
await queryClient.prefetchQuery({
queryKey: ['user', 'profile'],
queryFn: async () => {
// ❌ req에 접근할 수 없음! (클로저로 캡처해야 함)
const res = await fetch('...', {
headers: { Authorization: `Bearer ${req.user.token}` },
});
return res.json();
},
});
// 클로저로 해결:
const fetchWithAuth = (url) =>
fetch(url, { headers: { Authorization: `Bearer ${req.user.token}` } })
.then(r => r.json());
await queryClient.prefetchQuery({
queryKey: ['user', 'profile'],
queryFn: () => fetchWithAuth('https://api.example.com/user/profile'),
});
// 이런 식으로 매번 래퍼 함수를 만들어야 함
// Next.js는 cookies() 헬퍼로 간단히 해결
});핵심 개념 정리: 왜 Dehydrate/Hydrate인가?
"자, 이제 PM이 처음에 했던 질문으로 돌아가보자:"
김도연: "서버에서 미리 데이터를 가져와서 캐시에 채워두면 클라이언트가 API를 다시 호출하지 않아도 되지 않을까요?"
"맞아. 그런데 왜 '그냥 데이터 전달'이 아니라 'Dehydrate/Hydrate'라는 복잡한 과정을 거치는 걸까?"
단순 데이터 전달 vs Dehydrate/Hydrate
// ❌ 안티패턴: 그냥 데이터만 전달
// 서버
app.get('/posts', async (req, res) => {
const posts = await fetch('...').then(r => r.json());
res.send(`
<script>window.__POSTS__ = ${JSON.stringify(posts)}</script>
<div id="root"></div>
`);
});
// 클라이언트
function PostsList() {
const [posts, setPosts] = useState(window.__POSTS__);
// 문제 1: 이건 한 번만 쓰는 데이터야. 캐싱이 안 됨
// 문제 2: 페이지 이동 후 돌아오면? 다시 API 호출해야 함
// 문제 3: 다른 컴포넌트에서 같은 데이터가 필요하면? 중복 요청
// 문제 4: staleTime, cacheTime 같은 캐시 정책을 어떻게?
// 문제 5: 백그라운드 리페칭, 자동 재검증은?
}// ✅ Dehydrate/Hydrate: 캐시 상태를 통째로 전달
// 서버
const queryClient = new QueryClient();
await queryClient.prefetchQuery({ queryKey: ['posts'], queryFn: ... });
const dehydratedState = dehydrate(queryClient);
// → { queries: [{ queryKey: ["posts"], state: { data, dataUpdatedAt, ... } }] }
// 클라이언트
<HydrationBoundary state={dehydratedState}>
<PostsList />
</HydrationBoundary>
function PostsList() {
const { data: posts } = useQuery({ queryKey: ['posts'], queryFn: ... });
// 장점 1: queryClient 캐시에 저장됨 (staleTime 동안 유지)
// 장점 2: 페이지 이동 후 돌아와도 캐시에서 즉시 반환
// 장점 3: 다른 컴포넌트도 같은 queryKey로 접근 가능 (중복 요청 없음)
// 장점 4: staleTime 지나면 자동으로 백그라운드 리페칭
// 장점 5: 모든 TanStack Query 기능 사용 가능 (retry, refetchOnWindowFocus, ...)
}통조림 비유 최종 정리
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
🥫 통조림 공장 운영 (Dehydrate/Hydrate)
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
[서버: 통조림 공장]
1. 재료 준비 (API 호출)
└─ fetch('https://api.../posts')
2. 조리 (데이터 가공)
└─ queryClient에 저장
3. 통조림 만들기 (Dehydrate)
└─ 신선한 음식 → 보존 가능한 형태로 변환
└─ QueryClient → JSON 직렬화 가능한 순수 데이터
4. 포장/배송 (Serialize)
└─ 통조림 → 상자에 담아 배송
└─ JSON → HTML에 삽입
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
[클라이언트: 집에서 요리]
5. 택배 수령 (HTML 도착)
└─ 브라우저가 HTML 파싱
6. 개봉 (Parse)
└─ 상자 열기 → JSON.parse()
7. 물 붓기 (Hydrate)
└─ 통조림 내용물 → 냄비에 붓고 물 추가
└─ dehydratedState → QueryClient에 주입
8. 조리 완성 (React Hydration)
└─ 요리 완성! → 먹을 수 있음
└─ 컴포넌트 인터랙티브 → 사용 가능
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
왜 신선한 음식을 그대로 못 보내나요?
→ 상한다! (직렬화 불가능한 함수, 타이머 등)
왜 통조림으로 만드나요?
→ 보존 가능! (순수 데이터만 추출)
물 붓기(Hydrate)를 왜 해야 하나요?
→ 그대로는 못 먹어! (캐시에 복원해야 사용 가능)
Express vs Next.js 차이는?
→ Express: 통조림 공장 직접 운영 (모든 단계 수동)
→ Next.js: 자동 통조림 제조기 (버튼만 누르면 끝)깨달음 포인트
"오늘 배운 걸 정리해보자."
1. Dehydrate/Hydrate는 캐시 상태 전송 파이프라인
서버의 살아있는 QueryClient → dehydrate → 순수 데이터 (통조림)
↓
JSON 직렬화
↓
HTML에 주입 (배송)
↓
클라이언트로 전송
↓
JSON 파싱
↓
hydrate → QueryClient 복원 (물 붓기)
↓
캐시 히트! API 재호출 없음2. Express는 완전 수동 배관
✓ QueryClient 생성 (요청별 격리)
✓ prefetchQuery (데이터 로딩)
✓ dehydrate (순수 데이터 추출)
✓ JSON.stringify (직렬화)
✓ XSS 이스케이프 (보안)
✓ HTML 템플릿에 주입 (<script>window.__STATE__</script>)
✓ 클라이언트에서 window 전역 변수 접근
✓ HydrationBoundary로 연결
✓ queryClient.clear() (메모리 정리)
→ 120줄, 놓치기 쉬운 부분 많음
→ XSS, 메모리 누수, Race condition 등 직접 관리3. Next.js는 자동화의 정점
// 이것만 하면 됨:
const queryClient = new QueryClient();
await queryClient.prefetchQuery({ ... });
return (
<HydrationBoundary state={dehydrate(queryClient)}>
<Component />
</HydrationBoundary>
);
// 나머지는 프레임워크가 알아서:
✓ 직렬화 (자동)
✓ XSS 방지 (자동)
✓ RSC Payload 통신 (window 변수 없이)
✓ 클라이언트 파싱 (자동)
✓ Hydration (자동)
✓ 메모리 관리 (자동)
→ 30줄, 안전하고 간결
→ 보안/성능 이슈 프레임워크가 책임4. Dehydrate 없이 SSR하면 깜빡임 발생
서버 렌더링: <h1>Posts (100)</h1><div>게시글 1</div>...
↓
브라우저 수신: [서버 HTML 표시]
↓
클라이언트 JS 실행: useQuery → 캐시 비어있음 → isLoading = true
↓
화면 깜빡!: <div>Loading...</div> (서버 HTML 사라짐!)
↓
API 재호출: fetch('...')
↓
다시 렌더링: <h1>Posts (100)</h1><div>게시글 1</div>...
→ 사용자 경험 저하, 네트워크 낭비5. 핵심은 "캐시 상태 동기화"
"단순히 데이터를 전달하는 게 아니라, QueryClient의 캐시 상태를 서버와 클라이언트 간에 동기화하는 거야. 그래야 TanStack Query의 모든 기능(staleTime, cacheTime, 백그라운드 리페칭 등)을 SSR과 함께 쓸 수 있지."
다음 단계 예고
"이준혁님, 그럼 TanStack Query로 SSR을 하면서 인증도 추가하려면 어떻게 해야 하나요? 서버에서 prefetch할 때 사용자별 데이터를 가져와야 하잖아요. 쿠키나 토큰을 어떻게 전달하죠?"
"좋은 질문이야! 바로 Request Context 문제지. Express는 req 객체를 어떻게든 queryFn까지 전달해야 하고, Next.js는 cookies() 헬퍼로 간단히 해결해. 다음 스텝에서 인증/세션 관리를 다뤄볼게."
다음 편: Step 04: 인증/세션 관리에서 Request Context 배관과 인증 토큰 전달 방식을 비교합니다.
학습 체크리스트
- Dehydrate/Hydrate의 개념을 통조림 비유로 이해했다
- Express에서 수동 파이프라인 7단계를 구현할 수 있다
- XSS 이스케이프가 왜 필요한지 안다
- HydrationBoundary의 역할을 안다
- Next.js의 자동화가 어떤 부분인지 구분할 수 있다
- dehydratedState의 내부 구조를 안다
- Hydration 없이 SSR하면 왜 깜빡임이 생기는지 안다
- queryClient.clear()를 왜 해야 하는지 안다
- Next.js가 window 변수를 안 쓰는 이유를 안다
- 사용자별 데이터 prefetch 패턴을 이해했다
실습 과제
- Express 프로젝트에 TanStack Query SSR 구현 (수동 파이프라인)
- XSS 이스케이프를 빼먹으면 어떻게 되는지 실험
- HydrationBoundary를 제거하고 깜빡임 확인
- Next.js 프로젝트로 동일 기능 구현 (코드 라인 수 비교)
- 여러 쿼리를 prefetch하고 병렬 요청 확인
디버깅 팁
// Express: dehydratedState 확인
console.log('Dehydrated:', JSON.stringify(dehydratedState, null, 2));
// 클라이언트: Hydration 확인
console.log('Hydrated:', window.__REACT_QUERY_STATE__);
// React Query Devtools 사용
import { ReactQueryDevtools } from '@tanstack/react-query-devtools';
<ReactQueryDevtools initialIsOpen={false} />
// 캐시 상태 확인
queryClient.getQueryData(['posts']); // 캐시에 뭐가 있는지
queryClient.getQueryState(['posts']); // 상태 정보 (status, dataUpdatedAt 등)© 2026 Express vs Next.js 심층 비교 튜토리얼 | Step 03: TanStack Query SSR