next-redis-cache 2.0 — 버그를 테스트로 먼저 재현하고 다시 설계하기
테스트가 없던 Next.js Redis 캐시 핸들러를 재현 우선 테스트로 감사하고, 드러난 결함을 1.1.0 핫픽스와 2.0 재설계로 고친 과정
읽는 데 24분
- #nextjs
- #redis
- #cache-handler
- #use-cache
- #testing
- #chaos-engineering
- #mutation-testing
- #npm
이 문서의 목차
환경:
@mirunamu/next-redis-cache2.0.0, Next.js 16.1·16.3, Redis 7.2·8.4,@redis/client5·6, Node.js 20.9+
Next.js Redis 캐시 핸들러 직접 만들기에서 만든 @mirunamu/next-redis-cache는 1.0.6까지 이 사이트의 캐시를 맡았습니다. 그런데 패키지에는 테스트가 하나도 없었습니다. 초기 테스트 앱은 1.0.3에서 지웠고, 그 뒤로 패키지 레포에는 자동화된 검증이 없었습니다. 한편 npm 다운로드는 한 달에 약 4,400회(2026-08-29부터 09-27까지)로, 이 사이트의 CI만으로는 설명되지 않는 수치였습니다. 외부 사용자가 있을 가능성이 높은 패키지를 검증 없이 두고 있었던 셈입니다.
계기는 운영 사고였습니다. Redis 연결이 잠깐 끊기거나 Redis가 비어 있으면, dynamicParams = false로 만든 이 사이트의 문서 페이지가 404를 냈습니다. 앱 쪽에서 빌드 산출물 폴백, 연결 대기 제한, 옛 빌드 정리 래퍼를 덧대 막았지만, 같은 문제는 패키지를 쓰는 모든 앱에 그대로 남아 있었습니다.
그래서 패키지 전체를 감사하기로 했고, 원칙을 하나 세웠습니다. 어떤 결함도 먼저 실패하는 테스트로 재현한 뒤에 고친다. 이 글은 그 원칙으로 레포 안에 테스트 환경을 만들고, 1.0.6에서 13건의 결함을 재현하고, 1.1.0 핫픽스와 2.0.0 재설계로 고친 과정을 다룹니다.
- 1.0.6감사 대상테스트 없음
- 1.1.0핫픽스키 구조 유지, 1.0.x와 공존
- 2.0.0-next.0프리릴리스운영 검증에서 2건 추가 발견
- 2.0.0정식 릴리스현재 운영 버전
재현 없이 고친 버그는 "고쳤다"는 주장만 남습니다. 특히 캐시 핸들러의 결함은 Redis가 느려지거나, 롤링 업데이트 중이거나, 렌더링 도중 무효화가 들어오는 순간에만 드러나므로, 눈으로 확인하고 넘어가면 같은 결함이 다음 리팩터링에서 조용히 돌아옵니다.
이 패키지는 재현 테스트를 기대 실패(expected failure) 로 등록하는 규칙을 썼습니다. 재현 테스트는 올바른 동작을 단언하고, 아직 고치지 않았으므로 실패하는 것이 정상입니다. 결함을 고치면 테스트가 통과하고, 러너는 "실패해야 할 테스트가 통과했다"며 오히려 실패합니다. 따라서 수정 커밋은 반드시 기대 실패 표식을 함께 지워야 CI가 녹색이 됩니다.
| 계층 | 기대 실패 표식 | 고친 뒤 |
|---|---|---|
| Vitest | itRepro("7-x", ...) (내부적으로 it.fails) | 일반 it("[7-x] ...")로 교체 |
| Playwright | 첫 줄의 repro("7-x", "사유") (test.fail) | 해당 줄 삭제 |
| 타입 계약(tsc) | // @ts-expect-error [7-x] | "Unused @ts-expect-error"로 실패하므로 삭제 |
NRC_REPRO=show를 주면 세 종류 모두 일반 테스트로 돌아가 실제 실패 메시지를 출력하므로, 재현의 증거를 언제든 다시 볼 수 있습니다. 이 규칙 덕분에 결함 목록과 테스트가 어긋날 수 없습니다. 1.0.6 기준으로 7-1부터 7-13까지 전부, 그리고 "빈 Redis에서 프리렌더 페이지 404"(A2)가 이 방식으로 재현되었습니다. 기대 실패는 Vitest 50건, 카오스 10건, e2e 5건, 타입 계약 2건이었습니다.
재현하려면 결함이 드러나는 환경부터 있어야 합니다. 테스트 환경은 전부 패키지 레포 안에 커밋되어 있고, 계층마다 확인하는 대상이 다릅니다.
| 계층 | 도구 | 확인하는 것 |
|---|---|---|
| unit / property | Vitest, fast-check, 가짜 시계 | 키, 저장 형식, TTL, 태그 판정, 서킷 브레이커, 로거 |
| integration | testcontainers(Redis 7.2, 8.4) | 명령 의미(NX, TTL 범위), 축출, 정리, 1만 키 배치 |
| fault | 미니 Redis, toxiproxy | 연결 전·끊김·재연결·무응답·지연 상황에서 unhandledRejection 0건, 재연결 후 명령 폭주 없음 |
| contract | 버전별 tsc, 오라클 | Next.js의 CacheHandler 타입 만족, Next.js 기본 핸들러와 같은 판정 |
| e2e | Playwright + 인스턴스 2개 | HTML·RSC·세그먼트 프리페치, SWR, 인스턴스 간 무효화 전파, 404 없음 |
| chaos | 장시간 Vitest + 인스턴스 묶음 + toxiproxy | 시나리오 C1..C15, 불변식 I1..I5 |
| perf | autocannon, MONITOR | 요청당 Redis 명령 수, 한 빌드의 메모리 |
| mutation | Stryker | 핵심 모듈 테스트의 결함 검출력 |
e2e와 카오스는 세 개의 Next.js 앱으로 돌립니다. static-site는 이 사이트와 같은 패턴으로 dynamicParams = false인 문서 약 120개와 정적 OG 이미지 라우트를 갖고, full-legacy는 ISR·태그 붙은 fetch·서버 액션의 revalidateTag/revalidatePath를, full-cc는 cacheComponents를 켜고 "use cache"·cacheTag·cacheLife·PPR을 다룹니다. Next.js 16에서는 cacheComponents와 export const revalidate를 한 앱에 둘 수 없어서 두 앱으로 나눴고, 이 제약은 첫 빌드 오류로 확인했습니다.
이 앱들은 패키지를 심볼릭 링크가 아니라 npm pack tarball로 설치합니다. 그래야 exports 맵, 게시 파일 목록, ESM/CJS 해석이 실제 사용자와 같아지고, next와 @redis/client가 앱 안에서 한 벌만 해석됩니다. Next.js 버전은 16.1.7과 16.3.6을 lock 파일로 고정한 변형으로 돌리고, canary는 야간 작업에서만 실패를 허용하며 돌립니다. 같은 시나리오를 --pkg npm:1.0.6으로 돌리면 게시된 1.0.6으로 재현과 기준선을 잴 수 있습니다.
1.x 어댑터가 1.0.x README의 Quick Start 연결 코드를 그대로 쓰는 것도 의도한 설계입니다. 재현은 사용자가 실제로 겪는 상황이어야 하므로, 문서에 적힌 대로 연결해야 합니다. 뒤에서 다룰 7-2(연결 대기 무한 정지)는 바로 그 README 코드에서 재현되었습니다.
인스턴스 여러 개는 fleet 스크립트가 띄웁니다. BUILD_ID가 다른 두 빌드 A·B를 준비해 인스턴스 2~3개를 공유 Redis에 붙이고, 내장 라운드로빈 LB 뒤에서 A에서 B로의 롤링 업데이트와 B에서 A로의 롤백을 재현합니다. Redis는 docker compose로 8.4와 7.2, 운영 설정(AOF, volatile-lru, 비밀번호)을 흉내 낸 prodlike, 그리고 장애 주입용 toxiproxy를 띄웁니다.
"use cache" 핸들러의 의미를 가장 확실하게 검증하는 방법은 Next.js 자신의 구현과 비교하는 것입니다. 오라클 테스트는 fast-check로 set/get/updateTags/시간 경과를 섞은 연산 프로그램을 만들어 Next.js의 createDefaultCacheHandler와 이 패키지의 핸들러에 똑같이 적용하고, 각 get에서 Next.js의 use-cache 래퍼가 내릴 판정(miss/hit/stale)을 비교합니다. Date.now와 performance.now는 가상 시계로 바꿔 두 핸들러와 미니 Redis의 만료가 같은 시간을 보게 합니다.
7-1이 여기서 반례로 잡혔습니다. revalidateTag(tag, profile)에 해당하는 updateTags(tags, durations) 뒤에 Next.js 기본 핸들러는 이전 항목을 한 번 stale로 내주는데(k0=stale:v1), 1.x는 미스를 냈습니다(k0=miss). 2.0은 고정 반례 프로그램과 무작위 프로그램 60개로 이 동작이 일치함을 확인합니다.
카오스 시나리오는 실제 운영에서 일어날 수 있는 장애를 인스턴스 묶음에 주입하고, 그동안 불변식이 깨지지 않는지 봅니다.
| 불변식 | 내용 |
|---|---|
| I1 | 프리렌더 라우트에서 404·5xx 0건 |
| I2 | 비정상 종료와 unhandledRejection 0건 |
| I3 | 지연 상한 준수 |
| I4 | 복구 후 10초 안에 캐시 히트 재개 |
| I5 | Redis가 정상이면 무효화 이후 옛 데이터를 신선한 값으로 내지 않음 |
시나리오는 기동 시 Redis 없음(C1), 트래픽 중 Redis 종료(C2), 무응답(C3), 300ms 지연과 흔들림(C4), 연결 리셋(C5), FLUSHALL(C6), 메모리 압박으로 인한 축출(C7), AOF 재시작으로 태그 상태가 되돌아감(C8), 잘못된 비밀번호(C9), WRONGTYPE(C10), 롤링 업데이트(C11), 롤백(C12), 느린 렌더링 도중의 무효화(C13), 인스턴스 간 시계 차이(C14)를 다룹니다. C15는 2.0.0-next.0의 운영 검증에서 나온 결함(7-15)을 위해 나중에 추가했습니다.
장애를 어떻게 흉내 내는지도 결과를 좌우합니다. 무응답 Redis(C3)를 toxiproxy의 timeout 독성으로 만들면 버려진 바이트 때문에 복구 뒤 RESP 파이프라인의 응답이 엉뚱한 명령에 짝지어지는데, 이는 실제로 멈춘 Redis에서는 일어나지 않는 일입니다. 그래서 C3는 연결을 열어 둔 채 30초 동안 응답을 늦추는 지연 독성을 씁니다.
커버리지는 코드가 실행되었는지만 알려 줄 뿐, 테스트가 결함을 잡는지는 알려 주지 않습니다. Stryker는 소스에 작은 변형(조건 반전, 경계값 변경 등)을 넣고 테스트가 그 변형을 잡는지 셉니다. 2.0 코드에 대한 첫 전체 실행 점수는 71.45%였고, 도커 없이 도는 테스트를 보강한 뒤 85.67%(변형 2,030개 중 1,739개 검출)가 되었습니다. 주간 작업은 70% 미만이면 실패하며, 한 번에 71분 걸리던 실행을 5개 샤드로 나눠 18.4분으로 줄였습니다. 같은 코드도 실행마다 1% 안팎으로 점수가 흔들리므로(84.78%와 85.67%), 70% 게이트는 그 폭을 감안한 여유입니다.
커버리지는 unit·property·integration·fault 계층을 합친 보고서로 줄 90%, 분기 85%, 함수 90%, 파일별 줄 80%를 게이트로 겁니다. 2.0.0 게시 직전 CI의 합산 커버리지는 줄 98.44%, 분기 92.91%, 함수 96.8%였습니다.
재시도는 쓰지 않습니다. 불안정한 테스트는 격리 태그와 추적 이슈, 7일 기한을 달고 고치거나 지웁니다. 실제로 반복 실행에서 가짜 시계를 동적 import보다 먼저 진행시키던 단위 테스트와, property 테스트가 찾아낸 저장 형식의 "__proto__" 키 처리 결함이 이렇게 드러났습니다.
감사로 찾은 결함은 13건이며, 1.1.0 핫픽스는 키 구조를 바꾸지 않고 고칠 수 있는 것부터, 2.0.0은 구조를 바꿔야 하는 나머지를 맡았습니다.
| ID | 심각도 | 1.0.6의 결함 | 해결 |
|---|---|---|---|
| 7-1 | 높음 | revalidateTag(tag, "max")가 태그의 무효화 시각을 now + 1년으로 기록해, 그 태그의 모든 항목이 1년간 캐시 미스 | 1.1.0(현재 시각 기록) → 2.0(stale/expired 분리) |
| 7-2 | 높음 | README대로 await client.connect()를 쓰면 Redis 장애 시 훅이 끝나지 않아 모든 get/set이 무기한 대기. cleanupOldBuildKeys도 연결 타임아웃 없음 | 1.1.0(README·정리 타임아웃) → 2.0(connectRedis) |
| 7-3 | 중간 | use-cache 경로가 명령을 먼저 보내고 준비 여부를 나중에 확인. 3초 장애에 unhandledRejection 165~171건, 복구 후 쌓인 GET 재전송 | 1.1.0 |
| 7-4 | 중간 | 프리워밍이 Next.js와 다른 항목을 만듦(세그먼트 키 형식, / 누락, Route Handler 누락, 상태 코드 유실) → 16.3에서 세그먼트 프리페치 404 | 1.1.0 → 2.0(FileSystemCache 경유) |
| 7-5 | 중간 | 태그·TTL Hash 필드가 영원히 남음. cleanupExpired 호출부 없음. 무효화마다 Hash 전체 스캔, 필드 하나가 깨지면 무효화 전체 중단 | 2.0 |
| 7-6 | 중간 | 레거시 무효화가 키를 즉시 삭제 → SWR 불가, dynamicParams = false 404. 렌더링 도중 무효화 시 옛 데이터가 신선한 값으로 저장 | 2.0 |
| 7-7 | 중간 | 모든 오류 로그가 디버그 플래그 뒤에 있어 운영에서 실패가 조용함 | 1.1.0 |
| 7-8 | 중간 | Next.js 15 지원을 표방했지만 인터페이스가 달라 실제로는 동작하지 않음 | 2.0(peer next ^16.1) |
| 7-9 | 낮음~중간 | 옛 빌드 정리가 모든 키를 모았다가 DEL 한 번(블로킹), 중복 계산, 롤링 업데이트 중 옛 Pod의 키까지 삭제 | 1.1.0(배치 UNLINK) → 2.0(레지스트리) |
| 7-10 | 낮음 | 명령 타임아웃 타이머를 지우지 않아 명령마다 5초 타이머가 남음 | 1.1.0 |
| 7-11 | 낮음 | Route Handler가 cacheControl을 무시해 항상 1.5년 TTL. TTL이 lastModified 기준이라 옛 빌드 재시드가 즉시 만료 | 2.0 |
| 7-12 | 낮음 | set의 세 명령이 원자적이지 않아 다른 Pod의 get이 방금 쓴 값을 orphan으로 삭제. 겹치는 set이 서로의 대기 표식을 지움 | 2.0 |
| 7-13 | 낮음 | README 부정확(중복 요청 제거 과장, "모든 호출에 타임아웃", cacheLife("hours") 값 등), LICENSE 파일과 보안 안내 없음 | 1.1.0 → 2.0 |
2.0.0-next.0을 이 사이트에 먼저 배포해 운영 검증을 했고, 여기서 두 건이 더 나왔습니다. 7-14는 connectRedis가 느린 첫 연결을 "재연결"로 잘못 기록하던 로그 문구 문제였습니다. 7-15는 조금 더 흥미로웠습니다. 옛 빌드 정리가 "최근에 읽혔다"며 보류한 빌드를 그 프로세스가 다시 확인하지 않아, 그 키들이 1일 TTL 상한으로만 사라지고 있었습니다. 원인을 좁혀 보니 보류한 빌드에 TTL 상한을 거는 EXPIRE 자체가 OBJECT IDLETIME을 갱신해, 배포 간격이 짧으면 직전 빌드가 계속 "사용 중"으로 보였습니다. 이 동작은 Redis 7.2와 8.4의 통합 테스트로 먼저 확인했고, 보류한 빌드를 일정 시간 뒤 다시 확인하는 재검사를 넣어 2.0.0에서 고쳤습니다. 두 건 모두 이 글의 원칙대로 재현 테스트를 먼저 커밋한 뒤 수정했습니다.
1.1.0으로 급한 불을 끈 뒤에도 남은 결함들은 대부분 한 가지 설계 선택에서 나왔습니다. 무효화를 "키 삭제"로 구현한 것입니다. 2.0은 이 선택부터 뒤집었습니다.
2.0에는 태그에서 키를 찾는 역방향 인덱스가 없고, 무효화가 키를 지우지도 않습니다. Next.js 자신의 캐시와 같은 lazy invalidation입니다. updateTag, revalidatePath, revalidateTag는 {namespace}:_tagstate 해시에 태그마다 두 필드(s:<tag> stale 시각, x:<tag> expired 시각)를 쓰는 HSET 한 번이고, 항목은 읽을 때 Next.js의 areTagsExpired/areTagsStale 규칙으로 판정합니다. 필드를 둘로 나눈 덕분에 모든 갱신이 읽기-수정-쓰기 없는 멱등 HSET이 되며, 이는 Next.js가 태그 상태를 부분 갱신하는 방식과 같습니다.
| Next.js 호출 | 기록 | 이전 항목을 다음에 읽을 때 |
|---|---|---|
updateTag, revalidatePath, 프로필 없는 revalidateTag | expired = 지금 | 미스(쓴 직후 읽으면 새 값). 단 dynamicParams = false 프리렌더 경로는 404 대신 옛 항목을 lastModified: -1로 반환 |
revalidateTag(tag, "max") 등 프로필 | stale = 지금, expired = 지금 + 프로필 expire | 한 번 stale로 응답하고 재생성(SWR) |
dynamicParams = false 예외(onTagExpired: "auto")는 야간 e2e 매트릭스가 찾아낸 버전 차이에서 나왔습니다. Next.js 16.1은 lastModified: -1 항목을 한 번 내주고 백그라운드에서 재생성하는 반면, 16.3은 응답 전에 재생성합니다. 만료된 항목에 항상 -1을 돌려주면 16.1에서 updateTag가 쓴 직후 읽기에 옛 값을 보여 주게 되므로, 만료는 미스로 처리하되 미스가 곧 404가 되는 경로에서만 -1을 씁니다.
미스 뒤 렌더링한 항목은 set 시각이 아니라 그 미스가 일어난 시각을 lastModified로 저장합니다. 렌더링이 느린 사이에 무효화가 들어오면 무효화 시각이 항목보다 늦으므로, 결과가 신선한 값으로 취급되지 않습니다(C13, 7-6).
커스텀 cacheHandler는 Next.js의 파일시스템 캐시를 통째로 대체하므로, 핸들러가 항목을 못 찾으면 dynamicParams = false 라우트는 404가 됩니다. 2.0의 레거시 핸들러는 Redis에 항목이 없거나 Redis를 쓸 수 없을 때, 프리렌더된 페이지(APP_PAGE)와 Route Handler(APP_ROUTE)를 Next.js 자신의 FileSystemCache를 읽기 전용으로 써서 .next/server/app에서 읽습니다.
빌드 산출물도 태그 상태와 대조합니다. 빌드 이후 무효화되었다면 만료된 Redis 항목과 같은 규칙으로 답하고, 신선하면 파일 시각을 lastModified로 응답하면서 Redis에 SET NX로 다시 채웁니다. Redis를 쓸 수 없으면 태그 상태를 알 수 없으므로 산출물을 그대로 냅니다. 첫 요청이 Redis를 채우므로 프리워밍은 선택 사항이 되었고, 1.x의 프리워밍 결함(7-4)은 프리워밍도 같은 FileSystemCache를 거치게 하여 구조적으로 사라졌습니다.
FileSystemCache는 Next.js 내부 API라는 위험이 있습니다. 그래서 Next.js 변형마다 설치본 안에서 이 경로를 확인하는 런타임 계약 테스트를 PR마다 돌리고, 불러올 수 없으면 경고 한 줄과 함께 폴백만 꺼지게 했습니다.
2.0은 client.connect()를 직접 기다리지 않고, client.isReady가 거짓이면 명령을 아예 보내지 않습니다. 클라이언트의 오프라인 큐에 명령이 쌓였다가 복구 뒤 한꺼번에 재전송되는 일(7-3)이 없어집니다. connectRedis()는 첫 연결을 최대 1초 기다리고 이후 백그라운드에서 재연결하며, 프로세스 안에서 URL마다 클라이언트 하나를 공유합니다.
모든 명령은 읽기 1초, 쓰기 2초의 상한을 가집니다. @redis/client의 timeout 옵션은 명령이 소켓에 쓰이기 전까지만 적용된다는 것을 확인했기 때문에, 패키지가 왕복 전체에 자체 타이머를 겁니다. 타임아웃이 한 번 나면 서킷 브레이커가 10초간 열려 그동안의 호출은 Redis 없이 즉시 답하고(미스 또는 빌드 산출물 폴백), 그 뒤 다시 시도합니다. 응답하지 않는 Redis의 비용이 요청마다가 아니라 타임아웃 한 번으로 끝나는 구조입니다. WRONGTYPE이나 OOM 같은 명령 오류는 Redis가 응답한 것이므로 서킷을 열지 않습니다.
startCacheMaintenance()는 시작한 빌드를 {namespace}:_builds 정렬 집합에 기록하고, 현재 빌드와 가장 최근에 시작한 다른 빌드 하나를 남깁니다. 롤백하면 옛 빌드가 다시 등록되어 현재가 되므로, 하루 안의 롤백은 캐시가 데워진 채로 돌아갑니다. 나머지 빌드는 모든 키의 OBJECT IDLETIME이 30분 이상일 때만 지웁니다. 롤링 업데이트 중 옛 인스턴스가 아직 읽고 있는 빌드는 그 구간이 끝날 때까지 남는 것입니다. 보존한 빌드의 TTL은 1일로 줄이고, 보류한 빌드는 같은 인스턴스가 30분 뒤 최대 3번 다시 확인해 지웁니다(7-15).
이 작업은 Redis가 준비될 때까지 기다리고 백오프로 재시도하며, 절대 reject하지 않습니다. 같은 네임스페이스에 남은 1.x 키도 같은 규칙으로 정리되므로, 1.x에서 올린 앱은 별도 정리 없이 옛 키가 사라집니다.
1.x는 항목을 JSON 문자열로 저장하면서 Buffer를 base64로 바꿨습니다. 2.0의 저장 형식은 형식 버전이 들어간 헤더, 메타데이터 JSON, 원본 바이트 블롭으로 된 바이너리 봉투이며, 1 KiB 이상의 본문은 brotli(품질 4)로 압축합니다. 품질 11은 쓰기마다 너무 느렸고, 4는 gzip에 가까운 속도로 더 나은 압축률을 냈습니다. 알 수 없는 형식 버전이나 손상된 항목은 오류가 아니라 미스로 처리합니다.
조회도 줄였습니다. 레거시 핸들러의 캐시 히트는 항목 조회와 요청 태그의 상태 조회를 한 번에 보내고, 항목 자체의 태그가 더 필요할 때만 한 번 더 묻는 최대 2회 왕복입니다.
성능 측정은 같은 Redis에서 결정적인 지표(요청당 명령 수, 한 빌드의 메모리)를 PR 게이트로, 시간 지표(p50/p99)를 야간 경고로 다룹니다. 요청당 명령 수는 서버 전체 통계 대신 별도 연결의 MONITOR로 이번 실행의 네임스페이스에 닿은 명령만 셉니다.
| 지표 | 이전 | 2.0 |
|---|---|---|
| 정적 사이트 한 빌드의 Redis 메모리 | 149.5 MB (1.1.0) | 17.4 MB (brotli, -88%) |
| 요청당 Redis 명령 — 레거시 캐시 히트 | 3 (1.0.6) | 2 |
| 요청당 Redis 명령 — use-cache 페이지 | 8 (1.0.6) | 6 |
| 지원 범위 | Next.js 15 표방(미검증) | Next.js ^16.1(16.1, 16.3 테스트), Redis 7.2·8.4, @redis/client 5·6 |
압축 방식별로는 같은 빌드가 gzip 20.5 MB, 무압축 122.5 MB였고, 세 방식 모두 p50/p99는 측정 오차 안에서 같았습니다. use-cache 페이지 시나리오는 PPR 셸(레거시 핸들러)과 동적 부분(use-cache 핸들러)의 명령을 합친 값입니다.
2.0.0은 npm trusted publishing으로 게시했습니다. 게시 작업에는 npm 토큰이 없고, GitHub Actions의 OIDC 토큰을 교환해 게시하며 모든 버전에 provenance 증명이 붙습니다. 게시 작업은 전체 CI(Next.js 16.1·16.3 × Redis 7.2·8.4 × 앱 3개 e2e 포함)와 카오스 시나리오 일부를 통과해야만 실행되므로, 게이트를 통과하지 못한 커밋은 npm에 올라가지 않습니다.
이 사이트는 2.0으로 옮기면서 앱 쪽 래퍼를 전부 걷어냈습니다. 빌드 산출물 폴백, 연결 대기 제한, 레지스트리 기반 정리는 모두 패키지가 맡게 되었고, 앱에 남은 캐시 코드는 설정 파일 하나와 세 줄짜리 핸들러 파일 두 개, 그리고 REDIS_URL이 없을 때 경고를 남기는 기동 훅뿐입니다. 전체 구성은 홈 k3s 클러스터 구축기 5편의 현재 구성 요약에 있습니다.
운영에서는 2.0.0으로 sitemap의 모든 경로가 200으로 응답합니다. Redis가 1초 안에 응답하지 않은 순간이 한 번 있었는데, 서킷 브레이커가 열려 그동안의 요청을 빌드 산출물로 응답했고, Redis가 다시 응답하자 서킷이 스스로 닫혔습니다. 로그에는 장애 시작과 복구가 각각 한 줄씩 남고, 그 사이의 실패는 건수로 요약됩니다. 옛 빌드의 키는 레지스트리 규칙대로 정리되어 Redis에는 현재 빌드와 직전 빌드의 키만 남아 있습니다.
1.x에서 올리는 경우
2.x는 새 키 구조와 새 저장 형식을 쓰므로 1.x 항목을 읽지 않습니다. 새 빌드는 빈 캐시로 시작하지만 빌드 산출물 폴백이 응답하므로 실패하는 요청은 없고, 렌더링으로 Redis가 다시 채워질 뿐입니다. 1.x와 2.x 인스턴스는 롤링 업데이트 동안 나란히 동작할 수 있으며, 옛 1.x 키는 2.x의 정리 작업이 같은 규칙으로 지웁니다. onCreation 훅을 설정 객체로 바꾸는 방법과 옵션 대응표는 MIGRATION.md (새 창)에 있습니다.
핵심 요약
- 재현 우선: 모든 결함을 기대 실패 테스트로 먼저 커밋하고, 수정 커밋이 표식을 지워야 CI가 통과하게 하여 결함 목록과 테스트를 묶었습니다. 1.0.6의 13건과 운영 검증의 2건이 모두 이 절차를 거쳤습니다
- 실제 소비자와 같은 환경: tarball로 설치한 테스트 앱 3개, Next.js 버전 변형, 인스턴스 묶음과 롤링 업데이트, toxiproxy 카오스, Next.js 기본 핸들러를 기준으로 삼는 오라클, 뮤테이션 게이트로 검증합니다
- 설계의 전환: 무효화를 키 삭제에서 시각 기록(lazy invalidation)으로 바꾸고, Redis가 없어도 빌드 산출물로 응답하며, 모든 기다림에 상한을 두고, 아무도 읽지 않는 빌드만 지웁니다
- 결과: 한 빌드의 Redis 메모리 -88%, 캐시 히트당 명령 3 → 2, 운영에서 Redis 멈춤 중에도 404 없이 응답했습니다
- next-redis-cache GitHub (새 창) — README, MIGRATION.md (새 창), CHANGELOG (새 창), ROADMAP.md (새 창)(감사 결과·결정 기록·측정값)
- @mirunamu/next-redis-cache npm (새 창)
- Next.js
cacheHandler공식 문서 (새 창) - Next.js
cacheHandlers공식 문서 (새 창) - Next.js 기본 캐시 핸들러 구현체 (새 창) — 오라클 테스트의 기준
- fast-check (새 창) — property 기반 테스트
- Stryker Mutator (새 창) — 뮤테이션 테스트
- Toxiproxy (새 창) — 네트워크 장애 주입
- npm trusted publishing (새 창)
관련 문서
글쓴이 mirunamu00

