Next.js 캐싱 계층 전체 흐름
참고: Next.js 공식 문서 — Caching · yceffort — Next.js Caching Deep Dive
왜 Next.js 캐싱이 복잡한가
SSG / SSR / ISR / CSR / Streaming을 하나의 프레임워크에서 지원하기 때문입니다. 각 렌더링 전략마다 캐싱 요구사항이 다르고, App Router는 컴포넌트 어디서든 async/await로 데이터를 가져올 수 있어 "언제 캐시되는가?"가 불명확합니다.
또한 Next.js 14 → 15에서 기본값이 정반대로 바뀌었습니다.
Next.js 14: 모든 것이 기본 캐시 → "왜 데이터가 안 바뀌지?" 불만 폭발
Next.js 15: 기본 캐시 없음 → 개발자가 명시적으로 캐시를 선택
4가지 캐싱 계층 개요
| 계층 | 담당 | 저장 위치 | 공유 범위 | 지속 시간 |
|---|---|---|---|---|
| Request Memoization | React | 서버 메모리 | 렌더링 트리 내부만 | 단일 요청 |
| Data Cache | Next.js | 서버 디스크 | 모든 사용자·요청 | 영구 (명시적 무효화까지) |
| Full Route Cache | Next.js | 서버 디스크 | 모든 사용자 | 빌드 ~ 재배포 |
| Router Cache | Next.js | 클라이언트 메모리 | 현재 사용자만 | 30초 ~ 5분 |
공유 범위가 가장 중요한 구분 기준입니다. Data Cache와 Full Route Cache는 모든 사용자가 같은 캐시를 공유하므로 개인 데이터를 절대 넣어서는 안 됩니다.
요청 처리 전체 흐름
Request Memoization과 Data Cache의 순서에 주의해야 합니다. fetch()가 호출될 때 Request Memoization이 먼저 체크되고, 미스 시 Data Cache를 확인합니다. Request Memoization은 렌더링 중 중복 호출을 제거하는 레이어이고, Data Cache는 그 아래에서 실제 데이터를 영구 저장하는 계층입니다.
브라우저 요청
│
▼
[1] Router Cache (클라이언트 메모리)
│ 히트 → 서버 요청 없이 즉시 렌더링
│ 미스 ↓
▼
[2] Full Route Cache (서버 디스크 — 정적 페이지만)
│ 히트 → HTML + RSC Payload 즉시 반환
│ 미스 ↓ (동적 페이지이거나 무효화된 경우)
▼
렌더링 시작 (RSC 실행)
│
각 fetch() / unstable_cache() 호출마다:
▼
[3] Request Memoization (서버 메모리)
│ 히트 → 현재 렌더에서 이미 호출된 결과 반환
│ 미스 ↓
▼
[4] Data Cache (서버 디스크)
│ 히트 → DB 없이 영구 저장된 스냅샷 반환
│ 미스 ↓
▼
실제 데이터 소스 (DB, 외부 API 등)
│
├── Data Cache에 결과 저장 (revalidate/tags 설정된 경우)
└── Request Memoization에 결과 저장 (렌더 종료까지만 유효)
계층별 상세
흐름 순서대로 설명합니다.
1. Router Cache
사용자가 방문한 페이지의 RSC Payload를 브라우저 메모리에 저장합니다. 뒤로가기·앞으로가기 또는 같은 경로 재방문 시 서버 요청 없이 즉시 표시합니다. 4계층 중 가장 먼저 체크되는 계층입니다.
Next.js 14 → 15 변화 (중요):
| Next.js 14 | Next.js 15 | |
|---|---|---|
| 정적 페이지 | 5분 캐시 | 캐시 안 함 |
| 동적 페이지 | 30초 캐시 | 캐시 안 함 |
| Layout | 5분 캐시 | 5분 캐시 |
<Link> prefetch 동작:
ts// 정적 라우트: 전체 페이지 prefetch (5분 캐시) <Link href="/about">About</Link> // 동적 라우트: Loading 상태까지만 prefetch (클릭 시 실제 fetch) <Link href="/posts/1">Post</Link> // prefetch 강제 활성화 <Link href="/posts/1" prefetch={true}>Post</Link>
무효화:
ts// Server Action에서 — revalidatePath/revalidateTag 호출 시 자동 무효화 revalidatePath('/posts/1'); // 클라이언트에서 강제 갱신 router.refresh();
⚠️ 흔한 실수 — 삭제 후 뒤로가기:
ts// 문제: 글 삭제 후 뒤로가기 시 캐시된 글이 보임 // ✅ 해결: Server Action에서 revalidatePath 호출 'use server'; export async function deletePost(id: string) { await db.post.delete({ where: { id } }); revalidatePath('/posts'); revalidatePath(`/posts/${id}`); redirect('/posts'); }
이 프로젝트: Next.js 기본 동작. Server Action의
revalidatePath로 무효화합니다.
2. Full Route Cache
next build 시 정적으로 생성 가능한 페이지의 HTML + RSC Payload 전체를 서버 디스크에 저장합니다. Router Cache 미스 후 서버에 요청이 도달하면 렌더링 전에 이 캐시를 먼저 확인합니다.
정적 페이지(○) vs 동적 페이지(λ) 판단 기준:
정적으로 유지되는 조건 (모두 충족해야 함)
✓ cookies(), headers(), searchParams 미사용
✓ 동적 URL 파라미터([id]) 없거나 generateStaticParams 제공
✓ fetch에 revalidate 설정됨 또는 force-cache
✓ force-dynamic 설정 없음
아래 중 하나라도 해당되면 동적(λ)으로 전환
✗ cookies() / headers() 호출
✗ searchParams 접근
✗ cache: 'no-store' 또는 revalidate: 0 인 fetch
✗ export const dynamic = 'force-dynamic'
빌드 로그에서 ○(정적) / ●(ISR) / λ(동적)으로 확인할 수 있습니다.
⚠️ 주의 — 의도치 않은 동적 전환:
ts// ❌ 쿠키 하나 읽는 것만으로도 페이지 전체가 동적 렌더링으로 전환 export default async function Page({ params }) { const theme = cookies().get('theme')?.value // 동적 전환! const post = await fetch(`/api/posts/${params.slug}`, { next: { revalidate: 3600 }, }) return <Article post={post} theme={theme} /> } // ✅ 동적 부분만 Client Component로 분리 export default async function Page({ params }) { const post = await fetch(`/api/posts/${params.slug}`, { next: { revalidate: 3600 }, }) return ( <Article post={post}> <ThemeWrapper /> {/* 클라이언트에서 쿠키 읽기 */} </Article> ) }
이 프로젝트:
/posts/[id]는 동적 라우트,/posts는searchParams를 사용하므로 Full Route Cache 미적용입니다.
3. Request Memoization
Full Route Cache 미스로 렌더링이 시작된 이후, 각 fetch() 호출 시 가장 먼저 체크되는 계층입니다. React가 담당하며, 단일 렌더링 사이클 안에서 동일한 요청의 중복 실행을 제거합니다.
렌더링 중 같은 URL로 fetch() 3번 호출
├─ 1번째: Request Memoization 미스 → Data Cache 확인 → (미스 시) 실제 API 호출
├─ 2번째: Request Memoization 히트 → Data Cache·API 호출 없이 반환
└─ 3번째: Request Memoization 히트 → Data Cache·API 호출 없이 반환
렌더 종료 → 자동 초기화
fetch는 Next.js가 자동으로 메모이제이션합니다. Supabase SDK처럼 fetch를 쓰지 않는 경우는 React.cache() 로 직접 감싸야 합니다.
tsimport { cache } from 'react'; // fetch 없이 DB를 직접 호출하는 함수에 적용 export const getUser = cache(async (id: string) => { return await db.user.findUnique({ where: { id } }); });
동작하지 않는 상황: Route Handler(API 라우트), POST/DELETE 등 비-GET 메서드, AbortSignal 전달 시.
이 프로젝트:
get-query-client.ts에서React.cache()로getQueryClient를 감싸 동일 요청 내 QueryClient 인스턴스를 재사용합니다. (데이터 캐싱 목적이 아닌 인스턴스 공유 목적)
4. Data Cache
Request Memoization 미스 시 체크하는 계층입니다. 서버에서 조회한 데이터를 디스크에 영구 저장해 여러 요청·사용자에서 재사용합니다. revalidateTag / revalidatePath로 무효화하지 않으면 재배포 후에도 유지됩니다.
ts// fetch 방식 fetch(url, { next: { revalidate: 3600, tags: ['posts'] } }); // unstable_cache 방식 — Supabase처럼 fetch를 쓰지 않을 때 unstable_cache(fn, cacheKey, { revalidate: 3600, tags: ['posts'] });
무효화 두 가지:
revalidate: 3600 → 3600초 경과 시 자동 만료 (백업)
revalidateTag() → 코드에서 즉시 무효화 (주 수단)
Stale-While-Revalidate 패턴: 만료 후 첫 요청은 낡은 데이터를 먼저 반환하고, 백그라운드에서 새 데이터를 가져옵니다. 다음 요청부터 새 데이터가 나옵니다.
⚠️ 절대 넣으면 안 되는 것: 로그인 사용자의 개인 데이터. 모든 사용자가 같은 캐시를 공유하므로 A 사용자 데이터가 B에게 노출될 수 있습니다.
이 프로젝트:
unstable_cache로 글 상세·홈 목록·댓글·해시태그를 캐시합니다. 자세한 내용은nextjs-data-cache-strategy.md를 참고하세요.
계층 간 무효화 매트릭스
| 무효화 수단 | Data Cache | Full Route Cache | Router Cache |
|---|---|---|---|
revalidateTag() | ✅ | ✅ | ✅ |
revalidatePath() | ✅ | ✅ | ✅ |
router.refresh() | ❌ | ❌ | ✅ (현재 페이지만) |
| 새 배포 | ❌ (유지됨!) | ✅ | N/A |
핵심: revalidateTag() 하나로 Data Cache → Full Route Cache → Router Cache 세 계층이 연쇄 무효화됩니다. Request Memoization은 독립적으로 렌더마다 자동 초기화됩니다.
⚠️ 재배포해도 Data Cache는 유지됩니다. API 응답 구조가 바뀌었는데 캐시에 낡은 데이터가 남아있으면 오류가 발생할 수 있습니다. 배포 파이프라인에서 웹훅으로 무효화를 함께 처리하는 것이 안전합니다.
이 프로젝트의 캐시 계층 활용 현황
흐름 순서대로 나열합니다.
[1] Router Cache
└─ Next.js 기본 동작. Server Action의 revalidatePath로 무효화
[2] Full Route Cache
└─ 동적 라우트·searchParams 사용으로 대부분 미적용
렌더링 시작 후 각 fetch() 호출마다:
[3] Request Memoization
└─ React cache()로 getQueryClient를 감싸 요청 내 QueryClient 인스턴스 재사용
(데이터 캐싱 목적 아님 — 인스턴스 공유 목적)
[4] Data Cache ← 핵심 계층
├─ getCachedPost revalidate: 3600 tags: post-{id}, posts
├─ getCachedRecentPosts revalidate: 3600 tags: posts
├─ getCachedComments revalidate: 60 tags: comments-{postId}
├─ getCachedHashtagsWithCount revalidate: 3600 tags: hashtags
└─ getCachedHashtagById revalidate: 3600 tags: hashtags
✗ getLikeStatusAction — 사용자별 데이터이므로 캐시 제외
면접 빈출 포인트
Q. Data Cache와 Router Cache의 차이는?
Data Cache는 서버 디스크에 저장되며 모든 사용자가 공유합니다. Router Cache는 클라이언트 브라우저 메모리에 저장되며 현재 사용자만 사용합니다. 따라서 개인 데이터는 Data Cache에 절대 넣어서는 안 됩니다.
Q. 재배포하면 캐시가 초기화되나요?
Full Route Cache는 초기화되지만 Data Cache는 유지됩니다. API 구조 변경과 함께 배포할 때는
revalidateTag를 웹훅으로 함께 실행해야 합니다.
Q. unstable_cache와 React.cache()의 차이는?
React.cache()는 Request Memoization 계층 — 단일 렌더 안에서만 중복 호출을 제거합니다.unstable_cache는 Data Cache 계층 — 서버 디스크에 영구 저장되어 여러 요청·사용자에서 재사용됩니다. fetch() 호출 시 Request Memoization이 먼저 체크되고, 미스 시 Data Cache를 확인하는 순서로 동작합니다.
Q. Next.js 15에서 캐싱 기본값이 바뀐 이유는?
Next.js 14는 모든 것을 기본 캐시해 "왜 데이터가 안 바뀌지?" 같은 혼란을 유발했습니다. 15부터는 명시적으로 캐시를 선언해야 하는 방향으로 전환했습니다.
관련 문서: nextjs-data-cache-strategy.md — 이 프로젝트의 Data Cache 구체 전략
아직 댓글이 없습니다
첫 번째 댓글을 작성해보세요!