목록으로

Nextjs 실행 컨텍스트

약 28분 읽기Development#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> }
 
문제점:
  1. 초기 HTML이 비어있다: 검색 엔진이 보는 것도 "로딩중..."이다. SEO 망함 😭
  1. 로딩 스피너가 보인다: 사용자 경험 저하
  1. 느린 초기 로드: 브라우저 → 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={...})
문제점:
  1. 모든 중간 컴포넌트가 props를 알아야 함 — Container가 user를 안 써도 받아서 넘겨야 함
  1. 새 데이터 추가하면 전체 경로 수정 — cart 추가하려면 5개 컴포넌트 다 수정
  1. 타입 정의 지옥 — 모든 중간 컴포넌트에 타입 추가
  1. 컴포넌트 재사용 불가 — 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