Nextjs 실행 컨텍스트
main
date
Jan 6, 2026
slug
nextjs-server
author
status
Public
tags
Nextjs
summary
type
Post
thumbnail
category
Development
updatedAt
Sep 15, 2026 06:20 AM
Nextjs 동작 방식
Next.js의 5가지 실행 컨텍스트
┌─────────────────────────────────────────────────────────────┐ │ Next.js 앱 │ ├─────────────────────────────────────────────────────────────┤ │ │ │ 클라이언트 ──→ [Middleware] ──→ [Route Handler/Page] │ │ │ │ │ │ │ ┌─────┴─────┐ │ │ │ │ │ │ │ │ [Server [Client │ │ │ Component] Component] │ │ │ │ │ │ │ │ └─────┬─────┘ │ │ │ │ │ │ │ [Server Action] │ │ │ └─────────────────────────────────────────────────────────────┘
각 컨텍스트에서 캐싱이 다르게 동작
실행 컨텍스트 | Request Memoization | Data Cache | 주 용도 |
Server Component | ✅ | ✅ | 초기 데이터 로드, SEO |
Client Component | - | - | 인터랙션, 상태 관리 |
Server Action | ❌ | ✅ | 데이터 변경, form 처리 |
Route Handler | ❌ | ✅ | 외부 API, 웹훅 |
Middleware | ❌ | ⚠️ | 인증, 리다이렉트 |
Server Component vs Client Component
┌─────────────────────────────────────────────────────────────────┐ │ Next.js App Router │ ├─────────────────────────────────────────────────────────────────┤ │ │ │ Server Component (기본값) Client Component │ │ ───────────────────────── ──────────────── │ │ │ │ • 'use client' 없으면 자동 • 'use client' 선언 필요 │ │ • 서버에서만 실행 • 서버 + 브라우저에서 실행 │ │ • async/await 사용 가능 • useState, useEffect 사용 │ │ • 환경변수 안전하게 사용 • 이벤트 핸들러 사용 가능 │ │ • JS 번들에 포함 안됨 • JS 번들에 포함됨 │ │ │ └─────────────────────────────────────────────────────────────────┘
'use client'의 진짜 의미
'use client' // ❌ 흔한 오해: "이 컴포넌트는 브라우저에서만 실행된다" // ✅ 실제 의미: "이 컴포넌트는 클라이언트 번들에 포함되고, hydration을 위해 브라우저로 전송된다" export default function Counter() { // 실제로는: // 1. 서버에서 초기 HTML 생성을 위해 한번 실행 (SSR) // 2. 브라우저에서 hydration을 위해 또 실행 // 3. 이후 상호작용 시 브라우저에서 실행 console.log('렌더링됨') // 서버 로그에도, 브라우저 콘솔에도 찍힌다! const [count, setCount] = useState(0) return <button onClick={() => setCount(c => c + 1)}>{count}</button> }
핵심: 'use client'는 "브라우저 전용"이 아니라 "hydration이 필요한 컴포넌트"를 의미
Server Action이란?
// 'use server' 선언된 함수 = Server Action 'use server' export async function getProducts() { // 이 함수는 항상 서버에서 실행된다 // 클라이언트에서 호출해도 서버에서 실행된다! const res = await fetch('https://api.example.com/products', { headers: { 'Authorization': `Bearer ${process.env.API_SECRET_KEY}` } }) return res.json() }
핵심: Server Action은 어디서 호출하든 서버에서 실행
Server Action으로 다 해도 되는가?
Server Action의 강력함
Server Action을 알게 되면 이런 생각이 든다:
"Server Component에서도 쓸 수 있고, Client Component에서도 쓸 수 있다면,Server Action으로 다 하면 되는 거 아니야?"
맞다. 대부분의 경우 Server Action으로 충분하다.
'use server' // 조회 (GET 용도) export async function getProducts() { const res = await fetch('https://api.example.com/products') return res.json() } // 생성 (POST 용도) export async function createProduct(data: CreateProductInput) { const res = await fetch('https://api.example.com/products', { method: 'POST', body: JSON.stringify(data) }) return res.json() } // 삭제 (DELETE 용도) export async function deleteProduct(id: string) { const res = await fetch(`https://api.example.com/products/${id}`, { method: 'DELETE' }) return res.json() }
Server Component에서도, Client Component에서도 똑같이 import해서 쓸 수 있다
// Server Component에서 const products = await getProducts() // 직접 실행 // Client Component에서 const products = await getProducts() // 내부적으로 서버 요청 후 실행
"Server Action은 POST만 된다"는 말의 진짜 의미
Server Actions are invoked using the POST method
이걸 보고 "그럼 GET은 안 돼? 데이터 조회는 Route Handler로 해야 하나?"라고 오해하기 쉽다.
하지만 이 말의 진짜 의미는 다르다
┌─────────────────────────────────────────────────────────┐ │ │ │ "Server Action 자체가 호출될 때" = 항상 POST │ │ "Server Action 내부에서 외부 API 호출" = GET이든 자유 │ │ │ └─────────────────────────────────────────────────────────┘
'use server' export async function getProducts() { // 이 안에서는 GET 요청 완전히 가능하다! const res = await fetch('https://api.example.com/products', { method: 'GET' // 또는 생략 (기본값이 GET) }) return res.json() }
전체 흐름을 보면 바로 이해가 된다
브라우저에서 getProducts() 호출 │ │ POST /_next/server-action/... ← 여기가 POST! ▼ Next.js 서버에서 getProducts 함수 실행 │ │ GET https://api.example.com/products ← 여기는 GET! ▼ 외부 API에서 데이터 반환 │ ▼ 브라우저로 결과 전달
결론: Server Action으로 데이터 조회(GET 용도) 완전히 가능하다
빌드 타임에 일어나는 마법
"Client Component에서 Server Action을 어떻게 호출할 수 있는 거야?"
비밀은 빌드 타임에 있다. Next.js가 빌드할 때 코드를 쪼개서 변환한다.
원본 코드 (개발자가 작성) ────────────────────────── 'use server' export async function getProducts() { const res = await fetch('https://api.example.com/products') return res.json() } ↓ Next.js 빌드 ↓ 서버용 코드 (서버에 남음) ────────────────────────── // 실제 로직이 여기서 실행된다 async function getProducts() { const res = await fetch('https://api.example.com/products') return res.json() } 클라이언트용 코드 (브라우저로 전송) ──────────────────────────────── // 실제 로직은 없다! 서버 호출 함수로 대체된다 async function getProducts() { return fetch('/_next/server-action/abc123', { method: 'POST', body: JSON.stringify({ actionId: 'getProducts' }) }).then(res => res.json()) }
Client Component에서
getProducts를 import하면, 실제 로직이 아니라 "서버를 호출하는 함수"가 import된다.이게 왜 좋냐면:
┌──────────────────────────────────────────────────────────┐ │ │ │ Client Component에서 Server Action을 import하면, │ │ 실제 코드가 아니라 "서버에 요청하는 함수"가 import됨 │ │ │ │ 그래서 API 키 같은 환경변수가 브라우저에 절대 노출 안됨! │ │ 🔐 보안 완벽! │ │ │ └──────────────────────────────────────────────────────────┘
일반 서버 코드 vs Server Action
// 일반 서버 코드 (❌ Client Component에서 import 불가) // lib/api.ts ('use server' 없음) export async function getProducts() { return await fetch('https://api.example.com/products') .then(res => res.json()) } // Client Component에서: 'use client' import { getProducts } from '@/lib/api' // ❌ 에러! // → 서버 코드가 클라이언트 번들에 포함되려고 함 // → 빌드 에러 또는 런타임 에러 😱 // Server Action (✅ 어디서든 import 가능) // app/actions/products.ts 'use server' // ← 이 한 줄이 마법! export async function getProducts() { return await fetch('https://api.example.com/products') .then(res => res.json()) } // Server Component에서: import { getProducts } from '@/app/actions/products' // ✅ OK! const products = await getProducts() // Client Component에서: 'use client' import { getProducts } from '@/app/actions/products' // ✅ OK! // → 프록시 함수가 import된다 (실제 로직 아님)
일반 서버 코드 | Server Action ( 'use server') |
Server Component에서 import | ✅ 가능 |
Client Component에서 import | ❌ 에러 |
클라이언트 번들 크기 | 포함되면 커짐 |
환경변수 (API 키 등) | 노출 위험 😱 |
Route Handler는 언제 필요한가?
대부분의 경우 Route Handler는 필요 없다. Next.js 앱 외부에서 호출할 때만 필요하다.
Next.js 앱 내부에서 호출 → Server Action ✅ ─────────────────────────────────────────── • Server Component에서 호출 • Client Component에서 호출 • 둘 다 Server Action으로 충분! Next.js 앱 외부에서 호출 → Route Handler 필요 ─────────────────────────────────────────── • 모바일 앱 (React Native, Flutter) • 다른 백엔드 서버 • Webhook (결제 콜백, 알림 등)
"백엔드 API 호출도 외부 API 아니야?"
"외부 API 호출"의 두 가지 의미: ─────────────────────────────────────────────────────────────── 1️⃣ 우리 코드가 외부 API를 호출하는 것 (Server Action으로 OK) Next.js 앱 ─────▶ 외부 백엔드 API 예: Server Action에서 https://api.example.com 호출 → Server Action으로 충분하다! 2️⃣ 외부에서 우리 API를 호출하는 것 (Route Handler 필요) 모바일 앱 ─────▶ 우리 Next.js 앱 예: React Native 앱이 우리 서버에 요청 → Route Handler가 필요하다!
백엔드 API를 호출하는 건 "외부 API 호출"이 맞지만, 그걸 호출하는 우리 코드는 Next.js 앱 내부다. 그래서 Server Action으로 충분하다
언제 뭘 쓸까?
상황 | 추천 | 이유 |
Next.js 앱 내 form 제출 | Server Action | 간단하고 타입 안전 |
버튼 클릭으로 데이터 변경 | Server Action | 보일러플레이트 적음 |
모바일 앱에서 호출 | Route Handler | 표준 REST API 필요 |
웹훅 수신 (Stripe, GitHub) | Route Handler | 외부에서 호출됨 |
하이브리드 접근법
둘 다 필요하다면? 비즈니스 로직을 분리해서 재사용하면 된다.
// lib/orders.ts - 공유 비즈니스 로직 export async function createOrderLogic(user: User, items: CartItem[]) { const order = await db.orders.create({ userId: user.id, items }) return order } // app/actions/orders.ts - 내부용 Server Action 'use server' export async function createOrderAction(items: CartItem[]) { const user = await getCurrentUser() return createOrderLogic(user, items) } // app/api/orders/route.ts - 외부용 Route Handler export async function POST(request: Request) { const user = await authenticateRequest(request) const { items } = await request.json() return NextResponse.json(await createOrderLogic(user, items)) }
Client Component만 쓰면 안 되나?
"Client Component에서 useEffect로 데이터 가져오면 안 돼?"
가능하긴 한데... 문제가 있다.
// ❌ Client Component만 사용 'use client' function ProductsPage() { const [products, setProducts] = useState([]) const [loading, setLoading] = useState(true) useEffect(() => { getProducts().then(data => { setProducts(data) setLoading(false) }) }, []) if (loading) return <div>로딩중...</div> // 😢 사용자가 이걸 본다 return <ul>...</ul> }
문제점:
- 초기 HTML이 비어있다: 검색 엔진이 보는 것도 "로딩중..."이다. SEO 망함 😭
- 로딩 스피너가 보인다: 사용자 경험 저하
- 느린 초기 로드: 브라우저 → Next.js → 외부 API 왕복이 필요
왜 Server Component인데 로딩 스피너가 뜨지?
실제 프로젝트에서 이런 실수를 정말 많이 본다:
// page.tsx (서버 컴포넌트) export default async function Page() { const products = await getProducts() // 서버에서 호출은 했는데... return ( <Suspense fallback={<Spinner />}> <ProductList />{/* 결과를 전달 안 함! */} </Suspense> ) } // product-list.tsx (클라이언트 컴포넌트) 'use client' export default function ProductList() { const { data: products } = useSuspenseQuery({ queryKey: ['products'], queryFn: getProducts, }) // 🔴 왜 Suspense fallback(Spinner)이 보이지? return <ul>{products.map(...)}</ul> }
원인: 서버에서 호출한 결과가 TanStack Query 캐시에 들어가지 않았다!
page.tsx에서 getProducts()를 호출하긴 했지만, 그 결과가 어디에도 저장되지 않았다. 그냥 호출만 하고 버린 거다. Client Component의 useSuspenseQuery는 캐시에서 ['products'] 키를 찾는데, 없으니까 다시 fetch를 시작한다.데이터 흐름을 따라가보면:
1. 서버: getProducts() 호출 → 결과 받음 → 그런데 버림! 🗑️ 2. 서버: ProductList 렌더링 (캐시에 아무것도 없음) 3. 클라이언트: useSuspenseQuery 실행 → 캐시에 없음 → fetch 시작 4. 클라이언트: Suspense가 로딩 감지 → Spinner 표시! 🔴 5. 클라이언트: fetch 완료 → 상품 목록 표시
핵심: 서버에서 데이터를 가져오는 것과, 그 데이터를 TanStack Query 캐시에 넣는 것은 별개다!
해결책: prefetchQuery + HydrationBoundary 🚀
Server Component에서 데이터를 TanStack Query 캐시에 넣어서 Client Component로 전달해야 한다.
[안티패턴: 서버에서 호출만 하고 버림] 서버 브라우저 │ │ │ getProducts() 호출 │ │ → 결과 버림! 🗑️ │ │ │ │ HTML (빈 껍데기) │ │ ─────────────────────────────▶│ │ │ Spinner 표시 😢 │ │ useSuspenseQuery → fetch 시작 │ │ │ 서버 액션 응답 │ │ ─────────────────────────────▶│ │ │ 그제서야 상품 표시 총 왕복: 2번 (느림!) [권장 패턴: prefetchQuery + HydrationBoundary] 서버 브라우저 │ │ │ prefetchQuery 호출 │ │ → 캐시에 저장! │ │ → dehydrate로 직렬화 │ │ │ │ HTML (데이터 포함!) │ │ ─────────────────────────────▶│ │ │ HydrationBoundary가 캐시 복원 │ │ useSuspenseQuery → 캐시에 있음! │ │ 즉시 상품 표시! ✅ 총 왕복: 1번 (초기 로드 시) - 훨씬 빠름! 🚀
데이터 페칭 5가지 방법
방법 1: Server Component에서 직접 fetch
// app/products/page.tsx // 'use client' 없음 = Server Component export default async function ProductsPage() { // 서버에서 실행된다 - 브라우저가 이 코드를 절대 볼 수 없다 const products = await fetch('https://api.example.com/products') .then(res => res.json()) return ( <ul> {products.map(product => ( <li key={product.id}>{product.name}</li> ))} </ul> ) }
언제 사용? 첫 페이지 로드에 데이터 필요, SEO 중요, 실시간 갱신 불필요할 때
장점 | 단점 |
초기 HTML에 데이터 포함 (SEO ✅) | 실시간 갱신 불가 |
로딩 스피너 불필요 | 사용자 인터랙션 후 갱신 어려움 |
API 키 안전 🔐 | ㅤ |
방법 2: Server Component에서 Server Action 호출
// app/actions/products.ts 'use server' export async function getProducts() { const res = await fetch('https://api.example.com/products', { headers: { 'Authorization': `Bearer${process.env.API_KEY}` } }) return res.json() } // app/products/page.tsx import { getProducts } from '@/app/actions/products' export default async function ProductsPage() { const products = await getProducts() return <ul>{products.map(...)}</ul> }
방법 1과 차이점: 데이터 로직을 별도 파일로 분리, 같은 함수를 Client Component에서도 재사용 가능! 👍
방법 3: Client Component에서 Server Action 호출 (비권장)
// components/ProductList.tsx 'use client' import { useState, useEffect } from 'react' import { getProducts } from '@/app/actions/products' export default function ProductList() { const [products, setProducts] = useState([]) const [loading, setLoading] = useState(true) useEffect(() => { getProducts() .then(setProducts) .finally(() => setLoading(false)) }, []) if (loading) return <div>로딩 중...</div> return <ul>{products.map(...)}</ul> }
문제점: 초기 HTML에 데이터 없음 (SEO ❌), 로딩 스피너 필요 😢
방법 4: Client Component에서 TanStack Query + Server Action (비권장)
// components/ProductList.tsx 'use client' import { useQuery } from '@tanstack/react-query' import { getProducts } from '@/app/actions/products' export default function ProductList() { const { data: products, error, isLoading } = useQuery({ queryKey: ['products'], queryFn: getProducts, }) if (isLoading) return <div>로딩 중...</div> if (error) return <div>에러 발생</div> return <ul>{products.map(...)}</ul> }
TanStack Query의 장점: 자동 캐싱, 자동 재시도, 포커스 시 자동 갱신, 로딩/에러 상태 관리 🔥
여전한 문제: 초기 HTML에 데이터 없음 (SEO ❌), 로딩 스피너 필요
방법 5: Server Action + Server Component + Client Component + TanStack Query ⭐
내가 생각하는 가장 좋은 방법이다. 다음 장에서 자세히 설명한다
방법 5 완전 정복: Server Action + Server Component + Client Component + TanStack Query
전체 흐름 한 문장으로
"Server Action으로 API 호출 로직을 정의하고, Server Component에서 prefetch하고, HydrationBoundary로 캐시를 전달하고, Client Component에서 TanStack Query로 데이터를 관리한다.”
1단계: Server Action으로 API 호출 로직 정의
// app/actions/products.ts 'use server' import { IApiDefaultRes } from '@/types/api.type' import { IProduct, ICategory, IUser, ICart } from '@/types/main.type' import { apiIns } from '@/lib/api-ins' export const mainAction = { getProducts: async function getProducts() { return await apiIns('mainApi')<IApiDefaultRes<IProduct[]>>('/api/products', { method: 'GET', cache: 'no-store', next: { tags: ['products'], }, }) }, getCategories: async function getCategories() { return await apiIns('mainApi')<IApiDefaultRes<ICategory[]>>('/api/categories', { method: 'GET', cache: 'no-store', }) }, getCurrentUser: async function getCurrentUser() { return await apiIns('userApi')<IApiDefaultRes<IUser>>('/api/user/me',{ method: 'GET', cache: 'no-store', }) }, getCart: async function getCart(){ return await apiIns('userApi')<IApiDefaultRes<ICart>>('/api/cart',{ method: 'GET', cache: 'no-store', next: { tags: ['cart'], }, }) }, // mutation 예시 addToCart: async function addToCart(body: IAddCartBody){ return await apiIns('userApi')<IApiDefaultRes>('/api/cart',{ method: 'POST', cache: 'no-store', body, }) }, }
왜 이렇게 하나?
먼저
'use server'를 선언한 파일에서 데이터를 가져오는 함수를 만든다. 이게 Server Action이다. 이 함수는 항상 서버에서 실행되기 때문에 API 키 같은 환경변수를 안전하게 사용할 수 있다.그리고 여기서 중요한 건, Client Component에서 이 함수를 import해도 실제 로직이 브라우저로 노출되지 않는다는 거다. 왜냐면 빌드 타임에 Next.js가 클라이언트용 코드를 "서버에 요청하는 프록시 함수"로 변환해주기 때문이다.
2단계: QueryClient 설정
// lib/query-client.ts import { QueryClient } from '@tanstack/react-query' function makeQueryClient() { return new QueryClient({ defaultOptions: { queries: { staleTime: 60 * 1000, // 1분간 fresh }, }, }) } let browserQueryClient: QueryClient | undefined = undefined export function getQueryClient() { if (typeof window === 'undefined') { // 서버: 항상 새로 만들기 (요청 간 공유 방지) return makeQueryClient() } else { // 브라우저: 싱글톤으로 재사용 if (!browserQueryClient) browserQueryClient = makeQueryClient() return browserQueryClient } }
왜 이렇게 하나?
서버와 브라우저에서 QueryClient를 다르게 관리해야 한다.
- 서버: 요청마다 새로 만든다. 안 그러면 다른 사용자의 데이터가 섞일 수 있다!
- 브라우저: 싱글톤으로 재사용한다. 컴포넌트가 리렌더링되어도 캐시가 유지된다.
3단계: Server Component에서 prefetch + HydrationBoundary
// app/products/page.tsx (Server Component) import { dehydrate, HydrationBoundary } from '@tanstack/react-query' import { getQueryClient } from '@/lib/query-client' import { getProducts, getCategories, getCurrentUser, getCart } from '@/app/actions/products' import { Suspense } from 'react' import { ErrorBoundary } from 'react-error-boundary' import Header from '@/components/Header' import Sidebar from '@/components/Sidebar' import ProductList from '@/components/ProductList' export default async function ProductsPage() { const queryClient = getQueryClient() // 여러 데이터를 병렬로 prefetch await Promise.all([ queryClient.prefetchQuery({ queryKey: ['products'], queryFn: getProducts, }), queryClient.prefetchQuery({ queryKey: ['categories'], queryFn: getCategories, }), queryClient.prefetchQuery({ queryKey: ['user', 'me'], queryFn: getCurrentUser, }), queryClient.prefetchQuery({ queryKey: ['cart'], queryFn: getCart, }), ]) // dehydrate 한 번으로 모든 캐시가 직렬화됨! return ( <HydrationBoundary state={dehydrate(queryClient)}> <ErrorBoundary fallback={<div>에러가 발생했습니다</div>}> <Suspense fallback={<ProductSkeleton />}> <Header />{/* user, cart 사용 - props 없음! */} <Sidebar />{/* categories 사용 - props 없음! */} <ProductList />{/* products 사용 - props 없음! */} </Suspense> </ErrorBoundary> </HydrationBoundary> ) }
왜 이렇게 하나? 하나씩 뜯어보자.
Promise.all + prefetchQuery — 여러 API를 병렬로 동시에 호출한다. 순차 호출보다 훨씬 빠르다!dehydrate(queryClient) — 캐시를 직렬화한다. 서버의 메모리에 있는 데이터를 JSON 문자열로 변환해서 HTML에 포함시킬 수 있게 한다. 한 번 호출로 모든 prefetch된 쿼리가 포함된다!HydrationBoundary — 직렬화된 캐시를 HTML에 포함시켜 브라우저로 전송한다. 브라우저에서는 이걸 다시 역직렬화해서 QueryClient에 복원한다.Suspense + ErrorBoundary — useSuspenseQuery가 로딩/에러를 던지면 여기서 잡는다. 서버에서 prefetch 했으니까 첫 로드 시에는 Suspense가 작동하지 않는다. 하지만 staleTime이 지나거나, invalidateQueries 후 refetch할 때, 또는 prefetch가 실패했을 때는 Suspense가 작동할 수 있다. 그래서 안전장치로 반드시 감싸야 한다.props 없이 데이터 전달 — 하위 컴포넌트들이 각자 필요한 queryKey로 데이터에 접근한다. Prop Drilling이 없다! 이게 가능한 이유는 HydrationBoundary가 캐시를 복원해주기 때문이다.
4단계: Client Component에서 TanStack Query 사용
각 하위 컴포넌트에서는 해당 queryKey로 접근하면 된다:
// components/Header.tsx 'use client' import { useSuspenseQuery } from '@tanstack/react-query' import { getCurrentUser, getCart } from '@/app/actions/products' export function Header() { const { data: user } = useSuspenseQuery({ queryKey: ['user', 'me'], queryFn: getCurrentUser, }) const { data: cart } = useSuspenseQuery({ queryKey: ['cart'], queryFn: getCart, }) return ( <header> <span>{user.name}님</span> <CartIcon count={cart.items.length} /> </header> ) }
왜 이렇게 하나? 하나씩 뜯어보자.
queryKey — 서버에서 prefetch할 때와 똑같은 키를 쓴다. 그래야 복원된 캐시에서 데이터를 찾을 수 있다.queryFn — 같은 Server Action을 넣는다. 캐시가 만료되거나 무효화되면 이 함수로 다시 가져온다.useSuspenseQuery — 로딩 중이면 Suspense가, 에러면 ErrorBoundary가 처리한다. 그래서 이 훅이 실행되는 시점에는 데이터가 무조건 있다! if (isLoading) 체크가 필요 없다.왜 Server Action을 TanStack Query로 또 감싸나?
이런 의문이 들 수 있다:
"Server Action 자체가 서버에서 데이터 가져오는 거잖아?거기에 TanStack Query까지? 캐시가 두 겹인 거 아냐?"
핵심은 둘이 해결하는 문제가 다르다는 거다.
Server Action = "데이터를 어디서 가져올지" (서버 실행, 보안) TanStack Query = "클라이언트에서 어떻게 관리할지" (캐싱, 상태, 자동 갱신)
Server Action만 쓰면 이렇게 된다:
// 😩 Server Action만 쓰면... const [products, setProducts] = useState([]) const [loading, setLoading] = useState(true) useEffect(() => { getProducts().then(setProducts).finally(() => setLoading(false)) }, []) // 탭 포커스 시 자동 갱신? 직접 구현해야 함 // 여러 컴포넌트에서 같은 데이터? 각각 요청 나감 // 로딩/에러 상태? 매번 useState 3개...
Server Action + TanStack Query:
// ✅ TanStack Query로 감싸면! const { data: products } = useSuspenseQuery({ queryKey: ['products'], queryFn: getProducts, // Server Action을 그냥 넘기면 됨 }) // 탭 포커스 시 자동 갱신? ✅ 기본 제공 // 여러 컴포넌트에서 같은 queryKey로 호출? ✅ 캐시 공유! // 로딩/에러 상태? ✅ Suspense/ErrorBoundary가 처리!
동작 원리 정리
서버에서: ┌─────────────────────────────────────────┐ │ QueryClient 캐시 │ │ │ │ ['products'] → { data: [...] } │ │ ['categories'] → { data: [...] } │ │ ['user', 'me'] → { data: {...} } │ │ ['cart'] → { data: {...} } │ │ │ └─────────────────────────────────────────┘ │ │ dehydrate() ▼ ┌─────────────────────────────────────────┐ │ JSON 직렬화된 상태 (HTML에 포함) │ └─────────────────────────────────────────┘ │ │ 브라우저로 전송 ▼ ┌─────────────────────────────────────────┐ │ HydrationBoundary가 복원 │ │ │ │ → 각 컴포넌트에서 useSuspenseQuery로 │ │ 해당 키의 데이터 즉시 사용! │ └─────────────────────────────────────────┘
동적 파라미터가 있는 경우
// app/products/[id]/page.tsx export default async function ProductDetailPage({ params }: { params: { id: string } }) { const queryClient = getQueryClient() await Promise.all([ queryClient.prefetchQuery({ queryKey: ['product', params.id], queryFn: () => getProduct(params.id), }), queryClient.prefetchQuery({ queryKey: ['reviews', params.id], queryFn: () => getReviews(params.id), }), queryClient.prefetchQuery({ queryKey: ['related-products', params.id], queryFn: () => getRelatedProducts(params.id), }), ]) return ( <HydrationBoundary state={dehydrate(queryClient)}> <ErrorBoundary fallback={<에러 />}> <Suspense fallback={<스켈레톤 />}> <ProductDetail id={params.id} /> </Suspense> </ErrorBoundary> </HydrationBoundary> ) }
6-9. 왜 이 패턴이 최강인가?
Server Action + Server Component + Client Component + TanStack Query ─────────────────────────────────────────────────────────────── 1. Server Action으로 로직 정의 → 환경변수 안전 (보안 ✅) 2. Server Component에서 prefetch → 데이터가 HTML에 포함 (SEO ✅) 3. dehydrate로 캐시 직렬화 → 클라이언트에 전달 4. HydrationBoundary가 캐시 복원 → Prop Drilling 없음! 5. TanStack Query 훅 호출 → 캐시에 이미 있음! → 로딩 없음 6. 이후 갱신은 TanStack Query가 자동 관리 결과: • SEO ✅ (초기 HTML에 데이터 포함) • 빠른 초기 로드 ✅ (로딩 스피너 없음) • 실시간 갱신 ✅ (TanStack Query) • 환경변수 안전 ✅ (Server Action) • 타입 안전 ✅ (useSuspenseQuery면 data가 undefined 아님) • Prop Drilling 없음 ✅ (HydrationBoundary)
6-10. 5가지 방법 한눈에 비교
방법 | SEO | 초기 속도 | 실시간 갱신 | 코드 복잡도 | 추천 상황 |
1. SC에서 직접 fetch | ✅ | ⚡ 빠름 | ❌ | 낮음 | 정적 콘텐츠 |
2. SC에서 Server Action | ✅ | ⚡ 빠름 | ❌ | 낮음 | 코드 재사용 필요 시 |
3. CC에서 Server Action | ❌ | 🐢 느림 | ⚠️ 수동 | 중간 | 비권장 |
4. CC + TanStack Query | ❌ | 🐢 느림 | ✅ 자동 | 중간 | 비권장 |
5. SC + CC + TanStack Query | ✅ | ⚡ 빠름 | ✅ 자동 | 중간 | 대부분의 경우 🏆 |
SC = Server Component, CC = Client Component
HydrationBoundary 없이 하면 어떻게 되나? (Prop Drilling 문제)
7-1. 첫 번째 시도: initialData를 props로 전달
HydrationBoundary를 사용하기 전에, 많은 개발자들이 이렇게 시도한다:
// app/products/page.tsx export default async function Page() { const products = await getProducts() const categories = await getCategories() const user = await getCurrentUser() return ( <ProductsPage initialProducts={products} initialCategories={categories} initialUser={user} /> ) } // components/ProductsPage.tsx 'use client' function ProductsPage({ initialProducts, initialCategories, initialUser }) { const { data: products } = useQuery({ queryKey: ['products'], queryFn: getProducts, initialData: initialProducts, }) // 하위 컴포넌트에 또 전달해야 함... return ( <Header user={initialUser} /> <Sidebar categories={initialCategories} /> <ProductList products={products} /> ) }
이건 작동하긴 한다. 하지만 컴포넌트가 깊어지면 문제가 시작된다.
7-2. Prop Drilling 지옥
page.tsx └─ ProductsPage (initialUser={...}, initialCategories={...}, initialProducts={...}) └─ Container (user={...}) └─ Header (user={...}) └─ UserMenu (user={...}) └─ ProfileDropdown (user={...})
문제점:
- 모든 중간 컴포넌트가 props를 알아야 함 — Container가 user를 안 써도 받아서 넘겨야 함
- 새 데이터 추가하면 전체 경로 수정 — cart 추가하려면 5개 컴포넌트 다 수정
- 타입 정의 지옥 — 모든 중간 컴포넌트에 타입 추가
- 컴포넌트 재사용 불가 — Header가 특정 props 구조에 의존
7-3. 해결: HydrationBoundary
HydrationBoundary를 쓰면 props 없이 어디서든 데이터에 접근할 수 있다:
// app/products/page.tsx export default async function Page() { const queryClient = getQueryClient() await Promise.all([ queryClient.prefetchQuery({ queryKey: ['products'], queryFn: getProducts }), queryClient.prefetchQuery({ queryKey: ['categories'], queryFn: getCategories }), queryClient.prefetchQuery({ queryKey: ['user'], queryFn: getCurrentUser }), ]) return ( <HydrationBoundary state={dehydrate(queryClient)}> {/* props 없음! */} <Container> <Header /> <Sidebar /> <ProductList /> </Container> </HydrationBoundary> ) } // components/Header.tsx - 5단계 아래에서도 직접 접근! 'use client' function ProfileDropdown() { // props 받을 필요 없이 직접 접근! const { data: user } = useSuspenseQuery({ queryKey: ['user'], queryFn: getCurrentUser, }) return <span>{user.name}</span> }
7-4. 비교 정리
항목 | initialData + Props | HydrationBoundary |
Depth 영향 | 깊어질수록 복잡 😱 | 무관 ✅ |
중간 컴포넌트 | 모두 props 전달 필요 | 신경 안 써도 됨 |
새 데이터 추가 | 전체 경로 수정 | prefetchQuery 추가만 |
컴포넌트 재사용 | props 구조에 의존 | 독립적 🎉 |
타입 정의 | 모든 컴포넌트에 필요 | 사용하는 곳만 |
그래서 HydrationBoundary를 쓴다! 🎯
데이터 변경 후 캐시 갱신
데이터를 변경한 후에는 3가지 캐시를 모두 고려해야 한다. 하나라도 빠뜨리면 "수정했는데 안 바뀌어요!" 민원이 들어온다. 😱
8-1. 3가지 캐시
Next.js의 캐시들 ─────────────────────────────────────────────────────────────── 1️⃣ Data Cache (서버) • fetch() 결과 캐시 • revalidateTag(), revalidatePath()로 무효화 2️⃣ Router Cache (클라이언트) • 방문한 페이지 캐시 • router.refresh()로 무효화 3️⃣ TanStack Query 캐시 (클라이언트) • 라이브러리가 관리하는 캐시 • invalidateQueries()로 무효화
8-2. 한눈에 보는 갱신 패턴 🔥
// Server Action 내부 'use server' export async function deleteProduct(id: string) { await fetch(`${API_URL}/products/${id}`, { method: 'DELETE' }) revalidateTag('products') // 1. Data Cache (서버) } // Client Component 'use client' const queryClient = useQueryClient() const router = useRouter() const mutation = useMutation({ mutationFn: deleteProduct, onSuccess: () => { queryClient.invalidateQueries({ queryKey: ['products'] }) // 2. TanStack Query router.refresh() // 3. Router Cache }, })
이 패턴을 따르면 서버와 클라이언트의 모든 캐시가 동기화된다. 완벽! ✨
9. 조건부 fetch는 useQuery로
9-1. useSuspenseQuery에는 enabled가 없다
// ❌ useSuspenseQuery는 enabled 옵션이 없다! const { data } = useSuspenseQuery({ queryKey: ['user', userId], queryFn: () => getUser(userId), // enabled: !!userId ← 없음! })
왜? useSuspenseQuery는 항상 데이터가 있어야 하는 상황을 위한 훅이다. "조건에 따라 안 가져올 수도 있음"은 그 철학과 맞지 않는다.
9-2. 조건부 fetch는 useQuery 사용
// ✅ 조건부 fetch는 useQuery 사용 const { data, isLoading } = useQuery({ queryKey: ['user', userId], queryFn: () => getUser(userId), enabled: !!userId, // userId 있을 때만 fetch }) if (isLoading) return <Spinner /> if (!data) return <div>사용자를 선택하세요</div> return <div>{data.name}</div>
9-3. 언제 뭘 쓸까?
상황 | 사용할 훅 | 이유 |
페이지 로드 시 필수 데이터 | useSuspenseQuery | 데이터 없으면 의미 없음 |
드롭다운 선택 후 조회 | useQuery | 선택 전에는 fetch 불필요 |
검색 결과 | useQuery | 검색어 없으면 fetch 불필요 |
모달 열릴 때 조회 | useQuery | 모달 닫혀있으면 불필요 |
10. SWR vs TanStack Query
"SWR이 더 가볍지 않아? 왜 TanStack Query야?"
좋은 질문이다! 둘 다 훌륭한 라이브러리지만, 이 가이드에서 TanStack Query를 선택한 이유가 있다.
10-1. 비교표
ㅤ | SWR | TanStack Query |
번들 크기 | 작음 (~4KB) | 큼 (~12KB) |
학습 곡선 | 낮음 | 높음 |
기능 | 심플 | 풍부 |
Mutation | 직접 구현 😅 | 내장 (useMutation) ✅ |
DevTools | 없음 | 있음 🔧 |
Optimistic UI | 직접 구현 | 내장 지원 ✅ |
Suspense 지원 | suspense: true 옵션 | useSuspenseQuery 훅 |
SSR Hydration | SWRConfig fallback | HydrationBoundary ✅ |
캐시 무효화 | mutate() | invalidateQueries() |
추천 | 간단한 프로젝트 | 복잡한 프로젝트 ✅ |
10-2. SSR Hydration 방식의 차이 ⭐
둘의 가장 큰 철학적 차이는 서버 데이터를 클라이언트로 전달하는 방식이다.
SWR 방식: "초기값을 선언해줄게"
// SWR 방식 import { SWRConfig } from 'swr' export default async function Page() { const products = await getProducts() return ( <SWRConfig value={{ fallback: { '/api/products': products // 키: 초기값 } }}> <ProductList /> </SWRConfig> ) } // 하위 컴포넌트 function ProductList() { const { data } = useSWR('/api/products', fetcher) // fallback에서 초기값을 찾아서 사용 return <ul>{data.map(...)}</ul> }
SWR의 사고방식:
"이 키('/api/products')로 요청하면, 일단 이 초기값(products)을 써. 나중에 실제로 fetch하면 그걸로 대체할게."
React Context Provider와 비슷하다! SWRConfig가 감싼 범위 안에서 fallback 값을 공유한다. 마치 Context.Provider가 value를 하위 컴포넌트에 전달하는 것처럼.
TanStack Query 방식: "캐시 상태 전체를 직렬화해서 넘길게"
// TanStack Query 방식 import { dehydrate, HydrationBoundary } from '@tanstack/react-query' export default async function Page() { const queryClient = getQueryClient() // 1. 서버에서 prefetch (캐시에 저장됨) await queryClient.prefetchQuery({ queryKey: ['products'], queryFn: getProducts, }) // 2. 캐시 상태 전체를 직렬화 const dehydratedState = dehydrate(queryClient) return ( // 3. 직렬화된 상태를 HTML에 포함시켜 전송 <HydrationBoundary state={dehydratedState}> <ProductList /> </HydrationBoundary> ) }
TanStack Query의 사고방식:
"서버에서 QueryClient에 데이터를 미리 채워놨어. 이 캐시 상태 전체를 JSON으로 직렬화해서 브라우저로 보낼게. 브라우저에서 이걸 복원하면 마치 이미 fetch한 것처럼 돼."
핵심 차이: "초기값" vs "캐시 상태"
SWR fallback: ┌─────────────────────────────────┐ │ 키: '/api/products' │ │ 값: [{ id: 1, name: '상품' }] │ ← 단순히 "값"만 전달 └─────────────────────────────────┘ TanStack Query dehydrate: ┌─────────────────────────────────────────────────┐ │ queryKey: ['products'] │ │ data: [{ id: 1, name: '상품' }] │ │ dataUpdatedAt: 1704067200000 │ ← 언제 fetch했는지 │ state: 'success' │ ← 상태 정보 │ fetchStatus: 'idle' │ │ ... (메타데이터 전부) │ └─────────────────────────────────────────────────┘
TanStack Query가 더 많은 정보를 전달하는 이유:
// SWR: 초기값만 있으면 "이게 언제 가져온 건지" 몰라 // → staleTime 계산이 부정확할 수 있음 // TanStack Query: dataUpdatedAt까지 전달 // → "서버에서 10:00에 가져왔으니까, // staleTime 5분이면 10:05까지 fresh야" // → 정확한 캐시 만료 계산 가능
실제로 HTML에 뭐가 들어가나?
<!-- TanStack Query가 생성하는 HTML --> <script> window.__REACT_QUERY_STATE__ = { "mutations": [], "queries": [ { "queryKey": ["products"], "queryHash": "[\"products\"]", "state": { "data": [{"id":1,"name":"맥북"}], "dataUpdatedAt": 1704067200000, "error": null, "errorUpdatedAt": 0, "fetchFailureCount": 0, "fetchStatus": "idle", "status": "success" } } ] } </script>
브라우저에서 HydrationBoundary가 이걸 읽어서 QueryClient에 복원한다.
한 마디로:
- SWR: "이 키에 대한 기본값 줄게" (Provider 패턴과 비슷)
- TanStack Query: "서버 QueryClient의 스냅샷 줄게" (상태 직렬화)
그래서 TanStack Query가 더 복잡해 보이지만, 캐시 동작이 더 정확하고 예측 가능하다! 🎯
10-3. 왜 TanStack Query를 선택했나?
실무에서 자주 필요한 기능들: ─────────────────────────────────────────────────────────────── 1️⃣ Mutation 후 캐시 갱신 SWR: mutate() 직접 호출, 로직 분산 TanStack Query: useMutation의 onSuccess에서 한 번에 처리 ✅ 2️⃣ 디버깅 SWR: console.log 찍어보기... TanStack Query: DevTools에서 캐시 상태 한눈에! 🔧 3️⃣ 서버 데이터 Hydration SWR: SWRConfig fallback (간단하지만 제한적) TanStack Query: HydrationBoundary + prefetchQuery (강력) ✅ 4️⃣ Optimistic Update SWR: 직접 구현 (코드 많음) TanStack Query: useMutation 옵션으로 간단하게 ✅
결론:
- 간단한 프로젝트, 빠른 프로토타입 → SWR 👍
- 실무, 복잡한 앱, 팀 프로젝트 → TanStack Query 👍
이 가이드는 실무 패턴을 다루기 때문에 TanStack Query를 기준으로 설명한다!
11. 의사결정 플로우차트
11-1. 데이터 페칭 방법 선택
데이터가 필요한가? │ ├─ 초기 페이지 로드에 필요 │ │ │ 실시간 갱신 필요? │ │ │ Yes ─┼─ No │ │ │ │ ▼ ▼ │ Server prefetch Server Component만 │ + TanStack Query │ └─ 사용자 인터랙션 후 필요 │ 조건부 fetch? │ Yes ─┼─ No │ │ ▼ ▼ useQuery useSuspenseQuery (enabled) (권장)
11-2. 빠른 선택 가이드
상황 | 추천 방법 |
블로그 포스트 목록 | Server Component + revalidate |
상품 목록 (실시간 갱신) | Server prefetch + TanStack Query |
대시보드 차트 | Server prefetch + TanStack Query |
검색 결과 | useQuery (enabled) |
모바일 앱 API | Route Handler |
12. 빠른 참조
12-1. 캐시 무효화 치트시트
데이터 변경 후 이 3가지를 기억하자! ─────────────────────────────────────────────────────────────── 1️⃣ Server Action 내부 revalidateTag('products') 2️⃣ Client Component에서 queryClient.invalidateQueries({ queryKey: ['products'] }) 3️⃣ 페이지 갱신 router.refresh()
12-2. 자주 하는 실수 체크리스트
실수 | 증상 | 해결 |
prefetch 누락 | 로딩 스피너 보임 😢 | page.tsx에서 prefetchQuery |
서버에서 호출만 하고 캐시 안 넣음 | 로딩 스피너 보임 😢 | prefetchQuery 사용 |
ErrorBoundary 없이 useSuspenseQuery | 앱 크래시 | ErrorBoundary로 감싸기 |
캐시 무효화 누락 | 변경 후 화면 안 바뀜 😱 | 3가지 캐시 모두 무효화 |
조건부 fetch에 useSuspenseQuery | enabled 없음 에러 | useQuery 사용 |
환경변수 반환 | API 키 노출 | Server Action 내부에서만 사용 |
Prop Drilling | 코드 복잡도 증가 | HydrationBoundary 사용 |
13. 핵심 요약
13-1. 한 문장 요약
Server Action으로 데이터 로직을 정의하고,Server Component에서 prefetch하고,HydrationBoundary로 전달하고,Client Component에서 TanStack Query로 관리한다.
13-2. 체크리스트
Server Action으로 데이터 로직 정의했는가?
Server Component에서 prefetchQuery 했는가?
여러 쿼리면 Promise.all로 병렬 prefetch 했는가?
HydrationBoundary로 캐시 전달했는가?
useSuspenseQuery 쓴다면 ErrorBoundary + Suspense로 감쌌는가?
TanStack Query 훅으로 데이터 사용했는가?
데이터 변경 시 3가지 캐시 모두 무효화했는가?
조건부 fetch는 useQuery (enabled)로 처리했는가?
13-3. 이 패턴의 장점
장점 | 설명 |
✅ SEO 완벽 | Server Component 초기 렌더링 |
✅ 빠른 초기 로드 | 로딩 스피너 없음 🚀 |
✅ 실시간 갱신 | TanStack Query |
✅ 환경변수 안전 | Server Action 🔐 |
✅ 타입 안전 | useSuspenseQuery면 data가 undefined 아님 |
✅ Prop Drilling 없음 | HydrationBoundary |

