`revalidateTag`로 CMS 웹훅 연동하기 — Next.js 온디맨드 캐시 즉시 갱신 실전 가이드
이 글은 Next.js 캐시 전략 시리즈 2편으로, Next.js App Router와 Route Handler에 대한 기본 이해가 있는 환경을 전제합니다.
콘텐츠를 수정하고 발행 버튼을 눌렀는데 사이트에 반영되기까지 수십 분을 기다려본 경험이 있으신가요? revalidate: 60처럼 시간 기반 캐시를 쓰는 한, TTL이 만료될 때까지 독자에게는 오래된 내용이 그대로 보이고, 트래픽이 없는 시간대라면 만료 이후에도 갱신이 일어나지 않을 수 있습니다. 캐시 갱신은 서버가 능동적으로 실행하는 것이 아니라 다음 요청이 들어올 때 비로소 트리거되기 때문입니다.
시간 기반(revalidate: N) |
온디맨드(revalidateTag) |
|
|---|---|---|
| 갱신 타이밍 | TTL 만료 후 다음 요청 시 | 이벤트(웹훅) 발생 즉시 |
| 무효화 범위 | 경로 단위 | 태그 단위 (세밀한 선택 가능) |
| 트래픽 없을 때 | 만료 후에도 stale 유지 가능 | 이벤트 시점에 stale 마킹 |
| 불필요한 재빌드 | 항상 주기적으로 발생 | 변경된 콘텐츠만 갱신 |
Next.js App Router는 이 문제를 해결하기 위해 'use cache' 디렉티브, cacheTag(), revalidateTag()를 조합한 온디맨드 캐시 무효화를 제공합니다. CMS에서 콘텐츠가 저장되는 순간 웹훅이 발사되고, Next.js가 해당 캐시를 즉시 stale 상태로 마킹하여 다음 방문자는 최신 데이터를 받아가는 구조입니다. 이 글에서는 Sanity·Contentful 같은 헤드리스 CMS와 연동하는 태그 기반 온디맨드 캐시 무효화를 처음부터 구축하는 방법을 단계별로 살펴봅니다.
핵심 개념
세 가지 API가 함께 동작하는 방식
Next.js 15의 온디맨드 무효화는 세 가지가 유기적으로 동작합니다.
| API | 역할 |
|---|---|
'use cache' 디렉티브 |
함수·컴포넌트·파일 단위로 캐시 적용하는 컴파일러 지시어 |
cacheTag(tag) |
캐시 엔트리에 식별자 태그를 부착 |
revalidateTag(tag) |
해당 태그를 가진 모든 캐시를 무효화 |
기본 패턴은 다음과 같습니다.
import { cacheTag, cacheLife } from 'next/cache';
async function getPost(slug: string) {
'use cache';
cacheTag('post', `post-${slug}`);
// 'max' 프로파일: stale 최대 1년의 장기 캐시 수명 (Next.js 기본 프로파일)
cacheLife('max');
return db.post.findUnique({ where: { slug } });
}
cacheTag와 숫자형revalidate옵션은 동시에 사용할 수 없습니다. 태그 기반 전략으로 전환할 때는 기존revalidate: N설정을cacheLife()로 교체하는 것을 권장합니다.
전체 흐름 다이어그램
[CMS 편집 저장]
↓ 웹훅 HTTP POST
[Next.js Route Handler]
↓ revalidateTag() 호출
[해당 태그 캐시 → stale 마킹]
↓ 다음 방문자 요청
[백그라운드에서 최신 데이터 fetch → 캐시 갱신]
(웹훅 누락 시) ·········→ [폴백 TTL 만료 후 자동 갱신]stale 마킹된 캐시는 갱신이 완료될 때까지 기존 데이터를 계속 서빙합니다(stale-while-revalidate). 사용자 경험 저하 없이 백그라운드에서 조용히 갱신되고, 웹훅이 누락되더라도 폴백 TTL이 안전망 역할을 합니다(웹훅 누락 대비 패턴은 아래 예시에서 자세히 다룹니다).
revalidateTag vs updateTag
Next.js 15에서 updateTag가 새롭게 추가되었습니다. 두 API는 무효화 방식이 다릅니다(Next.js 공식 문서 참조).
| API | 동작 방식 | 적합한 시나리오 |
|---|---|---|
revalidateTag |
stale-while-revalidate (백그라운드 갱신) | 블로그 포스트, 마케팅 페이지 등 일반 CMS 편집 |
updateTag |
즉시 만료 (다음 요청에서 대기 후 신규 데이터) | 결제 상태, 재고 수량 등 즉시성이 중요한 경우 |
실전 적용
Sanity 연동
Sanity는 GROQ-powered Webhook을 통해 특정 _type이 변경될 때만 선택적으로 웹훅을 발사할 수 있습니다. next-sanity 패키지의 parseBody 유틸을 활용하면 서명 검증도 간편하게 처리됩니다.
Step 1: 데이터 페칭 함수에 태그 부착
// lib/queries.ts
import { cacheTag, cacheLife } from 'next/cache';
import { sanityClient } from './sanity';
async function getPosts() {
'use cache';
cacheTag('post'); // Sanity 문서 타입과 동일한 태그
cacheLife('max');
// 주의: 실무에서 데이터 규모가 클 경우 페이지네이션을 적용하는 것을 권장합니다
return sanityClient.fetch(`*[_type == "post"]`);
}
async function getPostBySlug(slug: string) {
'use cache';
cacheTag('post', `post-${slug}`); // 타입 태그 + 개별 문서 태그 동시 부착
cacheLife('max');
return sanityClient.fetch(
`*[_type == "post" && slug.current == $slug][0]`,
{ slug }
);
}Step 2: 웹훅 수신 Route Handler 작성
// app/api/revalidate/route.ts
import { revalidateTag } from 'next/cache';
import { parseBody } from 'next-sanity/webhook';
export async function POST(req: Request) {
const { body, isValidSignature } = await parseBody(
req,
process.env.SANITY_WEBHOOK_SECRET
);
if (!isValidSignature) {
return new Response('Invalid signature', { status: 401 });
}
// Sanity의 _type('post', 'author' 등)을 태그로 사용해 해당 타입 전체 무효화
revalidateTag(body._type);
// slug가 있다면 변경된 문서 하나만 정밀 무효화
if (body.slug?.current) {
revalidateTag(`post-${body.slug.current}`);
}
return Response.json({ revalidated: true, now: Date.now() });
}Sanity Studio 웹훅 설정:
- Filter:
*[_type == "post"](post 타입 변경 시만 발사) - HTTP Method: POST
- Secret 헤더:
x-sanity-webhook-secret에 환경변수와 동일한 값 설정
Contentful 연동
// app/api/revalidate/route.ts
import { revalidateTag } from 'next/cache';
export async function POST(req: Request) {
const secret = req.headers.get('x-webhook-secret');
if (secret !== process.env.CONTENTFUL_REVALIDATE_SECRET) {
return new Response('Unauthorized', { status: 401 });
}
const payload = await req.json();
const contentType = payload.sys?.contentType?.sys?.id; // 'blogPost', 'author' 등
revalidateTag(contentType); // 콘텐츠 타입 전체 무효화
revalidateTag(`entry-${payload.sys.id}`); // 특정 엔트리 ID 무효화
return Response.json({ revalidated: true });
}태그 계층 설계 — 작가 정보 변경 시 관련 포스트 일괄 갱신
태그를 계층적으로 설계하면 한 번의 revalidateTag 호출로 연관된 모든 콘텐츠를 함께 갱신할 수 있습니다.
// lib/queries.ts
import { cacheTag, cacheLife } from 'next/cache';
async function getPostBySlug(slug: string) {
'use cache';
const post = await db.post.findUnique({
where: { slug },
include: { author: true },
});
// post가 null이면 조기 반환 (태그에 undefined가 섞이는 것을 방지)
if (!post) return null;
// 포스트 고유 태그 + 작가 태그 동시 부착
// author 정보가 바뀌면 revalidateTag(`author-${id}`) 하나로 이 포스트도 무효화됨
cacheTag('post', `post-${slug}`, `author-${post.author.id}`);
cacheLife('max');
return post;
}// app/api/revalidate/route.ts — 작가 정보 변경 이벤트 처리
if (payload._type === 'author') {
revalidateTag(`author-${payload._id}`);
// 이 작가의 모든 포스트 캐시가 함께 무효화됩니다
}웹훅 누락 대비 폴백 TTL 패턴
웹훅은 네트워크 오류나 CMS 장애로 누락될 수 있습니다. 폴백 TTL을 설정해두면 최악의 경우에도 일정 시간 후 자동 갱신됩니다.
import { cacheTag, cacheLife } from 'next/cache';
async function getPost(slug: string) {
'use cache';
cacheTag(`post-${slug}`);
cacheLife({
max: 3600, // 최대 캐시 수명: 1시간
revalidate: 3600, // 웹훅이 누락되더라도 1시간 후 자동 갱신되는 안전망
});
return db.post.findUnique({ where: { slug } });
}장단점 분석
장점
| 항목 | 내용 |
|---|---|
| 편집 즉시 반영 | 발행 버튼 클릭 → 웹훅 → 캐시 무효화까지 수초 내 완료 |
| 태그 단위 선택적 무효화 | 관련 없는 콘텐츠 캐시는 유지, 불필요한 재빌드 없음 |
| stale-while-revalidate | 무효화 직후에도 이전 캐시를 계속 서빙하므로 UX 저하 없음 |
| 크로스 레이어 공유 | 서버 컴포넌트·Route Handler·Server Action이 동일한 태그 공유 |
| TTL 0 대비 효율 | 전체 캐시를 끄지 않아도 콘텐츠 신선도 보장 가능 |
단점 및 주의사항
| 항목 | 내용 | 대응 방안 |
|---|---|---|
| 갱신 지연 가능성 | revalidateTag 호출 후 누군가 방문해야만 실제 갱신 |
updateTag 사용 또는 Vercel ISR pre-render 활용 |
| 웹훅 누락 위험 | 웹훅 실패 시 캐시가 영구 stale 상태 가능 | 폴백 TTL(cacheLife) 설정 권장 |
| 웹훅 보안 필수 | 시크릿 미검증 시 외부에서 캐시 무한 무효화 공격 가능 | HMAC 서명 검증 또는 시크릿 헤더 필수 |
| 태그 한도 | 태그 최대 128개, 문자열 최대 256자 | 다국어 콘텐츠나 유저별 태그 세분화 시 네이밍 전략을 사전에 설계하는 것이 좋습니다 |
| 배포 환경 제약 | Vercel 외 자체 서버에서는 별도 캐시 어댑터 구성 필요 | Node.js 커스텀 캐시 핸들러 또는 Redis 어댑터 연동 |
웹훅 보안 상세: 헤더의 시크릿을 단순 문자열(===)로 비교하면 두 값의 일치 여부에 따라 응답 시간이 미세하게 달라지는 타이밍 공격(timing attack)에 취약해질 수 있습니다. 공격자가 이 시간 차이를 반복 측정해 시크릿을 한 글자씩 추론하는 방식입니다. crypto.timingSafeEqual()을 사용하거나, CMS 공식 SDK(next-sanity의 parseBody 등)를 활용하면 이러한 위험을 피할 수 있습니다.
실무에서 가장 흔한 실수
- 태그 없이
'use cache'만 선언 —cacheTag()를 빠뜨리면revalidateTag()를 호출해도 아무 캐시도 무효화되지 않습니다.'use cache'와cacheTag()는 항상 함께 사용하는 것이 좋습니다. - 웹훅 시크릿 검증 생략 — 개발 단계에서 빠르게 테스트하다가 시크릿 검증 없이 배포하면, 공개된 엔드포인트가 캐시 DoS 공격의 대상이 될 수 있습니다.
- 폴백 TTL 미설정 — 태그 기반만 믿고
cacheLife()를 생략하면 웹훅 누락 시 오래된 캐시가 무기한 서빙됩니다. 최소한의 안전망 TTL은 함께 설정하는 것을 권장합니다.
마치며
cacheTag() + revalidateTag()의 조합은 시간 기반 재검증의 불확실성을 CMS 이벤트 기반의 정밀한 제어로 전환하는 가장 실용적인 접근입니다.
지금 바로 시작해볼 수 있는 3단계입니다.
- 기존 데이터 페칭 함수에
'use cache'디렉티브와cacheTag()를 추가합니다.import { cacheTag, cacheLife } from 'next/cache'로 불러온 뒤cacheTag('post', \post-${slug}`)형태로 타입 태그와 개별 ID 태그를 동시에 부착하고,cacheLife('max')`로 기본 수명을 설정합니다. app/api/revalidate/route.ts에 Route Handler를 생성하고, CMS의 웹훅 시크릿 검증 로직과revalidateTag()호출을 구현합니다. Sanity를 사용 중이라면next-sanity의parseBody, Contentful이라면 헤더 기반 시크릿 검증을 활용할 수 있습니다.- CMS 대시보드에서 웹훅을 등록하고 테스트 발송으로 캐시가 실제로 갱신되는지 확인합니다. Vercel 환경이라면 Functions 로그에서
revalidateTag호출 여부를 바로 확인할 수 있습니다.
다음 글:
cacheLife()프로파일 커스터마이징과use cache: remote/use cache: private지시어로 CDN 레이어까지 제어하는 고급 캐시 전략
참고 자료
- cacheTag API Reference | Next.js
- revalidateTag API Reference | Next.js
- updateTag API Reference | Next.js
- use cache 디렉티브 | Next.js
- How Revalidation Works | Next.js
- Caching and Revalidating | Next.js
- Tag-based Revalidation 강좌 | Sanity
- Caching and Revalidation in Next.js | Sanity Docs
- Sanity Webhooks and On-demand Revalidation in Next.js | Sanity Guide
- Sanity Webhooks and On-demand Revalidation in Next.js | Victor Eke
- revalidateTag & updateTag In Next.js | DEV Community
- Next.js 15 Caching — use cache, dynamicIO and tags in practice | uniquedevs