Next.js 캐시가 안 되는 진짜 이유 (2026년 완벽 해결법)
Next.js로 개발하다 보면 이런 경험 한 번쯤은 있으시죠? "분명 데이터 바꿨는데 왜 안 바뀌지?", "revalidate 설정했는데 왜 안 먹히지?", "로컬에선 되는데 프로덕션에선 왜 안 되지?" 혼자만 겪는 문제가 아닙니다. Next.js App Router의 캐싱은 정말 강력하지만, 그만큼 헷갈리는 부분이기도 해요.
이 글에서는 Next.js 15와 16의 캐싱 레이어를 하나씩 파헤쳐보고, 캐시가 왜 말을 안 듣는지 알아본 다음, 실제로 써먹을 수 있는 해결책들을 정리해 드릴게요.
Next.js 캐싱, 4개 레이어부터 이해하자
디버깅하기 전에 먼저 알아야 할 게 있어요. Next.js App Router는 캐시가 하나가 아니라 4개의 독립적인 레이어로 동작합니다. 각각 다른 역할을 하는데, 이걸 헷갈리면 캐시 문제 해결이 안 돼요.
1. Request Memoization (요청 메모이제이션)
범위: 단일 렌더 패스 (서버 사이드 전용)
수명: 단일 요청 기간
목적: 단일 렌더 내에서 동일한 데이터 fetch 중복 제거
// 이 두 fetch 호출은 자동으로 중복 제거됩니다 // 실제로는 단 하나의 HTTP 요청만 발생해요 async function ProductDetails({ id }: { id: string }) { const product = await fetch(`/api/products/${id}`); return <ProductPrice productId={id} />; } async function ProductPrice({ productId }: { productId: string }) { // 정확히 같은 fetch는 메모이제이션됩니다—중복 요청 없음 const product = await fetch(`/api/products/${productId}`); return <span>${product.price}</span>; }
Request Memoization은 단일 서버 렌더 동안 동일한 URL과 옵션을 가진 fetch 호출에 대해 자동으로 발생합니다. 이것은 Next.js가 확장한 React의 기본 동작이에요.
흔한 오해: 이것은 영구적인 캐싱이 아닙니다! 요청이 완료되면 이 메모이제이션은 사라져요. 같은 렌더 트리 순회 내에서만 중복 fetch를 방지할 뿐입니다.
2. Data Cache (데이터 캐시)
범위: 서버 사이드, 영구적
수명: 재검증 또는 수동 무효화까지
목적: 요청과 배포 전반에 걸쳐 fetch 응답을 캐싱
// 무기한 캐싱 (정적 동작) const data = await fetch('https://api.example.com/data'); // 60초 동안 캐싱 const data = await fetch('https://api.example.com/data', { next: { revalidate: 60 } }); // 캐싱 안 함 const data = await fetch('https://api.example.com/data', { cache: 'no-store' });
Data Cache가 대부분의 혼란이 발생하는 곳입니다. fetch 호출의 원시 응답을 저장하고 다음 전반에 걸쳐 지속됩니다:
- 여러 사용자 요청
- 서버 재시작 (적절한 인프라가 있는 프로덕션에서)
- 배포 (Vercel 같은 플랫폼에서)
3. Full Route Cache (전체 라우트 캐시)
범위: 서버 사이드, 영구적
수명: 재검증까지
목적: 라우트에 대한 완전한 HTML과 RSC (React Server Component) 페이로드를 캐싱
Next.js 애플리케이션을 빌드할 때, 정적 라우트는 빌드 시점에 사전 렌더링됩니다. Full Route Cache는 다음을 저장해요:
- 초기 페이지 로드를 위한 렌더링된 HTML
- 클라이언트 사이드 네비게이션을 위한 RSC Payload
// 이 페이지는 정적으로 생성되고 완전히 캐싱됩니다 export default function AboutPage() { return <div>회사 소개</div>; } // 이 페이지는 Full Route Cache에서 제외됩니다 export const dynamic = 'force-dynamic'; export default function DashboardPage() { return <div>대시보드: {new Date().toISOString()}</div>; }
4. Router Cache (라우터 캐시, 클라이언트 사이드)
범위: 클라이언트 사이드, 세션별
수명: 자동 무효화가 있는 세션 기반
목적: 즉각적인 뒤로/앞으로 네비게이션을 위해 방문한 라우트 세그먼트를 브라우저에 캐싱
사용자가 /products 방문 → Router Cache가 /products 레이아웃 + 페이지 저장 사용자가 /products/123으로 네비게이션 → Router Cache가 /products/123 페이지 저장 → /products 레이아웃은 캐시에서 재사용 사용자가 뒤로가기 버튼 클릭 → /products 페이지가 Router Cache에서 즉시 제공
Router Cache는 Next.js 15에서 많이 바뀌어서 가장 헷갈리는 부분이에요. 아래에서 자세히 다룰게요.
캐시가 안 될 때 이렇게 디버깅하세요
데이터가 안 바뀔 때, 이 순서대로 체크해보세요:
1단계: 어떤 캐시 레이어가 관련되어 있는지 확인
다음 질문들을 스스로에게 해보세요:
| 질문 | 예인 경우 | 아니오인 경우 |
|---|---|---|
| 첫 로드에서 바로 오래된 데이터가 나타나나요? | Data Cache 또는 Full Route Cache | Router Cache (클라이언트 사이드) |
| 강력 새로고침(Cmd+Shift+R)으로 해결되나요? | Router Cache | 서버 사이드 캐시 |
| 재배포하면 해결되나요? | Full Route Cache | Data Cache 설정 오류 |
| 개발 환경인가요? | 개발 서버는 캐싱이 다름 | 프로덕션 빌드 확인 필요 |
2단계: Fetch 설정 확인하기
가장 흔한 문제는 fetch 캐시 기본값을 오해하는 것입니다.
// ❌ 흔한 실수: 이게 동적이라고 가정 const res = await fetch('https://api.example.com/user'); // Next.js에서 이것은 기본적으로 캐싱됩니다! // ✅ 올바른 방법: 명시적으로 캐싱에서 제외 const res = await fetch('https://api.example.com/user', { cache: 'no-store' }); // ✅ 또는 시간 기반 재검증 사용 const res = await fetch('https://api.example.com/user', { next: { revalidate: 0 } // 매 요청마다 재검증 });
3단계: Route Segment Config 이해하기
Route segment 설정은 전체 라우트가 어떻게 캐싱되는지에 영향을 미칩니다:
// app/dashboard/page.tsx // 전체 라우트를 동적으로 강제 export const dynamic = 'force-dynamic'; // 정적 생성 강제 (동적 함수 사용 시 에러 발생) export const dynamic = 'force-static'; // 전체 라우트에 대한 재검증 설정 export const revalidate = 60; // 60초마다 재검증 // 모든 캐싱 비활성화 export const revalidate = 0; export const fetchCache = 'force-no-store';
문서에 안 나온 함정들
함정 #1: cookies()와 headers()는 자동으로 제외
Server Component에서 동적 함수를 사용하면, 전체 라우트가 동적이 됩니다:
import { cookies } from 'next/headers'; export default async function UserPage() { // cookies()를 호출하기만 해도 이 라우트는 동적이 됩니다 // 결과를 사용하지 않더라도요! const cookieStore = await cookies(); // 이 fetch도 이제 동적입니다 const user = await fetch('/api/user'); return <div>{user.name}</div>; }
해결책: 쿠키가 필요하지만 캐싱도 원한다면, 컴포넌트를 재구조화하세요:
// 동적 부분과 정적 부분을 분리 import { Suspense } from 'react'; export default function UserPage() { return ( <div> <StaticHeader /> {/* 이 부분은 캐싱됨 */} <Suspense fallback={<Loading />}> <DynamicUserContent /> {/* 이 부분만 동적 */} </Suspense> </div> ); }
함정 #2: searchParams 패러독스
페이지에서 searchParams에 접근하면, URL 파라미터를 사용하지 않더라도 동적이 됩니다:
// ❌ 검색 파라미터가 전달되지 않아도 이 페이지는 동적입니다 export default function ProductsPage({ searchParams, }: { searchParams: { sort?: string }; }) { // props에 searchParams가 있기만 해도 = 동적 return <ProductGrid />; } // ✅ 더 좋은 방법: 필요할 때만 searchParams에 접근 export default function ProductsPage() { return <ProductGrid />; // 기본적으로 정적 } // 필터링된 뷰를 위해서는 별도 라우트나 클라이언트 컴포넌트 사용
함정 #3: POST 요청과 Data Cache
POST 요청은 많은 개발자들을 당황시키는 독특한 캐싱 동작을 가집니다:
// POST 요청은 기본적으로 캐싱되지 않습니다 const res = await fetch('/api/data', { method: 'POST', body: JSON.stringify({ id: 1 }), }); // 하지만 응답이 캐시 헤더를 사용하면 캐싱될 수 있어요 // API 응답 헤더를 확인하세요!
함정 #4: 개발 환경 vs 프로덕션 차이
이것이 아마도 가장 짜증나는 함정일 거예요. 개발 환경에서는:
- Data Cache가 기본적으로 비활성화됨
- Full Route Cache가 존재하지 않음
- 핫 리로드가 때때로 예기치 않게 캐시를 지움
# 항상 프로덕션 빌드로 캐싱 동작을 테스트하세요 npm run build && npm start
캐싱 문제가 프로덕션에서만 나타난다면, 이것이 이유입니다.
함정 #5: ISR과 revalidate 타이밍
revalidate는 "N초마다 새로고침"을 의미하지 않습니다—"N초 후, 다음 요청이 백그라운드 재생성을 트리거함"을 의미해요:
export const revalidate = 60; // 타임라인: // t=0: 페이지 생성, 캐싱됨 // t=30: 사용자 방문 → 캐시된 버전 받음 (30초 된 것) // t=65: 사용자 방문 → 캐시된 버전 받음 (65초 된 것), 백그라운드 재생성 트리거 // t=66: 새 캐시 준비됨 // t=70: 사용자 방문 → 신선한 버전 받음
stale-while-revalidate 패턴은 사용자가 revalidate 기간 이후에도 오래된 콘텐츠를 볼 수 있다는 것을 의미합니다.
실전에서 바로 쓰는 해결법
레시피 1: 실시간 대시보드 데이터
문제: 업데이트 후에도 대시보드가 오래된 데이터를 보여줍니다.
// app/dashboard/page.tsx import { unstable_noStore as noStore } from 'next/cache'; export default async function Dashboard() { noStore(); // 이 컴포넌트에 대한 모든 캐싱 제외 const stats = await fetchDashboardStats(); return <DashboardView stats={stats} />; } // 또는 route segment config 사용 export const dynamic = 'force-dynamic'; export const revalidate = 0;
레시피 2: 공유 레이아웃과 사용자별 콘텐츠
문제: 레이아웃은 캐싱되지만, 페이지 콘텐츠는 사용자별로 달라야 합니다.
// app/dashboard/layout.tsx // 이 레이아웃은 정적으로 유지될 수 있습니다 export default function DashboardLayout({ children }) { return ( <div className="dashboard-container"> <Sidebar /> {/* 캐싱됨 */} {children} </div> ); } // app/dashboard/page.tsx import { cookies } from 'next/headers'; export default async function DashboardPage() { const cookieStore = await cookies(); const userId = cookieStore.get('userId')?.value; // 이 페이지는 동적이지만, 레이아웃은 여전히 캐싱됨 const userData = await fetch(`/api/users/${userId}`, { cache: 'no-store' }); return <UserDashboard data={userData} />; }
레시피 3: 가격 업데이트가 필요한 이커머스 상품 페이지
문제: 가격 변경은 5분 내에 반영되어야 하지만, 상품 설명은 더 오래 캐싱될 수 있습니다.
// app/products/[id]/page.tsx export const revalidate = 300; // 5분 export default async function ProductPage({ params }) { // 상품 상세는 5분마다 재검증 const product = await fetch(`/api/products/${params.id}`, { next: { revalidate: 300 } }); // 재고는 실시간이어야 함 const inventory = await fetch(`/api/inventory/${params.id}`, { cache: 'no-store' }); return <ProductView product={product} inventory={inventory} />; }
레시피 4: 온디맨드 재검증을 사용하는 블로그
문제: 블로그 포스트는 캐싱되어야 하지만, CMS에서 콘텐츠가 업데이트되면 재검증되어야 합니다.
// app/blog/[slug]/page.tsx export const revalidate = false; // 무기한 캐싱 export default async function BlogPost({ params }) { const post = await fetch(`/api/posts/${params.slug}`, { next: { tags: [`post-${params.slug}`] } }); return <Article post={post} />; } // app/api/revalidate/route.ts import { revalidateTag } from 'next/cache'; import { NextRequest } from 'next/server'; export async function POST(request: NextRequest) { const { slug, secret } = await request.json(); if (secret !== process.env.REVALIDATION_SECRET) { return Response.json({ error: 'Invalid secret' }, { status: 401 }); } revalidateTag(`post-${slug}`); return Response.json({ revalidated: true }); }
레시피 5: Router Cache 문제 해결하기 (Next.js 15)
문제: 클라이언트 사이드 네비게이션이 오래된 데이터를 보여줍니다.
Next.js 15부터 Router Cache 동작이 크게 변경되었습니다. 동적 페이지는 더 이상 클라이언트에서 기본적으로 캐싱되지 않지만, 정적 페이지는 여전히 캐싱됩니다. Next.js 16에서도 이러한 접근 방식이 더욱 개선되어 유지되고 있어요.
// 모든 네비게이션에서 신선한 데이터가 필요한 동적 라우트의 경우: import { useRouter } from 'next/navigation'; function RefreshButton() { const router = useRouter(); const handleRefresh = () => { router.refresh(); // 현재 라우트의 Router Cache 무효화 }; return <button onClick={handleRefresh}>데이터 새로고침</button>; }
더 공격적인 캐시 버스팅을 위해:
// middleware.ts import { NextResponse } from 'next/server'; import type { NextRequest } from 'next/server'; export function middleware(request: NextRequest) { const response = NextResponse.next(); // 특정 라우트에 대해 Router Cache 방지 if (request.nextUrl.pathname.startsWith('/dashboard')) { response.headers.set( 'Cache-Control', 'no-store, must-revalidate' ); } return response; }
고급 패턴: 캐시 태그 전략
캐시 태그를 사용하면 캐시된 데이터 간의 관계를 만들고 정밀하게 무효화할 수 있습니다:
// lib/data.ts export async function getProducts(category: string) { return fetch(`/api/products?category=${category}`, { next: { tags: [ 'products', // 모든 상품 `category-${category}`, // 특정 카테고리 ] } }); } export async function getProductById(id: string) { return fetch(`/api/products/${id}`, { next: { tags: [ 'products', `product-${id}`, ] } }); } // 단일 상품이 업데이트될 때: revalidateTag(`product-${updatedProductId}`); // 카테고리가 재구성될 때: revalidateTag(`category-${categoryName}`); // 핵 옵션 - 모든 상품: revalidateTag('products');
디버깅 도구와 기법
1. 캐시 헤더 검사
// 개발 환경에서 캐시 디버그 헤더 추가 // next.config.js module.exports = { async headers() { return [ { source: '/(.*)', headers: [ { key: 'x-next-cache-status', value: process.env.NODE_ENV === 'development' ? 'disabled' : 'enabled', }, ], }, ]; }, };
2. 캐시 동작 로깅
// lib/fetch-with-logging.ts export async function fetchWithCacheLogging(url: string, options?: RequestInit) { const startTime = performance.now(); const response = await fetch(url, options); console.log({ url, cacheMode: options?.cache ?? 'default', revalidate: (options as any)?.next?.revalidate ?? 'not set', responseTime: `${(performance.now() - startTime).toFixed(2)}ms`, fromCache: response.headers.get('x-cache') === 'HIT', }); return response; }
3. 시각적 캐시 인디케이터
// components/CacheDebugBadge.tsx import { unstable_cache } from 'next/cache'; export function CacheDebugBadge() { if (process.env.NODE_ENV !== 'development') return null; const renderTime = new Date().toISOString(); return ( <div className="fixed bottom-4 right-4 bg-black text-white p-2 text-xs rounded"> 렌더링 시간: {renderTime} </div> ); }
Next.js 15/16 캐싱 변경사항: 무엇이 새로워졌나요?
Next.js 15는 캐싱 기본값에 몇 가지 중요한 변경사항을 도입했으며, Next.js 16 (2025년 10월 출시)은 완전히 새로운 접근 방식으로 캐싱을 혁신했습니다.
Next.js 15 변경사항 (여전히 유효)
1. fetch 요청이 더 이상 기본적으로 캐싱되지 않음
// Next.js 14 이하: 기본적으로 캐싱됨 // Next.js 15+: 기본적으로 캐싱되지 않음 // 이전 동작을 복원하려면: const data = await fetch(url, { cache: 'force-cache' });
2. Route Handler가 기본적으로 동적
// Next.js 14 이하: GET 핸들러가 캐싱됨 // Next.js 15+: 모든 핸들러가 기본적으로 동적 // route handler를 정적으로 만들려면: export const dynamic = 'force-static'; export async function GET() { return Response.json({ time: new Date().toISOString() }); }
3. 클라이언트 Router Cache 변경
// Next.js 14 이하: 페이지가 30초(동적) 또는 5분(정적) 동안 캐싱됨 // Next.js 15+: 동적 페이지는 0초의 staleness time을 가짐 // staleness time을 구성하려면: // next.config.js module.exports = { experimental: { staleTimes: { dynamic: 30, // 초 static: 180, // 초 }, }, };
Next.js 16: "use cache" 혁명
Next.js 16은 "use cache" 디렉티브와 함께 Cache Components를 도입했습니다—App Router 도입 이후 가장 중요한 캐싱 변화예요. 이것은 Partial Pre-Rendering (PPR)의 비전을 완성합니다.
패러다임 전환: 암시적 → 명시적 캐싱
Next.js 16에서는 모든 동적 코드가 기본적으로 요청 시 실행됩니다. 캐싱은 이제 완전히 opt-in이에요:
// Next.js 16: 이 페이지는 기본적으로 동적입니다 export default async function ProductPage({ params }) { const product = await fetch(`/api/products/${params.id}`); return <ProductView product={product} />; } // 캐싱하려면 반드시 "use cache"를 명시적으로 사용해야 합니다 "use cache" export default async function ProductPage({ params }) { const product = await fetch(`/api/products/${params.id}`); return <ProductView product={product} />; }
다양한 레벨에서 "use cache" 사용하기
// 전체 페이지 캐싱 "use cache" export default async function BlogPostPage({ params }) { const post = await fetchPost(params.slug); return <Article post={post} />; } // 특정 컴포넌트 캐싱 async function CachedSidebar() { "use cache" const categories = await fetchCategories(); return <Sidebar categories={categories} />; } // 함수 캐싱 async function getExpensiveData(id: string) { "use cache" // 이 결과는 캐싱됩니다 return await computeExpensiveData(id); }
캐시 태그와 결합하기
"use cache" import { cacheTag } from 'next/cache'; export default async function ProductPage({ params }) { cacheTag(`product-${params.id}`); const product = await fetch(`/api/products/${params.id}`); return <ProductView product={product} />; } // revalidateTag로 무효화 import { revalidateTag } from 'next/cache'; revalidateTag(`product-${params.id}`);
새로운 기능: cacheLife로 만료 제어
"use cache" import { cacheLife } from 'next/cache'; export default async function DashboardStats() { cacheLife('minutes'); // 미리 정의된 프로필: 'seconds', 'minutes', 'hours', 'days', 'weeks', 'max' const stats = await fetchStats(); return <StatsView stats={stats} />; } // 또는 커스텀 값으로 cacheLife({ stale: 60, // 60초 동안 stale 제공 revalidate: 300, // 5분 후 재검증 expire: 3600, // 1시간 후 만료 });
Turbopack 파일 시스템 캐싱 (베타)
Next.js 16은 파일 시스템 캐싱과 함께 Turbopack을 기본 번들러로 만들었습니다:
// next.config.js module.exports = { experimental: { turbo: { // 더 빠른 리빌드를 위한 파일 시스템 캐싱 persistentCaching: true, }, }, };
이것은 컴파일러 아티팩트를 실행 사이에 디스크에 저장하여 시작 및 컴파일 시간을 극적으로 개선합니다.
성능 관점: 무엇을 언제 캐싱해야 할까요?
캐싱을 이해하는 것은 버그를 피하는 것만이 아닙니다—성능을 최적화하는 것이기도 해요:
| 콘텐츠 유형 | 권장 전략 | 이유 |
|---|---|---|
| 마케팅 페이지 | revalidate: 3600 또는 빌드 시 정적 | 변경이 드물어서 CDN 히트 극대화 |
| 이커머스 목록 | revalidate: 60 + 온디맨드 | 신선함과 성능의 균형 |
| 사용자 대시보드 | dynamic = 'force-dynamic' | 사용자별, 신선한 데이터 필요 |
| API 라우트 (공개) | revalidate: 300 + 캐시 태그 | 백엔드 부하 감소 |
| API 라우트 (인증) | cache: 'no-store' | 보안과 신선함 |
| 실시간 데이터 | 클라이언트 사이드 fetching | 서버 캐시 부적절 |
흔한 에러 메시지와 해결책
"DYNAMIC_SERVER_USAGE"
Error: Dynamic server usage: cookies
해결책: 정적 exports 라우트에서 동적 함수를 사용하고 있습니다. 다음 중 하나를 선택하세요:
- 동적 함수 제거
export const dynamic = 'force-dynamic'추가- 동적 로직을 클라이언트 컴포넌트로 이동
"Invariant: static generation store missing"
이것은 보통 빌드 중에 동적 API를 사용하려고 할 때 발생합니다:
// ❌ 문제 export async function generateStaticParams() { const cookieStore = await cookies(); // 여기서는 사용할 수 없어요! return []; } // ✅ 해결책: generateStaticParams에서는 정적 데이터만 사용 export async function generateStaticParams() { const products = await fetch('/api/products', { cache: 'force-cache' }).then(r => r.json()); return products.map(p => ({ id: p.id })); }
Vercel에서 캐시가 무효화되지 않음
// 올바른 재검증 API를 사용하고 있는지 확인하세요 import { revalidatePath, revalidateTag } from 'next/cache'; // revalidatePath는 경로에 대한 모든 데이터를 무효화 revalidatePath('/products'); // revalidateTag는 더 수술적 revalidateTag('products-list'); // 둘 다 같은 배포에서 요청이 시작되어야 함 // CMS에서 Webhook → API Route → revalidate 함수 사용
결론: Next.js 캐싱에 대한 멘탈 모델
이 모든 것을 소화한 후, 앞으로 가져갈 멘탈 모델은 다음과 같습니다:
-
Next.js 15/16에서는 동적을 기본으로 하세요. 캐싱 없이 시작하고 유익한 곳에 추가하세요. 예상치 못한 캐시 히트를 디버깅하는 것보다 낫습니다.
-
정적과 동적을 분리하세요. Suspense 경계와 컴포넌트 구성을 사용하여 캐시 가능한 콘텐츠를 극대화하세요.
-
적절한 레벨에서 캐싱하세요. 특정 fetch에 대한 Data Cache로 충분할 때 Full Route Cache를 사용하지 마세요.
-
항상 프로덕션 빌드로 테스트하세요. 개발 서버는 캐싱 동작에 대해 거짓말합니다.
-
정밀함을 위해 캐시 태그를 사용하세요. 복잡한 앱에서는 경로 기반 무효화보다 더 유지보수하기 쉽습니다.
-
프로덕션에서 캐시 동작을 모니터링하세요. 로깅을 추가하고, Vercel Analytics를 사용하거나, 커스텀 캐시 헤더를 구현하세요.
Next.js의 캐싱이 복잡한 이유는 복잡한 문제를 해결하고 있기 때문입니다: 대규모로 빠르고 신선한 콘텐츠를 전달하는 것. 네 가지 레이어와 그 상호작용을 이해하면, 엄청나게 빠르면서도 안정적으로 최신인 애플리케이션을 구축할 수 있을 거예요.
핵심은 캐시와 싸우는 게 아니라 함께 작업하는 것입니다—각 레이어를 여러분의 특정 사용 사례에 적절하게 구성하고, 때로는 최선의 캐시 구성이 아예 캐싱하지 않는 것임을 이해하는 거죠.
관련 도구 둘러보기
Pockit의 무료 개발자 도구를 사용해 보세요