Next.js의 캐싱 레이어를 전부 해부해봅니다
Article
"왜 내 페이지가 안 바뀌지?"
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를 제대로 다루기 시작한 겁니다.