요청 메모이제이션·데이터·풀라우트·라우터 캐시를 정밀 제어한다.
Next.js 4계층 캐싱 아키텍처 심층 제어
입문편에서 fetch의 cache 옵션과 revalidate로 기본 캐싱을 맛봤다면, 이번 레슨은 그 아래에서 무슨 일이 벌어지는지를 파고듭니다. Next.js는 단순한 HTTP 캐시가 아니라 4개의 서로 다른 캐시 계층을 조율하는 복잡한 시스템을 갖추고 있습니다. 이 계층들이 어떻게 상호작용하고, 무효화 신호가 어떻게 전파되며, 운영 환경에서 어떤 함정이 도사리는지를 이해해야만 예측 가능한 애플리케이션을 만들 수 있습니다.
학습 목표
- Request Memoization, Data Cache, Full Route Cache, Router Cache 4계층의 경계와 수명 주기를 구분할 수 있다.
- revalidateTag / revalidatePath의 무효화 전파 범위와 타이밍 차이를 설명할 수 있다.
unstable_cache,fetch옵션,cache()함수로 데이터 캐시를 세밀하게 제어할 수 있다.x-nextjs-cache헤더와 서버 로그를 활용해 HIT/MISS 상태를 추적할 수 있다.- 분산 환경과
stale-while-revalidate에서 발생하는 캐시 일관성 함정을 피할 수 있다.
1. 4계층 캐시 구조와 상호작용
Next.js의 캐싱은 요청이 들어온 순간부터 브라우저에 응답이 도달하기까지 4개의 관문을 통과합니다.
| 계층 | 위치 | 범위 | 기본 수명 |
|---|---|---|---|
| Request Memoization | 서버 메모리 | 단일 렌더링 트리, 단일 요청 | 요청 종료 시 파기 |
| Data Cache | 서버 파일시스템/외부 스토어 | 서버 프로세스 간 공유 가능 | 영구 (명시적 무효화 전까지) |
| Full Route Cache | CDN / 서버 파일시스템 | 정적으로 렌더링된 라우트 전체 | 빌드 or revalidate 까지 |
| Router Cache | 브라우저 메모리 | 현재 탭·세션 | 탐색 사이 (Next.js 14 기준 동적 30초·정적 5분, 15부터 동적 기본 0초·staleTimes로 조정) |
계층 간 데이터 흐름
요청이 들어오면 Next.js는 아래 순서로 각 캐시를 확인합니다.
브라우저 요청
└─> [Router Cache] HIT → 즉시 반환
MISS ↓
└─> [Full Route Cache] HIT → HTML 반환, Router Cache 채움
MISS ↓
└─> React 렌더링 시작
└─> fetch() 호출
└─> [Request Memoization] HIT → 동일 렌더 트리 내 중복 제거
MISS ↓
└─> [Data Cache] HIT → 캐시된 응답 반환
MISS ↓
└─> 실제 네트워크 요청 → Data Cache 채움
Request Memoization은 특히 이해하기 쉽게 놓칩니다. 서버 컴포넌트 트리에서 여러 컴포넌트가 동일한 URL로 fetch를 호출하더라도 실제 네트워크 요청은 단 한 번만 발생합니다. 이는 React의 cache() 래퍼로 구현되어 있으며, 렌더링이 끝나면 즉시 파기됩니다.
// app/components/UserCard.tsx
// 여러 컴포넌트에서 같은 URL fetch → 네트워크 요청은 1번만 발생
async function UserCard({ userId }: { userId: string }) {
// 동일 렌더 트리 내 다른 컴포넌트가 이미 호출했다면 메모이즈된 값 반환
// 서버 측 fetch는 절대 URL을 요구하므로 베이스 URL을 환경변수로 구성
const user = await fetch(`${process.env.API_BASE_URL}/api/users/${userId}`).then(r => r.json())
return <div>{user.name}</div>
}
💡 TIP Request Memoization은
fetch뿐 아니라 React의cache()함수로 감싼 임의의 비동기 함수에도 동일하게 적용됩니다.
2. 태그 기반 무효화: revalidateTag와 revalidatePath
무효화는 캐싱에서 가장 까다로운 부분입니다. revalidateTag와 revalidatePath는 비슷해 보이지만 전파 범위가 다릅니다.
revalidateTag — 데이터 중심 무효화
revalidateTag는 특정 태그가 붙은 모든 Data Cache 항목을 무효화합니다. 같은 태그를 공유하는 여러 라우트의 캐시가 동시에 만료됩니다.
// app/lib/data.ts
export async function getPost(id: string) {
const res = await fetch(`https://api.example.com/posts/${id}`, {
next: { tags: ['posts', `post-${id}`] }, // ✅ 태그 부여
})
return res.json()
}
export async function getPosts() {
const res = await fetch('https://api.example.com/posts', {
next: { tags: ['posts'] }, // ✅ 컬렉션 태그
})
return res.json()
}
// app/api/revalidate/route.ts
import { revalidateTag } from 'next/cache'
import { NextRequest } from 'next/server'
export async function POST(req: NextRequest) {
const { tag, secret } = await req.json()
if (secret !== process.env.REVALIDATION_SECRET) {
return Response.json({ error: 'Unauthorized' }, { status: 401 })
}
revalidateTag(tag)
// 'posts' 태그를 무효화하면 getPost, getPosts 캐시 모두 만료됨
return Response.json({ revalidated: true, tag })
}
revalidatePath — 라우트 중심 무효화
revalidatePath는 특정 경로의 Full Route Cache와 해당 경로에서 사용된 Data Cache를 함께 무효화합니다.
// app/actions/posts.ts
'use server'
import { revalidatePath, revalidateTag } from 'next/cache'
export async function updatePost(id: string, data: FormData) {
await fetch(`https://api.example.com/posts/${id}`, {
method: 'PATCH',
body: data,
})
// 방법 1: 특정 포스트 경로 무효화 (Full Route Cache + Data Cache)
revalidatePath(`/blog/${id}`)
// 방법 2: 태그 기반으로 데이터만 무효화 (더 정교한 제어)
revalidateTag(`post-${id}`)
revalidateTag('posts') // 목록도 같이 무효화
}
| 구분 | revalidateTag | revalidatePath |
|---|---|---|
| 무효화 대상 | 해당 태그의 Data Cache | 해당 경로의 Full Route Cache + Data Cache |
| 적합한 상황 | 데이터 레이어 중심, 여러 페이지 공유 데이터 | 특정 URL의 전체 캐시를 확실히 날릴 때 |
| 전파 속도 | 즉시 (다음 요청에 MISS 처리) | 즉시 (다음 요청에 재렌더링) |
⚠️ 주의
revalidatePath('/', 'layout')을 호출하면 루트 레이아웃 아래 모든 경로의 Full Route Cache가 무효화됩니다. 편리하지만 트래픽이 많은 서비스에서는 캐시 stampede(동시 재생성)가 발생할 수 있으니 신중하게 사용하세요.
3. On-demand ISR과 시간 기반 revalidate 조합 전략
시간 기반 revalidate와 On-demand ISR은 배타적이 아닙니다. 두 전략을 조합하면 신선도와 서버 부하를 동시에 제어할 수 있습니다.
// app/blog/[slug]/page.tsx
import { getPost } from '@/lib/data'
export const revalidate = 3600 // ✅ 1시간마다 자동 재검증 (폴백 전략)
export default async function BlogPost({ params }: { params: { slug: string } }) {
// 이 fetch는 Data Cache에 저장되며 태그도 함께 부여
const post = await fetch(`https://cms.example.com/posts/${params.slug}`, {
next: {
tags: [`post-${params.slug}`, 'posts'],
revalidate: 3600, // 개별 fetch 수준에서도 제어 가능
},
}).then(r => r.json())
return <article>{post.content}</article>
}
// app/api/webhook/cms/route.ts
// CMS 웹훅: 콘텐츠 수정 즉시 On-demand 무효화
import { revalidateTag } from 'next/cache'
export async function POST(req: Request) {
const payload = await req.json()
const { slug, event } = payload
if (event === 'entry.update' || event === 'entry.publish') {
revalidateTag(`post-${slug}`) // ✅ 즉시 무효화
// 다음 요청에서 재렌더링 후 새 캐시 생성
// 그 사이 1시간 기반 revalidate가 만료되지 않아도 최신 데이터 보장
}
return Response.json({ ok: true })
}
이 패턴의 핵심은 시간 기반 revalidate를 안전망으로, On-demand ISR을 주 무효화 수단으로 사용하는 것입니다. CMS 웹훅이 실패하더라도 최대 1시간 안에 자동 갱신됩니다.
4. unstable_cache · fetch 옵션 · cache() 함수로 데이터 계층 세밀 제어
fetch를 직접 쓸 수 없는 ORM 쿼리나 SDK 호출도 Data Cache에 통합할 수 있습니다.
unstable_cache — fetch 외부 데이터 소스 캐싱
// app/lib/db.ts
import { unstable_cache } from 'next/cache'
import { db } from './database' // Prisma, Drizzle 등
export const getCachedPosts = unstable_cache(
async (authorId: string) => {
// ORM 쿼리도 Data Cache에 저장됨
return db.post.findMany({ where: { authorId } })
},
['posts-by-author'], // 캐시 키 접두사
{
tags: ['posts'], // revalidateTag로 무효화 가능
revalidate: 600, // 10분
}
)
// app/author/[id]/page.tsx
import { getCachedPosts } from '@/lib/db'
export default async function AuthorPage({ params }: { params: { id: string } }) {
// 동일한 authorId로 여러 번 호출해도 캐시 HIT
const posts = await getCachedPosts(params.id)
return <PostList posts={posts} />
}
cache() 함수 — Request Memoization 확장
React의 cache() 함수는 동일 렌더링 트리 내 중복 호출을 제거합니다. unstable_cache와 조합하면 두 계층을 동시에 활용할 수 있습니다.
// app/lib/data.ts
import { cache } from 'react'
import { unstable_cache } from 'next/cache'
// unstable_cache: Data Cache (요청 간 지속)
// cache(): Request Memoization (단일 렌더링 내 중복 제거)
export const getUser = cache(
unstable_cache(
async (id: string) => {
return db.user.findUnique({ where: { id } })
},
['user'],
{ tags: ['users'], revalidate: 300 }
)
)
fetch 옵션 조합 치트시트
// ✅ 항상 최신 데이터 (캐시 완전 비활성)
fetch(url, { cache: 'no-store' })
// ✅ 무효화 전까지 Data Cache에 무기한 보관 (미스 시 런타임에도 가져옴)
fetch(url, { cache: 'force-cache' })
// ✅ 시간 기반 재검증
fetch(url, { next: { revalidate: 60 } })
// ✅ 태그 기반 무효화 (On-demand ISR)
fetch(url, { next: { tags: ['posts'] } })
// ✅ 태그 + 시간 기반 동시 적용
fetch(url, { next: { tags: ['posts'], revalidate: 3600 } })
// ❌ cache: 'no-store'와 revalidate 동시 사용 — 의미 충돌
fetch(url, { cache: 'no-store', next: { revalidate: 60 } })
⚠️ 주의
export const dynamic = 'force-dynamic'을 라우트에 설정하면 해당 라우트 내 모든fetch호출이cache: 'no-store'로 강제됩니다. 개별 fetch 옵션이 무시되므로, 일부 데이터만 동적으로 만들고 싶다면 라우트 레벨 옵션 대신 fetch 수준에서 제어하세요.
5. 캐시 디버깅: 로그와 헤더로 HIT/MISS 추적
캐시 동작을 눈으로 확인하지 않으면 문제가 생겼을 때 원인을 찾기 어렵습니다.
x-nextjs-cache 응답 헤더
프로덕션(또는 next start)에서 라우트 응답 헤더를 확인하면 Full Route Cache 상태를 알 수 있습니다.
curl -I https://your-app.com/blog/hello-world
# HTTP/2 200
# x-nextjs-cache: HIT ← Full Route Cache에서 서빙됨
# x-nextjs-cache: MISS ← 캐시 없음, 새로 렌더링
# x-nextjs-cache: STALE ← 만료됐으나 백그라운드 재생성 중 (stale-while-revalidate)
# x-nextjs-cache: SKIP ← 동적 렌더링, 캐시 대상 아님
서버 로그로 Data Cache 추적
next.config.js에서 캐시 로깅을 활성화하면 개발 중 Data Cache HIT/MISS를 콘솔에서 볼 수 있습니다.
// next.config.js
/** @type {import('next').NextConfig} */
const nextConfig = {
logging: {
fetches: {
fullUrl: true, // ✅ 전체 URL 표시
},
},
}
module.exports = nextConfig
개발 서버를 실행하면 터미널에서 다음과 같은 출력이 나타납니다.
GET /blog/hello-world 200 in 45ms
│ GET https://api.example.com/posts/hello-world 200 in 12ms (cache: HIT)
│ GET https://api.example.com/author/123 200 in 0ms (cache: HIT, deduplicated)
커스텀 캐시 핸들러로 분산 환경 디버깅
멀티 인스턴스 환경에서는 각 인스턴스가 독립적인 Data Cache를 가지므로 Redis 같은 공유 스토어가 필요합니다.
// cache-handler.js (프로젝트 루트)
const { CacheHandler } = require('@neshca/cache-handler')
const createRedisHandler = require('@neshca/cache-handler/redis-strings').default
const { createClient } = require('redis')
CacheHandler.onCreation(async () => {
const client = createClient({ url: process.env.REDIS_URL })
await client.connect()
return {
handlers: [
createRedisHandler({ client }),
],
}
})
module.exports = CacheHandler
// next.config.js
module.exports = {
cacheHandler: require.resolve('./cache-handler.js'),
cacheMaxMemorySize: 0, // 인메모리 캐시 비활성, Redis 단독 사용
}
6. 캐시 일관성 함정과 회피 전략
Stale-While-Revalidate 함정
x-nextjs-cache: STALE 상태는 만료된 캐시를 즉시 반환하고 백그라운드에서 재생성을 시작한다는 의미입니다. 이 시간 동안 두 번째 요청자도 여전히 구버전을 받습니다.
// 금융·재고 데이터처럼 반드시 최신이어야 하는 경우
// ❌ revalidate는 stale-while-revalidate 구간이 존재함
fetch(url, { next: { revalidate: 60 } })
// ✅ 항상 최신 데이터가 필요하다면 cache: 'no-store'
fetch(url, { cache: 'no-store' })
분산 환경 캐시 공유 문제
기본 Next.js Data Cache는 파일시스템에 저장되기 때문에 로드 밸런서 뒤 여러 인스턴스를 운영하면 인스턴스마다 캐시 상태가 달라집니다.
인스턴스 A: POST /api/posts → revalidateTag('posts') → A의 캐시 무효화
인스턴스 B: 여전히 구버전 캐시를 서빙 ← 문제 발생!
이를 해결하려면 위 섹션에서 소개한 Redis 공유 캐시 핸들러를 적용하거나, Vercel처럼 플랫폼 레벨에서 캐시 무효화를 처리하는 환경을 사용해야 합니다.
Router Cache 강제 무효화
브라우저 측 Router Cache는 클라이언트에서만 제어할 수 있습니다. 서버에서 데이터를 변경해도 이미 방문한 경로의 Router Cache가 남아 있으면 사용자는 구버전을 볼 수 있습니다.
// app/components/RefreshButton.tsx
'use client'
import { useRouter } from 'next/navigation'
export function RefreshButton() {
const router = useRouter()
return (
<button
onClick={() => {
// ✅ Router Cache를 무효화하고 현재 경로를 재요청
router.refresh()
}}
>
새로고침
</button>
)
}
💡 TIP 서버 액션이 완료된 후
revalidatePath/revalidateTag를 호출하면 Next.js가 자동으로 클라이언트에 갱신 신호를 보내 Router Cache도 함께 무효화됩니다. 별도의router.refresh()호출 없이도 UI가 자동으로 업데이트됩니다.
빌드 시 정적 생성과 Runtime revalidate 충돌
generateStaticParams로 빌드 시 생성된 페이지는 Full Route Cache에 초기값이 들어가 있습니다. On-demand ISR로 무효화하면 다음 요청에서 재렌더링 후 새 캐시가 생성됩니다. 문제는 빌드 환경과 런타임 환경의 환경 변수나 외부 서비스가 다를 때 발생합니다.
// app/blog/[slug]/page.tsx
// ✅ fallback 동작: 빌드 시 생성되지 않은 경로는 런타임에 생성
export async function generateStaticParams() {
const posts = await fetch('https://cms.example.com/posts?limit=100').then(r => r.json())
return posts.map((p: { slug: string }) => ({ slug: p.slug }))
}
// 빌드 시 없던 slug → 런타임에 동적 렌더링 후 캐시 저장
// revalidateTag로 무효화 → 다음 요청에서 재렌더링
요약
- Next.js는 Request Memoization → Data Cache → Full Route Cache → Router Cache 4계층을 순서대로 확인하며, 각 계층은 수명과 범위가 다릅니다.
- revalidateTag는 데이터 중심, revalidatePath는 라우트 중심으로 무효화하며, 서버 액션 내에서 호출하면 Router Cache까지 자동으로 갱신됩니다.
- 시간 기반
revalidate를 안전망으로, On-demand ISR(웹훅 트리거)을 주 무효화 수단으로 조합하면 신선도와 성능을 동시에 얻을 수 있습니다. - ORM·SDK 등
fetch외 데이터 소스는unstable_cache와cache()조합으로 Data Cache와 Request Memoization 양쪽에 통합하세요. x-nextjs-cache헤더와logging.fetches설정으로 캐시 동작을 눈으로 확인하고, 멀티 인스턴스 환경에서는 Redis 공유 캐시 핸들러로 일관성을 확보하세요.stale-while-revalidate구간의 존재를 인식하고, 실시간성이 중요한 데이터는cache: 'no-store'로 명시적으로 제어하세요.
연습문제
- 다음 코드에서
getPosts()와getPostById(id)는 각각 어떤 캐시 계층에 저장되며,revalidateTag('posts')를 호출했을 때 두 함수의 캐시가 모두 무효화되는지 설명하세요.
export async function getPosts() {
return fetch('https://api.example.com/posts', {
next: { tags: ['posts'] },
}).then(r => r.json())
}
export async function getPostById(id: string) {
return fetch(`https://api.example.com/posts/${id}`, {
next: { tags: ['posts', `post-${id}`] },
}).then(r => r.json())
}
힌트
next.tags배열에 동일한 태그가 있으면revalidateTag한 번으로 모두 무효화됩니다.
- 블로그 CMS에서 글을 수정하면 즉시 반영되어야 하지만, CMS 웹훅이 가끔 실패할 수 있습니다. 안전망까지 갖춘 웹훅 API 라우트를 작성하세요.
힌트 On-demand ISR과 시간 기반 revalidate를 동시에 적용합니다. 웹훅에서는
revalidateTag를, fetch에서는next.revalidate를 사용하세요.
- 다음 서버 컴포넌트 트리에서
getUser('123')은 몇 번의 실제 네트워크 요청을 발생시킬까요? 그 이유를 Request Memoization 관점에서 설명하세요.
// UserProfile.tsx
async function UserProfile() {
const user = await getUser('123')
return <div><Avatar userId="123" /><Bio userId="123" /></div>
}
// Avatar.tsx
async function Avatar({ userId }: { userId: string }) {
const user = await getUser(userId) // getUser('123') 재호출
return <img src={user.avatar} />
}
// Bio.tsx
async function Bio({ userId }: { userId: string }) {
const user = await getUser(userId) // getUser('123') 재호출
return <p>{user.bio}</p>
}
힌트
getUser가cache()또는 동일 URL의fetch로 구현되어 있다고 가정하세요.
- 멀티 인스턴스 배포 환경에서 서버 액션으로
revalidateTag('products')를 호출했는데 일부 사용자에게는 여전히 구버전 데이터가 보입니다. 원인과 해결책을 설명하세요.
힌트 기본 Data Cache 저장소의 위치를 생각해보세요.
💡 연습문제 풀이
불러오는 중…
댓글 0
“Next.js 심화” 강좌에 대한 댓글입니다.