Next.js 15 | cacheTag + Webhook으로 CMS 캐시 선택 무효화 구현하기
헤드리스 CMS(Headless CMS)란 콘텐츠 관리와 프론트엔드 렌더링을 분리한 구조로, Sanity·DatoCMS·Contentful 같은 서비스가 대표적입니다. 이 구조를 사용하다 보면 한 번쯤 마주치는 상황이 있습니다. 에디터가 상품 가격이나 뉴스 기사를 수정했는데, 실제 사이트에 반영되려면 전체 재빌드를 기다려야 하는 상황입니다. ISR(Incremental Static Regeneration)로 revalidate: 60을 설정해도 수정 후 최대 60초의 지연이 생기고, 더 긴 TTL을 사용할수록 데이터 신선도는 떨어집니다. 이커머스 환경에서 상품 가격이 오표시되면 CS 문의가 증가하고, 뉴스 도메인에서는 오보가 수정되지 않은 채 노출되는 문제로 이어집니다.
Next.js 15는 이 문제를 근본적으로 해결하는 캐시 프리미티브인 use cache, cacheTag(), revalidateTag()를 제공합니다. CMS의 Webhook과 이 API들을 연결하면, 변경된 콘텐츠와 관련된 캐시만 선택적으로 폐기하는 정밀한 무효화 파이프라인을 구성할 수 있습니다. 전체 경로를 재빌드하거나 TTL 만료를 기다릴 필요가 없어집니다.
이 글에서는 cacheTag() + revalidateTag()의 동작 원리를 이해하고, CMS Webhook을 Route Handler에서 수신해 영향받는 캐시만 선택적으로 무효화하는 완전한 파이프라인을 직접 구성하는 방법을 다룹니다. Sanity, DatoCMS 실제 통합 예시와 함께, 실무에서 마주치는 주의사항까지 정리했습니다.
핵심 개념
전체 파이프라인 흐름
코드를 보기 전에 전체 흐름을 한눈에 파악합니다.
CMS 콘텐츠 수정
│
▼
Webhook 이벤트 발송 (HTTP POST)
│
▼
Next.js Route Handler (app/api/revalidate/route.ts)
│ ① 시크릿 토큰 검증
│ ② 페이로드에서 영향 범위 파악
▼
revalidateTag(`post:${id}`) + revalidateTag('posts')
│
▼
해당 태그가 부착된 캐시 항목만 폐기
│
▼
다음 요청부터 신선한 데이터 반환use cache — 명시적 캐시 경계 선언
기존 Next.js의 캐싱은 fetch() 옵션, unstable_cache(), 라우트 세그먼트 설정 등 여러 API에 분산되어 있었습니다. Next.js 15는 이를 하나의 통합된 프리미티브인 use cache 디렉티브로 나아가고 있습니다. 현재는 unstable_cache와 병행 사용 단계이며, 새 프로젝트에서는 use cache를 권장합니다.
async function getPost(id: string) {
'use cache'
cacheLife('days') // 기본 TTL: stale 1시간, revalidate 1일, expire 1주
cacheTag(`post:${id}`, 'posts')
return db.post.findUnique({ where: { id } })
}함수 최상단에 'use cache'를 선언하면 해당 함수의 반환값이 캐시됩니다. 서버 컴포넌트, 서버 액션, 일반 async 함수 모두에 적용할 수 있습니다.
cacheLife()기본 프리셋 TTL: Next.js는'seconds','minutes','hours','days','weeks','max'프리셋을 제공합니다.'hours'는 stale 5분·revalidate 1시간·expire 1일,'days'는 stale 1시간·revalidate 1일·expire 1주로 동작합니다.next.config.ts의cacheLife설정으로 커스터마이징도 가능합니다.
dynamicIO플래그:next.config.ts에서experimental: { dynamicIO: true }를 활성화하면use cache가 없는 동적 데이터 접근이 빌드 타임에 오류로 감지됩니다. 새 프로젝트 시작 시 함께 설정해두면 캐시 누락을 명시적으로 확인할 수 있습니다.
cacheTag() — 캐시 항목에 태그 부착
cacheTag()는 use cache 블록 내부에서 호출해 캐시 항목에 하나 이상의 문자열 태그를 연결합니다.
cacheTag(`post:${id}`) // 개별 항목 태그
cacheTag(`post:${id}`, 'posts') // 복수 태그 동시 부착
cacheTag(`author:${authorId}`) // 관계 기반 태그태그 네이밍 전략: 콜론(:)을 구분자로 사용하는 타입:id 계층 구조가 업계 표준으로 자리잡고 있습니다. 이 방식에는 두 가지 근거가 있습니다. 첫째, 콜론은 URL·파일 경로에서 예약되지 않아 태그 충돌 가능성이 낮습니다. 둘째, post:abc처럼 타입을 접두어로 붙이면 다른 콘텐츠 타입과의 네임스페이스가 자연스럽게 분리됩니다. post:abc는 특정 포스트 하나를 식별하고, posts는 전체 목록에 부착해 "전체 무효화"가 필요할 때 활용합니다. 이 다중 태그 전략이 이 패턴의 핵심입니다.
revalidateTag() — 태그 기반 선택적 무효화
revalidateTag(tag)는 해당 태그가 부착된 모든 캐시 항목을 무효화합니다. 현재 공식 시그니처는 다음과 같습니다.
// 현재 실제 시그니처
revalidateTag(tag: string): void기본 동작은 Stale-While-Revalidate(SWR) 시맨틱입니다. 무효화 후 첫 요청에 기존 캐시(stale)를 즉시 반환하고, 백그라운드에서 새 데이터를 재생성합니다. 사용자는 응답 지연 없이 페이지를 받으며, 그다음 요청부터 신선한 데이터를 받습니다.
즉시 폐기가 필요한 경우:
revalidateTag(tag, { expire: 0 })형태의 즉시 폐기 옵션은 현재 Next.js 공식 문서에서 지원하지 않습니다. 즉시 폐기는unstable_expireTag등 별도의 실험적 메커니즘을 통해 가능하며, 프로덕션 적용 전 공식 릴리스 노트에서 지원 여부를 확인하는 것을 권장합니다.
실전 적용
예시 1: 기본 Webhook 파이프라인 구성
가장 기본적인 구조입니다. 데이터 레이어에 태그를 부착하고, Route Handler에서 Webhook을 수신해 무효화하는 흐름입니다.
1단계 — 데이터 레이어에 태그 부착
// lib/posts.ts
import { cacheTag, cacheLife } from 'next/cache'
export async function getPost(id: string) {
'use cache'
cacheTag(`post:${id}`, 'posts') // 개별 태그 + 목록 태그 동시 부착
cacheLife('days')
const res = await fetch(`https://api.example.com/posts/${id}`)
if (!res.ok) throw new Error(`Failed to fetch post: ${id}`)
return res.json()
}
export async function getPosts() {
'use cache'
cacheTag('posts') // 목록 전용 태그만 부착
cacheLife('hours') // 기본 TTL: stale 5분, revalidate 1시간, expire 1일
const res = await fetch('https://api.example.com/posts')
if (!res.ok) throw new Error('Failed to fetch posts')
return res.json()
}목록 vs 상세 캐시 분리:
getPost에는post:${id}와posts태그를 모두 부착합니다. 특정 포스트 수정 시revalidateTag('posts')를 호출하면 목록 캐시와 해당 포스트 상세 캐시가 함께 갱신됩니다.getPosts는posts태그만 보유하므로 목록 페이지 독립 무효화도 가능합니다.
2단계 — Webhook 수신 Route Handler
// app/api/revalidate/route.ts
import { revalidateTag } from 'next/cache'
import { headers } from 'next/headers'
import { NextRequest } from 'next/server'
export async function POST(request: NextRequest) {
const headersList = await headers()
const secret = headersList.get('x-webhook-secret')
if (secret !== process.env.WEBHOOK_SECRET) {
return Response.json({ error: 'Unauthorized' }, { status: 401 })
}
let payload: { type?: string; id?: string }
try {
payload = await request.json()
} catch {
return Response.json({ error: 'Invalid JSON body' }, { status: 400 })
}
const { type, id } = payload
if (!id || !type) {
return Response.json({ error: 'Missing required fields: type, id' }, { status: 400 })
}
revalidateTag(`post:${id}`) // 수정된 포스트 캐시만 폐기
revalidateTag(type) // 동일 타입 목록 캐시도 갱신
return Response.json({ revalidated: true, tag: `post:${id}` })
}| 코드 포인트 | 역할 |
|---|---|
x-webhook-secret 헤더 검증 |
CMS 이외의 무단 무효화 요청 차단 |
try/catch JSON 파싱 |
잘못된 페이로드로 인한 서버 오류 방지 |
!id || !type 가드 |
필수 필드 누락 시 명시적 에러 반환 |
revalidateTag(\post:${id}`)` |
변경된 포스트 1개에 대한 캐시만 폐기 |
revalidateTag(type) |
목록 페이지 등 타입 전체 캐시도 동시에 갱신 |
예시 2: Sanity CMS 통합
Sanity는 next-sanity 패키지를 통해 태그 기반 캐싱 패턴을 공식 지원합니다. GROQ(Sanity의 전용 쿼리 언어)로 데이터를 조회할 때 자동으로 태그가 부착되도록 sanityFetch() 래퍼를 구성합니다.
// lib/sanity.ts
import { createClient } from '@sanity/client'
import { cacheTag, cacheLife } from 'next/cache'
const client = createClient({
projectId: process.env.SANITY_PROJECT_ID!,
dataset: 'production',
apiVersion: '2024-01-01',
useCdn: false,
})
export async function sanityFetch<T>({
query,
tags,
}: {
query: string
tags: [string, ...string[]] // 최소 1개 이상의 태그 타입으로 강제
}) {
'use cache'
cacheTag(...tags)
cacheLife('hours')
return client.fetch<T>(query)
}// app/shop/[slug]/page.tsx — 이커머스 상품 페이지 예시
import { sanityFetch } from '@/lib/sanity'
interface Product {
title: string
price: number
slug: { current: string }
}
export default async function ProductPage({ params }: { params: { slug: string } }) {
const product = await sanityFetch<Product>({
query: `*[_type == "product" && slug.current == $slug][0]`,
tags: [`product:${params.slug}`, 'products'],
})
return (
<article>
<h1>{product.title}</h1>
<p>₩{product.price.toLocaleString()}</p>
</article>
)
}Sanity Webhook 핸들러에서는 next-sanity가 내장하는 parseBody 유틸리티로 요청 서명을 검증한 후 무효화를 수행합니다.
// app/api/revalidate/route.ts (Sanity)
import { revalidateTag } from 'next/cache'
import { parseBody } from 'next-sanity/webhook'
export async function POST(request: Request) {
const { isValidSignature, body } = await parseBody<{
_type: string
_id: string
slug?: { current: string }
}>(request, process.env.SANITY_WEBHOOK_SECRET)
if (!isValidSignature) {
return Response.json({ error: 'Invalid signature' }, { status: 401 })
}
const { _type, _id, slug } = body
if (slug?.current) {
revalidateTag(`product:${slug.current}`)
}
revalidateTag(_type) // 콘텐츠 타입 전체 무효화
return Response.json({ revalidated: true })
}
parseBody:next-sanity패키지가 제공하는 유틸리티로, Sanity가 전송하는 Webhook의 HMAC-SHA256 서명을 검증합니다.isValidSignature가false이면 서명 불일치이므로 요청을 거부합니다.tags: [string, ...string[]]타입은 빈 배열 전달 시 태그가 부착되지 않는 무음 실패를 컴파일 타임에 방지합니다.
예시 3: DatoCMS Cache Tags 네이티브 통합
DatoCMS는 Webhook 페이로드에 영향받은 캐시 태그 목록(cache_tags)을 직접 포함해서 전송하는 방식을 지원합니다. 별도의 태그 계산 로직 없이 페이로드를 그대로 활용할 수 있습니다.
// app/api/revalidate/route.ts (DatoCMS)
import { revalidateTag } from 'next/cache'
export async function POST(request: Request) {
const secret = request.headers.get('x-webhook-token')
if (secret !== process.env.DATOCMS_WEBHOOK_TOKEN) {
return Response.json({ error: 'Unauthorized' }, { status: 401 })
}
let cache_tags: string[]
try {
const body = await request.json() as { cache_tags?: string[] }
cache_tags = body.cache_tags ?? []
} catch {
return Response.json({ error: 'Invalid JSON body' }, { status: 400 })
}
// revalidateTag는 동기 함수이므로 forEach로 순회
cache_tags.forEach((tag) => revalidateTag(tag))
return Response.json({ revalidated: true, tags: cache_tags })
}DatoCMS Cache Tags API: DatoCMS는 콘텐츠 변경 시 해당 레코드와 연결된 모든 캐시 태그를 자동으로 계산해
cache_tags배열에 담아 전송합니다. 복잡한 콘텐츠 관계 그래프에서도 무효화 범위를 CMS 측이 보장해주므로 클라이언트 로직이 단순해집니다.revalidateTag는 동기 함수이므로await Promise.all없이forEach로 처리합니다.
장단점 분석
장점
| 항목 | 내용 |
|---|---|
| 정밀한 선택적 무효화 | 변경된 콘텐츠와 관련된 캐시만 폐기되므로 무관한 페이지는 캐시 상태를 유지합니다 |
| 빌드 불필요 | ISR처럼 전체 경로를 재생성하지 않고 런타임에서 즉각 처리됩니다 |
| 다중 태그 지원 | 하나의 캐시 항목에 여러 태그를 부착해 "개별 무효화 vs 전체 무효화"를 유연하게 선택할 수 있습니다 |
| 서버/데이터 레이어 통합 | 서버 컴포넌트와 데이터 페칭 레이어 모두에 동일한 API로 적용됩니다 |
| CMS 에코시스템 지원 | Sanity, DatoCMS, Contentful 등 주요 헤드리스 CMS가 공식 통합 패턴을 제공합니다 |
단점 및 주의사항
| 항목 | 내용 | 대응 방안 |
|---|---|---|
| SWR 지연 | 기본 동작은 Stale-While-Revalidate로, 무효화 직후 요청에 구 데이터가 반환될 수 있습니다 | 즉시 폐기 필요 시 unstable_expireTag 등 실험적 API를 공식 문서에서 확인 후 검토합니다 |
| 태그 관리 복잡성 | 콘텐츠 관계가 복잡해질수록 태그 설계 비용이 높아지며, 과잉 태깅 시 성능 저하가 발생할 수 있습니다 | 타입:id 계층 구조를 표준화하고 태그 상수 파일로 중앙 관리합니다 |
| Webhook 신뢰성 | CMS → Next.js Webhook 전달 실패 시 캐시가 갱신되지 않습니다 | CMS 측 재시도 정책 설정과 핸들러 내 시크릿 토큰 검증을 반드시 구성합니다 |
use cache 실험적 단계 |
2025년 기준 아직 실험적 기능으로, 프로덕션 도입 시 안정성 모니터링이 필요합니다 | Next.js 릴리스 노트를 주기적으로 확인하고 안정화 여부를 추적합니다 |
| 멀티 인스턴스 환경 | 자체 호스팅 환경에서는 서버 간 캐시 동기화가 지원되지 않습니다 | use cache remote(Vercel 전용) 또는 Redis 기반 캐시 어댑터를 검토합니다 |
| 사용자별 캐시 충돌 | 세션 데이터, 개인화 피드 등 사용자별로 다른 응답에 use cache를 페이지 레벨에 적용하면 데이터가 교차 노출될 위험이 있습니다 |
use cache는 공유 가능한 데이터 레이어에만 적용하고, 사용자별 데이터는 클라이언트 페칭 또는 쿠키 스코핑을 사용합니다 |
실무에서 가장 흔한 실수
-
Webhook 시크릿 검증 생략:
WEBHOOK_SECRET환경 변수 없이 Route Handler를 공개 배포하면, 누구든revalidateTag()를 임의로 트리거할 수 있습니다. 모든 Webhook 핸들러에는 시크릿 헤더 검증을 포함하는 것을 권장합니다. -
revalidateTag를 클라이언트 컴포넌트에서 호출하려는 시도:revalidateTag()는 서버 전용(Server-only) 함수입니다. 클라이언트 컴포넌트에서 import해 호출하면 런타임 오류가 발생합니다. Server Action 또는 Route Handler를 통해서만 호출해야 합니다. -
태그를 너무 광범위하게 설계: 모든 콘텐츠에
all이나content같은 단일 최상위 태그만 사용하면, 사소한 수정 하나에도 전체 캐시가 날아갑니다.타입:id계층 구조로 개별 무효화와 전체 무효화를 분리해 설계하는 것을 권장합니다.
마치며
cacheTag() + revalidateTag() + Webhook의 조합은 "빌드 없이, 지연 없이, 관련 캐시만"이라는 세 가지 조건을 동시에 만족하는 정밀한 Next.js 캐시 무효화 전략입니다. CMS와 프론트엔드 사이의 데이터 신선도 문제를 근본적으로 해결하고 싶다면, 이 파이프라인이 좋은 출발점이 될 수 있습니다. 다만 use cache는 2025년 기준 아직 실험적 기능이므로, 프로덕션 환경에서는 Next.js 릴리스 노트에서 안정화 여부를 확인한 후 도입을 검토하는 것을 권장합니다.
지금 바로 시작할 수 있는 3단계:
next.config.ts에experimental: { dynamicIO: true }를 추가합니다. 기존 데이터 페칭 함수 중use cache가 없는 곳이 빌드 오류로 감지되어, 어디에 태그를 부착해야 할지 파악하기 쉬워집니다.- **
lib/posts.ts등 데이터 레이어 함수에'use cache'와cacheTag(\post:${id}`, 'posts')패턴을 적용해 태그 계층을 설계합니다.**타입:id+타입전체` 두 레벨로 시작하는 것을 권장합니다. app/api/revalidate/route.ts를 생성하고, 사용 중인 CMS의 Webhook 설정 페이지에서 해당 URL로 이벤트를 전송하도록 연결합니다. 시크릿 토큰 검증과revalidateTag()호출만으로 완전한 파이프라인이 완성됩니다.
다음 글:
cacheLife()프리셋 커스터마이징과use cache remote(Vercel 전용)를 활용해 멀티 인스턴스 환경에서 캐시 일관성을 확보하는 방법을 다룰 예정입니다.
참고 자료
- Functions: cacheTag | Next.js 공식 문서
- Functions: revalidateTag | Next.js 공식 문서
- Directives: use cache | Next.js 공식 문서
- Getting Started: Caching and Revalidating | Next.js
- Our Journey with Caching | Next.js 공식 블로그
- Tag-based revalidation | Sanity Learn
- Caching and revalidation in Next.js | Sanity Docs
- Sanity Webhooks and On-demand Revalidation in Next.js | Sanity
- DatoCMS Cache Tags and Next.js | DatoCMS Docs
- Mastering Next.js 15 Caching: dynamicIO, "use cache" & More | Strapi Blog
- Fix over-caching with Dynamic IO caching in Next.js 15 | LogRocket
- NextJS Tag Revalidation Caching Strategy | RudderStack
- Cache Revalidation Options · vercel/next.js Discussion #78513
- revalidateTag & updateTag In NextJs | DEV Community