
1편에서 HTTP 캐시의 기본을,
2편에서 TanStack Query를 통한 클라이언트 상태 캐싱을 다뤘다.
3부에서는 Next.js 서버가 관리하는 4개의 캐싱 레이어를 파헤쳐보자.
"왜 데이터가 안 바뀌지?"라는 질문의 80%는 이 4개 중 하나가 원인이다.
1편에서 배운 HTTP 캐시는 브라우저 ↔ 서버 간의 캐시다.
2편에서 배운 TanStack Query는 클라이언트에서 API 응답을 캐싱한다.
그런데 Next.js 서버 내부에서도 최적화가 필요하다.
HTTP 캐시만 있을 때:
브라우저 → Next.js 서버 → 외부 API
│
├─ fetch('/api/products') → DB 쿼리
├─ fetch('/api/products') → DB 쿼리 (중복!)
└─ fetch('/api/products') → DB 쿼리 (중복!)
HTTP 캐시나 TanStack Query로는 해결할 수 없는 문제들이 있다.
| 문제 | HTTP 캐시의 한계 | TanStack Query의 한계 |
|---|---|---|
| 서버 렌더링 결과 재사용 | HTTP 캐시는 "요청-응답" 단위로 동작. 렌더링 중간 결과를 저장할 수 없음 | 클라이언트에서만 동작 |
| 같은 렌더링 내 중복 요청 | Layout에서도 fetch, Page에서도 같은 fetch. HTTP 캐시는 요청이 나가야 동작 | Server Component에서 사용 불가 |
| 클라이언트 네비게이션 최적화 | SPA처럼 부드러운 페이지 전환. 매번 전체 HTML을 받으면 느림 | 서버 데이터와 별개 |
그래서 Next.js는 HTTP 캐시 위에 4개의 추가 레이어를 만들었다.
🤔 비판적 시각: 복잡성의 대가
일부 개발자들은 Next.js가 CSR, SSR, SSG, ISR이라는 4가지 렌더링 모드(실행 컨텍스트)를 하나의 프레임워크에 모두 담으면서, 그 위에 스트리밍까지 얹었다고 비판한다. Next.js가 "UI 렌더링 어댑터"로 포지셔닝했다면 이런 설계가 이해되지만, "풀스택 프레임워크"로 제시되면서 소프트웨어 엔지니어링 원칙인 조합성(composability), 모듈성(modularity), 아키텍처 명확성 측면에서 혼란을 준다는 것이다.
이 가이드에서는 이런 복잡성을 인정하면서도, 실용적으로 각 캐시 레이어를 이해하고 활용하는 방법을 다룬다.
Next.js 캐싱을 이해하려면 RSC Payload를 먼저 알아야 한다. 4개 캐시 중 3개가 이걸 다루기 때문이다.
RSC Payload = React Server Component의 렌더링 결과물
Server Component를 렌더링하면 HTML이 아니라 특별한 형식의 데이터가 만들어진다. 이게 RSC Payload다.
// 이 컴포넌트를 렌더링하면...
async function ProductPage() {
const product = await fetchProduct(1)
return (
<div>
<h1>{product.name}</h1>
<p>{product.price}원</p>
</div>
)
}
// 이런 RSC Payload가 생성된다:
0:["$","div",null,{"children":[
["$","h1",null,{"children":"맥북 프로"}],
["$","p",null,{"children":"2,500,000원"}]
]}]
| HTML | RSC Payload | |
|---|---|---|
| 형태 | 문자열 마크업 | JSON-like 직렬화 데이터 |
| 용도 | 브라우저가 직접 렌더링 | React가 해석해서 DOM 생성 |
| 크기 | 더 큼 (태그 반복) | 더 작음 (구조화됨) |
| 활용 | 첫 페이지 로드 (SEO) | 클라이언트 네비게이션 |
1. 첫 페이지 로드 (Full Page Load)
─────────────────────────────────────────────────────────
브라우저 → Next.js 서버
서버가 보내는 것:
├─ HTML (초기 화면용, SEO)
└─ RSC Payload (React hydration용, script에 포함)
2. 클라이언트 네비게이션 (Link 클릭)
─────────────────────────────────────────────────────────
브라우저 → Next.js 서버
서버가 보내는 것:
└─ RSC Payload만! (HTML 안 보냄)
React가 RSC Payload를 받아서 DOM을 업데이트한다.
클라이언트 네비게이션 시:
방법 1: 전체 HTML 받기
├─ 서버: 전체 HTML 렌더링 (느림)
├─ 네트워크: HTML 전송 (크기 큼)
├─ 브라우저: 전체 DOM 교체 (깜빡임)
└─ 결과: 느리고 UX 나쁨
방법 2: RSC Payload 받기 (Next.js 방식)
├─ 서버: RSC Payload 생성 (빠름)
├─ 네트워크: Payload 전송 (크기 작음)
├─ 브라우저: React가 필요한 부분만 DOM 업데이트
└─ 결과: 빠르고 부드러운 전환
RSC Payload를 이해했으니, 이제 4개의 캐싱 레이어를 살펴보자.
요청 흐름과 캐시 레이어 (위에서 아래로)
─────────────────────────────────────────────────────────
클라이언트 (브라우저)
────────────────────
┌──────────────────────────────────────────────────┐
│ 1. Router Cache │
│ • 이미 방문한 페이지? → 메모리에서 즉시 로드 │
│ • 없으면 서버로 요청 │
└──────────────────────────────────────────────────┘
│ MISS
▼
서버 (Next.js)
──────────────
┌──────────────────────────────────────────────────┐
│ 2. Full Route Cache │
│ • 빌드된 정적 페이지? → 즉시 반환 │
│ • 동적 페이지면 렌더링 시작 │
└──────────────────────────────────────────────────┘
│ MISS (동적 페이지)
▼
┌──────────────────────────────────────────────────┐
│ 3. Request Memoization │
│ • 이번 렌더링 중 같은 fetch 또 호출? │
│ • 중복 제거 (단일 요청 내에서만) │
└──────────────────────────────────────────────────┘
│
▼
┌──────────────────────────────────────────────────┐
│ 4. Data Cache │
│ • 이 URL 데이터 캐시에 있나? │
│ • 있으면 반환, 없으면 외부 요청 │
└──────────────────────────────────────────────────┘
│ MISS
▼
외부 API / DB
| 캐시 | 해결하는 문제 | 저장 위치 | 수명 |
|---|---|---|---|
| Router Cache | 페이지 이동마다 서버 요청 | 브라우저 메모리 | 0초~5분 |
| Full Route Cache | 정적 페이지를 매번 렌더링 | Next.js 서버 디스크 | 재배포까지 |
| Request Memoization | 같은 데이터를 여러 컴포넌트가 fetch | Next.js 서버 메모리 | 렌더링 끝까지 |
| Data Cache | 같은 API를 여러 사용자가 호출 | Next.js 서버 디스크 | 무제한 |
| 항목 | Next.js 14 | Next.js 15 |
|---|---|---|
| Data Cache 기본값 | force-cache (캐시함) | no-store (캐시 안 함) |
| Router Cache 유지 시간 | 30초~5분 | 0초 (비활성화) |
이 변경의 이유는 뒤에서 자세히 다룬다.
🔥 Vercel의 고백: "Our Journey with Caching"
Next.js 팀은 2024년 10월 공식 블로그에서 캐싱 문제를 인정했다:
"개발자 경험이 우리가 제공한 캐싱 기본값과 제어 방식 때문에 어려움을 겪었다.
fetch()의 기본값이 성능을 위해 캐싱하도록 변경되었지만, 빠른 프로토타이핑과 동적 앱에서 문제가 되었다."이것이 Next.js 15에서 기본값을 뒤집은 이유다. 하지만 일부는 이런 급격한 방향 전환 자체가 프레임워크의 명확한 철학 부재를 보여준다고 비판한다.
브라우저 메모리에 RSC Payload를 저장하여 클라이언트 네비게이션을 빠르게 한다.
브라우저 (Router Cache)
─────────────────────────────────────────────────────────
사용자: /products 페이지에서 /products/1 클릭
1. Router Cache 확인
└─ /products/1 있음? → 없음
2. Next.js 서버에 RSC Payload 요청
└─ GET /products/1?_rsc=xxxxx
3. 응답 받아서 Router Cache에 저장
┌─────────────────────────────────────────────────┐
│ /products/1: RSC Payload 저장됨 │
└─────────────────────────────────────────────────┘
4. 사용자: 뒤로가기 (다시 /products/1)
└─ Router Cache에서 즉시 반환 (네트워크 요청 X)
| 페이지 유형 | Next.js 14 | Next.js 15 |
|---|---|---|
| 정적 페이지 | 5분 | 0초 |
| 동적 페이지 | 30초 | 0초 |
Next.js 14까지는 Router Cache가 30초(동적) ~ 5분(정적) 동안 유지됐는데, 이게 개발자들에게 많은 혼란을 줬다.
// 문제 상황
const handleDelete = async (id) => {
await deleteProduct(id)
router.push('/products') // 목록으로 이동
}
// 사용자: "어? 삭제한 상품이 아직 보이네?"
// 원인: Router Cache가 이전 페이지를 30초간 보여줌
개발자들의 불만:
router.refresh()를 여기저기 뿌려야 했음그래서 Next.js 15에서는 "캐시로 인한 버그보다 약간의 성능 손해가 낫다"는 판단으로 기본값을 0초로 변경했다.
성능 최적화가 필요하면 명시적으로 켤 수 있다.
// next.config.js
module.exports = {
experimental: {
staleTimes: {
dynamic: 30, // 동적 페이지 30초 캐시
static: 180, // 정적 페이지 3분 캐시
},
},
}
'use client'
import { useRouter } from 'next/navigation'
function RefreshButton() {
const router = useRouter()
return (
<button onClick={() => router.refresh()}>
새로고침
</button>
)
}
빌드 시점에 렌더링된 HTML과 RSC Payload를 Next.js 서버에 저장한다. 정적 페이지에만 적용된다.
빌드 시점 (npm run build):
─────────────────────────────────────────────────────────
/about 페이지 렌더링
│
▼
┌─────────────────────────────────────────────────────┐
│ Full Route Cache (Next.js 서버 디스크) │
│ │
│ /about: │
│ ├─ HTML: <!DOCTYPE html>... │
│ └─ RSC Payload: 0:["$","div",...] │
│ │
└─────────────────────────────────────────────────────┘
사용자 요청 시:
서버 → 캐시된 HTML/RSC Payload 즉시 반환 (렌더링 없음!)
많이 헷갈리는 부분이다. 정리하면:
Full Route Cache = 저장소 (어디에 저장되는가)
ISR = 갱신 전략 (어떻게 갱신하는가)
관계: ISR = Full Route Cache + 시간 기반 재생성 전략
| Full Route Cache만 | ISR | |
|---|---|---|
| 재생성 방식 | 수동 (revalidatePath) | 시간 기반 (revalidate: 60) |
| 예시 | 회사 소개 페이지 | 블로그 글 목록 |
// 1) Full Route Cache만 (ISR 아님)
// 빌드 시 생성, 수동으로만 갱신
export default async function Page() {
const data = await fetch('https://api.example.com/products', {
cache: 'force-cache' // 또는 태그 기반
})
return <div>{data}</div>
}
// 갱신: revalidatePath('/products') 호출 시에만
// 2) ISR (Full Route Cache + 시간 기반 재생성)
export default async function Page() {
const data = await fetch('https://api.example.com/products', {
next: { revalidate: 60 } // 60초마다 백그라운드 재생성
})
return <div>{data}</div>
}
| 구분 | 정적 페이지 | 동적 페이지 |
|---|---|---|
| Full Route Cache | 적용 | 미적용 |
| 렌더링 시점 | 빌드 시 | 요청 시 |
| 예시 | 블로그 글, 회사 소개 | 검색 결과, 대시보드 |
동적 렌더링이 되는 조건:
// 1. cookies() 또는 headers() 사용
import { cookies } from 'next/headers'
export default function Page() {
const token = cookies().get('token') // 동적!
return <div>...</div>
}
// 2. searchParams 사용
export default function Page({
searchParams
}: {
searchParams: { q: string }
}) {
// searchParams가 있으면 동적!
return <div>검색어: {searchParams.q}</div>
}
// 3. fetch에 no-store
async function Page() {
const data = await fetch(url, { cache: 'no-store' }) // 동적!
return <div>...</div>
}
// 4. dynamic 설정
export const dynamic = 'force-dynamic' // 강제 동적
$ npm run build
Route (app) Size First Load JS
┌ ○ / 5.2 kB 89.2 kB
├ ○ /about 1.2 kB 85.2 kB
├ ● /blog/[slug] 2.1 kB 86.1 kB
├ λ /dashboard 3.5 kB 87.5 kB
└ λ /search 2.8 kB 86.8 kB
○ (Static) 정적 페이지 - Full Route Cache 적용
● (SSG) generateStaticParams로 빌드 시 생성
λ (Dynamic) 동적 페이지 - 요청마다 렌더링
문제 상황:
┌──────────────────────────────────────────────────────────┐
│ Layout │
│ └─ await fetchUser(1) ─────────┐ │
│ │ │
│ Page │ 같은 요청이 │
│ └─ await fetchUser(1) ─────────┤ 3번 발생? │
│ │ │
│ Component │ │
│ └─ await fetchUser(1) ─────────┘ │
└──────────────────────────────────────────────────────────┘
Request Memoization 적용 후:
─────────────────────────────────────────────────────────
첫 번째 호출: fetchUser(1)
→ 실제 네트워크 요청 발생
→ 결과를 Next.js 서버 메모리에 저장
두 번째 호출: fetchUser(1)
→ 메모리에서 결과 반환 (네트워크 요청 X)
세 번째 호출: fetchUser(1)
→ 메모리에서 결과 반환 (네트워크 요청 X)
결과: 네트워크 요청 1번만 발생!
Request Memoization이 적용되는 조건:
// 메모이제이션 O - 같은 URL과 옵션
await fetch('/api/user/1')
await fetch('/api/user/1') // 캐시된 결과 반환
// 메모이제이션 X - URL이 다름
await fetch('/api/user/1')
await fetch('/api/user/2') // 새 요청
// 메모이제이션 X - 옵션이 다름
await fetch('/api/user/1', { cache: 'no-store' })
await fetch('/api/user/1', { cache: 'force-cache' }) // 새 요청
요청 시작 (렌더링 시작)
│
├─ fetch 호출 → 메모이제이션에 저장
├─ fetch 호출 → 메모이제이션에서 반환
├─ fetch 호출 → 메모이제이션에서 반환
│
응답 완료 (렌더링 종료) → 메모이제이션 자동 삭제
다음 요청 → 새로운 메모이제이션 시작
React의 cache() 함수를 사용하면 fetch 외의 함수도 메모이제이션할 수 있다.
import { cache } from 'react'
// DB 조회 함수를 메모이제이션
export const getUser = cache(async (id: string) => {
const user = await db.user.findUnique({ where: { id } })
return user
})
// 여러 컴포넌트에서 호출해도 DB 쿼리는 1번만 실행
// Route Handler에서는 메모이제이션이 적용되지 않는다!
// app/api/data/route.ts
export async function GET() {
const a = await fetch('https://api.example.com/data') // 요청 1
const b = await fetch('https://api.example.com/data') // 요청 2 (중복!)
return Response.json({ a, b })
}
// Server Component에서는 적용됨
// app/page.tsx
export default async function Page() {
const a = await fetch('https://api.example.com/data') // 요청 1
const b = await fetch('https://api.example.com/data') // 메모이제이션됨
return <div>...</div>
}
fetch() 결과를 Next.js 서버에 영구적으로 저장한다. 여러 요청, 여러 사용자가 공유한다.
"서버"가 어디인지 명확히 하자:
| 배포 환경 | Data Cache 저장 위치 |
|---|---|
| Vercel | Vercel Data Cache 서비스 (전역 분산 KV 스토리지) |
| 자체 호스팅 (AWS 등) | .next/cache/fetch-cache/ (로컬 파일시스템) |
| Docker | 컨테이너 내 파일시스템 (재시작 시 사라질 수 있음) |
// 명시적으로 캐시 (Next.js 14 기본)
const data = await fetch('https://api.example.com/products', {
cache: 'force-cache'
})
// 캐시 안 함 (Next.js 15 기본)
const data = await fetch('https://api.example.com/products', {
cache: 'no-store'
})
// 60초마다 재검증
const data = await fetch('https://api.example.com/products', {
next: { revalidate: 60 }
})
// 태그 기반 (수동 무효화용)
const data = await fetch('https://api.example.com/products', {
next: { tags: ['products'] }
})
사용자 A 요청:
fetch('/api/products')
│
▼
Data Cache 확인 → 없음 → API 호출 → 결과 저장 → 응답
│
▼
┌─────────────────┐
│ Data Cache │
│ (Next.js 서버) │
│ products: [...] │
└─────────────────┘
│
사용자 B 요청: │
fetch('/api/products') │
│ │
▼ ▼
Data Cache 확인 → 있음! → 바로 반환 (API 호출 X)
| 옵션 | 동작 | Next.js 14 | Next.js 15 |
|---|---|---|---|
cache: 'force-cache' | 영구 캐시 | 기본값 | 명시 필요 |
cache: 'no-store' | 캐시 안 함 | 명시 필요 | 기본값 |
next: { revalidate: 60 } | 60초마다 재검증 | - | - |
next: { tags: ['x'] } | 태그로 수동 무효화 | - | - |
// 상황: 상품 가격을 수정했는데 화면에 안 바뀜!
// 1. 사용자가 상품 페이지 방문
// 2. 가격이 10,000원으로 Data Cache에 저장됨
// 3. 관리자가 가격을 8,000원으로 수정
// 4. 사용자가 새로고침해도 여전히 10,000원 (Data Cache)
// 5. 심지어 다른 사용자도 10,000원을 봄!
// 해결: revalidateTag 호출 필요
import { revalidateTag } from 'next/cache'
async function updatePrice(productId, newPrice) {
await db.updatePrice(productId, newPrice)
revalidateTag('products') // 이걸 잊으면 대참사
}
💬 실제 개발자들의 목소리 (GitHub Discussion #54075)
"우리 회사는 B2B SaaS로 비즈니스 크리티컬한 데이터를 다룹니다. 데이터 신선함이 핵심인데, NextJS는 모든 앱에 하나의 캐시 정책이 맞다고 가정합니다. 페이스북을 만드는 게 아니라면, stale 뉴스피드는 괜찮겠지만 우리 앱에서는 치명적입니다."
"사용자가 잘못된 데이터를 보는 것이 로딩 스피너를 피하는 것보다 훨씬 해롭습니다. 그런데 프레임워크가 이 선택을 우리 대신 해버립니다."
타임라인 (revalidate: 60 설정 시):
0초 30초 60초 70초 80초
│ │ │ │ │
▼ ▼ ▼ ▼ ▼
요청1 요청2 요청3 요청4 요청5
캐시저장 캐시사용 캐시사용 캐시반환 새캐시사용
(만료됨) +백그라운드
갱신 시작
60초가 지나도 바로 새 데이터를 가져오는 게 아니라, 일단 캐시된 데이터를 반환하고 백그라운드에서 새 데이터를 가져온다.
import { revalidatePath, revalidateTag } from 'next/cache'
// 경로 기반
revalidatePath('/products')
revalidatePath('/products', 'layout') // 레이아웃 포함
revalidatePath('/', 'layout') // 전체 사이트
// 태그 기반 (권장)
revalidateTag('products')
revalidateTag(`product-${id}`)
| Router Cache | Full Route Cache | Request Memoization | Data Cache | |
|---|---|---|---|---|
| 위치 | 브라우저 메모리 | Next.js 서버 디스크 | Next.js 서버 메모리 | Next.js 서버 디스크 |
| 범위 | 현재 탭 | 모든 사용자 | 단일 렌더링 | 모든 사용자 |
| 수명 | 0초~5분 | 재배포까지 | 렌더링 끝까지 | 무제한 |
| 대상 | 페이지 이동 | 전체 페이지 | fetch 호출 | fetch 결과 |
| 무효화 | router.refresh() | revalidatePath | 자동 | revalidateTag/Path |
사용자 요청: GET /products
─────────────────────────────────────────────────────────
┌─ Router Cache 확인
│ └─ 있으면 → 바로 반환 (끝)
│ └─ 없으면 → 서버로 요청
│
▼
┌─ Full Route Cache 확인
│ └─ 있으면 (정적) → 바로 반환
│ └─ 없으면 (동적) → 렌더링 시작
│
▼
┌─ 렌더링 중 fetch 호출
│
│ ┌─ Request Memoization 확인
│ │ └─ 같은 요청 있으면 → 재사용
│ │ └─ 없으면 → Data Cache 확인
│ │
│ ▼
│ ┌─ Data Cache 확인
│ │ └─ 있고 신선하면 → 반환
│ │ └─ 없거나 만료면 → 실제 API 호출 → 결과 캐시
│
▼
렌더링 완료 → 응답 → Router Cache에 저장
코드가 어디서 실행되는지에 따라 캐싱 동작이 달라진다.
| 실행 컨텍스트 | Request Memoization | Data Cache |
|---|---|---|
| Server Component | 적용됨 | 적용됨 |
| Client Component | - (브라우저 실행) | - |
| Server Action | 안 됨 | 적용됨 |
| Route Handler | 안 됨 | 적용됨 |
| Middleware | 안 됨 | 제한적 |
⚠️ 이게 바로 문제다: "같은 코드, 다른 동작"
비판자들이 지적하는 핵심이 바로 이것이다. 동일한
fetch()코드가 실행 컨텍스트에 따라 완전히 다르게 동작한다. Server Component에서는 메모이제이션이 되고, Route Handler에서는 안 된다. 이런 "마법 같은" 동작이 디버깅을 어렵게 만든다.프레임워크가 제어권을 가져가는 건 괜찮지만, 명확하고 신뢰할 수 있는 애플리케이션 라이프사이클을 제공하지 않으면 개발자가 시스템을 예측하기 어렵다.
같은 코드, 다른 캐싱 동작 예시:
// 1. Server Component에서 fetch
async function ServerFetch() {
const data = await fetch('https://api.com/data') // Memoization + Data Cache
return <div>{data}</div>
}
// 2. Route Handler에서 fetch
export async function GET() {
const data = await fetch('https://api.com/data') // Data Cache만
return NextResponse.json(data)
}
// 3. Server Action에서 fetch
'use server'
export async function getData() {
const data = await fetch('https://api.com/data') // Data Cache만
return data
}
Next.js의 4가지 렌더링 모드와 4가지 캐시 레이어가 어떻게 조합되는지 살펴보자.
| 모드 | 렌더링 시점 | 특징 |
|---|---|---|
| CSR | 브라우저 | 서버는 빈 HTML만, JS가 렌더링 |
| SSR | 요청마다 서버 | 매 요청 시 서버에서 렌더링 |
| SSG | 빌드 시 서버 | 빌드할 때 미리 HTML 생성 |
| ISR | 빌드 + 주기적 재생성 | SSG + 시간 기반 갱신 |
| Router Cache | Full Route Cache | Request Memoization | Data Cache | |
|---|---|---|---|---|
| CSR | ❌ | ❌ | ❌ | ❌ |
| SSR | ⚠️ | ❌ | ✅ | ✅ |
| SSG | ✅ | ✅ | ✅ | ✅ |
| ISR | ✅ | ✅ | ✅ | ✅ |
CSR은 서버 렌더링이 없어서 서버 캐시가 전부 무관하다.
// CSR 예시 - 'use client' 컴포넌트에서 useEffect로 fetch
'use client'
export default function Page() {
const [data, setData] = useState(null)
useEffect(() => {
fetch('/api/data').then(res => res.json()).then(setData)
}, [])
return <div>{data}</div>
}
// → 브라우저에서 fetch, Next.js 캐시 전혀 안 탐
// → 캐싱하려면 TanStack Query 사용
SSR은 매 요청마다 서버에서 렌더링한다.
// SSR 예시 - 동적 렌더링
export default async function Page({ searchParams }) {
// searchParams 사용 → 자동으로 SSR
const data = await fetch(`/api/search?q=${searchParams.q}`, {
cache: 'no-store'
})
return <div>{data}</div>
}
| 캐시 | 적용 | 이유 |
|---|---|---|
| Router Cache | ⚠️ 제한적 | Next.js 15 기본값 0초 |
| Full Route Cache | ❌ | 동적 렌더링이라 캐시 안 됨 |
| Request Memoization | ✅ | 같은 렌더링 내 중복 fetch 제거 |
| Data Cache | ✅ | fetch 옵션에 따라 캐시 가능 |
SSG는 빌드 시점에 미리 렌더링한다. 모든 캐시가 적용된다.
// SSG 예시 - 정적 페이지
export default async function Page() {
const data = await fetch('https://api.example.com/products', {
cache: 'force-cache'
})
return <div>{data}</div>
}
흐름:
빌드 시:
────────
렌더링 → Request Memoization → Data Cache 저장
↓
Full Route Cache에 HTML + RSC Payload 저장
요청 시:
────────
Router Cache 확인 → 없으면 서버로
↓
Full Route Cache에서 즉시 반환 (렌더링 없음!)
↓
Router Cache에 저장
ISR은 SSG + 시간 기반 재생성이다.
// ISR 예시 - 60초마다 재생성
export default async function Page() {
const data = await fetch('https://api.example.com/products', {
next: { revalidate: 60 }
})
return <div>{data}</div>
}
흐름:
0초: 첫 요청 → Full Route Cache에서 반환
60초: 요청 → 캐시 반환 + 백그라운드 재생성 시작
70초: 요청 → 새로 갱신된 캐시에서 반환
| 상황 | 렌더링 모드 | 주요 캐시 |
|---|---|---|
| 마케팅 페이지, 블로그 | SSG | Full Route + Data |
| 상품 목록 (가끔 변경) | ISR | Full Route + Data (revalidate) |
| 검색 결과, 대시보드 | SSR | Request Memoization + Data (선택적) |
| 실시간 데이터 | SSR + no-store | Request Memoization만 |
| 인터랙티브 앱 | CSR | 없음 (TanStack Query로 대체) |
캐시 많이 탐 ←─────────────────────→ 캐시 안 탐
SSG ISR SSR CSR
│ │ │ │
4개 다 적용 4개 다 적용 2~3개 0개
│ │ │ │
가장 빠름 꽤 빠름 요청마다 브라우저 의존
태그는 캐시된 데이터에 "라벨"을 붙이는 것이다.
// 전체 상품 목록
const products = await fetch('https://api.example.com/products', {
next: { tags: ['products'] }
})
// 개별 상품 (여러 태그 가능)
const product = await fetch(`https://api.example.com/products/${id}`, {
next: { tags: ['products', `product-${id}`] }
})
// 카테고리별 상품
const categoryProducts = await fetch(
`https://api.example.com/products?category=${categoryId}`,
{ next: { tags: ['products', `category-${categoryId}`] } }
)
'use server'
import { revalidateTag } from 'next/cache'
// 상품 수정 → 해당 상품만 무효화
export async function updateProduct(id, data) {
await db.products.update(id, data)
revalidateTag(`product-${id}`)
}
// 상품 추가 → 전체 목록 무효화
export async function createProduct(data) {
await db.products.create(data)
revalidateTag('products')
}
| revalidatePath | revalidateTag | |
|---|---|---|
| 용도 | 특정 페이지만 갱신 | 여러 페이지에 걸친 데이터 갱신 |
| 범위 | 경로 기반 | 태그 기반 |
| 유연성 | 낮음 | 높음 |
| 추천 | 간단한 케이스 | 복잡한 케이스 |
// 상품 상세 페이지가 100개 있을 때
// revalidatePath (비효율적)
revalidatePath('/products/1')
revalidatePath('/products/2')
// ... 100개를 일일이?
// revalidateTag (효율적)
revalidateTag('products') // 한 번에 해결!
원인: Data Cache가 이전 데이터를 가지고 있음
'use server'
import { revalidateTag } from 'next/cache'
export async function updateProduct(id, data) {
await db.products.update(id, data)
revalidateTag('products')
revalidateTag(`product-${id}`)
}
원인: Router Cache가 이전 버전을 가지고 있음
'use client'
import { useRouter } from 'next/navigation'
function ProductList() {
const router = useRouter()
const handleUpdate = async () => {
await updateProduct(id, data)
router.refresh() // 이게 핵심!
}
}
원인: Data Cache + Router Cache
// 1. 추가할 때 캐시 무효화
'use server'
export async function createProduct(data) {
await db.products.create(data)
revalidateTag('products')
}
// 2. 목록에서 주기적 재검증
const products = await fetch('/api/products', {
next: { revalidate: 60, tags: ['products'] }
})
// 3. 또는 실시간 필요 시 캐시 안 함
const products = await fetch('/api/products', {
cache: 'no-store'
})
원인: Full Route Cache + 브라우저 HTTP 캐시
// next.config.js - HTML은 캐시 안 함
module.exports = {
async headers() {
return [
{
source: '/:path*',
headers: [
{ key: 'Cache-Control', value: 'no-cache' }
]
}
]
}
}
이건 캐시 문제가 아니라 HTTP 헤더 전달 문제다. 하지만 Next.js Route Handler를 쓸 때 자주 겪는 문제라서 여기서 다룬다.
우리 회사 구조:
프론트엔드 (Next.js) ──── 백엔드 (Spring/Express 등)
│ │
3000번 포트 8080번 포트
CORS 문제나 API 키 보안 때문에 Next.js를 중간 프록시로 쓰는 경우가 많다.
브라우저 → Next.js Route Handler → 백엔드
(프록시 역할)
// app/api/login/route.ts
export async function POST(request: Request) {
// 1. 백엔드에 로그인 요청
const res = await fetch('http://backend:8080/login', {
method: 'POST',
body: await request.text(),
})
// 2. 백엔드가 보내준 것:
// - Body: { success: true, user: {...} }
// - Header: Set-Cookie: token=abc123; HttpOnly
// 3. 우리가 반환하는 것:
return Response.json(await res.json())
// - Body만 반환됨!
// - Set-Cookie 헤더는 사라짐!
}
택배 비유:
판매자(백엔드) → 택배기사(Next.js) → 구매자(브라우저)
판매자가 보낸 것:
├─ 상품 (Body) ✅
└─ 편지 (Set-Cookie 헤더) ❌ ← 택배기사가 안 전해줌!
Set-Cookie는 브라우저가 직접 받은 HTTP 응답에 있어야 쿠키로 저장된다. Next.js Route Handler가 중간에서 받아서 새 응답을 만들면, 원래 헤더는 사라진다.
// app/api/login/route.ts
import { NextResponse } from 'next/server'
export async function POST(request: Request) {
const res = await fetch('http://backend:8080/login', {
method: 'POST',
body: await request.text(),
})
// 백엔드 응답에서 Set-Cookie 꺼내기
const setCookie = res.headers.get('set-cookie')
// 새 응답 만들기
const response = NextResponse.json(await res.json())
// Set-Cookie 붙이기
if (setCookie) {
response.headers.set('Set-Cookie', setCookie)
}
return response
}
수정 전:
─────────
브라우저 → Next.js → 백엔드
│ │ │
│ │←─ Set-Cookie ─│
│ │
│←─ Body만 ─│ ← Set-Cookie 사라짐!
│
쿠키 저장 안 됨 😱
수정 후:
─────────
브라우저 → Next.js → 백엔드
│ │ │
│ │←─ Set-Cookie ─│
│ │
│←─ Body + Set-Cookie ─│ ← 명시적으로 복사!
│
쿠키 정상 저장 ✅
| 자동 전달됨 | 자동 전달 안 됨 |
|---|---|
| 응답 Body | Set-Cookie |
| Content-Type (명시 시) | Cache-Control |
| 커스텀 헤더 |
Next.js 프록시를 안 거치면 이 문제가 없다.
// 클라이언트에서 백엔드 직접 호출
const res = await fetch('https://backend.com/login', {
method: 'POST',
credentials: 'include', // 쿠키 자동 처리
body: JSON.stringify(data),
})
// Set-Cookie가 자동으로 브라우저에 저장됨!
단, 이 경우 백엔드에서 CORS 설정이 필요하다.
// 실시간 데이터 (주식, 채팅)
fetch(url, { cache: 'no-store' })
// 자주 바뀌는 데이터 (뉴스, 댓글)
fetch(url, { next: { revalidate: 60 } })
// 가끔 바뀌는 데이터 (상품 목록)
fetch(url, { next: { tags: ['products'] } })
// 거의 안 바뀌는 데이터 (설정)
fetch(url, { cache: 'force-cache' })
// Server Action - Data Cache 무효화
'use server'
export async function deleteProduct(id: string) {
await fetch(`${API_URL}/products/${id}`, { method: 'DELETE' })
revalidateTag('products') // 1. Data Cache
}
// Client Component - TanStack Query + Router Cache 무효화
'use client'
import { useQueryClient } from '@tanstack/react-query'
const handleDelete = async (id) => {
await deleteProduct(id) // 1. Data Cache (서버)
queryClient.invalidateQueries({ // 2. TanStack Query 캐시 (클라이언트)
queryKey: ['products']
})
router.refresh() // 3. Router Cache (클라이언트)
}
2편에서 배운 TanStack Query와 Next.js Data Cache는 둘 다 API 응답을 캐싱하지만, 실행 위치가 다르다.
TanStack Query (클라이언트):
├─ 위치: 브라우저 메모리
├─ 범위: 현재 사용자의 현재 탭
├─ 특징: 탭 닫으면 사라짐
└─ 용도: Client Component에서 데이터 fetching
Next.js Data Cache (서버):
├─ 위치: Next.js 서버 (또는 Vercel)
├─ 범위: 모든 사용자가 공유
├─ 특징: 서버 재시작해도 유지
└─ 용도: Server Component에서 데이터 fetching
| 상황 | 추천 | 이유 |
|---|---|---|
| Server Component에서 fetch | Next.js Data Cache | 서버에서 캐싱, SEO 유리 |
| Client Component에서 fetch | TanStack Query | 클라이언트 상태 관리 |
| 실시간 데이터 (polling) | TanStack Query | refetchInterval 지원 |
| 정적 데이터 (SSG/ISR) | Next.js Full Route Cache | 빌드 시 생성 |
| 사용자별 다른 데이터 | TanStack Query + private | 개인화 데이터 |
| 증상 | 의심 캐시 | 해결 |
|---|---|---|
| 같은 fetch 중복 | Request Memoization | 자동 처리됨 |
| 오래된 데이터 | Data Cache | revalidateTag |
| 정적 페이지 갱신 | Full Route Cache | revalidatePath |
| 뒤로가기 시 오래됨 | Router Cache | router.refresh() |
| 쿠키 저장 안 됨 | 프록시 헤더 전달 | Set-Cookie 명시적 복사 |
fetch(url, { cache: 'no-store' }) // 캐시 안 함
fetch(url, { cache: 'force-cache' }) // 영구 캐시
fetch(url, { next: { revalidate: 60 }}) // 시간 기반
fetch(url, { next: { tags: ['x'] }}) // 태그 기반
import { revalidatePath, revalidateTag } from 'next/cache'
revalidatePath('/products') // 경로 기반
revalidateTag('products') // 태그 기반
router.refresh() // Router Cache
Next.js 팀은 캐싱 문제를 인식하고 새로운 접근법을 실험 중이다.
현재 (Next.js 14~15):
─────────────────────────────────────────────────────────
- fetch()의 암묵적 캐싱 동작
- export const dynamic, revalidate 등 세그먼트 설정이 탈출구로 남발
- unstable_cache()가 비직관적
- 개발자가 "어디서 캐시되는지" 추적하기 어려움
// 실험적 기능 (dynamicIO 플래그 필요)
// 동적: Suspense로 감싸면 동적
export default async function Page() {
return (
<Suspense fallback="...">
<DynamicComponent /> {/* 매 요청마다 실행 */}
</Suspense>
)
}
// 정적: "use cache"로 명시적 캐싱
"use cache"
export default async function Page() {
const data = await fetch(...) // 캐시됨
return <div>{data}</div>
}
// 함수 레벨 캐싱
async function getProducts() {
"use cache"
return await db.products.findMany()
}
| 현재 | 미래 (실험적) |
|---|---|
| 기본 캐시 → opt-out | 기본 동적 → opt-in 캐시 |
| 암묵적 동작 | 명시적 선언 (use cache) |
| 세그먼트 설정 탈출구 | Suspense + use cache 조합 |
아직 실험 단계
dynamicIO와use cache는 Next.js canary에서만 사용 가능하며, 프로덕션에는 권장되지 않는다. 하지만 이 방향성은 Next.js 팀이 커뮤니티 피드백을 반영하고 있음을 보여준다.비판자들이 원했던 "명시적이고 예측 가능한 동작"으로 한 발짝 다가가는 것이다.
Part 4: CDN 캐싱 완벽 가이드에서는:
Next.js의 캐싱 시스템은 강력하지만 복잡하다. 이 복잡성에 대해 두 가지 시각이 있다:
좋은 프레임워크의 3가지 특성 (Harshal Patil)
Next.js는 이 기준에서 어떨까?
긍정적 시각:
비판적 시각:
실용적 접근:
1. 기본값을 믿지 말 것
→ 캐싱 옵션을 항상 명시적으로 설정
2. 실행 컨텍스트를 파악할 것
→ 이 코드가 어디서 실행되는지 항상 인식
3. 단순함을 추구할 것
→ 필요 없는 캐싱은 끄기 (no-store)
→ 필요한 곳만 선택적으로 캐싱
4. 디버깅 도구 활용
→ 개발자 도구에서 캐시 상태 확인
→ 로깅으로 캐시 HIT/MISS 추적
5. 프록시 동작 이해하기
→ Next.js가 중간에서 어떤 역할을 하는지 파악
→ 헤더 전달이 필요하면 명시적으로 처리
Next.js를 사용한다면, 이 캐싱 시스템을 이해하고 제어해야 한다. 이해 없이 사용하면 "왜 데이터가 안 바뀌지?", "왜 쿠키가 안 저장되지?"라는 질문에 시달리게 될 것이다.