분산 배포에서 revalidateTag가 전파되지 않을 때 — Next.js 커스텀 캐시 핸들러로 softTags 무효화 완성하기
상품 정보를 수정했는데 일부 사용자에게만 구 데이터가 보이는 현상을 경험한 적 있으신가요? Next.js 앱을 멀티 인스턴스 환경(서버리스, 컨테이너 오토스케일링 등)에 배포할 때 흔히 마주치는 문제입니다. 인스턴스 A에서 revalidateTag('products')를 호출해도, 동시에 다른 요청을 처리하는 인스턴스 B·C는 그 사실을 전혀 알지 못합니다. 결과적으로 캐시 무효화가 일부 인스턴스에만 적용되고, 사용자마다 서로 다른 버전의 데이터를 보게 됩니다.
이 문제의 해법은 Next.js 15/16에서 새롭게 도입된 'use cache' 디렉티브 기반 캐싱 아키텍처 안에 있습니다. 'use cache'는 기존의 암묵적 fetch 캐싱을 대체하는 명시적 캐싱 방식으로, 컴포넌트나 함수 단위로 독립적인 캐시 생명주기를 선언할 수 있습니다. 이 시스템에서 분산 무효화의 핵심 열쇠가 바로 softTags입니다. pages/ 라우터나 getServerSideProps에 익숙하신 분이라면, 이 새로운 캐시 제어 방식이 훨씬 정밀하고 명시적이라는 점을 느끼실 수 있습니다.
커스텀 캐시 핸들러의 get() 메서드에서 softTags의 무효화 타임스탬프를 엔트리 생성 시각과 비교하고, updateTags() / refreshTags() 훅으로 Redis 같은 공유 저장소와 동기화하는 것이 원격 핸들러 패턴의 핵심입니다.
핵심 개념
hardTags와 softTags — 두 종류의 태그 체계
Next.js의 'use cache' 시스템에서 태그는 두 종류로 나뉩니다.
- hardTags (명시적 태그):
cacheTag('products')처럼 개발자가 코드에서 직접 선언하는 태그 - softTags (묵시적 태그): Next.js가 라우트 경로로부터 자동 생성하는 태그.
_N_T_접두사를 가집니다
예를 들어 /blog/hello 경로에 접근하면 Next.js는 다음과 같은 softTags를 자동으로 생성합니다.
_N_T_/layout
_N_T_/blog/layout
_N_T_/blog/hello/layout
_N_T_/blog/hello이 구조 덕분에 revalidatePath('/blog/hello')를 호출하면 해당 경로뿐 아니라 상위 레이아웃 경로까지 함께 무효화됩니다. 내부적으로 revalidatePath는 태그 기반 무효화 시스템과 동일한 레이어를 공유합니다.
softTags란? 개발자가 선언하지 않아도 Next.js가 라우트 경로로부터 자동 생성하는 묵시적 캐시 태그입니다.
_N_T_접두사를 가지며,revalidatePath()동작의 기반이 됩니다.
캐시 핸들러가 softTags를 처리해야 하는 이유
커스텀 캐시 핸들러의 get() 메서드는 캐시 키뿐 아니라 softTags: string[]를 두 번째 인자로 받습니다. Next.js는 이 태그들이 여전히 유효한지 판단하는 책임을 핸들러에 위임합니다. 핸들러가 이 비교 로직을 구현하지 않으면, revalidatePath나 revalidateTag를 호출해도 캐시가 실제로 무효화되지 않습니다.
아래는 커스텀 캐시 핸들러가 구현해야 하는 인터페이스입니다.
// ⚠️ updateTags, refreshTags, getExpiration은 현재(2025년 기준) Next.js 내부 API입니다.
// 공식 문서에 stable로 표기되기 전까지는 내부 훅으로 취급하고,
// Next.js 버전 업그레이드 시 changelog에서 시그니처 변경 여부를 반드시 확인하세요.
interface CacheHandler {
get(cacheKey: string, softTags: string[]): Promise<CacheEntry | undefined>;
set(cacheKey: string, value: ReadableStream, options: CacheOptions): Promise<void>;
// 분산 환경 원격 핸들러 전용 훅
getExpiration(tags: string[]): Promise<number>; // 태그 중 최신 무효화 timestamp 반환
updateTags(tags: string[], expireAt: number): Promise<void>; // revalidateTag 호출 시 실행
refreshTags(): Promise<void>; // 각 요청 전 공유 저장소와 동기화
}revalidateTag vs updateTag — 일관성 전략의 두 갈래
Next.js 15/16에서 캐시 무효화 전략은 목적에 따라 두 가지로 나뉩니다.
| 구분 | revalidateTag |
updateTag |
|---|---|---|
| 일관성 방식 | Eventual (stale-while-revalidate) | Immediate |
| 동작 | 기존 캐시를 stale 마킹 → 현재 사용자에게 구 데이터 즉시 반환, 다음 요청부터 데이터 소스를 직접 조회 | 캐시 즉시 만료 → 다음 요청은 캐시 없이 데이터 소스를 직접 조회하며 결과를 기다림 |
| 사용 가능 위치 | Route Handlers, Server Actions, API Routes | Server Actions 전용 |
| 적합 사례 | 블로그 포스트, 제품 카탈로그 | 유저 본인의 수정 즉시 반영(read-your-own-writes) |
Stale-While-Revalidate(SWR)란? 오래된(stale) 콘텐츠를 즉시 반환하고, 백그라운드에서 새 데이터를 가져와 다음 요청에 제공하는 HTTP 캐시 전략입니다. 사용자 체감 응답 속도를 희생하지 않으면서 데이터를 갱신할 수 있습니다.
실전 적용
예시 1: softTags를 처리하는 커스텀 캐시 핸들러 기본 구조
단일 인스턴스 환경에서도 softTags 무효화를 올바르게 처리하려면, get() 메서드 안에서 엔트리 생성 시각과 softTags의 최신 무효화 타임스탬프를 반드시 비교해야 합니다.
// cache-handler.ts
// ⚠️ 아래 경로는 Next.js 내부 경로 import입니다.
// 버전 업그레이드 시 경로가 변경될 수 있으므로, 공식 export 경로가 생기면 교체하세요.
import type { CacheHandler, CacheEntry } from 'next/dist/server/lib/cache-handlers/types';
// storage는 캐시 엔트리를 실제로 저장하는 저장소입니다.
// 메모리 Map, Redis, 파일시스템 등 어떤 구현체든 사용할 수 있습니다.
// 예시: const storage = new Map<string, CacheEntry>();
declare const storage: { get(key: string): Promise<CacheEntry | undefined> };
export class MyCacheHandler implements CacheHandler {
private tagTimestamps = new Map<string, number>();
async get(cacheKey: string, softTags: string[]): Promise<CacheEntry | undefined> {
const entry = await storage.get(cacheKey);
if (!entry) return undefined;
const softTagExpiration = await this.getExpiration(softTags);
// ⚠️ softTagExpiration과 entry.timestamp의 단위(밀리초 vs 초)가 반드시 일치해야 합니다.
// 단위가 다르면 항상 stale 처리되거나 절대 stale 처리되지 않는 조용한 버그가 발생합니다.
if (softTagExpiration > entry.timestamp) {
// softTag가 더 최근에 무효화됨 → 엔트리를 stale 처리
return undefined;
}
return entry;
}
async getExpiration(tags: string[]): Promise<number> {
return Math.max(0, ...tags.map(t => this.tagTimestamps.get(t) ?? 0));
}
async updateTags(tags: string[], expireAt: number): Promise<void> {
// revalidateTag() 호출 시 Next.js가 내부적으로 이 메서드를 실행합니다.
for (const tag of tags) {
this.tagTimestamps.set(tag, expireAt);
}
}
async refreshTags(): Promise<void> {
// 각 요청 시작 전 호출됩니다.
// 단일 인스턴스에서는 구현이 필요 없고, 분산 환경에서 공유 저장소와 동기화합니다.
}
}| 코드 포인트 | 설명 |
|---|---|
getExpiration(softTags) |
softTags 중 가장 최근에 무효화된 타임스탬프를 반환 |
softTagExpiration > entry.timestamp |
무효화가 엔트리 생성 이후에 일어났다면 stale 처리 |
updateTags() |
revalidateTag() 호출 시 Next.js가 자동 실행 |
| 타임스탬프 단위 | 두 값의 단위(밀리초/초)가 반드시 일치해야 정확한 비교 가능 |
예시 2: Redis 기반 원격 핸들러로 멀티 인스턴스 동기화
예시 1은 단일 인스턴스 핸들러입니다. 서버리스나 컨테이너 기반 배포에서는 인스턴스마다 tagTimestamps가 별도로 존재하기 때문에, 인스턴스 간 무효화 전파가 되지 않습니다. 예시 2는 예시 1의 refreshTags()와 updateTags()를 Redis 기반으로 교체한 원격 핸들러 버전입니다. Redis가 처음이라면 Redis 공식 문서를 먼저 참고하시면 좋습니다.
// redis-cache-handler.ts
import { createClient } from 'redis';
// Redis 클라이언트는 모듈 수준에서 초기화하고,
// 애플리케이션 시작 시 await redis.connect()를 호출해야 합니다.
// 연결 오류는 redis.on('error', handler)로 별도 처리하는 것을 권장합니다.
const redis = createClient({ url: process.env.REDIS_URL });
const TAG_PREFIX = 'tag:';
export class RedisCacheHandler implements CacheHandler {
private localTagCache = new Map<string, number>();
async updateTags(tags: string[], expireAt: number): Promise<void> {
// revalidateTag() 호출 시 Redis에 무효화 시각을 기록합니다.
// pipeline(multi)으로 묶어 네트워크 왕복을 최소화합니다.
const pipeline = redis.multi();
for (const tag of tags) {
pipeline.set(`${TAG_PREFIX}${tag}`, String(expireAt));
}
await pipeline.exec();
}
async refreshTags(): Promise<void> {
// 매 요청 전 Redis에서 전체 태그 상태를 로컬 캐시에 동기화합니다.
// ⚠️ 이 메서드가 throw하면 요청 자체가 실패하므로 반드시 try/catch 처리해야 합니다.
try {
// ⚠️ redis.keys()는 O(N) blocking 명령으로 프로덕션에서 절대 사용하면 안 됩니다.
// SCAN을 사용해 비blocking 방식으로 키를 순회해야 합니다.
const tagKeys: string[] = [];
let cursor = 0;
do {
const result = await redis.scan(cursor, {
MATCH: `${TAG_PREFIX}*`,
COUNT: 100,
});
cursor = result.cursor;
tagKeys.push(...result.keys);
} while (cursor !== 0);
for (const key of tagKeys) {
const value = await redis.get(key);
if (value) {
const tag = key.slice(TAG_PREFIX.length);
this.localTagCache.set(tag, Number(value));
}
}
} catch (err) {
console.error('refreshTags failed, using stale local state:', err);
// Redis 장애 시 마지막으로 알려진 로컬 태그 상태로 서비스를 계속 유지합니다.
}
}
async getExpiration(tags: string[]): Promise<number> {
return Math.max(0, ...tags.map(t => this.localTagCache.get(t) ?? 0));
}
}왜
redis.keys()가 아닌SCAN인가?KEYS명령은 Redis의 모든 키를 한 번에 스캔하는 O(N) blocking 명령입니다. 키가 수만 개를 넘으면 Redis 서버 전체가 해당 시간 동안 다른 명령을 처리하지 못해 장애로 이어질 수 있습니다.SCAN은 동일한 작업을 소량씩 나누어 처리하므로 대규모 환경에서도 안전합니다.
예시 3: next.config.ts 등록 및 태그 선언·무효화 코드
핸들러를 구현한 후에는 next.config.ts에 등록하고, 애플리케이션 코드에서 태그를 선언하고 무효화할 수 있습니다.
// next.config.ts
import type { NextConfig } from 'next';
const config: NextConfig = {
cacheHandlers: {
default: require.resolve('./cache-handler'), // 'use cache' 대상
remote: require.resolve('./redis-cache-handler'), // 'use cache: remote' 대상
},
};
export default config;// app/actions.ts
'use server';
import { cacheTag } from 'next/cache';
import { revalidateTag, updateTag } from 'next/cache';
// 태그 선언 — 'use cache' 디렉티브가 있는 함수 내부에서만 유효합니다.
// categoryId처럼 동적 값을 태그에 포함하면 특정 카테고리만 선택적으로 무효화할 수 있습니다.
async function getProducts(categoryId: string) {
'use cache';
cacheTag('products', `products:category:${categoryId}`);
return await db.products.findAll({ where: { categoryId } });
}
// 즉시 반영이 필요한 경우 (Server Action 전용)
export async function deleteProduct(id: string) {
await db.products.delete(id);
// 다음 요청은 캐시 없이 데이터 소스를 직접 조회합니다.
updateTag('products');
}
// 백그라운드 갱신으로 충분한 경우
export async function triggerRevalidate() {
// 현재 사용자에게는 구 데이터를 즉시 반환하고, 다음 요청부터 신규 데이터를 제공합니다.
revalidateTag('products');
}장단점 분석
장점
| 항목 | 내용 |
|---|---|
| 라우트 기반 자동 무효화 | revalidatePath()와 revalidateTag() 양쪽을 통일된 태그 시스템으로 처리할 수 있습니다 |
| 세밀한 캐시 제어 | 컴포넌트·함수 단위로 독립적인 생명주기를 관리할 수 있습니다 |
| 분산 환경 확장성 | updateTags() / refreshTags() 훅으로 멀티 인스턴스 간 무효화 전파를 구현할 수 있습니다 |
| 응답 지연 없는 갱신 | SWR 전략으로 사용자에게 즉시 응답하면서 백그라운드에서 갱신됩니다 |
단점 및 주의사항
| 항목 | 내용 | 대응 방안 |
|---|---|---|
refreshTags() 에러 전파 |
메서드가 throw하면 요청 자체가 실패합니다 | try/catch로 감싸고 Redis 장애 시 로컬 태그 상태로 graceful degradation 구현 |
| 타임스탬프 단위 불일치 | softTagExpiration과 entry.timestamp의 단위(밀리초/초)가 다르면 비교 오류 발생 |
두 값의 단위를 명시적으로 통일하고 주석으로 기록 |
| 클럭 스큐(Clock Skew) | 분산 환경에서 인스턴스마다 시스템 클럭이 달라 타임스탬프 비교 오류 가능 | Redis TIME 명령 등 단일 타임스탬프 소스를 사용 |
| Redis 조회 부하 | 매 요청마다 refreshTags()가 Redis를 조회합니다 |
로컬 메모리 캐시 + TTL 기반 polling으로 Redis 부하를 제어 |
updateTag 사용 위치 제한 |
Server Action 전용이므로 Route Handler에서는 사용할 수 없습니다 | Route Handler에서는 revalidateTag 사용 |
| 내부 API 안정성 | updateTags(), refreshTags() 등은 아직 내부 API로, 버전 업그레이드 시 변경 가능 |
업그레이드 전 공식 changelog에서 시그니처 변경 여부를 확인 |
Graceful Degradation이란? 일부 기능(예: Redis 연결)이 실패하더라도 나머지 서비스는 가능한 수준에서 계속 동작하도록 설계하는 패턴입니다.
Clock Skew란? 분산 시스템에서 서버마다 시스템 클럭이 미세하게 다를 때 발생하는 시간 오차입니다. 타임스탬프 기반 비교 로직에서 인스턴스 간 시간 차이가 캐시 무효화 판단을 잘못되게 만들 수 있습니다.
실무에서 가장 흔한 실수
-
refreshTags()에서 예외를 catch하지 않는 경우 — Redis 장애 시 throw가 그대로 전파되면 전체 요청이 실패합니다. try/catch와 fallback 로직은 선택이 아닌 필수입니다. -
redis.keys()를 프로덕션 코드에 그대로 사용하는 경우 —KEYS명령은 Redis 서버를 blocking시키는 O(N) 명령입니다. 태그 수가 수천 개 이상이 되면 Redis 전체가 멈출 수 있으므로, 반드시SCAN기반으로 대체해야 합니다. -
updateTag()를 Route Handler나 API Routes에서 사용하려다 런타임 오류를 경험하는 경우 —updateTag는 Server Action 전용 API입니다. Route Handler에서 즉시 무효화가 필요한 경우에도revalidateTag를 사용해야 합니다.
마치며
멀티 인스턴스 환경에서 revalidateTag가 모든 인스턴스에 전파되지 않는다면, 원인은 대부분 softTags 무효화 로직이 빠져 있거나 공유 저장소와 동기화되지 않은 것입니다. 커스텀 캐시 핸들러의 get() 메서드에서 softTags의 무효화 타임스탬프를 엔트리 생성 시각과 비교하고, updateTags() / refreshTags() 훅을 통해 Redis 같은 공유 저장소와 동기화하면, 분산 배포 환경 전체에서 일관된 캐시 무효화를 실현할 수 있습니다. 이 패턴을 적용하면 "수정했는데 일부 사용자에게는 구 데이터가 보인다"는 증상이 사라지고, 트래픽이 늘어나도 인스턴스를 자유롭게 확장할 수 있는 상태가 됩니다.
지금 바로 시작해볼 수 있는 3단계:
-
캐시 핸들러 파일을 생성해볼 수 있습니다.
cache-handler.ts를 만들고get()메서드 안에softTags비교 로직을 작성한 뒤,next.config.ts의cacheHandlers.default에 등록하는 것으로 시작할 수 있습니다. -
revalidateTag와updateTag중 어떤 전략이 맞는지 먼저 판단해보시면 좋습니다. 사용자가 본인의 수정 결과를 즉시 봐야 한다면updateTag, 전체 사용자에게 결과적으로 전파되면 충분하다면revalidateTag가 적합합니다. -
분산 배포 환경이라면 Redis 기반 원격 핸들러로 확장해볼 수 있습니다.
refreshTags()안에 반드시 try/catch를 추가하고, Redis를 의도적으로 내려보며 로컬 태그 상태로 서비스가 유지되는지 직접 검증해보시기를 권장합니다.
다음 글:
cacheLife()의stale / revalidate / expire세 값을 시나리오별로 튜닝하는 방법과, 트래픽이 없는 구간 이후 첫 요청의 blocking 갱신을 최소화하는 워밍업 전략을 다룰 예정입니다.
참고 자료
필독
- revalidateTag | Next.js 공식 문서
- cacheHandlers 설정 | Next.js 공식 문서
- Revalidation 동작 방식 | Next.js 공식 문서
- Scaling Next.js with Redis cache handler | DEV Community
추가 참고
- cacheTag | Next.js 공식 문서
- updateTag | Next.js 공식 문서
- use cache 디렉티브 | Next.js 공식 문서
- use cache: remote 디렉티브 | Next.js 공식 문서
- updateTag vs revalidateTag | GitHub Discussion
- Cache Revalidation Options | GitHub Discussion
- revalidateTag & updateTag In NextJs | DEV Community
- Next.js revalidateTag vs updateTag: Cache Strategy Guide | Build with Matija
- @neshca/cache-handler 공식 문서
- fortedigital/nextjs-cache-handler | GitHub
- Next.js 16: use cache with Watt v3.18 | Platformatic Blog