Next.js App Router는 성능 최적화를 위해 네 가지 독립적인 캐싱 레이어를 제공한다. 각 레이어는 서로 다른 범위와 생명주기를 가지며, 잘못 이해하면 오래된 데이터가 사용자에게 노출되는 문제가 생긴다. 레이어별 동작 원리와 무효화 방법을 명확히 파악해두면 예측 가능한 캐싱 전략을 세울 수 있다.
요청 서버 클라이언트
│ │ │
├─ Request │ │
│ Memoization ─────┤ 동일 렌더 트리 내 중복 제거 │
│ │ │
├─ Data Cache ──────┤ fetch() 결과, 빌드~만료까지 │
│ │ │
├─ Full Route ─────┤ 렌더링된 HTML/RSC Payload │
│ Cache │ │
│ │ │
└───────────────────┴──── Router Cache ────────────┤
클라이언트 인메모리 │
| 레이어 | 저장 위치 | 기본 지속 시간 | 범위 |
|---|---|---|---|
| Request Memoization | 서버 메모리 | 단일 렌더 트리 | 중복 fetch 제거 |
| Data Cache | 서버 파일시스템 | 영구 (명시 해제 전) | fetch 응답 |
| Full Route Cache | 서버 파일시스템 | 빌드 시 생성 | 정적 라우트 HTML |
| Router Cache | 브라우저 메모리 | 세션 동안 | prefetch된 라우트 |
Request Memoization은 동일 렌더 패스 내에서 같은 URL과 옵션으로 호출된 fetch를 자동으로 중복 제거한다. 여러 Server Component에서 같은 API를 호출해도 실제 네트워크 요청은 한 번만 발생한다.
// layout.tsx와 page.tsx에서 동일 fetch → 요청 1회만 발생
async function getUser(id: string) {
const res = await fetch(`/api/users/${id}`)
return res.json()
}
Request Memoization: React의 cache 함수를 기반으로 동작하며, 렌더 트리가 완료되면 캐시가 초기화된다.
fetch의 cache 옵션으로 Data Cache 동작을 제어한다. force-cache(기본값)는 빌드 이후에도 캐시를 유지하고, no-store는 매 요청마다 새로 가져온다.
// 영구 캐시 (기본)
fetch('/api/data', { cache: 'force-cache' })
// 캐시 없음
fetch('/api/data', { cache: 'no-store' })
// 시간 기반 재검증 (초 단위)
fetch('/api/data', { next: { revalidate: 60 } })
revalidate 값을 설정하면 stale-while-revalidate 방식으로 동작한다. 만료된 캐시를 즉시 반환하면서 백그라운드에서 새 데이터를 가져온다.
stale-while-revalidate: 오래된 데이터를 먼저 보여주고 동시에 갱신 요청을 보내는 HTTP 캐싱 패턴.
빌드 시 정적으로 렌더링된 라우트의 HTML과 RSC Payload를 서버에 캐싱한다. dynamic = 'force-dynamic' 또는 revalidate = 0으로 정적 캐싱을 비활성화할 수 있다.
// app/posts/page.tsx
export const revalidate = 3600 // 1시간마다 재검증
export const dynamic = 'force-dynamic' // 항상 동적 렌더링
On-demand Revalidation으로 특정 태그나 경로의 캐시를 즉시 무효화할 수 있다.
// app/actions.ts
import { revalidateTag, revalidatePath } from 'next/cache'
export async function updatePost(id: string) {
await db.post.update({ where: { id }, data: { ... } })
revalidateTag('posts') // 태그 기반 무효화
revalidatePath('/posts') // 경로 기반 무효화
}
fetch에 태그를 붙여두면 revalidateTag로 관련 캐시를 일괄 제거할 수 있다.
fetch('/api/posts', { next: { tags: ['posts'] } })
클라이언트 사이드 네비게이션 시 prefetch된 라우트 페이로드를 브라우저 메모리에 보관한다. 정적 라우트는 5분, 동적 라우트는 30초 동안 유지된다. router.refresh를 호출하면 현재 라우트의 Router Cache를 즉시 비운다.
'use client'
import { useRouter } from 'next/navigation'
function RefreshButton() {
const router = useRouter()
return <button onClick={() => router.refresh()}>새로고침</button>
}
Data Cache 무효화(revalidateTag, revalidatePath)는 서버 캐시만 비우므로, 클라이언트에서도 최신 데이터를 즉시 보여야 한다면 router.refresh를 함께 호출해야 한다.