`Next.js 15 revalidateTag vs updateTag`: 캐시 무효화 전략 완전 가이드
Next.js 15에서 'use cache' 디렉티브가 도입되면서 서버 캐싱 아키텍처가 근본적으로 바뀌었습니다. 'use cache'는 async 함수나 컴포넌트 상단에 선언하는 것만으로 해당 결과를 서버 캐시에 저장하는 방식으로, fetch() 옵션 기반의 이전 패턴을 대체합니다. 이 변화와 함께 캐시 무효화 함수도 새롭게 정비됐는데, revalidateTag()와 updateTag() 중 어느 것을 선택해야 할지 헷갈리기 시작합니다.
"캐시 무효화 함수를 호출했는데 왜 새로고침해도 이전 데이터가 보이죠?"라는 질문이 커뮤니티에서 반복되는 이유가 바로 여기에 있습니다. 두 함수는 이름도 비슷하고 캐시를 "무효화"한다는 목적도 같아 보이지만, 내부 동작 방식과 사용 가능한 컨텍스트가 전혀 다릅니다.
revalidateTag()는 stale-while-revalidate(SWR) 시맨틱으로 동작해 백그라운드에서 캐시를 갱신하고, updateTag()는 서버 액션 완료 시점에 즉시 동기 무효화(synchronous invalidation)를 수행합니다. 이 차이를 모르고 잘못 선택하면 "저장했는데 반영이 안 된다"거나 반대로 "캐시 성능이 너무 떨어진다"는 문제로 이어집니다. 이 글에서는 두 함수의 동작 원리, 실전 시나리오별 선택 기준, 그리고 흔한 실수를 살펴봅니다.
이 글의 대상: Next.js App Router를 사용해본 경험이 있는 개발자를 기준으로 설명합니다.
'use cache',cacheTag(),cacheLife(), Server Action, Route Handler의 기본 개념을 아직 잘 모른다면, 먼저 Next.js 공식 문서의 Caching 가이드를 살펴보시면 좋습니다.
핵심 개념
stale-while-revalidate란 무엇인가
SWR(stale-while-revalidate)은 HTTP 캐시 세계에서 오래전부터 쓰인 전략입니다. 핵심 아이디어는 "캐시가 만료되더라도 일단 이전 데이터로 즉시 응답하고, 백그라운드에서 새 데이터를 가져와 교체한다"는 것입니다.
SWR(stale-while-revalidate): 캐시 만료 시 사용자를 블로킹하지 않고 stale 데이터를 먼저 반환한 뒤, 백그라운드 revalidation이 완료되면 그 이후의 요청부터 fresh 데이터를 반환하는 캐시 전략입니다.
revalidateTag(tag, 'max')를 호출하면 해당 태그가 달린 캐시 항목이 즉시 삭제되는 것이 아니라 "stale" 상태로 마킹됩니다.
revalidateTag('post', 'max') 호출
↓
캐시 항목 → "stale" 상태로 마킹 (즉시 삭제 X)
↓
첫 번째 요청 → stale 데이터로 즉시 응답 (사용자 블로킹 없음)
↓
백그라운드에서 새 데이터 fetch 시작
↓
완료 후 캐시 교체 → 이후 모든 방문자의 요청에 fresh 데이터 반환중요한 점은 Next.js의 서버 캐시가 전역 공유라는 것입니다. 위 다이어그램에서 "첫 번째 요청"은 동일 사용자의 재방문이 아닐 수 있습니다. revalidateTag 호출 직후에 완전히 다른 방문자가 접속하더라도 백그라운드 재생성이 완료되기 전까지는 stale 데이터를 받게 됩니다.
백그라운드 재생성이 실패하는 경우도 고려해야 합니다. 데이터 소스 오류나 네트워크 문제로 재생성이 실패하면 다음 요청이 들어올 때까지 stale 상태가 계속 유지됩니다. 별도의 재시도 메커니즘은 없으며, 에러 모니터링을 통해 재생성 실패를 감지하는 것을 권장합니다.
updateTag() — 즉시 동기 무효화
updateTag()는 Server Action 전용 함수로, Server Action이 완료되는 시점에 즉시 캐시를 만료시킵니다. 다음 렌더링 시점에 반드시 새 데이터를 fetch합니다.
'use server'
import { updateTag } from 'next/cache'
export async function updateUserProfile(formData: FormData) {
await db.user.update({ /* ... */ })
// 서버 액션 완료 시점에 즉시 만료 → 다음 렌더에서 fresh 데이터 보장
updateTag('user-profile')
}Read-your-own-writes: 사용자가 직접 수행한 쓰기 작업의 결과를 그 사용자가 즉시 읽을 수 있음을 보장하는 일관성 모델입니다.
updateTag()가 이 속성을 제공합니다.
"낙관적 업데이트(optimistic update)"와는 다른 개념입니다. 낙관적 업데이트는 서버 응답을 기다리지 않고 UI를 먼저 갱신한 뒤 나중에 보정하는 패턴인 반면, updateTag()는 서버 액션이 완전히 완료된 이후 동기적으로 캐시를 만료하는 즉시 동기 무효화입니다.
두 방식의 근본적 차이
| 구분 | revalidateTag(tag, 'max') |
updateTag(tag) |
|---|---|---|
| 동작 방식 | Stale 마킹 → 백그라운드 재생성 | 즉시 동기 만료 |
| 첫 요청 응답 | stale 데이터 (빠름) | 새 데이터 대기 |
| 사용 가능 컨텍스트 | Server Action, Route Handler | Server Action 전용 |
| 일관성 수준 | Eventual consistency | Read-your-own-writes |
| 적합한 케이스 | 공개 콘텐츠, 외부 Webhook | 사용자 직접 수행 변경 |
revalidateTag() 두 번째 인수 이해하기
두 번째 인수는 무효화 방식을 결정합니다. 단일 인수 형태는 deprecated 상태이므로 두 번째 인수를 명시하는 것을 권장합니다.
// ✅ 권장
revalidateTag('post', 'max') // SWR 시맨틱: stale 마킹 후 백그라운드 재생성
revalidateTag('post', { expire: 0 }) // 즉시 만료 (Route Handler용)
// ❌ Deprecated (단일 인수)
revalidateTag('post')여기서 'max'는 cacheLife('max')의 프로필 이름과 동일한 개념입니다. cacheLife('max')가 stale: Infinity, revalidate: Infinity, expire: 5년으로 설정되듯이, revalidateTag(tag, 'max')는 해당 캐시 항목을 max 프로필 기준의 SWR 시맨틱으로 무효화합니다.
주의:
revalidateTag()두 번째 인수의 정확한 동작 사양은 Next.js 공식 문서와 GitHub Discussion을 함께 확인하는 것을 권장합니다. 참고 자료에서 공식 문서 링크와 Discussion 링크가 혼재되어 있으며, deprecated 처리 여부와{ expire: 0 }형태는 사용 중인 Next.js 버전에 따라 다를 수 있습니다.
의사결정 트리
쓰기 작업이 Server Action인가?
├── YES → 사용자가 직접 수행한 변경인가?
│ ├── YES → updateTag() (즉시 갱신, read-your-own-writes)
│ └── NO → revalidateTag('tag', 'max') (SWR, 백그라운드 재생성)
└── NO (Route Handler / Webhook)
├── 즉시 만료 필요? → revalidateTag('tag', { expire: 0 })
└── SWR 허용? → revalidateTag('tag', 'max')실전 적용
예시 1: 외부 CMS Webhook 연동 (SWR 무효화)
Contentful, Sanity 같은 외부 CMS에서 콘텐츠가 업데이트될 때 Webhook으로 캐시를 무효화하는 패턴입니다. 첫 방문자가 잠깐 stale 데이터를 보더라도 서비스 운영에 큰 영향이 없기 때문에 SWR 방식이 적합합니다.
// app/api/revalidate/route.ts
import { revalidateTag } from 'next/cache'
import { NextRequest } from 'next/server'
export async function POST(req: NextRequest) {
const { tag, secret } = await req.json()
if (secret !== process.env.REVALIDATION_SECRET) {
return Response.json({ error: 'Unauthorized' }, { status: 401 })
}
revalidateTag(tag, 'max') // SWR: stale 마킹 후 백그라운드 재생성
return Response.json({ revalidated: true })
}// app/blog/[slug]/page.tsx
async function getBlogPost(slug: string) {
'use cache'
cacheTag(`post-${slug}`, 'blog')
cacheLife('max') // stale: ∞, revalidate: ∞, expire: 5년 — Webhook 호출 전까지 최대한 유지
return await cms.getPost(slug)
}| 코드 포인트 | 설명 |
|---|---|
cacheTag('post-${slug}', 'blog') |
슬러그별 태그와 전체 블로그 태그를 동시에 부여해 개별/일괄 무효화 모두 가능 |
cacheLife('max') |
Webhook 호출 전까지 캐시를 최대한 오래 유지 |
revalidateTag(tag, 'max') |
백그라운드 재생성 완료 이후 방문자부터 fresh 데이터 반환 |
예시 2: 사용자 프로필 업데이트 (즉시 무효화 필수)
사용자가 직접 정보를 수정하는 경우, 저장 직후 페이지를 새로고침했을 때 이전 데이터가 보이면 안 됩니다. updateTag()가 이 시나리오에 맞는 선택입니다.
// app/profile/actions.ts
'use server'
import { updateTag } from 'next/cache'
export async function updateProfile(formData: FormData) {
// getSessionUserId()는 프로젝트의 인증 라이브러리(예: next-auth의 getServerSession)에서
// 현재 로그인된 사용자의 ID를 가져오는 함수입니다
const userId = await getSessionUserId()
await db.user.update({
where: { id: userId },
data: { name: formData.get('name') as string },
})
updateTag(`user-${userId}`) // 즉시 동기 만료 → 다음 렌더에서 fresh 데이터 보장
}| 코드 포인트 | 설명 |
|---|---|
updateTag('user-${userId}') |
해당 유저 캐시만 정확하게 타겟팅해 즉시 만료 |
| Server Action 내부에서만 호출 | updateTag는 Server Action 컨텍스트 외부에서 호출하면 Runtime 에러 발생 |
예시 3: 실시간 재고 시스템 Webhook (Route Handler에서 즉시 만료)
결제·재고 시스템처럼 데이터 정확성이 중요하지만 Server Action을 사용할 수 없는 경우, { expire: 0 }으로 즉시 만료를 처리할 수 있습니다. Webhook 엔드포인트는 인증 검증이 필수입니다.
// app/api/inventory/webhook/route.ts
import { revalidateTag } from 'next/cache'
import { NextRequest } from 'next/server'
export async function POST(req: NextRequest) {
// Webhook 보안: 요청 출처 검증 — 없으면 누구나 캐시를 무효화할 수 있음
const signature = req.headers.get('x-webhook-signature')
if (signature !== process.env.INVENTORY_WEBHOOK_SECRET) {
return Response.json({ error: 'Unauthorized' }, { status: 401 })
}
const { productId } = await req.json()
// Route Handler에서는 updateTag 사용 불가
// expire: 0 으로 즉시 만료 처리
revalidateTag(`product-${productId}`, { expire: 0 })
return Response.json({ ok: true })
}주의:
{ expire: 0 }은 Route Handler에서 즉시 만료를 트리거하지만, UI가 자동으로 업데이트되지 않는 알려진 이슈가 있습니다. 사용자의 수동 새로고침이 필요할 수 있으며, 관련 논의는 vercel/next.js Discussion #84805에서 확인할 수 있습니다.
예시 4: 계층적 태그로 bulk 무효화
상품 카탈로그처럼 여러 페이지가 공유 태그를 사용할 때, 복수 태그를 부여해 계층적으로 관리할 수 있습니다. 공유 태그 하나를 무효화하면 해당 태그가 달린 모든 캐시 항목이 한 번에 무효화됩니다.
// 태그 계층 선언 — 각 카테고리 페이지에서 사용
async function getProducts(categoryId: string) {
'use cache'
// catalog: 전체 카탈로그, products: 전체 상품, category-{id}: 특정 카테고리
cacheTag(`category-${categoryId}`, 'products', 'catalog')
cacheLife('hours')
return await db.product.findMany({ where: { categoryId } })
}// app/api/catalog/webhook/route.ts — 카탈로그 업데이트 Webhook
import { revalidateTag } from 'next/cache'
import { NextRequest } from 'next/server'
export async function POST(req: NextRequest) {
const { scope, categoryId, secret } = await req.json()
if (secret !== process.env.WEBHOOK_SECRET) {
return Response.json({ error: 'Unauthorized' }, { status: 401 })
}
if (scope === 'all') {
revalidateTag('catalog', 'max') // 전체 카탈로그 SWR 무효화
} else if (categoryId) {
revalidateTag(`category-${categoryId}`, 'max') // 특정 카테고리만 무효화
}
return Response.json({ revalidated: true })
}| 무효화 대상 | 호출 방식 | 영향 범위 |
|---|---|---|
| 전체 카탈로그 | revalidateTag('catalog', 'max') |
catalog 태그가 달린 모든 캐시 항목 |
| 특정 카테고리 | revalidateTag('category-${id}', 'max') |
해당 카테고리 캐시 항목만 |
장단점 분석
장점
| 방식 | 항목 | 내용 |
|---|---|---|
revalidateTag('tag', 'max') |
응답 성능 | stale 데이터로 즉시 응답 → 사용자 블로킹 없음 |
revalidateTag('tag', 'max') |
서버 부하 분산 | 대량 무효화 시에도 백그라운드에서 순차 재생성 |
revalidateTag('tag', 'max') |
적용 범위 | 공개 콘텐츠(블로그, 상품, 문서) 케이스 대다수에 적합 |
updateTag() |
데이터 일관성 | Read-your-own-writes 보장 — 저장 즉시 새 데이터 확인 가능 |
updateTag() |
통합 자연스러움 | Server Action 흐름과 자연스럽게 통합 |
단점 및 주의사항
| 항목 | 내용 | 대응 방안 |
|---|---|---|
| SWR — 첫 요청 stale | 무효화 직후 접속하는 방문자는 이전 데이터를 받음 | 즉각 일관성이 필요한 경우 updateTag 또는 { expire: 0 } 사용 |
| SWR — 백그라운드 실패 | 재생성 실패 시 stale 상태 지속, 재시도 메커니즘 없음 | 에러 모니터링 추가 권장 |
updateTag — 컨텍스트 제한 |
Server Action 외부에서 호출 시 Runtime 에러 발생 | Route Handler에서는 revalidateTag(tag, { expire: 0 }) 사용 |
updateTag — 캐시 miss 폭증 |
많은 사용자가 동시에 액션 트리거 시 성능 저하 가능 | 사용자 직접 변경에만 한정해 사용 |
{ expire: 0 } — UI 미갱신 |
Route Handler 호출 후 UI 자동 업데이트 안 됨 | 사용자 새로고침 안내 또는 대안 방법 검토 |
Eventual consistency(최종 일관성): 즉각적인 일관성은 보장하지 않지만, 일정 시간이 지나면 모든 요청이 동일한 최신 데이터를 받게 되는 속성입니다.
revalidateTag()SWR 방식이 이에 해당합니다.
실무에서 가장 흔한 실수
- 사용자 직접 변경에
revalidateTag()SWR을 사용하는 것 — 저장 후 새로고침해도 이전 데이터가 보이는 UX 문제가 발생합니다. 폼 제출, 프로필 수정, 댓글 작성처럼 사용자가 직접 수행한 변경에는updateTag()를 사용하는 것이 올바른 선택입니다. - 단일 인수
revalidateTag('tag')형태를 그대로 사용하는 것 — Next.js 15에서 deprecated 상태입니다.revalidateTag('tag', 'max')또는revalidateTag('tag', { expire: 0 })형태로 두 번째 인수를 명시하는 것을 권장합니다. - Route Handler에서
updateTag()호출을 시도하는 것 — Server Action 전용 함수이므로 Runtime 에러가 발생합니다. Route Handler에서는revalidateTag(tag, { expire: 0 })을 대신 활용할 수 있습니다.
마치며
revalidateTag()와 updateTag()의 차이는 단순한 API 선택이 아니라, "이 데이터의 일관성을 언제 보장해야 하는가"라는 설계 질문에 대한 답입니다. 공개 콘텐츠는 SWR로 성능을 얻고, 사용자 직접 변경에는 즉시 동기 무효화로 일관성을 보장하는 것이 기본 원칙입니다.
지금 바로 점검해볼 수 있는 3단계입니다.
- 현재 프로젝트에서
revalidateTag()단일 인수 사용 여부를 확인할 수 있습니다. 터미널에서grep -r "revalidateTag(" ./app --include="*.ts" --include="*.tsx"를 실행해 deprecated 형태가 남아 있는지 확인해보시면 좋습니다. - 사용자가 직접 수행하는 쓰기 작업(Server Action)을 목록화하고, 각각에
updateTag()가 적용돼 있는지 검토해보시면 좋습니다. 특히 폼 제출, 프로필 수정, 댓글 작성 등의 Server Action이 주요 대상입니다. - Route Handler 기반 Webhook이 있다면,
revalidateTag(tag, 'max')와revalidateTag(tag, { expire: 0 })중 서비스 요구사항에 맞는 방식으로 두 번째 인수를 명시적으로 지정하는 것을 권장합니다.
다음 글: 이 글의 예시에서 사용한
cacheLife('max')를 서비스 성격에 맞게 직접 커스텀하는 방법과, Vercel 엣지 캐시와 서버 캐시를 계층적으로 조합하는 전략을 다룰 예정입니다.
참고 자료
- Functions: revalidateTag | Next.js 공식 문서
- Functions: updateTag | Next.js 공식 문서
- Guides: How Revalidation Works | Next.js
- Getting Started: Revalidating | Next.js
- Directives: use cache | Next.js
- Functions: cacheTag | Next.js
- Functions: cacheLife | Next.js
- updateTag vs revalidateTag · vercel/next.js Discussion #84805
- Cache Revalidation Options · vercel/next.js Discussion #78513
- Deep Dive: Caching and Revalidating · vercel/next.js Discussion #54075
- Next.js revalidateTag vs updateTag: Cache Strategy Guide | Build with Matija
- revalidateTag & updateTag In NextJs - DEV Community
- Guides: ISR | Next.js
- Caching - OpenNext