Dev Notes
Web Vitals
Web Vitals
Dev Notes
Docs홈 k3s 클러스터 구축기 (5) — Next.js Redis 캐시 연동
Web Vitals
Web Vitals
GitHub
Infra64분

홈 k3s 클러스터 구축기 (5) — Next.js Redis 캐시 연동

Next.js 16 캐시 아키텍처, @fortedigital/nextjs-cache-handler 연동, 빌드 ID 기반 캐시 격리까지

2026년 2월 9일
k3skubernetesnextjsrediscache-handlerisrrevalidatedocker

환경: Next.js 16.1.4, @fortedigital/nextjs-cache-handler v3.0.0-next16-beta.1, k3s v1.31+

들어가며

4편에서 k3s 클러스터에 Redis를 배포하고 RedisInsight로 모니터링 환경을 구축했습니다. Redis가 준비되었으니, 이제 Next.js 앱의 캐시를 연동할 차례입니다.

그런데 왜 굳이 커스텀 캐시 핸들러가 필요할까요? Next.js는 기본적으로 캐시를 파일시스템과 인메모리에 저장합니다. 단일 서버라면 이것으로 충분하지만, k8s처럼 Pod이 여러 개인 환경에서는 근본적인 한계에 부딪힙니다. Pod A에서 ISR로 재생성한 페이지를 Pod B는 알 수 없고, 사용자는 어느 Pod에 요청이 라우팅되느냐에 따라 최신 페이지를 보기도 하고 stale 페이지를 보기도 합니다. revalidateTag()로 캐시를 무효화해도 해당 Pod의 로컬 캐시만 날아가고 나머지 Pod에는 반영되지 않습니다. Pod이 재시작되면 인메모리 캐시는 당연히 사라지고, 컨테이너 환경에서는 파일시스템 캐시마저 유실됩니다.

결국 Pod 간 캐시를 공유할 수 있는 외부 저장소가 필요하며, 이것이 Redis를 캐시 백엔드로 연동하는 이유입니다. 이 편에서는 캐시 아키텍처를 먼저 이해하고, 라이브러리 연동과 빌드 ID 기반 캐시 격리까지 한 편에 담았습니다.

Next.js 캐시 아키텍처

Redis에 무엇을 저장할지 결정하려면, 먼저 Next.js가 캐시를 어떻게 관리하는지 정확히 이해해야 합니다.

정적 페이지와 캐시의 관계

Next.js에서 "정적 페이지"와 "캐시"는 분리된 개념이 아닙니다. 프리렌더된 HTML/RSC payload 자체가 Incremental Cache의 엔트리입니다. 단, 저장 위치는 생성 시점에 따라 다릅니다.

시점저장 위치경로
빌드 타임 (next build)파일시스템.next/server/app/docs/[slug].html
런타임 ISR 재생성cacheHandler (Redis)Redis 키 (docs: prefix)

빌드 타임 페이지는 cacheHandler.set()을 거치지 않고 파일시스템에 직접 저장됩니다. 런타임에 ISR 재생성이 일어나면 그때 cacheHandler.set()을 통해 Redis에 저장됩니다. Next.js는 런타임에 cacheHandler.get()을 먼저 확인하고, 없으면 파일시스템으로 fallback합니다.

cacheHandler (단수) vs cacheHandlers (복수)

Next.js 16에는 캐시 핸들러 설정이 두 가지 있습니다. 이름이 비슷해서 리네이밍처럼 보이지만, 완전히 별개의 시스템이며 공존합니다.

설정용도인터페이스상태
cacheHandler (단수)ISR, fetch 캐시 (revalidate), 라우트 핸들러클래스 생성자v14.1.0~ 현역
cacheHandlers (복수)'use cache' 디렉티브 전용객체v16.0.0~ 신규

단수는 클래스 생성자를 요구하고, get/set/revalidateTag 메서드로 캐시를 관리합니다.

TypeScript
class CacheHandler {
  constructor(ctx)
  get(key, ctx): Promise<{ lastModified, value } | null>
  set(key, data, ctx): Promise<void>
  revalidateTag(tags): Promise<void>
  resetRequestCache(): void
}

반면 복수는 'use cache' 디렉티브 전용으로, 태그 기반 만료(getExpiration, updateTags)를 중심으로 한 다른 형태의 인터페이스를 사용합니다.

TypeScript
interface DataCacheHandler {
  get(cacheKey, softTags): Promise<DataCacheEntry | undefined>
  set(cacheKey, pendingEntry): Promise<void>
  refreshTags(): Promise<void>
  getExpiration(tags): Promise<Timestamp>
  updateTags(tags, durations?): Promise<void>
}

인터페이스가 완전히 다르므로 하나의 핸들러로 양쪽을 커버할 수 없습니다. cacheHandlers(복수)를 설정하지 않으면 Next.js 16이 내장 인메모리 LRU 캐시를 기본값으로 사용하며, 이 프로젝트에서는 단수만 Redis로 연동합니다.

cacheMaxMemorySize와 'use cache'

cacheMaxMemorySize는 cacheHandler(단수)뿐 아니라 cacheHandlers(복수)의 'use cache' 인메모리 LRU에도 동시에 적용됩니다(소스 코드). 0으로 설정하면 멀티 Pod 환경에서 단수 캐시의 Pod 간 일관성은 보장되지만, 복수를 Redis로 연동하지 않은 구성에서는 'use cache' 캐시까지 비활성화됩니다. 이 프로젝트에서는 'use cache' 내장 인메모리 캐시를 활성화하기 위해 cacheMaxMemorySize를 설정하지 않고 기본값(50 MB)을 사용합니다. 단수 쪽에도 Pod-로컬 인메모리 레이어가 생기지만, Redis가 source of truth이므로 ISR revalidate 시점에 갱신됩니다.

런타임에 캐시되는 것

Next.js 16에서는 기본적으로 캐시가 비활성화되어 있습니다. 아래와 같이 명시적으로 옵트인한 경우에만 cacheHandler를 통해 Redis에 캐시가 쌓입니다.

캐시 대상옵트인 방법
ISR 페이지export const revalidate = 60
fetch 캐시fetch(url, { next: { revalidate: 3600 } })
force-cachefetch(url, { cache: 'force-cache' })
on-demand 정적 생성generateStaticParams에 없는 경로의 첫 요청

옵트인 설정이 없는 코드에서는 Redis에 아무것도 쌓이지 않으므로, 캐시 핸들러를 연동했다고 해서 모든 요청이 Redis를 거치는 것은 아닙니다.

fetch revalidate — 서버사이드 API 캐싱

ISR과 fetch revalidate는 캐싱 레벨이 다릅니다. ISR은 페이지 전체를 재생성하는 반면, fetch revalidate는 개별 API 응답을 캐싱합니다. 둘 다 cacheHandler를 통해 Redis에 저장되지만, 캐시 키와 갱신 주기는 독립적입니다.

TypeScript
const data = await fetch("https://api.github.com/users/...", {
  next: { revalidate: 3600 }  // 1시간
});

이 코드의 동작은 stale-while-revalidate 패턴을 따릅니다. 서버(Next.js)가 GitHub API를 호출하고 응답을 Redis에 캐싱하면, 이후 1시간 동안은 동일한 fetch 요청에 대해 Redis의 캐싱된 응답을 반환합니다. 1시간이 지난 뒤에는 캐싱된 응답을 즉시 반환하면서 백그라운드에서 API를 다시 호출하여 캐시를 갱신합니다. 브라우저는 이 과정을 전혀 인지하지 못하고, 렌더링된 HTML만 받게 됩니다.

현재 docs 프로젝트에서 캐시되는 부분

TypeScript
// src/lib/github-stats.ts — 5개 fetch
const CACHE_CONFIG = {
  profile:       { next: { revalidate: 3600 } },   // 1시간
  repos:         { next: { revalidate: 3600 } },   // 1시간
  events:        { next: { revalidate: 1800 } },   // 30분
  languages:     { next: { revalidate: 86400 } },  // 24시간
  contributions: { next: { revalidate: 3600 } },   // 1시간
};
 
// src/lib/npm-stats.ts — 2개 fetch
const CACHE_CONFIG = {
  packages:  { next: { revalidate: 86400 } },  // 24시간
  downloads: { next: { revalidate: 3600 } },   // 1시간
};

이 7개의 fetch가 revalidate를 쓰고 있어서 Redis에 캐시가 쌓입니다. 나머지 문서 페이지들은 빌드타임에 전부 정적 생성되며, revalidate 없이 파일시스템에서 영구 서빙됩니다.

현재 사이트는 완전 정적입니다

이 docs 사이트의 모든 페이지는 generateStaticParams()로 빌드타임에 생성되고, ISR(revalidate)을 사용하지 않습니다. 즉 Redis가 실제로 캐싱하는 것은 위 7개의 fetch 응답뿐이며, 페이지 캐싱 인프라(registerInitialCache, 빌드 ID 격리, SCAN 정리)는 현재 시점에서 반드시 필요한 구성은 아닙니다. 이후 콘텐츠가 늘어나 빌드 시간이 길어지거나, 외부 데이터를 페이지에 직접 반영해야 할 때 ISR로 전환할 것을 염두에 두고 미리 구축해 둔 구성입니다.

라이브러리 선택

cacheHandler 인터페이스는 get/set/revalidateTag 세 메서드를 구현하면 되므로 커스텀 핸들러로도 충분히 만들 수 있습니다. Next.js 14까지는 @neshca/cache-handler가 사실상 표준으로 쓰였지만 16을 지원하지 않고, 그 외에 검증된 대안도 많지 않은 상황입니다. 그중 @fortedigital/nextjs-cache-handler가 주간 59K 다운로드로 가장 활발하게 유지보수되고 있어 이 라이브러리를 사용했습니다.

베타 리스크

Next.js 16용은 v3.0.0-next16-beta.1입니다. 활발하게 유지보수되고 있지만 프로덕션 도입 시 주의가 필요합니다.

설치 및 설정

라이브러리와 Redis 클라이언트를 설치합니다. @next 태그는 Next.js 16 대응 베타 버전을 가리킵니다.

Bash
cd docs/
npm install @fortedigital/nextjs-cache-handler@next @redis/client

설정 파일은 세 곳을 수정합니다. 캐시 핸들러 정의(cache-handler.mjs), 서버 시작 시 훅(instrumentation.ts), 그리고 Next.js 설정(next.config.ts)입니다.

cache-handler.mjs

JavaScript
// docs/cache-handler.mjs
import { CacheHandler } from "@fortedigital/nextjs-cache-handler";
import createRedisHandler from "@fortedigital/nextjs-cache-handler/redis-strings";
import createLruHandler from "@fortedigital/nextjs-cache-handler/local-lru";
import { createClient } from "@redis/client";
import { PHASE_PRODUCTION_BUILD } from "next/constants.js";
 
const buildId = process.env.BUILD_ID || "default";
 
CacheHandler.onCreation(async (context) => {
  // 빌드타임: Redis 없이 LRU 메모리 캐시만 사용
  if (context.serverOptions?.phase === PHASE_PRODUCTION_BUILD) {
    return { handlers: [createLruHandler()] };
  }
 
  // 런타임: Redis + LRU 폴백
  const client = createClient({ url: process.env.REDIS_URL });
 
  client.on("error", (err) => {
    console.error("[Redis]", err.message);
  });
 
  await client.connect();
 
  const redisHandler = await createRedisHandler({
    client,
    keyPrefix: `docs:${buildId}:`,
    sharedTagsKey: `docsTags:${buildId}`,
    sharedTagsTtlKey: `docsTagTtls:${buildId}`,
  });
 
  return {
    handlers: [redisHandler, createLruHandler()],
  };
});
 
export default CacheHandler;

PHASE_PRODUCTION_BUILD 분기가 핵심입니다. Docker 빌드 시에는 Redis가 없으므로 LRU 메모리 캐시만 사용하고, 런타임에는 Redis를 우선으로 쓰되 장애 시 LRU로 폴백합니다. keyPrefix에 빌드 ID를 포함시키는 이유는 빌드 ID 기반 캐시 격리 섹션에서 다룹니다.

시점handlers결과
next build (Docker)[lruHandler]Redis 연결 안 함
node server.js (k8s)[redisHandler, lruHandler]Redis 우선, 실패 시 LRU 폴백

instrumentation.ts — 이전 빌드 캐시 정리 + 프리로딩

Next.js는 src/instrumentation.ts의 register() 함수를 서버 시작 시 자동으로 한 번 호출합니다. 이 hook에서 두 가지 작업을 수행합니다.

  1. 이전 빌드 캐시 정리: KEYS로 현재 빌드 ID가 아닌 키를 찾아 삭제
  2. 빌드타임 캐시 프리로딩: 파일시스템의 정적 페이지를 Redis에 밀어넣기
TypeScript
// docs/src/instrumentation.ts
export async function register() {
  if (process.env.NEXT_RUNTIME === "nodejs") {
    const { createClient } = await import("@redis/client");
    const client = createClient({ url: process.env.REDIS_URL });
    await client.connect();
 
    const buildId = process.env.BUILD_ID || "default";
    const currentPrefix = `docs:${buildId}:`;
    const currentTagsKey = `docsTags:${buildId}`;
    const currentTtlKey = `docsTagTtls:${buildId}`;
 
    const [cacheKeys, tagsKeys, ttlKeys] = await Promise.all([
      client.keys("docs:*"),
      client.keys("docsTags:*"),
      client.keys("docsTagTtls:*"),
    ]);
 
    const oldKeys = [
      ...cacheKeys.filter((key) => !key.startsWith(currentPrefix)),
      ...tagsKeys.filter((key) => key !== currentTagsKey),
      ...ttlKeys.filter((key) => key !== currentTtlKey),
    ];
 
    if (oldKeys.length > 0) {
      console.log(`[cache-cleanup] Deleting ${oldKeys.length} old keys`);
      await client.del(oldKeys);
    }
 
    const remaining =
      cacheKeys.length + tagsKeys.length + ttlKeys.length - oldKeys.length;
    console.log(`[cache-cleanup] Done. Remaining: ${remaining} current keys`);
    await client.disconnect();
 
    const { registerInitialCache } = await import(
      "@fortedigital/nextjs-cache-handler/instrumentation"
    );
    const CacheHandler = (await import("../cache-handler.mjs")).default;
    await registerInitialCache(CacheHandler, { setOnlyIfNotExists: true });
  }
}

docs:*, docsTags:*, docsTagTtls:* 세 패턴을 병렬로 조회하여 현재 빌드 ID에 해당하지 않는 키를 일괄 삭제합니다. 이 사이트의 Redis 키 수는 수십 개 수준이므로 KEYS 명령으로도 블로킹 우려 없이 충분합니다. [cache-cleanup] 로그로 정리 과정을 추적할 수 있어, kubectl logs에서 각 배포마다 몇 개의 dead 키가 삭제되었는지 확인할 수 있습니다.

setOnlyIfNotExists: true — Redis에 이미 해당 키가 있으면 덮어쓰지 않습니다. 앱이 재시작되어도 런타임에 갱신된 더 최신 캐시를 보존합니다.

next.config.ts

TypeScript
import type { NextConfig } from "next";
 
const nextConfig: NextConfig = {
  output: "standalone",
  reactStrictMode: false,
  images: {
    unoptimized: true,
  },
  cacheHandler: require.resolve("./cache-handler.mjs"),
  generateBuildId: async () => process.env.BUILD_ID || "default",
};
 
export default nextConfig;

cacheMaxMemorySize는 별도로 설정하지 않아 기본값(50 MB)을 사용합니다. 'use cache' 디렉티브의 내장 인메모리 LRU를 활성 상태로 두기 위한 선택입니다. 단수 cacheHandler 쪽에도 Pod-로컬 인메모리 레이어가 생기지만, Redis가 source of truth 역할을 하고 ISR revalidate 주기에 맞춰 갱신되므로 실질적인 불일치 문제는 제한적입니다. generateBuildId는 빌드마다 고정된 ID를 부여하여 캐시 키를 빌드별로 격리합니다.

REDIS_URL 환경변수

cache-handler.mjs가 Redis에 접속하려면 REDIS_URL 환경변수가 필요합니다. 4편에서 생성한 docs-secret.yaml에 추가합니다.

YAML
stringData:
  REDIS_URL: "redis://default:비밀번호@redis-master.mirunamu.svc.cluster.local:6379"
Bash
kubectl apply -f secrets/docs-secret.yaml -n mirunamu

Dockerfile

기존 Dockerfile에 BUILD_ID ARG와 cache-handler.mjs COPY를 추가합니다. BUILD_ID는 빌드 스테이지와 런타임 스테이지 양쪽에 필요한데, 빌드 스테이지에서는 next build가 generateBuildId를 통해 읽고, 런타임 스테이지에서는 cache-handler.mjs가 Redis 키 접두어로 사용합니다.

DOCKERFILE
# 2. 빌드
FROM node:22-alpine AS builder
WORKDIR /app
COPY --from=deps /app/node_modules ./node_modules
COPY . .
 
ARG BUILD_ID
ENV BUILD_ID=${BUILD_ID}
 
RUN npm run build
 
# 3. 실행
FROM node:22-alpine AS runner
WORKDIR /app
 
ENV NODE_ENV=production
 
ARG BUILD_ID
ENV BUILD_ID=${BUILD_ID}
 
# ...
COPY --from=builder --chown=nextjs:1001 /app/cache-handler.mjs ./cache-handler.mjs

CI/CD에서 --build-arg BUILD_ID=${{ github.sha }}로 전달하면 빌드 ID가 git commit SHA와 일치합니다. 런타임 시작 시 instrumentation.ts의 registerInitialCache()가 빌드 출력물(.next/server/app/)을 읽어서 Redis에 밀어넣으므로, 파일시스템 폴백 디렉토리를 별도로 COPY할 필요가 없습니다.

배포 확인

커밋 및 푸시

Bash
cd docs/ && git add . && git commit -m "Redis 캐시 핸들러 연동" && git push

Pod 및 환경변수 확인

Bash
kubectl get pods -n mirunamu
kubectl exec -n mirunamu deploy/docs -- env | grep REDIS_URL

RedisInsight에서 캐시 키 확인

GitHub stats 페이지에 접속 후, RedisInsight에서 docs:<빌드ID>: 프리픽스 키가 생기는지 확인합니다. registerInitialCache()가 동작했다면 docs:<빌드ID>:/robots.txt, docs:<빌드ID>:/docs 같은 정적 페이지 키도 보입니다.

RedisInsight에서 보이는 TTL

RedisInsight에서 키를 확인하면 TTL이 종류마다 다른 것을 볼 수 있습니다.

키 종류TTL이유
fetch 캐시 (revalidate 있음)revalidate × 1.5stale window 확보
정적 페이지 (revalidate 없음)~1.5년라이브러리 기본값 365일 × 1.5

"1yr"로 보이는 키들은 registerInitialCache()가 밀어넣은 정적 페이지입니다. 라이브러리 내부에 defaultStaleAge = 60 * 60 * 24 * 365(365일)가 하드코딩되어 있고, 실제 Redis TTL은 이 값의 1.5배로 설정됩니다. 사실상 영구 캐시에 가깝지만, 빌드 ID 기반 격리 덕분에 재배포 시 이전 빌드의 키가 정리되므로 무한히 쌓이지는 않습니다.

빌드 ID 기반 캐시 격리

Redis 캐시 연동 후 배포하면 정상적으로 동작하는 것처럼 보이지만, 두 번째 배포부터 문제가 발생합니다. 페이지 HTML은 렌더링되는데 사이드바, 클라이언트 컴포넌트, 인터랙션이 전부 동작하지 않는 증상이 나타납니다. 브라우저 콘솔에는 JS 청크에 대한 404 에러가 쏟아집니다.

원인: 빌드 ID 불일치

Next.js는 빌드마다 고유한 빌드 ID를 생성하고, 클라이언트 JS 청크 경로에 이 ID를 포함시킵니다. 문제는 registerInitialCache()가 빌드타임 HTML을 Redis에 밀어넣을 때 발생합니다. 이 HTML 안에는 해당 빌드의 JS 청크 경로가 하드코딩되어 있습니다.

재배포 시 흐름은 이렇습니다.

  1. 새 빌드가 배포되면서 .next/static/ 디렉토리에 새로운 빌드 ID의 JS 청크가 생성됩니다
  2. 그런데 Redis에는 이전 빌드의 HTML이 아직 남아있고, setOnlyIfNotExists: true 때문에 덮어쓰지 않습니다
  3. 사용자가 페이지에 접속하면 Redis에서 이전 빌드의 HTML을 서빙합니다
  4. 이 HTML이 참조하는 JS 청크 경로는 /_next/static/<이전-빌드-ID>/...인데, 서버에는 새 빌드 ID의 청크만 존재합니다
  5. 브라우저가 JS 청크를 요청하면 404가 반환되고, React 하이드레이션이 실패합니다

결과적으로 서버 렌더링된 HTML은 보이지만 클라이언트 사이드 코드가 전혀 로드되지 않아, 사이드바 토글, 검색, GitHub 활동 섹션 등 모든 인터랙티브 요소가 죽습니다.

해결: 빌드 ID를 캐시 키에 포함

cache-handler.mjs의 keyPrefix에 빌드 ID를 포함시키면 빌드마다 완전히 다른 Redis 키 네임스페이스를 사용하게 됩니다. 이전 빌드의 캐시는 새 빌드에서 애초에 조회되지 않습니다.

JavaScript
const buildId = process.env.BUILD_ID || "default";
 
const redisHandler = await createRedisHandler({
  client,
  keyPrefix: `docs:${buildId}:`,           // docs:abc123: 형태
  sharedTagsKey: `docsTags:${buildId}`,
  sharedTagsTtlKey: `docsTagTtls:${buildId}`,
});

next.config.ts에서도 같은 BUILD_ID를 빌드 ID로 사용하여 양쪽이 일치하도록 합니다.

TypeScript
generateBuildId: async () => process.env.BUILD_ID || "default",

BUILD_ID는 CI/CD에서 git commit SHA로 주입합니다.

YAML
# .github/workflows/docker-publish.yml
- name: Build and push
  uses: docker/build-push-action@v6
  with:
    build-args: |
      BUILD_ID=${{ github.sha }}

이 구조에서 배포 흐름은 다음과 같이 바뀝니다.

  1. 새 Pod가 시작되면 instrumentation.ts의 register()가 실행됩니다
  2. KEYS로 이전 빌드의 캐시 키(docs:<이전-SHA>:*, docsTags:<이전-SHA>, docsTagTtls:<이전-SHA>)를 찾아 삭제합니다
  3. registerInitialCache()가 새 빌드의 HTML을 docs:<새-SHA>: 키에 밀어넣습니다

롤링 업데이트와 잔여 키

cleanup은 현재 빌드 ID가 아닌 모든 키를 삭제하므로, 이전 빌드의 키는 세대에 관계없이 전부 정리됩니다. 다만 k8s 롤링 업데이트 특성상, 배포마다 소수의 이전 빌드 키가 남을 수 있습니다.

Deployment의 RollingUpdate 전략은 새 Pod가 Ready 상태가 된 후에 Old Pod을 종료합니다. 이 전환 구간에서 Old Pod과 New Pod이 동시에 트래픽을 처리하게 되는데, New Pod의 cleanup이 이전 키를 삭제한 이후에도 Old Pod은 여전히 요청을 받고 있으므로, 캐시 핸들러가 Old 빌드 ID로 새 캐시 엔트리를 기록합니다.

이렇게 남은 키는 다음 배포의 cleanup에서 일괄 삭제됩니다. 현재 빌드 ID가 아닌 키를 전부 삭제하는 방식이기 때문에, 몇 세대 전 키든 한 번에 정리되며 누적되지 않습니다.

잔여 키의 영향

롤링 업데이트로 남은 이전 빌드의 캐시 키는 현재 빌드의 keyPrefix와 일치하지 않으므로 캐시 핸들러가 조회하지 않습니다. 서빙에 영향을 주지 않으며, 다음 배포 시 자동으로 정리됩니다.

현재 구성 요약

항목설정
cacheHandler (단수)@fortedigital/nextjs-cache-handler → Redis
cacheHandlers (복수)미설정 → Next.js 내장 인메모리 LRU
cacheMaxMemorySize기본값(50 MB) — 'use cache' 내장 인메모리 LRU 활성
generateBuildIdprocess.env.BUILD_ID — git SHA 기반
빌드타임 동작LRU 메모리 캐시 (Redis 연결 안 함)
런타임 동작Redis 우선, LRU 폴백
캐시 키 격리docs:<빌드ID>: prefix로 빌드별 분리
이전 빌드 캐시 정리instrumentation.ts에서 KEYS + DEL
빌드 캐시 → Redis 전파instrumentation.ts의 registerInitialCache()

한 가지 제약이 남아 있습니다. 'use cache' 디렉티브가 사용하는 cacheHandlers(복수)는 현재 Redis로 연동되지 않아, Pod-로컬 인메모리 LRU에 캐시됩니다. Pod 간 캐시가 공유되지 않으므로, 같은 요청이라도 어느 Pod이 받느냐에 따라 캐시 히트 여부가 달라집니다. @fortedigital이 복수를 지원하게 되면(Issue #152) Redis로 통합할 예정입니다.

정리

5편 요약

  1. 캐시 아키텍처: 빌드타임은 파일시스템, 런타임 재생성부터 Redis. 단수와 복수는 별개 시스템
  2. fetch revalidate: 서버사이드 API 캐싱. 브라우저는 모름. ISR과는 다른 레벨
  3. @fortedigital: 빌드타임 LRU 폴백 + 단수 지원 + Redis 장애 시 graceful degradation
  4. 빌드 ID 격리: Redis 캐시 키에 빌드 ID를 포함시켜 재배포 시 이전 빌드 캐시와의 충돌 방지
  5. 주의점: 캐시 핸들러 도입 시 빌드 ID 격리는 필수. 단수/복수 구분과 cacheMaxMemorySize 영향 범위를 반드시 확인

다음 편에서는 Redis PVC가 특정 노드에 묶이는 한계를 넘어, Longhorn 분산 스토리지를 도입하여 노드 장애에도 데이터가 유실되지 않는 구조를 만들어 봅니다.

참고 자료

  • @fortedigital/nextjs-cache-handler GitHub
  • Next.js cacheHandler (단수) 공식 문서
  • Next.js cacheHandlers (복수) 공식 문서
  • Next.js 'use cache' 디렉티브
  • @redis/client 공식 문서
Written by

Mirunamu (Park Geonwoo)

Software Developer

관련 글 더보기

Infra

홈 k3s 클러스터 구축기 (1) — Ubuntu 설치부터 워커 노드 조인까지

4코어 8GB 미니PC 2대로 시작하는 경량 Kubernetes 홈 클러스터 구축 과정

읽기
Infra

홈 k3s 클러스터 구축기 (2) — 외부 노출과 TLS 인증서

공유기 포트포워딩, 도메인 연결, cert-manager를 통한 HTTPS 적용 과정

읽기
Infra

홈 k3s 클러스터 구축기 (3) — ArgoCD와 GitOps 배포 파이프라인

ArgoCD App-of-Apps, Helm Chart 분리 설계, GitHub Actions CI를 통한 GitOps 자동 배포 구축 과정

읽기
Infra

홈 k3s 클러스터 구축기 (6) — Longhorn 분산 스토리지

CSI 기반 분산 스토리지 개념과 Longhorn ArgoCD 배포, 데이터베이스 워크로드 적용

읽기
이전 글홈 k3s 클러스터 구축기 (4) — Redis 배포와 RedisInsight
다음 글홈 k3s 클러스터 구축기 (6) — Longhorn 분산 스토리지
목차
  • 들어가며
  • Next.js 캐시 아키텍처
    • 정적 페이지와 캐시의 관계
    • cacheHandler (단수) vs cacheHandlers (복수)
    • 런타임에 캐시되는 것
    • fetch revalidate — 서버사이드 API 캐싱
    • 현재 docs 프로젝트에서 캐시되는 부분
  • 라이브러리 선택
  • 설치 및 설정
    • cache-handler.mjs
    • instrumentation.ts — 이전 빌드 캐시 정리 + 프리로딩
    • next.config.ts
    • REDIS_URL 환경변수
    • Dockerfile
  • 배포 확인
    • RedisInsight에서 보이는 TTL
  • 빌드 ID 기반 캐시 격리
    • 원인: 빌드 ID 불일치
    • 해결: 빌드 ID를 캐시 키에 포함
    • 롤링 업데이트와 잔여 키
  • 현재 구성 요약
  • 정리
  • 참고 자료