Next.js `cacheLife()` 튜닝 가이드: stale·revalidate·expire 시나리오별 설정과 콜드 스타트 대응
트래픽이 없는 새벽 이후 첫 방문자가 3~5초를 기다렸다는 제보를 받아본 적이 있다면, 그 원인은 십중팔구 캐시가 만료된 직후 발생하는 동기 블로킹 재생성입니다. Next.js 15가 등장하면서 캐싱 철학이 근본부터 바뀌었습니다. fetch()의 기본값이 no-store로 전환되었고, 개발자는 use cache 디렉티브와 cacheLife()를 통해 캐시 정책을 명시적으로 선언해야 합니다. 오히려 클라이언트 캐시와 서버 캐시를 독립적으로 조율할 수 있는 훨씬 강력한 제어권을 손에 쥐게 된 것입니다.
이 글은 Next.js App Router를 이미 사용하고 있는 개발자를 대상으로 합니다. cacheLife()의 세 파라미터 — stale, revalidate, expire — 가 각각 어떤 캐시 레이어에서 작동하는지 살펴보고, 뉴스 사이트·이커머스·대시보드 등 실제 시나리오별로 값을 어떻게 조율하면 좋은지 구체적인 코드와 함께 다룹니다. 특히 트래픽 공백 이후 첫 요청에서 발생하는 동기 블로킹(cold revalidation)을 최소화하는 워밍업 전략 네 가지를 집중적으로 정리했습니다.
핵심 개념
세 파라미터가 제어하는 타임라인
cacheLife()는 하나의 캐시 엔트리가 언제까지 그대로 반환되고, 언제 백그라운드에서 갱신되고, 언제 블로킹 방식으로 재생성되는지를 결정합니다.
|---stale---|---revalidate---|---expire---| 캐시 없음(블로킹) |
즉시 반환 백그라운드 갱신 블로킹 재생성 신규 요청 블로킹| 파라미터 | 역할 | 영향받는 캐시 레이어 |
|---|---|---|
stale |
이 시간 동안 클라이언트는 서버를 확인하지 않고 캐시를 그대로 사용 | 브라우저 Router Cache |
revalidate |
stale 만료 후 다음 요청이 들어올 때 백그라운드에서 새 데이터 생성 시작 |
서버 Data Cache |
expire |
이 시간이 지나면 다음 요청이 동기 블로킹 방식으로 재생성 | 서버 Data Cache |
stale-while-revalidate: 기존 캐시를 즉시 반환하면서 백그라운드에서 새 데이터를 가져오는 HTTP 캐싱 전략입니다. 갱신은 능동적으로 발생하지 않고,
stale이 만료된 후 다음 요청이 도착할 때 백그라운드에서 시작됩니다. 사용자는 블로킹 없이 빠른 응답을 받고, 그 다음 요청부터 새 데이터를 보게 됩니다.
세 값은 반드시 stale ≤ revalidate < expire 순서를 지켜야 합니다. 인라인 cacheLife({...}) 호출의 경우 빌드 단계에서 검증되며, Named Profile은 런타임에 적용될 때 검증됩니다. 또한 stale은 클라이언트 Router Cache의 특성상 30초 미만으로 설정해도 실질적인 효과가 없습니다.
Named Profile — 팀 단위 재사용 패턴
'seconds' | 'minutes' | 'hours' | 'days' | 'weeks' | 'max' 같은 기본 프로파일 외에도, next.config.ts에 팀 전용 프로파일을 등록해두면 코드 어디서든 이름으로 재사용할 수 있습니다.
// next.config.ts
const nextConfig = {
cacheLife: {
'product-listing': {
stale: 180,
revalidate: 900,
expire: 7200,
},
'user-feed': {
stale: 10,
revalidate: 60,
expire: 120,
},
},
};
export default nextConfig;이후 코드에서는 cacheLife('product-listing')처럼 문자열 하나로 정책을 적용할 수 있어, 캐시 정책 변경이 필요할 때 한 곳만 수정하면 됩니다.
실전 적용
예시 1: 뉴스·블로그 — 편집 주기에 맞춘 설정
콘텐츠가 하루에 몇 번 바뀌지 않는 구조라면, stale을 넉넉하게 잡아 클라이언트 재요청을 줄이고 revalidate로 서버 데이터를 주기적으로 갱신하는 전략이 적합합니다.
// app/articles/[slug]/page.tsx
import { cacheLife, cacheTag } from 'next/cache';
// 서버 컴포넌트 혹은 Server Action 내부에서 호출
async function getArticle(slug: string) {
'use cache';
cacheLife({
stale: 300, // 5분: 클라이언트 즉시 반환
revalidate: 3600, // 1시간: 백그라운드 갱신
expire: 86400, // 1일: 완전 만료 전 최대 유지
});
cacheTag(`article-${slug}`);
return db.article.findUnique({ where: { slug } });
}
export default async function ArticlePage({ params }: { params: { slug: string } }) {
const article = await getArticle(params.slug);
return <ArticleView data={article} />;
}| 설정 값 | 의도 |
|---|---|
stale: 300 |
5분 내 재방문 시 서버 요청 없이 즉시 반환 |
revalidate: 3600 |
1시간마다 백그라운드 갱신으로 최신성 유지 |
expire: 86400 |
트래픽 공백이 있어도 하루 동안 캐시 보존 |
편집자가 기사를 수정한 즉시 revalidateTag(\article-${slug}`)한 번으로 캐시를 무효화할 수 있어,expire`까지 기다릴 필요가 없습니다.
예시 2: 이커머스 상품 가격 — 실시간성과 성능의 균형
가격 정보는 오래된 값이 사용자 신뢰를 해칠 수 있으므로 갱신 주기를 짧게 유지하는 것이 중요합니다.
async function getPrice(productId: string) {
'use cache';
cacheLife({
stale: 30, // 30초: 클라이언트 Router Cache 실질적 최솟값
revalidate: 300, // 5분: 백그라운드 갱신
expire: 600, // 10분: 가격 오류 방지 상한
});
cacheTag('prices', `product-${productId}`);
return priceApi.get(productId);
}| 설정 값 | 의도 |
|---|---|
stale: 30 |
Router Cache의 실질적 최솟값 — 30초 미만은 효과 없음 |
revalidate: 300 |
5분마다 실제 가격 API 재호출 |
expire: 600 |
10분 이상 된 가격 데이터는 반드시 재생성 |
stale: 30은 클라이언트 Router Cache의 실질적 최솟값입니다. 더 짧은 값을 설정해도 브라우저 캐시 특성상 30초 이하로는 강제되지 않습니다. 1~5초로 설정해 실시간 효과를 기대하는 경우에는 캐시 없이 처리하는 방식을 검토하는 것이 좋습니다.
가격 변경 이벤트 발생 시 revalidateTag('prices')로 전체 가격 캐시를 일괄 무효화할 수 있습니다.
예시 3: 사용자별 대시보드 — 프라이빗 캐시
최신 Next.js에서 실험적으로 지원되는 use cache: private 지시어를 사용하면 사용자별로 격리된 캐시를 적용할 수 있습니다.
async function getUserDashboard(userId: string) {
'use cache: private'; // 사용자별 격리 캐시
cacheLife({
stale: 60,
revalidate: 120,
expire: 300,
});
cacheTag(`user-${userId}`);
return dashboardService.get(userId);
}짧은 expire: 300을 설정해 개인 데이터가 지나치게 오래 서버에 남지 않도록 합니다. use cache: private의 안정 버전 지원 여부는 사용 중인 Next.js 버전의 공식 문서에서 확인하는 것을 권장합니다.
예시 4: 글로벌 설정·사이트맵 — 영구 캐시
배포 전까지 거의 변경되지 않는 데이터는 'max' 프로파일을 활용하면 됩니다.
async function getSiteConfig() {
'use cache';
cacheLife('max'); // stale/revalidate/expire 모두 Infinity
cacheTag('site-config');
return configApi.getGlobal();
}배포 파이프라인에 revalidateTag('site-config') 호출을 넣어두면 배포 시점에 자동으로 갱신됩니다.
예시 5: 트래픽 공백 이후 블로킹 최소화 — 워밍업 전략
expire가 만료된 직후 첫 번째 요청은 동기 블로킹 방식으로 재생성됩니다. 이 "콜드 리밸리데이션"을 최소화하는 네 가지 전략을 살펴봅니다.
전략 1: generateStaticParams로 빌드 타임 사전 생성
// app/products/[slug]/page.tsx
export async function generateStaticParams() {
const products = await getTopProducts(); // 상위 N개만 선택
return products.map((p) => ({ slug: p.slug }));
}빌드 시 HTML과 RSC Payload를 미리 생성해두므로, expire 만료와 무관하게 정적 파일이 서빙됩니다. 트래픽이 높은 상위 페이지에 특히 효과적입니다.
RSC Payload: React Server Component의 렌더링 결과를 직렬화한 형식으로, 클라이언트에서 페이지를 재구성하는 데 사용됩니다.
전략 2: expire를 비트래픽 구간보다 훨씬 길게 설정
cacheLife({
stale: 60,
revalidate: 300, // 5분마다 백그라운드 갱신
expire: 604800, // 7일: 주말·공휴일 공백도 커버
});expire 기간을 예상되는 최장 공백 기간보다 길게 설정하면, 블로킹 재생성이 발생할 확률 자체를 낮출 수 있습니다.
전략 3: Vercel Cron + 워밍업 엔드포인트
// app/api/warmup/route.ts
export async function GET() {
const criticalPaths = ['/api/products', '/api/config'];
await Promise.all(
criticalPaths.map((p) =>
fetch(`${process.env.BASE_URL}${p}`, {
// no-store로 캐시를 우회해 실제 재계산을 강제함으로써
// 서버 Data Cache가 fresh 상태로 갱신됩니다
cache: 'no-store',
})
)
);
return Response.json({ warmed: true });
}Vercel Cron을 expire 만료 직전 주기로 실행해, 캐시가 비워지기 전에 미리 갱신해 둡니다. 실제로 Mintlify는 이와 유사한 전략으로 7,200만 월간 페이지뷰에서 콜드 스타트를 제거했습니다.
전략 4: <Suspense> 경계로 블로킹 UX 완화
// 블로킹 재생성이 발생할 수 있는 컴포넌트를 Suspense로 격리
<Suspense fallback={<ProductSkeleton />}>
<ProductList />
</Suspense>동기 재생성이 불가피하게 발생하더라도, 사용자에게는 스켈레톤 UI가 먼저 렌더링되어 체감 지연을 크게 줄일 수 있습니다. PPR(Partial Prerendering)¹ 환경에서는 정적 셸이 즉시 서빙되고 동적 아일랜드만 대기하므로 효과가 더욱 두드러집니다.
¹ PPR(Partial Prerendering): 한 페이지 내에서 정적으로 렌더링된 셸과 동적 콘텐츠 아일랜드를 혼합해 서빙하는 Next.js 기능입니다. 정적 셸은 즉시 반환되고, 동적 부분만 스트리밍됩니다.
장단점 분석
장점
| 항목 | 내용 |
|---|---|
| 세밀한 독립 제어 | 클라이언트 캐시(stale)와 서버 캐시(revalidate/expire)를 각각 조율할 수 있습니다 |
| 블로킹 없는 갱신 | revalidate 구간에서는 사용자가 블로킹을 경험하지 않습니다 |
| 선언적 캐싱 | 함수·컴포넌트 단위로 정책을 코드에 직접 선언하므로 추론이 쉽습니다 |
| Named Profile 재사용 | 팀 전체가 공통 캐시 정책을 next.config.ts 한 곳에서 관리할 수 있습니다 |
| on-demand 무효화 | revalidateTag() 한 번으로 관련 캐시를 즉시 무효화할 수 있습니다 |
단점 및 주의사항
| 항목 | 내용 | 대응 방안 |
|---|---|---|
| 동적 라우트 revalidate 버그 | 런타임 동적 컨텍스트에서 revalidate 구간에도 블로킹 발생 (Issue #74882) |
해당 경로는 expire를 넉넉히, Suspense로 UX 완화 |
| expire 만료 시 첫 요청 블로킹 | 트래픽 공백 후 반드시 한 번의 블로킹 재생성 발생 | 워밍업 전략 적용 (Cron, generateStaticParams) |
| 빌드 타임 캐시 시딩 불가 | next build에서 use cache 캐시를 미리 채울 수 없음 |
워밍업 엔드포인트를 배포 직후 실행 |
stale 최솟값 제약 |
클라이언트 Router Cache는 30초 미만 설정이 사실상 무의미 | 30초 이하 실시간 데이터는 캐시 없이 처리 고려 |
| 값 순서 강제 | stale ≤ revalidate < expire를 어기면 오류 발생 |
Named Profile로 검증된 값을 재사용 |
Router Cache: Next.js App Router가 브라우저 메모리에 RSC Payload를 보관하는 클라이언트 측 캐시입니다.
stale값이 이 캐시의 유효 기간을 결정합니다.
실무에서 가장 흔한 실수
expire를revalidate와 동일하게 설정 —expire > revalidate가 반드시 성립해야 하므로, 같은 값을 넣으면 오류가 발생합니다.- 트래픽 공백 구간을 고려하지 않고
expire를 짧게 설정 — 야간·주말 이후 첫 방문자가 항상 블로킹을 경험하게 됩니다. stale을 1~5초로 설정해 실시간 효과를 기대 — 클라이언트 Router Cache의 특성상 30초 미만stale설정은 실질적인 효과가 없습니다.
마치며
cacheLife()의 핵심은 stale로 클라이언트 응답 속도를 확보하고, revalidate로 신선도를 유지하며, expire를 비트래픽 구간보다 넉넉하게 잡아 콜드 리밸리데이션 발생 확률 자체를 낮추는 것입니다. 이 세 값을 올바르게 설정하면 응답 속도와 서버 부하를 동시에 개선하고, 워밍업 전략을 함께 적용하면 새벽 첫 방문자의 대기 시간도 제거할 수 있습니다.
지금 바로 시작해볼 수 있는 3단계:
- 현재 프로젝트에서
fetch()또는unstable_cache를 사용하는 함수 하나를 골라,'use cache'와cacheLife({ stale, revalidate, expire })로 마이그레이션해보는 것부터 시작할 수 있습니다. 공식 문서의 use cache 가이드가 좋은 출발점입니다. next.config.ts에 팀의 주요 데이터 유형에 맞는 Named Profile을 정의해두면, 이후 개발자들이 일관된 정책을 쉽게 적용하고 응집도를 높일 수 있습니다.- 트래픽이 낮은 시간대가 존재하는 서비스라면
app/api/warmup/route.ts를 생성하고, Vercel Cron 또는 외부 스케줄러로 주기적 워밍업을 설정해두면 콜드 스타트 없는 사용자 경험을 만들어갈 수 있습니다.
다음 글:
revalidateTag()의 stale-while-revalidate 무효화 동작을 심층 분석하고, 낙관적 즉시 무효화와 어떻게 구분해서 사용해야 하는지 비교합니다.
참고 자료
공식 문서
- Functions: cacheLife | Next.js
- next.config.js: cacheLife | Next.js
- Directives: use cache | Next.js
- Getting Started: Revalidating | Next.js
- Guides: How Revalidation Works | Next.js
- Cache Components for Instant and Fresh Pages | Vercel Academy
커뮤니티 논의
use cachestale revalidate 버그 Issue #74882 | GitHub- cacheComponents build time warming Discussion #88155 | GitHub
- Cache Revalidation Options Discussion #78513 | GitHub
사례 연구 및 심화