Next.js 16 + Redis | AWS Self-hosting 캐시 불일치 해결하기 (2편 · 구현)

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 Desktop 설치

  1. 실행하면 메뉴바/트레이에 고래 🐳 아이콘이 떠있을 거예요.

  2. 터미널에서 아래 명령으로 정상 설치 여부만 확인:

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 계층이 "캐시를 어디에, 어떤 형식으로 저장/조회할지" 커스터마이징하는 어댑터

우리가 하는 일:

  1. Next가 "use cache"로 만든 Cache Entry를
  2. 우리 코드(redis-handler.js)가 받아서
  3. Redis에 저장하고
  4. 나중에 revalidateTag, updateTag 등이 호출되면
  5. 태그 기준으로 관련 엔트리를 싹 지우는 역할까지 담당

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에서 사용됨)가 하는 일은:

  1. next-cache:tag:random-user Set에서 엔트리 키들을 모두 꺼내고
  2. 해당 엔트리 키들을 DEL
  3. 마지막으로 태그 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 시뮬레이션으로 캐시 무효화하기

자, 이제 진짜 손으로 눌러볼 수 있는 버튼을 만들어봅시다.

실제 테스트를 위해 두 개의 버튼을 만들 거예요:

  1. 서버 액션으로 Soft Stale 무효화revalidateTag("random-user", "max")

  2. 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-testtopic: 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편에서는 실제로 손을 더럽히면서(?) 전체 플로우를 구현해봤습니다.

우리가 한 일

  1. Docker로 로컬 Redis 띄우기

    • docker run --name next-redis -p 6379:6379 -d redis
  2. Redis CacheHandler 직접 구현

    • get, set, refreshTags, getExpiration, updateTags 5개 메서드
    • ReadableStream ↔ Base64 변환으로 Redis에 저장
  3. next.config.ts 설정

    • cacheComponents: true → Cache Components 활성화
    • cacheHandlers.default → Redis 핸들러로 교체
    • cacheMaxMemorySize: 0 → 인메모리 캐시 비활성화
  4. use cache + cacheTag + cacheLife

    • 랜덤 유저 API 결과를 Redis에 캐싱
    • cache: 'no-store'로 기존 Data Cache 계층은 우회
  5. 캐시 무효화 두 가지 패턴

    • revalidateTag("tag", "max") → Soft Stale (SWR)
    • revalidateTag("tag", { expire: 0 }) → Hard Stale (즉시 만료)
    • updateTag("tag") → Server Action 전용, read-your-own-writes
  6. 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로 인스턴스 간 캐시 불일치 없이 깔끔하게 데이터 캐시를 관리할 수 있을 거예요.

궁금한 점이나 개선 아이디어가 있다면 댓글로 남겨주세요! 🚀

JP
이중표Frontend Engineer

3년차 프론트엔드 개발자. Next.js, React, TypeScript 기반 웹 애플리케이션 개발 전문. 대규모 트래픽 환경에서 SSR·ISR 렌더링 전략 설계 경험.

이력서 보기