Back

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 CacheRouter Cache (클라이언트 사이드)
강력 새로고침(Cmd+Shift+R)으로 해결되나요?Router Cache서버 사이드 캐시
재배포하면 해결되나요?Full Route CacheData 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 캐싱에 대한 멘탈 모델

이 모든 것을 소화한 후, 앞으로 가져갈 멘탈 모델은 다음과 같습니다:

  1. Next.js 15/16에서는 동적을 기본으로 하세요. 캐싱 없이 시작하고 유익한 곳에 추가하세요. 예상치 못한 캐시 히트를 디버깅하는 것보다 낫습니다.

  2. 정적과 동적을 분리하세요. Suspense 경계와 컴포넌트 구성을 사용하여 캐시 가능한 콘텐츠를 극대화하세요.

  3. 적절한 레벨에서 캐싱하세요. 특정 fetch에 대한 Data Cache로 충분할 때 Full Route Cache를 사용하지 마세요.

  4. 항상 프로덕션 빌드로 테스트하세요. 개발 서버는 캐싱 동작에 대해 거짓말합니다.

  5. 정밀함을 위해 캐시 태그를 사용하세요. 복잡한 앱에서는 경로 기반 무효화보다 더 유지보수하기 쉽습니다.

  6. 프로덕션에서 캐시 동작을 모니터링하세요. 로깅을 추가하고, Vercel Analytics를 사용하거나, 커스텀 캐시 헤더를 구현하세요.

Next.js의 캐싱이 복잡한 이유는 복잡한 문제를 해결하고 있기 때문입니다: 대규모로 빠르고 신선한 콘텐츠를 전달하는 것. 네 가지 레이어와 그 상호작용을 이해하면, 엄청나게 빠르면서도 안정적으로 최신인 애플리케이션을 구축할 수 있을 거예요.

핵심은 캐시와 싸우는 게 아니라 함께 작업하는 것입니다—각 레이어를 여러분의 특정 사용 사례에 적절하게 구성하고, 때로는 최선의 캐시 구성이 아예 캐싱하지 않는 것임을 이해하는 거죠.

nextjscachingapp-routerreactweb-performancetroubleshooting

관련 도구 둘러보기

Pockit의 무료 개발자 도구를 사용해 보세요