Next.js `use cache` 심층 가이드: cacheTag와 revalidateTag로 구현하는 On-Demand 캐시 무효화 전략
fetch() 옵션을 cache: 'no-store'로 설정했더니 페이지 전체가 동적 렌더링이 되어버렸던 경험이 있으신가요? 반대로 캐시를 켜뒀더니 콘텐츠가 언제 갱신되는지 알 수 없어 결국 5분짜리 revalidate를 임시방편으로 달았던 경험도 있을 것입니다. Next.js의 암묵적 캐싱 시스템은 강력하지만, 그 작동 방식을 정확히 제어하기 어렵다는 구조적 한계가 있었습니다.
Next.js 15에서 실험적으로 도입된 use cache가 Next.js 16에서 정식 API로 승격되면서, 이 문제를 해결하는 새로운 접근이 가능해졌습니다. 이제는 개발자가 무엇을, 얼마나, 어떤 조건에서 캐시할지를 코드에 직접 선언하는 구조로 전환됐습니다. 이 변화는 단순한 API 교체가 아니라 캐싱을 표현하는 방식 자체의 전환입니다. 결과적으로 빌드 타임 정적 생성 범위가 늘어나고, 캐시 버그의 원인을 코드에서 직접 추적할 수 있게 됩니다.
이 글에서는 cacheLife의 내장·커스텀 프로파일 설정, cacheTag를 활용한 계층적 무효화 전략, 그리고 revalidateTag와 updateTag를 상황에 따라 구분해 사용하는 방법을 실제 코드 패턴 중심으로 살펴봅니다. Next.js App Router를 운영 중이며 Server Action 기본 사용 경험이 있는 분을 주요 대상으로, ISR의 페이지 단위 캐싱 한계를 넘어 도메인별로 최적화된 캐시 전략을 직접 설계할 수 있도록 안내합니다.
핵심 개념
use cache 디렉티브 — 명시적 캐싱의 시작
use cache는 비동기 함수나 컴포넌트의 반환값을 캐싱하도록 지시하는 React 디렉티브입니다. 선언 위치에 따라 적용 범위가 달라집니다.
// 파일 최상단 선언 → 해당 파일의 모든 export가 캐시됨
'use cache'
export async function getProducts() {
return db.product.findMany() // Prisma Client 예시
}// 함수/컴포넌트 내부 선언 → 해당 반환값만 캐시됨
export async function getPost(id: string) {
'use cache'
return db.post.findUnique({ where: { id } }) // Prisma Client 예시
}캐시 키 자동 생성: 함수에 전달된 인수와 클로저 값이 자동으로 직렬화되어 캐시 키에 포함됩니다.
getPost('abc')와getPost('xyz')는 별도의 캐시 엔트리로 관리됩니다. 단, 함수·클래스 인스턴스처럼 직렬화가 불가능한 값이 클로저에 포함되면 런타임 오류가 발생하므로, 캐시 스코프로 전달되는 값은 직렬화 가능한 원시값·객체·배열로 한정하는 것이 좋습니다.
cacheLife — 세 축으로 캐시 수명 제어하기
cacheLife(profile)은 use cache 스코프 내에서 캐시 엔트리의 수명을 설정합니다. 캐시 수명은 세 가지 속성으로 구성됩니다.
| 속성 | 의미 |
|---|---|
stale |
클라이언트 라우터가 서버 재검증 없이 캐시를 신선하다고 간주하는 시간 |
revalidate |
서버에서 백그라운드로 캐시를 재생성하는 주기 (stale-while-revalidate) |
expire |
캐시가 반드시 만료되는 최대 시간. revalidate보다 길어야 함 |
stale-while-revalidate 동작 원리는 다음 타임라인으로 이해하면 쉽습니다.
[요청 1] ──→ stale 캐시 즉시 반환 + 백그라운드 재생성 시작
↓
[재생성 완료] → 새 데이터가 캐시에 저장
[요청 2] ──→ 새 캐시 반환요청 1에서 사용자는 이전 캐시 데이터를 즉시 받아보고, 그 사이 서버는 새 데이터를 조용히 준비합니다. 요청 2부터는 갱신된 데이터가 제공됩니다. 응답 속도는 빠르지만, 사용자가 방금 직접 수정한 데이터의 경우 즉시 반영되지 않는다는 점에 주의가 필요합니다(이 문제는 뒤의 updateTag에서 다룹니다).
Next.js는 아래와 같은 내장 프로파일을 제공합니다.
| 프로파일 | stale | revalidate | expire | 특이사항 |
|---|---|---|---|---|
seconds ⚠️ |
30초 | 1초 | 1분 | 동적 영역 — 프리렌더 제외 |
minutes |
5분 | 1분 | 1시간 | |
hours |
5분 | 1시간 | 1일 | |
days |
5분 | 1일 | 1주 | |
weeks |
5분 | 1주 | 1개월 | |
max |
5분 | 1개월 | Infinity | 거의 영구 캐시 |
⚠️ 동적 영역(Dynamic Hole):
revalidate또는expire가 5분 미만인 프로파일(seconds)은 프리렌더에서 제외됩니다. "프리렌더 제외"란 빌드 시 정적 HTML로 생성되지 않아 매 요청마다 서버 함수가 실행된다는 의미입니다. 캐시 선언이 있더라도 성능 이점을 기대하기 어렵습니다. 1분 이내 갱신이 필요하다면 캐싱 대신 동적 렌더링을 고려하는 것이 좋습니다.
내장 프로파일이 도메인 요구사항과 맞지 않을 때는 next.config.ts에서 커스텀 프로파일을 정의할 수 있습니다.
// next.config.ts
// Next.js 16 기준 플래그 이름입니다.
// Next.js 15에서는 dynamicIO: true 또는 useCache: true 를 사용했습니다.
const nextConfig = {
experimental: { cacheComponents: true },
cacheLife: {
'product-catalog': {
stale: 300, // 5분
revalidate: 3600, // 1시간
expire: 86400, // 1일
},
'realtime-stock': {
stale: 0,
revalidate: 5,
expire: 10,
},
'static-content': {
stale: 300,
revalidate: 86400,
expire: Infinity,
},
},
}
export default nextConfigcacheTag — 선택적 무효화를 위한 태그 시스템
cacheTag(...tags: string[])는 캐시 엔트리에 식별자 태그를 부여합니다. 이후 revalidateTag(tag) 또는 updateTag(tag)를 호출하면 해당 태그를 가진 모든 캐시 엔트리가 무효화됩니다.
import {
unstable_cacheTag as cacheTag,
unstable_cacheLife as cacheLife
} from 'next/cache'
// Next.js 15에서는 unstable_ 접두사가 붙습니다.
// Next.js 16에서는 cacheTag, cacheLife로 안정화됐습니다.
export async function getPost(id: string) {
'use cache'
cacheLife('hours')
cacheTag(`post:${id}`, 'posts') // 개별 태그 + 그룹 태그 동시 부여
return db.post.findUnique({ where: { id } }) // Prisma Client 예시
}태그 제한: 태그 하나당 최대 256자, 캐시 엔트리당 최대 128개 태그를 지원합니다.
revalidateTag vs updateTag — 언제 어떤 것을 사용할까
두 함수 모두 태그 기반 무효화를 수행하지만, 동작 방식과 적합한 시나리오가 다릅니다.
| 함수 | 사용 위치 | 동작 방식 | 주 용도 |
|---|---|---|---|
revalidateTag |
Server Action, Route Handler | stale-while-revalidate: 기존 캐시 즉시 제공 후 백그라운드 재생성 | 웹훅, 외부 시스템 연동, 비긴급 갱신 |
updateTag |
Server Action 전용 | 즉시 만료: 다음 요청 시 반드시 새 데이터 반환 | 사용자가 직접 변경한 데이터 |
updateTag버전 안내: Next.js 15에서는unstable_updateTag로 제공됩니다. Next.js 16에서updateTag로 안정화됐습니다. 프로젝트의 Next.js 버전에 맞는 import를 사용하세요. GitHub Discussion #84805에서 두 함수의 설계 의도를 확인할 수 있습니다.
read-your-own-writes: 사용자가 프로필 이미지를 수정한 직후 변경된 이미지가 즉시 화면에 보이는 패턴입니다.
revalidateTag는 백그라운드 재생성이라 이 시나리오에서 변경사항이 즉시 반영되지 않을 수 있습니다. 이 경우updateTag가 적합합니다.
실전 적용
앞서 살펴본 세 가지 도구(cacheLife, cacheTag, revalidateTag/updateTag)는 조합해서 사용할 때 진가를 발휘합니다. 아래 예시들은 각각 다른 시나리오를 다루며, 상황에 따라 어떤 패턴을 선택할지 함께 설명합니다.
예시 1: 계층적 태그로 그룹·개별 무효화 분리
게시글 목록과 개별 게시글을 각각 독립적으로 무효화해야 하는 가장 흔한 패턴입니다. 두 태그를 동시에 부여하는 방식으로 "이 게시글 하나만" 또는 "게시글 목록 전체"를 선택적으로 무효화할 수 있습니다.
// lib/posts.ts
import {
unstable_cacheTag as cacheTag,
unstable_cacheLife as cacheLife
} from 'next/cache'
// Next.js 16: cacheTag, cacheLife (unstable_ 없이 import)
export async function getPost(id: string) {
'use cache'
cacheLife('hours')
cacheTag(`post:${id}`, 'posts') // 개별 태그 + 그룹 태그
return db.post.findUnique({ where: { id } }) // Prisma Client 예시
}
export async function getAllPosts() {
'use cache'
cacheLife('minutes')
cacheTag('posts') // 그룹 태그만 부여
return db.post.findMany()
}// app/actions/post.ts
'use server'
import { revalidateTag, updateTag } from 'next/cache'
// Next.js 15: unstable_updateTag as updateTag
import { redirect } from 'next/navigation'
// 사용자가 직접 수정한 경우 → updateTag로 즉시 만료
export async function updatePost(id: string, data: PostData) {
await db.post.update({ where: { id }, data })
updateTag(`post:${id}`) // 즉시 만료: 다음 요청에서 반드시 최신 데이터 반환
// 주의: redirect()는 내부적으로 예외를 throw합니다.
// 나중에 try/catch를 추가할 경우, redirect()는 반드시 catch 바깥에 두어야 합니다.
redirect(`/posts/${id}`)
}
// 새 게시글 추가 → 목록만 갱신
export async function createPost(data: PostData) {
const post = await db.post.create({ data })
updateTag('posts') // 'posts' 태그를 가진 모든 캐시 엔트리 무효화
redirect(`/posts/${post.id}`)
}| 코드 포인트 | 설명 |
|---|---|
cacheTag(post:${id}, 'posts') |
개별 게시글과 전체 목록 둘 다 무효화 가능하도록 두 태그를 동시 부여 |
updateTag(post:${id}) |
해당 게시글만 정확히 타겟하여 즉시 만료 |
updateTag('posts') |
posts 태그를 가진 모든 엔트리(목록 등)를 한 번에 무효화 |
예시 2: Webhook 기반 On-Demand 무효화 (Headless CMS 연동)
예시 1이 "사용자 직접 변경" 시나리오라면, 이 예시는 DatoCMS·Sanity 등 외부 시스템이 변경을 트리거하는 시나리오입니다. 외부 변경은 즉각 반영보다 최종 일관성이 허용되므로 revalidateTag가 적합합니다.
// app/api/revalidate/route.ts
import { revalidateTag } from 'next/cache'
import { NextRequest } from 'next/server'
export async function POST(req: NextRequest) {
// 웹훅 시크릿 검증
// ⚠️ 보안 주의: 쿼리스트링 시크릿은 서버·CDN 로그에 기록될 수 있습니다.
// 프로덕션에서는 Authorization 헤더 또는 HMAC 서명 검증 방식을 권장합니다.
const secret = req.headers.get('authorization')?.replace('Bearer ', '')
if (secret !== process.env.REVALIDATE_SECRET) {
return Response.json({ error: 'Invalid token' }, { status: 401 })
}
let tag: string
try {
const body = await req.json()
tag = body?.tag
if (!tag || typeof tag !== 'string') {
return Response.json({ error: 'tag field is required' }, { status: 400 })
}
} catch {
return Response.json({ error: 'Invalid JSON body' }, { status: 400 })
}
// stale-while-revalidate: 기존 캐시를 즉시 제공하고, 다음 요청부터 새 데이터 반환
revalidateTag(tag)
return Response.json({ revalidated: true, tag })
}CMS 설정에서 콘텐츠 변경 시 아래 형태로 웹훅을 호출하도록 구성하면 됩니다.
POST https://your-site.com/api/revalidate
Authorization: Bearer YOUR_SECRET
Body: { "tag": "products" }예시 3: 커스텀 프로파일로 도메인별 캐시 전략 분리
예시 1·2가 무효화 전략이라면, 이 예시는 애초에 "어떤 데이터를 얼마나 오래 유지할지"를 도메인별로 명시적으로 선언하는 패턴입니다. 실시간성이 중요한 주가 데이터와 거의 변하지 않는 정적 콘텐츠를 동일한 프로파일로 관리하는 것은 비효율적입니다.
// next.config.ts
export default {
experimental: { cacheComponents: true }, // Next.js 16 플래그
cacheLife: {
'realtime-stock': { stale: 0, revalidate: 5, expire: 10 },
// ⚠️ revalidate: 5 → 5분 미만이므로 동적 영역(Dynamic Hole)에 해당합니다.
// 이 프로파일은 캐시 인터페이스 통일 목적으로 사용하되, 정적 생성 이점은 없습니다.
'user-profile': { stale: 30, revalidate: 60, expire: 300 },
'static-content': { stale: 300, revalidate: 86400, expire: Infinity },
},
}// lib/stock.ts
import {
unstable_cacheTag as cacheTag,
unstable_cacheLife as cacheLife
} from 'next/cache'
export async function getStockPrice(ticker: string) {
'use cache'
cacheLife('realtime-stock') // 커스텀 프로파일 이름 사용
cacheTag(`stock:${ticker}`)
return fetchStockAPI(ticker)
}
export async function getStaticPage(slug: string) {
'use cache'
cacheLife('static-content')
cacheTag(`page:${slug}`)
return db.page.findUnique({ where: { slug } }) // Prisma Client 예시
}예시 4: Server Action에서 updateTag로 read-your-own-writes 보장
예시 2(웹훅)와 달리, 이 예시는 사용자가 직접 변경한 데이터가 화면에 즉시 반영되어야 하는 경우입니다. 두 예시의 차이점은 "누가 변경을 트리거하는가"에 있습니다.
// app/actions/profile.ts
'use server'
import { updateTag } from 'next/cache'
// Next.js 15: import { unstable_updateTag as updateTag } from 'next/cache'
import { redirect } from 'next/navigation'
export async function updateUserProfile(formData: FormData) {
const userId = await getCurrentUserId()
await db.user.update({
where: { id: userId },
data: {
name: formData.get('name') as string,
avatar: formData.get('avatar') as string,
},
})
// revalidateTag 대신 updateTag 사용:
// 다음 요청에서 반드시 최신 프로필 데이터를 반환합니다.
updateTag(`user:${userId}`)
// redirect()는 내부적으로 예외를 throw하므로, 나중에 try/catch 추가 시
// catch 블록 바깥에 위치해야 catch 블록으로 떨어지지 않습니다.
redirect('/profile')
}왜 여기서
revalidateTag는 적합하지 않을까요?revalidateTag는 기존 캐시를 즉시 제공하고 백그라운드에서 재생성합니다. 사용자가 방금 저장한 프로필 변경사항이 다음 요청에서도 이전 데이터로 보일 수 있어 UX를 해칩니다.updateTag는 즉시 만료 처리하므로 다음 요청에서 반드시 새 데이터를 가져옵니다.
실무에서 가장 흔한 실수
네 가지 예시를 살펴봤으니, 실제 구현 시 자주 마주치는 함정도 미리 확인해두면 좋습니다.
revalidateTag와updateTag를 혼동하는 경우: 사용자가 직접 변경한 데이터(프로필, 설정 등)에revalidateTag를 사용하면, 변경사항이 즉시 화면에 반영되지 않아 UX 문제가 발생할 수 있습니다. 사용자의 쓰기 작업 직후에는updateTag를 사용하는 것이 적합합니다.- 캐시 스코프 내에서
cookies()나headers()를 직접 호출하는 경우:use cache디렉티브가 선언된 함수 내에서 이 API들을 직접 호출하면 런타임 오류가 발생합니다. 반드시 캐시 스코프 외부에서 값을 읽은 뒤 인수로 전달해야 합니다. - 서버리스 환경에서 기본
use cache만 사용하는 경우: Lambda나 Vercel Functions처럼 인스턴스가 여러 개 생성될 수 있는 환경에서 기본 인메모리 캐시를 사용하면, 같은 데이터가 인스턴스마다 별도로 캐시되어 일관성 문제가 생길 수 있습니다. 프로덕션에서는use cache: remote또는 Redis/Upstash 기반 커스텀 핸들러를 구성하는 것을 권장합니다(다음 글에서 다룹니다).
장단점 분석
장점
| 항목 | 내용 |
|---|---|
| 세밀한 캐시 제어 | 컴포넌트·함수 단위로 독립적인 캐시 전략 적용 가능. 페이지 전체 ISR보다 훨씬 정밀함 |
| 명시적 캐싱 의도 | 코드에서 무엇이 캐시되는지 즉시 파악 가능. 암묵적 캐싱의 디버깅 어려움 해소 |
| 자동 캐시 키 생성 | 함수 인수와 클로저 값이 자동으로 직렬화되어 파라미터별 별도 캐시 엔트리 생성 |
| On-Demand 무효화 | 특정 태그만 선택적으로 무효화하여 관련 없는 캐시를 그대로 유지 가능 |
| PPR 통합 | 캐시 컴포넌트가 정적 셸에 포함되어 LCP(Largest Contentful Paint) 성능 향상 |
단점 및 주의사항
| 항목 | 내용 | 대응 방안 |
|---|---|---|
| 인메모리 캐시의 한계 | 서버리스 환경에서 인스턴스 간 캐시 공유 불가. 요청 후 메모리 해제 가능 | use cache: remote 또는 Redis/Upstash 커스텀 핸들러 사용 — 다음 편에서 구체적인 설정법을 다룹니다 |
| 런타임 API 접근 불가 | cookies(), headers(), searchParams를 캐시 스코프 내에서 직접 호출 불가 |
외부에서 값을 읽어 함수 인수로 전달 |
| stale-while-revalidate의 시각적 지연 | revalidateTag 사용 시 사용자 본인의 변경사항이 즉시 반영되지 않을 수 있음 |
사용자 직접 변경 시 updateTag 사용 |
seconds 프로파일 성능 문제 |
revalidate < 5분이면 프리렌더 제외, 매 요청마다 서버 함수 실행 | 1분 미만 갱신이 필요한 경우 캐싱 대신 동적 렌더링 고려 |
use cache: private 미안정 |
개인화 캐싱은 PPR 런타임 프리페칭 의존, 아직 실험적 단계 | 안정화 전까지는 서버 사이드 세션 등 기존 방식 병행 |
| 설정 복잡성 | cacheComponents: true 플래그 필요. 기존 fetch 캐싱, unstable_cache와 혼용 시 동작 혼란 가능 |
새 프로젝트는 use cache로 통일, 기존 프로젝트는 점진적 마이그레이션 |
ISR(Incremental Static Regeneration): 빌드 타임에 정적 HTML을 생성하고, 일정 주기마다 서버에서 재생성하는 Next.js의 기존 캐싱 전략입니다.
use cache는 페이지 단위가 아닌 컴포넌트·함수 단위로 이 전략을 적용할 수 있게 해줍니다.
PPR(Partial Prerendering): 하나의 페이지에서 정적 셸(캐시된 부분)과 동적 스트리밍(실시간 부분)을 함께 제공하는 Next.js의 하이브리드 렌더링 전략입니다.
use cache로 표시된 컴포넌트는 이 정적 셸에 포함됩니다.
마치며
use cache + cacheLife + cacheTag의 조합은 "무엇을, 얼마나, 어떤 조건에서 캐시할지"를 코드 레벨에서 명시적으로 표현하는 가장 강력한 방법입니다. ISR의 페이지 단위 캐싱 한계를 넘어, 컴포넌트와 데이터 패칭 함수 각각에 맞는 정밀한 전략을 적용할 수 있습니다.
지금 바로 시작해볼 수 있는 3단계입니다.
next.config.ts에experimental: { cacheComponents: true }를 추가해 Cache Components 기능을 활성화할 수 있습니다. 이미 Next.js 15+ 프로젝트를 운영 중이라면 한 줄의 설정으로 새 캐싱 시스템을 사용할 준비가 됩니다(Next.js 15에서는dynamicIO: true또는useCache: true를 사용합니다).- 가장 자주 호출되지만 자주 변경되지 않는 데이터 패칭 함수 하나를 골라
'use cache'와cacheLife('hours')를 선언해보시면 좋습니다.cacheTag로 태그를 함께 부여해두면 나중에 무효화 흐름을 연결하기 쉬워집니다. - 해당 데이터를 변경하는 Server Action에서
updateTag또는revalidateTag를 호출해 무효화 흐름을 완성할 수 있습니다. 사용자가 직접 변경하는 데이터라면updateTag, CMS 웹훅처럼 외부 시스템이 트리거하는 경우라면revalidateTag를 선택하시면 됩니다.
다음 글:
use cache: remote와 Upstash Redis를 연결하는 커스텀 캐시 핸들러 구성법 — 서버리스 환경에서 인스턴스 간 캐시 공유를 구현하는 프로덕션 패턴을 살펴봅니다.
참고 자료
공식 문서
- Functions: cacheLife | Next.js
- Functions: cacheTag | Next.js
- Functions: revalidateTag | Next.js
- Functions: updateTag | Next.js
- Directives: use cache | Next.js
- next.config.js: cacheLife | Next.js
- Getting Started: Caching and Revalidating | Next.js
- Getting Started: Revalidating | Next.js
공식 블로그 및 아카데미
- Our Journey with Caching | Next.js 공식 블로그
- Cache Components for Instant and Fresh Pages | Vercel Academy
커뮤니티 논의 (진행 중인 논의)
- updateTag vs revalidateTag · vercel/next.js Discussion #84805 —
updateTag설계 의도와revalidateTag와의 차이를 논의한 스레드. API 상태 변경 가능성이 있으므로 최신 릴리스 노트와 함께 참고하는 것을 권장합니다.
CMS 연동 가이드