Next.js 15/16 cacheLife()와 use cache: remote/private — 인스턴스 간 캐시 공유와 개인화 데이터 보호를 단일 API로
Next.js App Router를 서버리스 환경에 배포하면 예상치 못한 문제가 생깁니다. 기존 unstable_cache나 fetch 옵션 기반 캐싱은 Lambda 인스턴스마다 독립적인 메모리 캐시를 유지하기 때문에, 요청이 다른 인스턴스로 라우팅될 때마다 캐시 미스가 발생하고 데이터베이스 부하가 급격히 증가합니다. 반대로 개인화된 사용자 데이터를 CDN이 잘못 캐시하면 다른 사용자에게 노출되는 심각한 보안 사고로 이어질 수 있습니다.
Next.js 15에서 dynamicIO 실험 플래그와 함께 도입되고 Next.js 16에서 cacheComponents로 발전한 선언형 캐시 API는 이 문제들을 직접 해결합니다. unstable_cache를 완전히 대체하는 것은 아니지만, 컴포넌트·함수 단위로 캐시 정책을 선언하는 새로운 방식으로 코드 가독성과 유지보수성을 크게 높입니다. fetch 옵션처럼 호출마다 설정을 반복하거나 래퍼 함수를 따로 만들 필요가 없습니다.
이 글은 Next.js App Router를 실무에 사용하는 중급 이상 개발자를 대상으로 합니다. cacheLife() 프로파일로 콘텐츠 유형별 생명 주기를 분리하고, use cache: remote로 Redis 기반 공유 캐시를 구성하며, use cache: private로 개인화 데이터를 CDN에서 격리하는 세 가지 전략을 조합하면, 인메모리 캐시부터 CDN 레이어까지 일관된 캐시 전략을 단일 선언형 API로 관리할 수 있습니다.
핵심 개념
Cache Components 모델과 버전별 플래그
Next.js 15에서 이 캐시 시스템을 사용하려면 dynamicIO 플래그를 활성화합니다. Next.js 16에서는 cacheComponents로 이름이 변경되어 안정화 단계에 진입했습니다. 배포 환경의 Next.js 버전을 반드시 확인한 후 해당 플래그를 적용하세요.
// next.config.ts — Next.js 15
const nextConfig = {
experimental: {
dynamicIO: true, // Next.js 15 기준
},
}
// next.config.ts — Next.js 16
const nextConfig = {
experimental: {
cacheComponents: true, // Next.js 16 기준
},
}'use cache' 지시어를 파일 최상단에 선언하면 파일 전체에 적용되고, 함수 내부에 선언하면 해당 함수에만 적용됩니다. 이 범위 차이는 use cache: remote와 use cache: private 변형에서도 동일하게 작동하므로, 의도한 범위에 정확히 선언해야 합니다.
// 파일 전체에 캐시 적용
'use cache'
export async function BlogList() { ... }
export async function BlogSidebar() { ... } // 이 함수도 캐시 적용됨// 특정 함수에만 캐시 적용
export async function BlogList() {
'use cache'
// 이 함수만 캐시 적용
}cacheLife() — 세 가지 속성으로 캐시 생명 주기 설계하기
cacheLife() 함수는 stale, revalidate, expire 세 가지 속성으로 캐시의 생명 주기를 제어합니다.
| 속성 | 역할 | 동작 방식 |
|---|---|---|
stale |
클라이언트 라우터 캐시 유효 시간 + CDN stale-while-revalidate 연동 |
x-nextjs-stale-time 헤더로 전달 |
revalidate |
이 시간 이후 첫 요청 시 백그라운드 재검증 트리거 | s-maxage 헤더로 변환되어 CDN에 전달 |
expire |
캐시 완전 만료 절대 시간 (최소 300초 이상 설정 필요) | 이후 요청은 항상 새 데이터 fetch |
백그라운드 재검증(stale-while-revalidate):
revalidate시간이 지난 후 들어오는 첫 번째 요청에 현재 캐시된 응답을 즉시 반환하면서, 동시에 새 데이터를 백그라운드에서 가져옵니다. 사용자 경험을 해치지 않으면서 데이터 신선도를 유지하는 전략입니다.
expire를 5분(300초) 미만으로 설정하면 정적 프리렌더 대상에서 제외되어 "Dynamic Hole"로 처리됩니다. 해당 구간은 요청마다 동적으로 렌더링되므로 캐시의 성능 이점이 사라집니다.
revalidate 값은 CDN의 s-maxage 헤더로 변환되어 CDN 레이어의 캐시 갱신 주기를 제어합니다. use cache: remote가 Redis 같은 서버-사이드 공유 캐시를 담당한다면, CDN 레이어 제어는 이 revalidate → s-maxage 변환을 통해 이루어집니다.
커스텀 프로파일로 콘텐츠 유형별 전략 분리하기
next.config.ts에 이름 있는 프로파일을 등록하면 코드에서 문자열 키로 재사용할 수 있습니다. 설정 변경 시 한 곳만 수정해도 전체 적용 범위에 반영됩니다.
// next.config.ts
const nextConfig = {
experimental: {
cacheComponents: true,
},
cacheLife: {
blog: {
stale: 3600, // 1시간: 클라이언트·CDN 캐시 신뢰
revalidate: 900, // 15분 후 백그라운드 갱신 (s-maxage: 900)
expire: 86400, // 24시간: 완전 만료
},
realtime: {
stale: 0,
revalidate: 30,
expire: 300, // 최소 5분 이상
},
static: {
stale: 86400,
revalidate: 3600,
expire: 604800, // 7일
},
},
}실전 적용
예시 1: 블로그 플랫폼 — 콘텐츠 유형별 프로파일 분리
갱신 빈도가 다른 게시글 목록, 상세 페이지, 인기 태그를 각각 다른 캐시 전략으로 관리하는 시나리오입니다. cacheTag()를 함께 사용하면 게시글 수정 시 해당 캐시만 선택적으로 폐기할 수 있습니다.
// components/BlogList.tsx
'use cache'
import { cacheLife, cacheTag } from 'next/cache'
import { db } from '@/lib/db' // 직접 DB 접근 — 서버 컴포넌트에서 상대 경로 fetch는 동작하지 않습니다
export async function BlogList() {
cacheLife('blog')
cacheTag('blog-list')
const posts = await db.post.findMany({
orderBy: { createdAt: 'desc' },
take: 20,
select: { id: true, title: true, slug: true, createdAt: true },
})
return <ul>{posts.map(p => <li key={p.id}>{p.title}</li>)}</ul>
}// app/blog/[slug]/page.tsx
'use cache'
import { cacheLife, cacheTag } from 'next/cache'
import { db } from '@/lib/db'
export default async function BlogPost({ params }: { params: { slug: string } }) {
cacheLife('blog')
cacheTag(`post-${params.slug}`)
const post = await db.post.findUnique({ where: { slug: params.slug } })
if (!post) return <NotFound />
return <article>{post.content}</article>
}// app/actions/invalidate.ts — 게시글 수정 시 온디맨드 무효화
'use server'
import { revalidateTag } from 'next/cache'
export async function invalidatePost(slug: string) {
await revalidateTag(`post-${slug}`)
await revalidateTag('blog-list') // 목록도 함께 갱신
}| 구성 요소 | 역할 |
|---|---|
cacheLife('blog') |
next.config.ts 프로파일 적용 (stale 1h / revalidate 15m) |
cacheTag('blog-list') |
목록 전체를 하나의 태그로 묶어 일괄 무효화 가능 |
cacheTag(\post-${slug}`)` |
특정 게시글만 선택적으로 무효화 가능 |
revalidateTag() |
수정 이벤트 발생 시 해당 캐시만 폐기 |
예시 2: 서버리스 E-commerce — Redis로 상품 카탈로그 공유 캐시 구성
Lambda나 Vercel Functions 같은 서버리스 환경에서는 기본 use cache(인메모리)가 인스턴스 간 공유가 되지 않아 캐시 히트율이 낮고 DB 부하가 예상보다 높아집니다. use cache: remote를 사용하면 캐시 저장소를 Redis 같은 외부 서버-사이드 저장소로 변경하여 모든 인스턴스가 동일한 캐시를 공유하게 됩니다.
use cache: remote: 캐시 결과물을 인메모리가 아닌 외부 저장소(Redis, Valkey 등)에 보관합니다. Vercel 환경에서는 자체 인프라가 자동 구성되며, 자체 호스팅 환경에서는cacheHandlers.remote로 Redis를 직접 연결합니다. CDN 응답 헤더 제어와는 별개의 메커니즘입니다.
// cache-handlers/redis-handler.ts
import { createClient } from 'redis'
const client = createClient({ url: process.env.REDIS_URL })
async function getClient() {
if (!client.isOpen) {
await client.connect()
}
return client
}
export default {
async get(key: string) {
try {
const c = await getClient()
const value = await c.get(key)
return value ? JSON.parse(value) : undefined
} catch (error) {
console.error('[CacheHandler] get error:', error)
return undefined // 캐시 미스로 처리하여 서비스 중단 방지
}
},
async set(key: string, value: unknown, ttl: number) {
try {
const c = await getClient()
await c.setEx(key, ttl, JSON.stringify(value))
} catch (error) {
console.error('[CacheHandler] set error:', error)
}
},
async delete(key: string) {
try {
const c = await getClient()
await c.del(key)
} catch (error) {
console.error('[CacheHandler] delete error:', error)
}
},
}// next.config.ts
const nextConfig = {
experimental: { cacheComponents: true },
cacheHandlers: {
remote: require.resolve('./cache-handlers/redis-handler'),
},
cacheLife: {
catalog: { stale: 3600, revalidate: 1800, expire: 7200 },
},
}// components/ProductCatalog.tsx
// 파일 최상단 선언 → 이 파일의 모든 함수에 'use cache: remote' 적용
'use cache: remote'
import { cacheLife, cacheTag } from 'next/cache'
import { db } from '@/lib/db'
interface Props {
categoryId: string
}
// fetchProductsByCategory: DB에서 카테고리별 상품 목록을 가져오는 내부 함수
export async function ProductCatalog({ categoryId }: Props) {
cacheLife('catalog')
cacheTag(`catalog-${categoryId}`)
const products = await db.product.findMany({
where: { categoryId, isActive: true },
select: { id: true, name: true, price: true, imageUrl: true },
})
return <ProductGrid products={products} />
}| 구성 요소 | 역할 |
|---|---|
'use cache: remote' (파일 최상단) |
파일 전체의 캐시 저장소를 Redis로 위임 |
cacheHandlers.remote |
커스텀 Redis 핸들러 연결 경로 등록 |
cacheLife('catalog') |
카탈로그 전용 생명 주기 (stale 1h / revalidate 30m) |
cacheTag(\catalog-${categoryId}`)` |
카테고리별 선택적 무효화 |
예시 3: 사용자 대시보드 — use cache: private로 개인화 데이터 보호
로그인 사용자의 대시보드 데이터는 CDN이 캐시하면 다른 사용자에게 노출될 수 있습니다. use cache: private를 사용하면 cookies()에도 접근하면서 응답에 Cache-Control: private 헤더가 자동으로 설정되어 CDN 캐싱이 차단됩니다.
Cache-Control: private: 이 헤더가 설정된 응답은 중간 캐시(CDN, 프록시)에 저장되지 않고 최종 사용자의 브라우저에만 캐싱됩니다. 세션 토큰이나 개인화된 데이터를 CDN이 공유하는 것을 방지합니다.
// components/UserDashboard.tsx
// 함수 내부 선언 → 이 함수에만 'use cache: private' 적용
import { cacheLife } from 'next/cache'
import { cookies } from 'next/headers'
// fetchUserData: sessionToken으로 사용자 데이터를 조회하는 내부 함수
// 반환 타입: { name: string; stats: UserStats; recentActivity: Activity[] }
export async function UserDashboard() {
'use cache: private'
cacheLife({ stale: 300, revalidate: 60, expire: 600 })
const cookieStore = await cookies()
const sessionToken = cookieStore.get('session')?.value
if (!sessionToken) return <LoginPrompt />
const userData = await fetchUserData(sessionToken)
return <DashboardView data={userData} />
}주의: 일반
'use cache'내부에서cookies()나headers()를 호출하면 빌드 에러가 발생합니다. 요청 런타임 API에 접근이 필요한 경우 반드시'use cache: private'를 사용해야 합니다.
세 가지 예시는 서비스 규모와 요구사항에 따라 조합해서 사용합니다. 소규모 서비스라면 예시 1의 프로파일 분리만으로도 충분하지만, 서버리스 배포 환경이라면 예시 2의 Redis 공유 캐시를 추가하고, 로그인 기능이 있다면 예시 3을 병행 적용하는 방식으로 점진적으로 확장할 수 있습니다.
장단점 분석
장점
| 항목 | 내용 |
|---|---|
| 세밀한 캐시 제어 | 컴포넌트·함수 단위로 stale/revalidate/expire를 독립 설정 가능 |
| CDN 연계 자동화 | revalidate 값이 s-maxage 헤더로 변환되어 CDN 갱신 주기 자동 제어 |
| 선언형 API | 비즈니스 로직과 캐시 정책을 한 파일에서 관리하여 가독성 향상 |
| RSC 직렬화 지원 | JSON 데이터뿐 아니라 React Server Component 출력물 전체를 캐시 가능 |
| 온디맨드 무효화 | cacheTag() + revalidateTag()로 특정 캐시만 선택적 폐기 가능 |
| 인스턴스 간 공유 | use cache: remote + Redis로 서버리스 환경의 캐시 히트율 극대화 |
단점 및 주의사항
| 항목 | 내용 | 대응 방안 |
|---|---|---|
| 실험적 플래그 | cacheComponents(dynamicIO)가 아직 실험적 기능 |
프로덕션 적용 시 Next.js 버전 고정 필요 |
| 서버리스 인메모리 한계 | 기본 use cache는 인스턴스 간 공유 불가 |
use cache: remote + Redis 도입 |
use cache: private 제약 |
클라이언트 사이드 내비게이션 시 동작 이슈 존재 (GitHub #85672) | 페이지 전환 후 데이터 갱신 여부 검증 필요 |
Vary 헤더 한계 |
CDN 캐시 variant 관리가 완전히 지원되지 않음 (GitHub Discussion #82571) | 복잡한 변형 캐싱은 엣지 미들웨어로 보완 |
| 런타임 API 제한 | 일반 use cache 내부에서 cookies(), headers() 호출 시 빌드 에러 |
use cache: private로 변경 |
5분 미만 expire |
정적 프리렌더에서 제외되어 Dynamic Hole로 처리됨 | expire는 최소 300초 이상으로 설정 |
Dynamic Hole: 정적으로 프리렌더된 페이지에서 캐시 TTL이 너무 짧아 정적 처리가 불가능한 구간. 해당 구간은 요청마다 동적으로 렌더링되므로 캐시의 성능 이점이 사라집니다.
Vary 헤더: HTTP 응답이 어떤 요청 헤더 값에 따라 달라지는지 CDN에 알려주는 헤더. 예를 들어
Vary: Accept-Language는 언어별로 다른 캐시를 유지해야 한다는 의미입니다.
실무에서 가장 흔한 실수
-
일반
'use cache'안에서cookies()를 호출하는 경우 — 빌드 타임에 에러가 발생합니다. 요청 컨텍스트가 필요한 컴포넌트라면'use cache: private'로 변경하는 것이 올바른 접근입니다. -
expire를 5분 미만으로 설정하는 경우 — 정적 프리렌더 대상에서 제외되어 의도치 않게 동적 렌더링 경로로 빠지고, 기대했던 성능 이점을 얻지 못합니다.expire는 항상 300초(5분) 이상으로 설정하는 것이 안전합니다. -
서버리스 환경에서 기본
use cache만 사용하는 경우 — 각 Lambda 인스턴스가 독립적인 캐시를 유지하므로 캐시 히트율이 낮고 데이터베이스 부하가 예상보다 높아집니다. 서버리스 환경에서는use cache: remote와 Redis를 함께 도입하는 방향을 검토해보시면 좋습니다.
마치며
지시어 선택 기준을 한눈에 정리하면 다음과 같습니다.
- 정적·공개 콘텐츠(블로그, 상품 상세 등) →
'use cache'+cacheLife프로파일로 CDNs-maxage제어 - 서버리스 멀티 인스턴스 환경의 공유 캐시 →
'use cache: remote'+ RediscacheHandlers로 인스턴스 간 공유 - 개인화·세션 기반 데이터 →
'use cache: private'로 CDN 격리 + 브라우저 캐시만 활용
지금 바로 시작해볼 수 있는 3단계:
next.config.ts에 버전에 맞는 실험 플래그(dynamicIO또는cacheComponents)를 추가하고, 서비스 콘텐츠 유형에 맞는 프로파일(blog,realtime,static등)을cacheLife설정에 등록해보세요.- 가장 자주 조회되는 컴포넌트나 데이터 함수에
'use cache'와cacheLife('프로파일명')을 적용하고, Network 탭에서x-nextjs-stale-time헤더와s-maxage값이 의도한 대로 전달되는지 확인해보세요. - 서버리스 환경이라면
@neshca/cache-handler또는nextjs-turbo-redis-cache패키지를pnpm add로 설치하고,cacheHandlers.remote에 연결한 후'use cache: remote'로 공유 캐시가 구성되는지 Redis Monitor로 검증해보세요.
다음 글:
cacheTag()+revalidateTag()를 활용한 Webhook 기반 온디맨드 캐시 무효화 파이프라인 구성 — CMS 수정 이벤트를 받아 영향받는 캐시만 선택적으로 폐기하는 자동화 전략
참고 자료
- Directives: use cache | Next.js 공식 문서
- Directives: use cache: remote | Next.js 공식 문서
- Directives: use cache: private | Next.js 공식 문서
- Functions: cacheLife | Next.js 공식 문서
- next.config.js: cacheLife | Next.js 공식 문서
- next.config.js: cacheHandlers | Next.js 공식 문서
- Guides: CDN Caching | Next.js 공식 문서
- Cache Components 시작하기 | Next.js 공식 문서
- Cache Components for Instant and Fresh Pages | Vercel Academy
- Next.js 16 릴리스 노트 | Next.js 블로그
- Next.js 16.2 Caching: unstable_cache vs use cache | Build with Matija
- Next.js Caching Explained | DEV Community
- @neshca/cache-handler | Caching Tools