
Next.js 16 + Redis | AWS Self-hosting 캐시 불일치 해결하기 (2편 · 구현)
Next.js 16 + Redis | AWS Self-hosting 캐시 불일치 해결하기 (2편 · 구현)
2편에서는 실제로 Docker로 Redis를 띄우고, Next.js 16 + cacheHandlers + "use cache" 로 진짜 Redis에 데이터 캐시가 쌓이고, 버튼 / Webhook 으로 무효화되는 전체 플로우를 끝까지 구현해봅니다.
📦 데모 저장소: GitHub - next-redis-cache-demo
- Docker로 로컬 Redis 띄우기 (테스트용)
- Next.js 앱 생성 + redis 클라이언트 설치
- 직접 만든 Redis CacheHandler를 Next 16에 연결
- randomuser.me API를 이용해서 캐시 전/후 동작 눈으로 확인
- "use cache" + cacheTag + cacheLife + cacheHandlers(Redis) 연결
- revalidateTag("random-user", "max") vs revalidateTag("random-user", { expire: 0 }) 차이 이해
- Server Action 버튼 / Webhook 시뮬레이션 버튼 / 캐시 상태 UI까지 한 번에
6. Docker로 로컬 Redis 띄우기 (테스트용)
실습은 로컬 환경에서 진행할 거라 Docker + Redis 공식 이미지면 충분합니다.
6-1. Docker 설치 확인
Mac / Windows → Docker Desktop 설치
-
실행하면 메뉴바/트레이에 고래 🐳 아이콘이 떠있을 거예요.
-
터미널에서 아래 명령으로 정상 설치 여부만 확인:
docker version
에러 없이 버전 정보가 나오면 OK.
6-2. Redis 컨테이너 실행
docker run --name next-redis -p 6379:6379 -d redis
실행 확인:
docker ps
예:
CONTAINER ID IMAGE COMMAND NAMES abcd1234 redis "docker-entrypoint.s…" next-redis
6-3. Redis CLI 접속 & 초기화
docker exec -it next-redis redis-cli
프롬프트가 이렇게 바뀝니다:
127.0.0.1:6379>
간단 테스트:
127.0.0.1:6379> PING PONG 127.0.0.1:6379> DBSIZE (integer) 0 # 또는 초기 샘플이 있으면 다른 숫자
기존 샘플 키가 잔뜩 있다면 초기화 (전부 삭제):
127.0.0.1:6379> FLUSHDB OK 127.0.0.1:6379> DBSIZE (integer) 0
이제 깨끗한 Redis 준비 완료! CLI가 부담스러우면 Redis Insight 같은 GUI 클라이언트 써도 됩니다. (선택)
7. Next 프로젝트 생성 + 랜덤 유저 API 준비
7-1. Next.js + redis 클라이언트 설치
npx create-next-app@latest next-redis-cache-demo cd next-redis-cache-demo npm install redis
- 여기서는 공식 redis 클라이언트를 사용합니다.
- ioredis를 써도 되지만, Next 공식 문서에서 External Cache 예제를 redis 기준으로 설명해서 맞추겠습니다.
7-2. 왜 randomuser.me 인가?
테스트 API로 randomuser.me를 선택했습니다.
이 데모에서 랜덤 유저 API를 쓰는 이유:
- https://randomuser.me/api 는 호출할 때마다 다른 사람이 나온다.
- 그래서:
- 캐시를 안 쓰면 → 새로고침마다 얼굴/이름이 계속 바뀌고
- 캐시를 쓰면 → 같은 유저가 일정 시간 유지된다.
즉,
"캐시가 제대로 먹었는지" 를 눈으로 확인하기에 딱 좋은 API예요.
7-3. 아주 기본 랜덤 유저 페이지 만들기 (캐시 없음)
먼저 캐시 없이 랜덤 유저를 보여주는 페이지부터 만들어봅시다.
// app/page.tsx import Image from "next/image"; async function fetchRandomUser() { // 아직은 아무 캐시 옵션 없이, 그냥 API만 호출 const res = await fetch("https://randomuser.me/api"); if (!res.ok) { throw new Error("Failed to fetch random user"); } return res.json(); } export default async function Home() { const data = await fetchRandomUser(); const user = data.results[0]; const fullName = `${user.name.first} ${user.name.last}`; return ( <main className="min-h-screen flex items-center justify-center bg-slate-950 text-slate-50"> <div className="w-full max-w-md rounded-2xl bg-slate-900/80 border border-slate-800 p-6 shadow-xl flex flex-col items-center gap-4"> <div className="relative w-28 h-28 rounded-full overflow-hidden ring-2 ring-slate-700 ring-offset-2 ring-offset-slate-900"> <Image src={user.picture.large} alt={fullName} fill className="object-cover" /> </div> <div className="text-center"> <p className="text-xs uppercase tracking-[0.2em] text-slate-500"> Random User </p> <h1 className="mt-1 text-xl font-semibold">{fullName}</h1> <p className="text-sm text-slate-400 mt-1"> {user.location.city}, {user.location.country} </p> </div> </div> </main> ); }
그리고 next.config.ts에 이미지 도메인 허용을 추가합니다.
// next.config.ts import type { NextConfig } from "next"; const nextConfig: NextConfig = { images: { remotePatterns: [ { protocol: "https", hostname: "randomuser.me", pathname: "/api/portraits/**", }, ], }, }; export default nextConfig;
이 상태에서 계속 새로고침 해보면:
- 매번 다른 얼굴과 이름이 나온다 → 캐시 없음.
이제 여기서부터 Redis + use cache + Cache Components를 붙이면서 "얼굴이 안 바뀌는 순간"을 만들어보겠습니다.
8. Redis CacheHandler 직접 구현하기
8-0. 먼저, 왜 라이브러리를 안 쓰고 직접 만들까?
공식 문서에서도 이런 식의 외부 캐시 핸들러를 구현할 때 @neshca/cache-handler 라이브러리를 추천합니다.
하지만 @neshca/cache-handler는 Next 14 기준으로 동작합니다. (곧 15 대응 버전이 릴리즈 예정입니다.)
때문에 Next 15를 대응하기 위해 @fortedigital/nextjs-cache-handler를 사용하기도 하죠.
문제는:
Next는 이미 16까지 안정화되고 있는데, 캐시 관련 라이브러리들은 프레임워크 버전을 항상 한두 템포 늦게 따라온다는 점이에요.
그래서 이번 실습에서는:
- 공식 문서의 External Cache 패턴을 참고하되
- 직접 Redis CacheHandler를 구현하는 쪽을 택했습니다.
8-1. Redis는 "공용 캐시 저장소", CacheHandler는 "Cache Components 결과를 Redis에 넣고/꺼내는 어댑터"
한 번 더 요약하면:
-
Redis → 여러 인스턴스(EC2, 컨테이너…)가 공유하는 단일 캐시 저장소
-
CacheHandler (cacheHandlers) → Next 16의 Cache Components 계층이 "캐시를 어디에, 어떤 형식으로 저장/조회할지" 커스터마이징하는 어댑터
우리가 하는 일:
- Next가 "use cache"로 만든 Cache Entry를
- 우리 코드(redis-handler.js)가 받아서
- Redis에 저장하고
- 나중에 revalidateTag, updateTag 등이 호출되면
- 태그 기준으로 관련 엔트리를 싹 지우는 역할까지 담당
8-2. redis-handler.js 만들기
프로젝트 루트에 redis-handler.js를 하나 생성합니다.
/* eslint-disable @typescript-eslint/no-require-imports */ const { createClient } = require("redis"); const client = createClient({ url: "redis://localhost:6379", // 실제 서비스에서는 process.env.REDIS_URL 사용을 권장합니다. }); client.on("error", (err) => { console.error("[Redis CacheHandler] Redis error:", err); }); if (!client.isOpen) { client.connect().catch((err) => { console.error("[Redis CacheHandler] Failed to connect:", err); }); } // 태그 → 엔트리 키 목록을 저장할 Redis key prefix const TAG_KEY_PREFIX = "next-cache:tag:"; // 예: next-cache:tag:random-user const ENTRY_KEY_PREFIX = "next-cache:entry:"; // 예: next-cache:entry:<cacheKey> function entryKey(cacheKey) { return `${ENTRY_KEY_PREFIX}${cacheKey}`; } function tagKey(tag) { return `${TAG_KEY_PREFIX}${tag}`; } module.exports = { // 1) 캐시 조회 async get(cacheKey, softTags) { const redisKey = entryKey(cacheKey); const stored = await client.get(redisKey); if (!stored) return undefined; const data = JSON.parse(stored); // revalidate 시간이 이미 지났으면 여기서 만료 처리 (선택) const now = Date.now(); if (data.revalidate && now > data.timestamp + data.revalidate * 1000) { await client.del(redisKey); return undefined; } // Redis에 base64로 저장해둔 value → ReadableStream으로 복원 return { value: new ReadableStream({ start(controller) { controller.enqueue(Buffer.from(data.value, "base64")); controller.close(); }, }), tags: data.tags, stale: data.stale, timestamp: data.timestamp, expire: data.expire, revalidate: data.revalidate, }; }, // 2) 캐시 저장 async set(cacheKey, pendingEntry) { const entry = await pendingEntry; // entry.value (ReadableStream)를 전부 읽어서 Buffer로 합치기 const reader = entry.value.getReader(); const chunks = []; try { while (true) { const { done, value } = await reader.read(); if (done) break; chunks.push(value); } } finally { reader.releaseLock(); } const buffer = Buffer.concat(chunks.map((chunk) => Buffer.from(chunk))); const redisKey = entryKey(cacheKey); // 1) 엔트리 본문 저장 await client.set( redisKey, JSON.stringify({ value: buffer.toString("base64"), tags: entry.tags, stale: entry.stale, timestamp: entry.timestamp, expire: entry.expire, revalidate: entry.revalidate, }), { EX: entry.expire || entry.revalidate || 60 * 60, // 최소 TTL 1시간 정도 } ); // 2) 태그 인덱스 업데이트 // next-cache:tag:<tag> → Set(redisKey1, redisKey2, ...) if (Array.isArray(entry.tags)) { for (const tag of entry.tags) { if (!tag) continue; await client.sAdd(tagKey(tag), redisKey); } } }, // 3) refreshTags - 보통은 no-op async refreshTags() { // 여러 인스턴스 + 별도 태그 만료 시스템을 쓸 때 필요한 훅. // 우리는 Redis 하나를 single source로 쓰기 때문에 여기선 아무 것도 안 해도 됨. }, // 4) getExpiration - 태그별 만료 타임스탬프를 쓰지 않는다면 0 리턴 async getExpiration(tags) { // 필요하다면: "태그의 마지막 revalidate 시각"을 Redis에 저장해 두고 // Math.max(...) 해서 리턴하는 구조도 가능. return 0; }, // 5) updateTags - revalidateTag/updateTag 호출 시 여기로 옴 async updateTags(tags, durations) { if (!Array.isArray(tags) || tags.length === 0) return; for (const tag of tags) { const tKey = tagKey(tag); const entryKeys = await client.sMembers(tKey); if (entryKeys.length > 0) { // 엔트리 삭제 await client.del(entryKeys); } // 태그 세트 자체도 삭제 await client.del(tKey); } // durations?.expire 를 이용해, // "태그는 남겨두고 값만 소프트 만료" 같은 커스텀 전략도 구현 가능. }, };
9. next.config.ts에 Redis CacheHandler 연결하기
이제 Next에게 "Cache Components 캐시는 다 Redis로 보내"라고 알려줘야 합니다.
// next.config.ts import type { NextConfig } from "next"; const nextConfig: NextConfig = { // 1) Cache Components 기능 켜기 cacheComponents: true, // 2) Cache Components의 기본 핸들러를 Redis 기반으로 교체 cacheHandlers: { default: require.resolve("./redis-handler.js"), // 필요하다면 'use cache: remote' 용만 별도로: // remote: require.resolve("./redis-handler.js"), }, // 3) 기본 인메모리 캐시를 사실상 꺼서 // "캐시는 전부 Redis로 간다" 라는 걸 보장 cacheMaxMemorySize: 0, // 데모에서 randomuser.me 이미지를 쓸 거라면: images: { remotePatterns: [ { protocol: "https", hostname: "randomuser.me", pathname: "/api/portraits/**", }, ], }, logging: { fetches: { fullUrl: true, }, }, }; export default nextConfig;
핵심 포인트:
-
cacheComponents: true→ "use cache"를 실제로 활성화하는 스위치 -
cacheHandlers.default→ Cache Components 계층의 기본 스토리지를 우리가 만든 Redis 핸들러로 변경 -
cacheMaxMemorySize: 0→ 인메모리 LRU는 끄고, 캐시 엔트리는 전부 Redis로 몰아넣기
10. "use cache" + cacheTag + cacheLife로 Redis에 데이터 쌓기
이제 진짜로 랜덤 유저 API를 캐시해보겠습니다.
fetch(..., { next: { revalidate, tags } })도 여전히 동작하지만,
이건 기존 Data Cache/ISR 계층에 대한 옵션이고,
Redis CacheHandler를 쓰는 Cache Components 계층과는 별개입니다.
10-1. 기존 fetch 캐시(next.revalidate, next.tags)와의 차이
// (예전부터 많이 쓰던 패턴) await fetch("https://randomuser.me/api", { next: { revalidate: 3600, tags: ["posts"], }, });
이 옵션은 **"기존 Data Cache / ISR 계층"**에 대한 설정입니다.
- 이 계층의 캐시는
- prod: 로컬 파일시스템
- dev: 메모리
- 그리고 각 인스턴스가 각자 별도의 저장소를 가집니다.
- 우리가 방금 만들고 있는 Cache Components + Redis와는 다른 계층이에요.
즉:
"use cache"+cacheHandlers로 Redis를 붙여도,fetch(..., { next: { revalidate, tags } })는 여전히 로컬 캐시 계층에만 저장됩니다. → Redis에는 저장되지 않아요.
그래서 "Redis에 통합하려는 캐시"는 새 계층(Cache Components) 쪽인
- "use cache"
- cacheTag
- cacheLife
- revalidateTag / updateTag
를 활용하는 패턴으로 가는 게 맞습니다.
10-2. 랜덤 유저 캐시 함수 구현
// app/lib/getRandomUser.ts import { cacheTag, cacheLife } from "next/cache"; export async function getRandomUser() { "use cache"; // 1) 태그 지정 → 나중에 revalidateTag("random-user", ...) / updateTag("random-user") 로 날릴 수 있다 cacheTag("random-user"); // 2) 캐시 라이프 프로필 지정 (기본값도 있지만 예시로 명시) // - 'default' 프로필은 stale 5분, revalidate 15분, expire 없음 // - 'hours' / 'minutes' 같은 빌트인도 있고, 직접 숫자로도 넣을 수 있음 cacheLife("hours"); // 혹은: // cacheLife({ stale: 60, revalidate: 300, expire: 3600 }); // 3) fetch는 cache: 'no-store' 로 두되, // "use cache" 계층이 이 결과를 잡아서 Redis에 넣게 한다. const res = await fetch("https://randomuser.me/api", { cache: "no-store", }); if (!res.ok) { throw new Error("Failed to fetch random user"); } const fetchedAt = Date.now(); return { ...(await res.json()), fetchedAt }; }
❓ 왜
next: { revalidate, tags }말고cacheTag+cacheLife+cache: 'no-store'패턴을 권장할까요?
-
기존 fetch 캐시는 "옛날 ISR 계층"이고, 새 Cache Components 계층과는 관심사가 다르고 저장소도 달라요.
-
두 캐시 계층을 섞어 쓰면, "어디에 캐시가 있고, 어떤 TTL이 적용되었는지"가 매우 헷갈려집니다.
-
cacheLife/cacheTag를 사용하면, 한 계층(Cache Components + Redis) 안에서만 캐시 정책을 관리할 수 있어요. -
cache: "no-store"를 주면 fetch 레벨에서는 캐시를 건들지 않고, Cache Components 계층이 유일한 단일 진실(Single Source of Truth)이 됩니다. -
엄밀히는 기본값(Next 15 이상 기본 no-store)으로 둬도 동작은 하지만, 이 글에서는 Data Cache 계층을 완전히 우회하고 Cache Components(+Redis)만 사용한다는 의도를 드러내기 위해
cache: "no-store"를 명시했습니다.
이제 실행 후 Redis CLI에서 보면:
127.0.0.1:6379> DBSIZE (integer) 2 127.0.0.1:6379> SCAN 0 COUNT 10 1) "0" 2) 1) "next-cache:tag:random-user" 2) "next-cache:entry:[\"development\",\"8076e8...\",[],\"170\"]" 3) "next-cache:entry:[\"development\",\"8076e8...\",[],\"119\"]"
이제 진짜로:
"use cache"+cacheHandlers(Redis)조합으로 랜덤 유저 데이터가 Redis 안에 캐싱되었습니다! 🎉
10-3. Redis 키 구조 한 번 짚고 가기
-
next-cache:entry:... → 실제 캐시 데이터(바이트, 태그, timestamp, revalidate, expire 등)가 들어있는 JSON
-
next-cache:tag:random-user → 이 태그로 캐시된 엔트리 키 목록을 담고 있는 Redis Set
updateTags (→ 내부적으로 revalidateTag, updateTag에서 사용됨)가 하는 일은:
next-cache:tag:random-userSet에서 엔트리 키들을 모두 꺼내고- 해당 엔트리 키들을 DEL
- 마지막으로 태그 Set 자체도 DEL
그래서:
revalidateTag("random-user", { expire: 0 }); // 또는 updateTag("random-user");
를 호출하면, 랜덤 유저 캐시와 관련된 엔트리들이 Redis에서 싹 날아가는 구조가 됩니다.
11. Soft vs Hard Stale — revalidateTag의 두 가지 얼굴
이제 캐시 무효화를 살펴봅시다. 여기서 핵심 개념이 바로:
revalidateTag("random-user", "max")revalidateTag("random-user", { expire: 0 })
둘 다 "태그 기반 무효화"지만 성격이 다릅니다.
11-1. "max" 프로필: Soft Stale (stale-while-revalidate 느낌)
revalidateTag("random-user", "max");
Cache Components의 revalidation profile 중 하나를 쓰는 형태예요.
개념적으로 **"Soft Stale"**에 가깝습니다:
- 기존 캐시는 당장 없애지 않고,
- "이제 이 캐시는 오래됐으니까 최대한 빨리 다시 채워라"라는 힌트를 주는 것에 가까워요.
stale-while-revalidate:
- 지금 요청엔 기존 캐시를 그대로 쓰고
- 백그라운드에서 새 데이터를 채워
- 이후 요청부터 최신 데이터가 적용되도록 하는 방향
단, 우리 데모에서는:
- 버튼을 누르고
router.refresh()나 브라우저 새로고침을 "직접" 하기 때문에
사용자 입장에선 "max"나 { expire: 0 }나
"버튼 누르고 새로고침 → 바로 값 바뀐다"로 보일 수 있어요.
그래서 데모 UI에선 "max"를 Soft Stale로 레이블링만 해주면 됩니다.
11-2. { expire: 0 }: Hard Stale (즉시 만료 / 하드 만료)
revalidateTag("random-user", { expire: 0 });
이건 말 그대로:
"이 태그에 해당하는 캐시는 지금 바로 만료."
우리 Redis 핸들러 구현상에서는
updateTags에서 해당 태그의 엔트리들을 전부 DEL 해버리므로
다음 요청 때는 무조건 새로 fetch 해서 캐시를 채웁니다.
실무에서 주로 어디 쓰냐면:
CMS / Product 시스템 같은 서드파티가 보내는 Webhook에서 "데이터 바뀌었으니 지금 당장 CDN/캐시에서도 지워라" 패턴에 사용해요.
즉, "Soft Stale은 부드러운 캐시 갱신", **"Hard Stale은 당장 캐시를 날리고 다음 요청부터 새로 만들기"**로 이해하면 됩니다.
UI에서는 둘 다 cacheState === "stale" / "hard"로 분기해서
텍스트만 다르게 보여주면 돼요.
11-3. updateTag vs revalidateTag — 언제 뭘 써야 할까?
1편에서 다룬 내용이지만 한번 더 짚고 갈 게 있어요.
| | revalidateTag(tag, 'max') | updateTag(tag) |
|---|---|---|
| 한 줄 요약 | 캐시를 stale 처리 → 다음 요청에서 SWR로 갱신 | 캐시를 즉시 만료 → 다음 요청은 새 데이터가 올 때까지 대기 |
| 추천 상황 | **웹훅/관리자 API(route.ts)**에서 "갱신 트리거만" 걸고 싶을 때 | 폼 저장 직후 "내가 방금 바꾼 게 바로 보여야" 할 때 (read-your-own-writes) |
| 호출 위치 | Route Handler / Server Action 등 서버 어디서든 | Server Action 전용 (Route Handler에서 호출하면 에러!) |
| 사용자 체감 | 기존 캐시로 빠르게 보여줄 수 있고, 뒤에서 갱신됨 | 저장 직후 리다이렉트/재요청에서 무조건 최신 |
⚠️ 주의:
updateTag는 Server Action 내에서만 사용 가능합니다. Route Handler에서 호출하면 에러가 발생해요! 그래서 Webhook BFF 패턴에서는revalidateTag('tag', { expire: 0 })를 써야 합니다.
11-4. 현재 데모의 Soft Stale 구현에 대해
📌 참고: 현재 데모의 CacheHandler는 Next.js 공식 예제와 동일하게 구현되어 있습니다.
// redis-handler.js - updateTags async updateTags(tags, durations) { // durations 파라미터가 있지만, 공식 예제처럼 그냥 삭제 await client.del(entryKeys); }따라서
revalidateTag("tag", "max")와revalidateTag("tag", { expire: 0 })모두 캐시를 즉시 삭제합니다.실제 Soft Stale(stale-while-revalidate) 동작을 구현하려면
durations파라미터를 활용해 삭제 대신 stale 상태로 마킹하고,get()에서 stale이면 기존 값 반환 + 백그라운드 갱신하는 로직을 CacheHandler에서 직접 구현해야 합니다.이 부분은 3편 "프로덕션 레벨 CacheHandler"에서 다룰 예정이에요!
12. Server Action 버튼 + Webhook 시뮬레이션으로 캐시 무효화하기
자, 이제 진짜 손으로 눌러볼 수 있는 버튼을 만들어봅시다.
실제 테스트를 위해 두 개의 버튼을 만들 거예요:
-
서버 액션으로 Soft Stale 무효화 →
revalidateTag("random-user", "max") -
Webhook 시뮬레이션으로 Hard Stale 무효화 → 내부 BFF(
/api/revalidate)를 호출하고, 그 안에서revalidateTag("random-user", { expire: 0 })
12-1. Server Actions – actions.ts
// app/action/actions.ts import { revalidateTag } from "next/cache"; export async function invalidateRandomUser() { // 여기서 DB 업데이트 같은 걸 하고… // await db.user.update(...) // SOFT STALE: 기존 캐시는 유지하면서, 백그라운드에서 갱신되도록 revalidateTag("random-user", "max"); } export async function postRandomUser() { // HARD STALE: BFF(Route Handler)를 통해 Webhook 시뮬레이션 호출 const res = await fetch( "http://localhost:3000/api/revalidate?secret=eddy-test", { method: "POST", headers: { topic: "random-user/create", }, } ); const data = await res.json(); console.log("eddy webhook response", data); return data; }
12-2. Webhook BFF – /api/revalidate Route Handler
// app/api/revalidate/actions.ts import { revalidateTag } from "next/cache"; import { headers } from "next/headers"; import { NextRequest, NextResponse } from "next/server"; const PRODUCT_WEBHOOKS = [ "random-user/create", "random-user/delete", "random-user/update", ]; export async function handleWebhook(req: NextRequest): Promise<NextResponse> { const secretKey = process.env.REVALIDATION_SECRET || "eddy-test"; const headersList = await headers(); const topic = headersList.get("topic") || "unknown"; const secret = req.nextUrl.searchParams.get("secret"); const isProductUpdate = PRODUCT_WEBHOOKS.includes(topic); // 시크릿 안 맞으면 그냥 200만 리턴 (외부에는 OK만 보여주는 패턴) if (!secret || secret !== secretKey) { return NextResponse.json({ status: 200, reason: "invalid secret" }); } if (!isProductUpdate) { return NextResponse.json({ status: 200, reason: "not product topic", }); } // ✅ 여기서 Redis 기반 CacheHandler + use cache 계층의 캐시 무효화 revalidateTag("random-user", { expire: 0 }); // HARD STALE: 즉시 만료 console.log("캐시 무효화 완료 (random-user)"); return NextResponse.json({ status: 202, revalidated: true, tag: "random-user", now: Date.now(), }); }
// app/api/revalidate/route.ts import { NextRequest, NextResponse } from "next/server"; import { handleWebhook } from "./actions"; // export const runtime = 'edge'; export async function POST(req: NextRequest): Promise<NextResponse> { return await handleWebhook(req); }
이렇게 해두면:
Postman / 외부 시스템에서도
POST http://localhost:3000/api/revalidate?secret=eddy-test와
topic: random-user/create 헤더만 맞춰주면
곧바로 Redis 캐시가 Hard Stale로 날아가는 걸 확인할 수 있습니다.
12-3. 랜덤 유저 UI + Cache Control Panel 만들기
이제 전체 코드를 모아서 보겠습니다. 랜덤 유저를 불러와서 보여주는 간단한 컴포넌트와, 캐시 상태/무효화를 관리하는 패널을 Tailwind로 예쁘게 만들어볼게요.
1) 프로필 데이터 표시
// app/components/UserProfile.tsx import { getRandomUser } from "../lib/getRandomUser"; import Image from "next/image"; import { CacheControls } from "./CacheControls"; export default async function UserProfile() { const data = await getRandomUser(); const user = data.results[0]; const fetchedAt = data.fetchedAt as number; const fullName = `${user.name.first} ${user.name.last}`; return ( <> <section className="..."> {/* 프로필 이미지 + 이름 + 위치 표시 */} <Image src={user.picture.large} alt={fullName} fill /> <h1>{fullName}</h1> <p>{user.location.city}, {user.location.country}</p> </section> {/* 캐시 컨트롤 패널 */} <CacheControls lastUpdatedAt={fetchedAt} /> </> ); }
2) Cache Control Panel (Soft / Hard 구분 + Refresh 버튼)
// app/components/CacheControls.tsx "use client"; import { useState, useTransition } from "react"; import { useRouter } from "next/navigation"; import { invalidateRandomUser, postRandomUser } from "../action/actions"; type CacheState = "fresh" | "stale" | "hard"; export function CacheControls({ lastUpdatedAt }: { lastUpdatedAt: number }) { const router = useRouter(); const [isPending, startTransition] = useTransition(); const [cacheState, setCacheState] = useState<CacheState>("fresh"); const [canRefresh, setCanRefresh] = useState(false); // Soft Stale 무효화 const handleSoftInvalidate = () => { startTransition(async () => { await invalidateRandomUser(); setCacheState("stale"); setCanRefresh(true); }); }; // Hard Stale 무효화 (Webhook 시뮬레이션) const handleHardInvalidate = () => { startTransition(async () => { await postRandomUser(); setCacheState("hard"); setCanRefresh(true); }); }; // 새로고침 → 서버 컴포넌트 재호출 → Redis에서 새 데이터 const handleRefresh = () => { setCacheState("fresh"); setCanRefresh(false); router.refresh(); }; return ( <section> {/* 상태 뱃지: FRESH / STALE / HARD */} {/* 마지막 업데이트 시간 표시 */} {/* 버튼: Soft 무효화 / Hard 무효화 / 새로고침 */} </section> ); }
💡 전체 코드: Tailwind 스타일링이 포함된 전체 코드는 GitHub 저장소에서 확인하세요.
13. 정리 & 마무리
2편에서는 실제로 손을 더럽히면서(?) 전체 플로우를 구현해봤습니다.
우리가 한 일
-
Docker로 로컬 Redis 띄우기
docker run --name next-redis -p 6379:6379 -d redis
-
Redis CacheHandler 직접 구현
get,set,refreshTags,getExpiration,updateTags5개 메서드- ReadableStream ↔ Base64 변환으로 Redis에 저장
-
next.config.ts 설정
cacheComponents: true→ Cache Components 활성화cacheHandlers.default→ Redis 핸들러로 교체cacheMaxMemorySize: 0→ 인메모리 캐시 비활성화
-
use cache + cacheTag + cacheLife
- 랜덤 유저 API 결과를 Redis에 캐싱
cache: 'no-store'로 기존 Data Cache 계층은 우회
-
캐시 무효화 두 가지 패턴
revalidateTag("tag", "max")→ Soft Stale (SWR)revalidateTag("tag", { expire: 0 })→ Hard Stale (즉시 만료)updateTag("tag")→ Server Action 전용, read-your-own-writes
-
UI로 확인
- 버튼 클릭 → 캐시 무효화 → 새로고침 → 새 데이터 확인
핵심 포인트 다시 한번
| 구분 | 기존 Data Cache/ISR | Cache Components (use cache) |
|------|---------------------|------------------------------|
| 활성화 | 기본 | cacheComponents: true |
| 저장소 | 로컬 파일시스템/메모리 | 인메모리 LRU (기본) → Redis (커스텀) |
| 태그 | fetch next.tags | cacheTag() |
| 수명 | fetch next.revalidate | cacheLife() |
| 무효화 | revalidateTag(tag) (deprecated) | revalidateTag(tag, profile) / updateTag(tag) |
| 커스텀 핸들러 | cacheHandler (단수) | cacheHandlers (복수) |
실제 서비스에 적용할 때
- Redis URL:
process.env.REDIS_URL로 환경변수 처리 - AWS ElastiCache / Upstash / Redis Cloud 등 매니지드 서비스 사용 권장
- 에러 핸들링: Redis 연결 실패 시 fallback 전략 고려
- 모니터링: Redis 키 개수, 메모리 사용량, TTL 만료 등
데모 저장소
전체 코드는 GitHub에서 확인할 수 있어요:
👉 https://github.com/.../next-redis-cache-demo
🔜 3편에서 다룰 내용
3편에서는 실제 AWS 환경에서 검증하는 파트를 다룹니다.
-
AWS ElastiCache로 Redis 교체하기
- 로컬 Docker Redis → 매니지드 서비스로 전환
- 환경변수 설정 및 보안 그룹 구성
-
EC2 2대 + ALB로 다중 인스턴스 환경 구축
- 실제로 인스턴스 간 캐시 공유가 되는지 검증
- 한 인스턴스에서 무효화 → 다른 인스턴스에서 확인
-
프로덕션 레벨 Redis CacheHandler
- 에러 핸들링 및 재연결 로직
- Redis 연결 실패 시 fallback 전략
- Soft Stale(stale-while-revalidate) 제대로 구현하기
durations파라미터 활용- 삭제 대신 stale 마킹 → 백그라운드 갱신
- 성능 비교 (캐시 히트 vs 미스)
-
여러 태그 동시 무효화 테스트
- 복수 태그 관리 패턴
- 태그 네이밍 컨벤션
1편에서 "왜 이런 구조가 필요한지"를 이해했다면, 2편에서는 "실제로 어떻게 구현하는지"를 직접 해봤습니다.
이제 여러분의 AWS Self-hosting 환경에서도 Next.js 16의 Cache Components + Redis로 인스턴스 간 캐시 불일치 없이 깔끔하게 데이터 캐시를 관리할 수 있을 거예요.
궁금한 점이나 개선 아이디어가 있다면 댓글로 남겨주세요! 🚀