Next.js의 캐싱 레이어를 전부 해부해본다
Article
이 글은 Next.js 15 이전 기준이다.
Next.js 16에서 캐싱 모델이 통째로 바뀌었다.
fetch는 더 이상 기본으로 캐싱되지 않고, 캐싱은cacheComponents: true와"use cache"로 켜는 것이 됐다. 아래에서 설명하는 네 레이어 중 Data Cache와 Full Route Cache는 지금과 동작이 다르다. 요청 메모이제이션만 그대로 남아 있다.최신 기준으로 정리한 강의는 이쪽이다 — 정적 렌더링과 동적 렌더링 ·
"use cache"와 Cache Components · 캐시 무효화이 글은 당시의 기록으로 남겨둔다. 네 레이어라는 모델이 왜 그렇게 헷갈렸는지, 그래서 무엇이 바뀌었는지 이해하는 데는 여전히 쓸모가 있다.
"왜 내 페이지가 안 바뀌지?"
Next.js App Router를 쓰면 한 번쯤은 이런 경험을 한다.
데이터를 분명히 수정했는데 페이지에 반영이 안 된다. 새로고침해도 안 된다. 빌드를 다시 해도 안 된다. 뭔가가 캐시하고 있는데, 뭐가 캐시하고 있는지 모르겠는 거다.
나도 이걸로 반나절을 날린 적이 있다. 원인을 찾고 나서야 알았다. Next.js에는 캐싱 레이어가 4개나 있다는 걸.
4개의 캐싱 레이어
Next.js App Router의 캐싱은 이 순서로 작동한다.
요청 → [1. Request Memoization]
→ [2. Data Cache]
→ [3. Full Route Cache]
→ [4. Router Cache (클라이언트)]
각각 무엇을 캐시하고, 언제 무효화되는지가 다르다.
레이어 1: Request Memoization
같은 렌더링 사이클 안에서 동일한 fetch를 중복 제거한다.
// 이 두 컴포넌트가 같은 페이지에서 렌더링될 때
async function ProductTitle() {
const product = await fetch(`https://api.example.com/products/1`);
return <h1>{product.name}</h1>;
}
async function ProductPrice() {
// 같은 URL, 같은 옵션 → 실제 네트워크 요청은 1번만
const product = await fetch(`https://api.example.com/products/1`);
return <span>{product.price}원</span>;
}
특징:
- React의 렌더링 사이클 동안만 유효하다
- 요청이 끝나면 자동으로 사라진다
- 같은 URL + 같은 옵션일 때만 중복 제거된다
fetch에만 적용된다.axios나 직접 DB 호출에는 적용 안 된다
무효화: 렌더링이 끝나면 자동으로 초기화된다. 신경 쓸 필요 없다.
실무 의미: 여러 컴포넌트에서 같은 데이터가 필요하면, 각 컴포넌트에서 자유롭게 fetch해도 된다. 네트워크 비용 걱정할 필요 없다.
레이어 2: Data Cache
fetch 결과를 서버에 영구적으로 저장한다.
// 기본값: 영구 캐시 (Next.js 14 기준)
const data = await fetch('https://api.example.com/products');
// 60초마다 재검증
const data = await fetch('https://api.example.com/products', {
next: { revalidate: 60 }
});
// 캐시 안 함
const data = await fetch('https://api.example.com/products', {
cache: 'no-store'
});
특징:
- 서버 사이드에 저장된다 (배포 간에도 유지될 수 있다)
- 빌드 시점에 fetch한 결과도 여기에 저장된다
revalidate로 시간 기반 재검증 가능revalidateTag/revalidatePath로 수동 무효화 가능
무효화 방법:
// 방법 1: 시간 기반
fetch(url, { next: { revalidate: 60 } }); // 60초 후 재검증
// 방법 2: 태그 기반
fetch(url, { next: { tags: ['products'] } });
// Server Action에서
import { revalidateTag } from 'next/cache';
revalidateTag('products');
// 방법 3: 경로 기반
import { revalidatePath } from 'next/cache';
revalidatePath('/products');
실무 의미: "데이터를 수정했는데 반영이 안 돼요"의 80%는 이 레이어 때문이다. revalidate 설정을 항상 의식적으로 해야 한다.
레이어 3: Full Route Cache
빌드 시점에 전체 라우트의 렌더링 결과(HTML + RSC Payload)를 저장한다.
// 이 페이지는 빌드 시점에 렌더링되어 캐시된다
export default async function ProductsPage() {
const products = await fetch('https://api.example.com/products');
return <ProductList products={products} />;
}
정적 라우트 vs 동적 라우트:
// 정적 → Full Route Cache 적용
export default async function AboutPage() {
return <div>About Us</div>;
}
// 동적 → Full Route Cache 적용 안 됨
export default async function ProductPage({
searchParams
}: {
searchParams: { q: string }
}) {
// searchParams를 사용하면 동적 라우트가 된다
const products = await searchProducts(searchParams.q);
return <ProductList products={products} />;
}
동적 라우트가 되는 조건:
searchParams사용cookies()/headers()사용fetch에cache: 'no-store'사용export const dynamic = 'force-dynamic'설정
무효화: Data Cache가 무효화되면 Full Route Cache도 함께 무효화된다.
실무 의미: 정적 페이지가 예상보다 많이 캐시될 수 있다. dynamic = 'force-dynamic'을 남발하지 말고, 정말 동적이어야 하는 페이지만 동적으로 만들자.
레이어 4: Router Cache (Client-side)
클라이언트 브라우저에서 방문한 라우트의 RSC Payload를 저장한다.
이건 서버가 아니라 브라우저 메모리에 저장된다.
사용자가 /products 방문 → RSC Payload 캐시
사용자가 /about 이동 → /about RSC Payload 캐시
사용자가 /products 돌아옴 → 캐시에서 즉시 렌더링 (네트워크 요청 없음)
특징:
<Link>컴포넌트의 prefetch도 이 캐시에 저장된다- 동적 라우트: 30초 유지
- 정적 라우트: 5분 유지
- 브라우저를 새로고침하면 초기화된다
무효화 방법:
// Server Action 안에서
import { revalidatePath } from 'next/cache';
revalidatePath('/products'); // 서버 + 클라이언트 캐시 모두 무효화
// 클라이언트에서 강제 새로고침
import { useRouter } from 'next/navigation';
const router = useRouter();
router.refresh(); // Router Cache만 무효화
실무 의미: "서버에서는 바뀌었는데 브라우저에서 안 바뀌어요"의 원인이다. router.refresh()를 알아두면 디버깅 시간을 절약할 수 있다.
4개 레이어 한눈에 보기
| 레이어 | 위치 | 캐시 대상 | 유지 기간 | 무효화 방법 |
|---|---|---|---|---|
| Request Memoization | 서버 | fetch 결과 | 렌더링 사이클 | 자동 |
| Data Cache | 서버 | fetch 결과 | 영구 (기본값) | revalidate, revalidateTag |
| Full Route Cache | 서버 | HTML + RSC Payload | 영구 (정적) | Data Cache 무효화 시 |
| Router Cache | 클라이언트 | RSC Payload | 30초~5분 | refresh(), revalidatePath |
디버깅 체크리스트
페이지가 업데이트되지 않을 때, 이 순서로 확인해보자.
1. Router Cache인가?
→ 브라우저 새로고침(Ctrl+Shift+R)으로 확인한다. 새로고침 후 바뀌면 Router Cache 문제다.
2. Data Cache인가?
→ fetch에 cache: 'no-store'를 임시로 넣어본다. 바뀌면 Data Cache 문제다.
3. Full Route Cache인가?
→ export const dynamic = 'force-dynamic'을 페이지에 추가해본다.
4. 빌드 캐시인가?
→ .next 폴더를 삭제하고 다시 빌드한다.
이 체크리스트만 알아도 "왜 안 바뀌지?" 디버깅 시간을 90% 줄일 수 있다.
내가 쓰는 기본 전략
// 자주 바뀌는 데이터: 짧은 revalidate
fetch(url, { next: { revalidate: 30 } });
// 가끔 바뀌는 데이터: 태그 기반 수동 무효화
fetch(url, { next: { tags: ['products'] } });
// 절대 캐시하면 안 되는 데이터 (결제, 재고 등)
fetch(url, { cache: 'no-store' });
// 거의 안 바뀌는 데이터: 긴 revalidate
fetch(url, { next: { revalidate: 3600 } }); // 1시간
캐싱은 Next.js에서 가장 강력한 기능이면서 동시에 가장 혼란스러운 기능이다. 4개 레이어가 각각 뭘 하는지 이해하면, 성능과 최신성 사이의 균형을 의도적으로 설계할 수 있다.
"왜 안 바뀌지?"에서 "이건 Data Cache 때문이니까 revalidateTag를 쓰면 되겠다"로 바뀌는 순간, Next.js를 제대로 다루기 시작한 거다.