환경: Next.js 16.1.4,
@redis/clientv5.10, Node.js 22+, tsup 8
들어가며
Next.js는 캐시 저장소를 커스터마이징할 수 있는 두 가지 인터페이스를 제공합니다. cacheHandler(단수)는 ISR과 Route Handler 응답을 저장하는 클래스 기반 핸들러이고, cacheHandlers(복수)는 "use cache" 디렉티브의 캐시를 관리하는 객체 기반 핸들러입니다. 기본값은 둘 다 인메모리 LRU이므로, 멀티 인스턴스 환경에서는 Pod 간 캐시가 공유되지 않고 재시작 시 유실됩니다.
홈 k3s 클러스터 구축기 5편에서 @fortedigital/nextjs-cache-handler로 cacheHandler(단수)를 Redis에 연동하여 ISR과 fetch 캐시를 해결했지만, cacheHandlers(복수) 지원은 아직 미구현 상태였습니다.
이 글은 기존 레거시 핸들러 구조를 기반으로, "use cache" 핸들러와 빌드 키 정리 기능을 추가하여 @mirunamu/next-redis-cache라는 독립 패키지로 배포한 과정을 다룹니다. 코드 레벨에서 "왜 이렇게 구현했는가"를 중심으로 서술합니다.
설계 방향
기반 구조
@fortedigital/nextjs-cache-handler의 Redis 구현에서 검증된 핵심 패턴들을 추출했습니다. 플러그인 아키텍처(Redis, LRU, Composite)는 걷어내고 Redis 단일 백엔드에 집중한 구조입니다.
| 패턴 | 설명 |
|---|---|
onCreation 훅 + 지연 초기화 | 모듈 로드 시점에 훅을 등록하고, 첫 get/set 호출 시 실제 Redis 연결 |
LifespanParameters 모델 | staleAt / expireAt 이중 만료 체계, estimateExpireAge 함수 |
| Redis Hash 3개 태그 시스템 | sharedTags, sharedTagsTtl, revalidatedTags |
| Buffer ↔ base64 직렬화 | APP_PAGE의 rscData, segmentData, APP_ROUTE의 body 변환 |
registerInitialCache | 빌드 출력물을 Redis에 프리로딩 |
새로 추가한 것
이 라이브러리의 차별점은 두 가지입니다.
| 기능 | 설명 |
|---|---|
createUseCacheHandler | cacheHandlers(복수) 인터페이스 구현. ReadableStream 직렬화, pendingSets 동시성 제어 |
cleanupOldBuildKeys | SCAN 기반 이전 빌드 키 정리. TTL 만료에만 의존하지 않고 능동적으로 삭제 |
이 두 기능을 중심으로 코드를 살펴보겠습니다.
레거시 핸들러: LegacyCacheHandler
onCreation과 지연 초기화
공식 문서에 따르면, cacheHandler는 ISR과 Route Handler 응답의 저장·조회·무효화를 담당하며 get, set, revalidateTag, resetRequestCache 메서드를 구현해야 합니다. Next.js는 이 모듈을 서버 시작 시 import하는데, 이 시점에 Redis 연결 정보가 준비되지 않았을 수 있고 빌드 타임에는 Redis 자체가 필요 없습니다. 이 문제를 해결하기 위해 모듈 로드 시점과 실제 초기화 시점을 분리하는 패턴을 사용합니다.
// cache-handler.mjs (사용자 코드)
import { LegacyCacheHandler } from "@mirunamu/next-redis-cache";
const buildId = process.env.BUILD_ID || "default";
LegacyCacheHandler.onCreation(async () => {
if (process.env.NEXT_PHASE === "phase-production-build" || !process.env.REDIS_URL) {
return null; // 빌드 타임이거나 Redis URL이 없으면 null → Redis 없이 동작
}
const { createClient } = await import("@redis/client");
const client = createClient({ url: process.env.REDIS_URL });
await client.connect();
return { client, keyPrefix: `myapp:${buildId}:` };
});
export default LegacyCacheHandler;onCreation()은 훅을 등록만 합니다. 실제 실행은 첫 번째 get() 또는 set() 호출 시점까지 미뤄집니다.
// legacy-handler.ts
static async #ensureConfigured(): Promise<void> {
if (LegacyCacheHandler.#configured) return;
if (!LegacyCacheHandler.#configTask) {
LegacyCacheHandler.#configTask = (async () => {
try {
await LegacyCacheHandler.#init();
} finally {
LegacyCacheHandler.#configTask = undefined;
}
})();
}
await LegacyCacheHandler.#configTask;
}#configTask를 Promise로 공유하는 이유는 동시 초기화 방지 때문입니다. 서버 시작 직후 여러 요청이 동시에 들어오면 get()이 병렬로 호출될 수 있는데, #configTask가 없으면 #init()이 여러 번 실행되어 Redis 연결이 중복으로 생성됩니다. Promise를 공유하면 첫 번째 호출이 초기화를 완료할 때까지 나머지 호출이 같은 Promise를 await합니다.
get() — 캐시 조회의 4단계 검증
get()은 단순히 Redis에서 값을 가져오는 것이 아닙니다. 값을 가져온 후 4단계 검증을 거쳐 유효한 캐시인지 판단합니다.
async get(cacheKey: string, _ctx?: Record<string, unknown>): Promise<CacheHandlerValue | null> {
await LegacyCacheHandler.#ensureConfigured();
const client = LegacyCacheHandler.#client;
const tm = LegacyCacheHandler.#tagManager;
if (!client || !tm) return null;
// 1단계: Redis에서 값 조회
const raw = await withTimeout(
client.get(LegacyCacheHandler.#keyPrefix + cacheKey),
LegacyCacheHandler.#timeoutMs
);
if (!raw) return null;
const stored: StoredCacheValue = JSON.parse(raw);
if (stored.value) convertStringsToBuffers(stored.value); // base64 → Buffer 복원
// 2단계: 태그 항목 존재 확인
const hasEntry = await tm.hasTagEntry(cacheKey);
if (!hasEntry) {
await withTimeout(client.unlink(LegacyCacheHandler.#keyPrefix + cacheKey), LegacyCacheHandler.#timeoutMs);
return null; // orphan 키 — 태그 정보가 없으면 무효
}
// 3단계: lifespan 만료 확인
if (stored.lifespan && stored.lifespan.expireAt < Math.floor(Date.now() / 1000)) {
return null;
}
// 4단계: 태그 revalidation 확인
const softTags = (_ctx as Record<string, unknown>)?.softTags as string[] | undefined;
const combinedTags = [...(stored.tags ?? []), ...(softTags ?? [])];
if (await tm.isStale(combinedTags, stored.lastModified)) {
await withTimeout(client.unlink(LegacyCacheHandler.#keyPrefix + cacheKey), LegacyCacheHandler.#timeoutMs);
return null;
}
return stored; // 모든 검증 통과 → 캐시 히트
}2단계의 orphan 체크가 왜 필요한지 의문이 들 수 있습니다. Redis 키 자체는 EX(TTL)로 자동 만료되지만, revalidateTag()가 호출되면 캐시 키와 함께 sharedTags Hash의 항목도 삭제됩니다. 만약 타이밍 문제로 캐시 키는 남아있는데 태그 항목은 이미 삭제된 상태라면, 이 캐시를 서빙하면 안 됩니다. 태그 항목이 없다는 것은 이 캐시가 이미 무효화 대상이었다는 의미이기 때문입니다.
4단계에서 softTags는 Next.js가 get() 호출 시 전달하는 implicit tag 목록입니다. revalidatePath("/blog")를 호출하면 _N_T_/blog라는 implicit tag의 revalidation 타임스탬프가 기록되고, 이후 get()에서 이 타임스탬프와 캐시의 lastModified를 비교하여 stale 여부를 판단합니다.
set() — 값 저장과 태그 등록
async set(cacheKey: string, data: unknown, ctx?: Record<string, unknown>): Promise<void> {
// 태그 추출: ctx.tags가 있으면 사용, 없으면 headers에서 추출
const tags: string[] =
(ctx?.tags as string[]) ??
getTagsFromHeaders((data as Record<string, unknown>)?.headers);
// revalidate 값 결정
const revalidate = resolveRevalidate(data, ctx ?? {});
const lastModified = Math.round((ctx?.internal_lastModified as number) ?? Date.now());
const lifespan = getLifespan(lastModified, revalidate, ...);
// 이미 만료된 값은 저장하지 않음
if (Date.now() > lifespan.expireAt * 1000) return;
// Buffer → base64 변환 (Redis는 문자열만 저장 가능)
const valueForStorage = data ? { ...(data as object) } : null;
if (valueForStorage) parseBuffersToStrings(valueForStorage);
// Redis 저장 + 태그 등록 + TTL 등록을 병렬 실행
await Promise.all([
client.set(fullKey, serialized, { EX: ttlSeconds }),
tm.setTags(cacheKey, tags),
tm.setTtl(cacheKey, lifespan.expireAt),
]);
}resolveRevalidate()는 Next.js가 캐시 값의 종류에 따라 revalidate 정보를 다른 위치에 넣기 때문에 필요합니다.
| kind | revalidate 위치 |
|---|---|
FETCH | value.revalidate |
APP_PAGE, PAGES | ctx.cacheControl.revalidate |
| 기타 | ctx.revalidate |
세 곳을 순서대로 확인하여 첫 번째로 발견된 값을 사용합니다.
태그 시스템: TagManager
태그 기반 무효화는 이 라이브러리에서 가장 복잡한 부분입니다. Redis Hash 3개가 서로 다른 역할을 수행합니다.
| Hash | 키 | 값 | 용도 |
|---|---|---|---|
sharedTagsKey | 캐시 키 | JSON(tags[]) | revalidateTag() 시 삭제 대상을 찾는 역방향 인덱스 |
sharedTagsTtlKey | 캐시 키 | expireAt (Unix 초) | Redis 키 만료 후 남은 태그 항목을 정리하는 용도 |
revalidatedTagsKey | 태그 이름 | Date.now() (밀리초) | 태그의 revalidation 시각. get() 시 staleness 판단 기준 |
revalidateTag의 이중 전략
revalidateTag()는 태그 종류에 따라 다른 전략을 사용합니다.
async revalidateTag(tag: string): Promise<void> {
// implicit tag만 타임스탬프 기록
if (isImplicitTag(tag)) {
await this.client.hSet(this.revalidatedTagsKey, tag, Date.now().toString());
}
// 모든 태그에 대해: sharedTags를 스캔하여 해당 태그를 포함한 캐시 키를 찾아 삭제
const keysToDelete: string[] = [];
const fieldsToDelete: string[] = [];
let cursor = "0";
do {
const result = await this.client.hScan(this.sharedTagsKey, cursor, { COUNT: 10000 });
for (const { field, value } of result.entries) {
const tags = JSON.parse(value) as string[];
if (tags.includes(tag)) {
keysToDelete.push(this.keyPrefix + field);
fieldsToDelete.push(field);
}
}
cursor = result.cursor;
} while (cursor !== "0");
// 캐시 키 + 태그 항목 + TTL 항목을 일괄 삭제
await Promise.all([
this.client.unlink(keysToDelete),
this.client.hDel(this.sharedTagsKey, fieldsToDelete),
this.client.hDel(this.sharedTagsTtlKey, fieldsToDelete),
]);
}implicit tag(_N_T_ 접두사)에만 타임스탬프를 기록하는 이유가 있습니다. revalidatePath("/blog")가 호출되면 Next.js는 _N_T_/blog라는 implicit tag로 revalidateTag()를 호출합니다. 그런데 implicit tag는 빌드 타임에 .meta 파일에 기록된 태그이므로, registerInitialCache()로 프리워밍된 캐시에도 포함되어 있습니다. 이런 캐시는 sharedTags Hash에 등록되어 있으므로 스캔+삭제로 충분히 무효화할 수 있지만, 타임스탬프를 추가로 기록해두면 get() 시점에서도 이중으로 staleness를 체크할 수 있어 더 안전합니다.
반면 explicit tag("product" 같은 사용자 정의 태그)는 스캔+삭제만으로 충분합니다. sharedTags Hash에서 해당 태그를 포함한 모든 캐시 키를 찾아 즉시 삭제하므로, 별도의 타임스탬프 기록이 필요 없습니다.
레거시 핸들러와 use-cache 핸들러 간 태그 무효화 비대칭
이 설계에는 의도적인 비대칭이 있습니다. 레거시 핸들러의 revalidateTag()는 explicit tag에 대해 타임스탬프를 기록하지 않습니다. 반면 use-cache 핸들러의 get()은 revalidatedTagsKey의 타임스탬프로 staleness를 판단합니다.
이것이 문제가 되지 않는 이유는, Next.js가 revalidateTag()를 호출할 때 두 핸들러 모두에 디스패치하기 때문입니다. 레거시 쪽에서는 revalidateTag()가, use-cache 쪽에서는 updateTags()가 각각 호출되며, updateTags()는 모든 태그에 대해 타임스탬프를 기록합니다.
sharedTagsTtlKey가 존재하는 이유
Redis 키에 EX(TTL)를 설정하면 만료 시 자동으로 삭제됩니다. 그런데 sharedTagsKey Hash 안의 항목은 Redis 키와 별개의 데이터이므로, 캐시 키가 만료되어도 해당 Hash 항목은 남아있게 됩니다. 시간이 지나면 이미 존재하지 않는 캐시 키에 대한 태그 정보가 sharedTagsKey에 계속 쌓입니다.
sharedTagsTtlKey는 이 찌꺼기를 정리하기 위한 메타데이터입니다. 각 캐시 키의 expireAt 타임스탬프를 기록해두면, cleanupExpired() 메서드가 만료된 항목을 찾아서 sharedTagsKey와 sharedTagsTtlKey 양쪽에서 일괄 삭제할 수 있습니다.
Buffer ↔ base64 직렬화
Next.js는 캐시 값의 종류에 따라 Buffer를 포함한 객체를 전달합니다. Redis는 문자열만 저장할 수 있으므로, JSON 직렬화 전에 Buffer를 base64 문자열로 변환하고 조회 시 복원해야 합니다.
// buffer-utils.ts
export function parseBuffersToStrings(value: any): void {
if (value.kind === "APP_ROUTE") {
// Route Handler의 응답 body
if (Buffer.isBuffer(value.body)) {
value.body = value.body.toString("base64");
}
} else if (value.kind === "APP_PAGE") {
// App Page의 RSC payload
if (Buffer.isBuffer(value.rscData)) {
value.rscData = value.rscData.toString("base64");
}
// Segment별 RSC 데이터 (Map<string, Buffer> → Record<string, string>)
if (value.segmentData instanceof Map) {
const entries = value.segmentData as Map<string, Buffer>;
value.segmentData = Object.fromEntries(
[...entries.entries()].map(([key, val]) => [key, val.toString("base64")])
);
}
}
}| kind | Buffer 필드 | 설명 |
|---|---|---|
APP_ROUTE | body | Route Handler(app/api/*/route.ts)의 응답 본문 |
APP_PAGE | rscData | React Server Component의 직렬화된 payload |
APP_PAGE | segmentData | 레이아웃/페이지 세그먼트별 RSC 데이터. Map<string, Buffer> 형태 |
segmentData가 Map인 이유는 Next.js가 세그먼트별로 독립적인 RSC payload를 생성하기 때문입니다. /docs/[slug] 경로라면 루트 레이아웃, docs 레이아웃, slug 페이지 각각에 대한 RSC 데이터가 별도로 존재합니다. Map은 JSON으로 직렬화할 수 없으므로, Object.fromEntries()로 일반 객체로 변환한 뒤 base64 인코딩합니다. 복원 시에는 역순으로 new Map(Object.entries(...))를 사용합니다.
use-cache 핸들러: createUseCacheHandler
레거시와의 인터페이스 차이
Next.js는 "use cache" 디렉티브를 위해 레거시와 완전히 다른 CacheHandler 인터페이스를 정의합니다. 두 인터페이스는 근본적으로 다른 설계 사상을 가지고 있습니다.
| 항목 | cacheHandler (레거시) | cacheHandlers (use-cache) |
|---|---|---|
| 형태 | 클래스 (Next.js가 인스턴스 생성) | 일반 객체 |
| 값 타입 | JSON 직렬화 가능한 객체 + Buffer | ReadableStream<Uint8Array> |
set() 인자 | (key, data, ctx) | (key, Promise<CacheEntry>) |
| 태그 무효화 | revalidateTag(tag) | updateTags(tags, durations?) |
| 태그 조회 | 없음 (내부적으로 처리) | getExpiration(tags) |
공식 문서에서 명시하듯, set()의 두 번째 인자가 Promise라는 점이 가장 큰 차이입니다. "The entry may still be pending when this is called" — Next.js가 캐시 엔트리가 아직 준비되지 않은 상태에서 set()을 호출하므로, 핸들러는 이 Promise를 await한 뒤 값을 저장해야 합니다.
ReadableStream 직렬화
레거시 핸들러는 Buffer를 base64로 변환했지만, use-cache 핸들러의 CacheEntry.value는 ReadableStream<Uint8Array>입니다. 공식 문서도 "Use .tee() if you need to read and store the stream data"라고 명시하고 있는데, ReadableStream은 한 번만 소비할 수 있으므로 저장용 복사본과 원본 보존을 동시에 해야 하기 때문입니다.
// use-cache-handler.ts — set()
const entry = await pendingEntry;
// stream을 두 갈래로 분기: 하나는 저장용, 하나는 원본 보존
const [forStorage, preserved] = entry.value.tee();
entry.value = preserved; // Next.js가 이후에도 이 stream을 소비할 수 있도록 원본 교체
// 저장용 stream을 Buffer로 변환 → base64 인코딩
const buffer = await streamToBuffer(forStorage);
const data = buffer.toString("base64");entry.value.tee()가 핵심입니다. ReadableStream.tee()는 하나의 스트림을 두 개의 독립적인 스트림으로 복제합니다. forStorage는 Redis 저장을 위해 소비하고, preserved는 entry.value에 다시 할당하여 Next.js가 이후에 사용할 수 있도록 보존합니다. 만약 tee() 없이 스트림을 직접 소비하면, Next.js가 같은 스트림을 다시 읽으려 할 때 에러가 발생합니다.
복원 시에는 base64 → Buffer → ReadableStream으로 역변환합니다.
// stream-utils.ts
export function bufferToStream(buffer: Buffer): ReadableStream<Uint8Array> {
return new ReadableStream<Uint8Array>({
start(controller) {
controller.enqueue(new Uint8Array(buffer));
controller.close();
},
});
}pendingSets — 동시 요청 처리
use-cache 핸들러에서 해결해야 하는 경쟁 조건이 있습니다. 같은 캐시 키에 대해 여러 요청이 동시에 들어왔을 때, 모든 요청이 캐시 미스를 경험하고 각각 독립적으로 값을 계산하는 thundering herd 문제입니다. Next.js 공식 문서의 기본 인메모리 핸들러 예제에서도 이 패턴을 사용하고 있으며, 이 라이브러리는 이를 Redis 환경에 맞게 구현합니다.
const pendingSets = new Map<string, Promise<void>>();
const handler: CacheHandler = {
async get(cacheKey: string, softTags: string[]) {
// 같은 키에 대한 set()이 진행 중이면 완료를 기다림
const pending = pendingSets.get(cacheKey);
if (pending) {
await pending;
}
// set() 완료 후 Redis에서 읽기
const raw = await exec(client.get(keyPrefix + cacheKey));
// ...
},
async set(cacheKey: string, pendingEntry: Promise<CacheEntry>) {
// set 시작을 pendingSets에 등록
let resolvePending: () => void = () => {};
const pendingPromise = new Promise<void>((resolve) => {
resolvePending = resolve;
});
pendingSets.set(cacheKey, pendingPromise);
try {
const entry = await pendingEntry;
// ... Redis에 저장
} finally {
resolvePending(); // 대기 중인 get()을 깨움
pendingSets.delete(cacheKey);
}
},
};흐름을 정리하면 이렇습니다.
- 요청 A:
get("key")→ 캐시 미스 → Next.js가 값을 계산하면서set("key", promise)호출 - 요청 B:
get("key")→pendingSets에 "key"가 있으므로 대기 - 요청 A의
set()이 완료 →resolvePending()호출 → 요청 B의await pending이 해제 - 요청 B: Redis에서 값을 읽어 반환
이 패턴 덕분에 요청 B는 값을 중복 계산하지 않고, 요청 A가 저장한 캐시를 즉시 활용할 수 있습니다.
refreshTags — Redis에서는 no-op
async refreshTags(): Promise<void> {
// Redis는 분산 저장소이므로 모든 인스턴스가 동일한 Hash를 읽습니다.
// 별도의 동기화가 필요 없어 no-op으로 처리합니다.
}Next.js는 get() 호출 전에 refreshTags()를 호출하여 태그 정보를 최신 상태로 갱신할 기회를 줍니다. 공식 문서에서도 "For in-memory caches, this can be a no-op. For distributed caches, use this to sync tag state from an external service"라고 안내하고 있습니다. Redis는 모든 Pod이 같은 Hash를 읽으므로 별도 동기화 없이 no-op으로 충분합니다.
Instrumentation 헬퍼
registerInitialCache — 빌드 출력물 프리로딩
이 함수는 사용자가 instrumentation.ts에서 직접 호출해야 합니다. 패키지가 자동으로 실행하는 것이 아닙니다.
// 사용자의 instrumentation.ts
export async function register() {
if (process.env.NEXT_RUNTIME === "nodejs") {
const { registerInitialCache } = await import(
"@mirunamu/next-redis-cache/instrumentation"
);
const CacheHandler = (await import("../cache-handler.mjs")).default;
await registerInitialCache(CacheHandler, { setOnlyIfNotExists: true });
}
}내부 동작은 .next/prerender-manifest.json을 파싱하여 빌드 타임에 생성된 정적 페이지를 순회하고, 각 라우트의 디스크 파일을 읽어 CacheHandler.set()을 호출합니다.
// instrumentation.ts (라이브러리 내부)
const manifest: PrerenderManifest = JSON.parse(
await fs.readFile(manifestPath, "utf-8")
);
for (const [route, routeInfo] of Object.entries(manifest.routes)) {
if (route.startsWith("/_")) continue; // 내부 라우트 스킵
if (!routeInfo.dataRoute) continue; // App Route(dataRoute=null)는 프리워밍 대상에서 제외
const value = await readRouteFromDisk(appDir, route, routeInfo);
if (!value) continue;
await handler.set(route, value, {
revalidate: routeInfo.initialRevalidateSeconds,
setOnlyIfNotExists: true, // Redis NX 플래그 — 기존 값 덮어쓰기 방지
});
}setOnlyIfNotExists: true가 중요합니다. Pod이 재시작되어 register()가 다시 호출될 때, 런타임에 ISR로 갱신된 더 최신 캐시를 빌드타임 데이터로 덮어쓰면 안 되기 때문입니다.
프리워밍 시 Redis에 등록되는 태그는 .meta 파일의 x-next-cache-tags 헤더에서 추출됩니다. 이 헤더에는 implicit tag만 포함되어 있습니다.
// .next/server/app/docs/architecture/feature-sliced-design.meta
{
"headers": {
"x-next-cache-tags": "_N_T_/layout,_N_T_/docs/layout,_N_T_/docs/[...slug]/layout,_N_T_/docs/[...slug]/page,_N_T_/docs/architecture/feature-sliced-design"
}
}빌드 타임에는 fetch(..., { tags: ["product"] }) 같은 런타임 태그가 존재하지 않으므로, .meta 파일에는 Next.js가 경로 기반으로 자동 생성한 implicit tag만 기록됩니다. 런타임에 fetch()가 호출되면 그때 explicit tag가 포함된 캐시 엔트리가 Redis에 저장됩니다.
cleanupOldBuildKeys — 이전 빌드 키 정리
Redis 키의 TTL 만료에만 의존하면 한 가지 문제가 생깁니다. revalidate가 false인 정적 페이지는 TTL이 1.5년으로 설정되므로, 이전 빌드의 키가 오랫동안 Redis에 남아있게 됩니다.
cleanupOldBuildKeys()는 SCAN을 사용하여 이전 빌드의 키를 능동적으로 삭제합니다.
export async function cleanupOldBuildKeys(options: CleanupOptions): Promise<{ deleted: number }> {
const { redisUrl, patterns } = options;
const client = createClient({ url: redisUrl });
await client.connect();
const allOldKeys: string[] = [];
for (const { scan, keepExact, keepPrefix } of patterns) {
for await (const key of client.scanIterator({ MATCH: scan, COUNT: 200 })) {
const k = String(key);
if (keepExact && k === keepExact) continue; // 정확히 일치하는 키 보존
if (keepPrefix && k.startsWith(keepPrefix)) continue; // 현재 빌드 키 보존
allOldKeys.push(k);
}
}
if (allOldKeys.length > 0) {
await client.del(allOldKeys);
}
await client.disconnect();
return { deleted: allOldKeys.length };
}SCAN을 KEYS 대신 사용한 이유는 프로덕션 안전성 때문입니다. KEYS는 모든 키를 한 번에 반환하므로 키가 수만 개일 경우 Redis를 블로킹합니다. SCAN은 커서 기반으로 COUNT 200개씩 나눠서 순회하므로 Redis의 이벤트 루프를 차단하지 않습니다.
이전 글의 KEYS 사용에 대해
이전 글에서는 instrumentation.ts에 client.keys("docs:*")를 직접 사용했습니다. 해당 프로젝트의 Redis 키가 수십 개 수준이라 블로킹 우려가 없었기 때문인데, 라이브러리로 범용화하면서 키 수를 예측할 수 없는 환경을 고려하여 SCAN으로 전환했습니다.
keyPrefix에 빌드 ID를 포함시키는 전략 덕분에 cleanup 패턴이 단순해집니다. 캐시 데이터, 태그 Hash, TTL Hash, revalidation Hash가 모두 같은 접두어를 공유하므로, { scan: "myapp:*", keepPrefix: "myapp:<현재빌드ID>:" } 하나로 이전 빌드의 모든 키를 정리할 수 있습니다.
빌드와 배포
tsup 설정
패키지는 세 개의 엔트리 포인트를 제공합니다. tsup으로 ESM과 CJS를 동시에 빌드합니다.
// tsup.config.ts
export default defineConfig({
entry: {
index: "src/index.ts", // LegacyCacheHandler
"use-cache": "src/use-cache.ts", // createUseCacheHandler
instrumentation: "src/instrumentation.ts", // registerInitialCache, cleanupOldBuildKeys
},
format: ["esm", "cjs"],
dts: true,
splitting: true,
clean: true,
target: "node18",
external: ["next", "@redis/client"],
});package.json의 "type": "module" 설정 때문에 ESM 파일은 .js, CJS 파일은 .cjs 확장자를 갖습니다. exports 맵에서 types 필드를 import/require보다 앞에 배치해야 TypeScript가 타입을 올바르게 해석합니다.
{
"exports": {
".": {
"types": "./dist/index.d.ts",
"import": "./dist/index.js",
"require": "./dist/index.cjs"
}
}
}Changeset으로 버전 관리
@changesets/cli를 사용하여 시맨틱 버저닝과 CHANGELOG 생성을 자동화합니다.
npx changeset # 변경 사항 기록
npx changeset version # 버전 범프 + CHANGELOG 생성
npm run release # 빌드 + npm publish정리
핵심 요약
- 레거시 핸들러(
cacheHandler)는 onCreation 훅으로 지연 초기화하며, 4단계 검증(get)과 Redis Hash 3개 태그 시스템으로 캐시 일관성을 보장합니다 - use-cache 핸들러(
cacheHandlers)는ReadableStream.tee()를 사용한 스트림 직렬화,pendingSetsMap을 활용한 동시 요청 처리, 타임스탬프 기반 lazy invalidation으로"use cache"디렉티브를 Redis에서 지원합니다 - cleanupOldBuildKeys는 SCAN 기반 능동적 키 정리로, TTL 만료에만 의존하지 않고 이전 빌드의 잔여 키를 즉시 제거합니다
- 두 핸들러는
revalidatedTagsKeyHash를 공유하여revalidateTag()호출 시 양쪽 캐시가 모두 무효화됩니다
참고 자료
- @mirunamu/next-redis-cache npm
- Next.js
cacheHandler공식 문서 — 레거시 ISR/Route Handler 캐시 인터페이스 - Next.js
cacheHandlers공식 문서 —"use cache"디렉티브용 캐시 인터페이스 - Next.js
"use cache"디렉티브 - Next.js Self-Hosting 가이드 — Caching and ISR
- Next.js 기본 캐시 핸들러 구현체 —
cacheHandlers레퍼런스 구현 - @fortedigital/nextjs-cache-handler GitHub — 레거시 핸들러 기반 구현 참고
- @redis/client 공식 문서