Advanced (업데이트: 2026. 6. 1.)

Claude Code Redis 캐싱 실전 가이드: TTL, 무효화, Stampede 방지

Claude Code로 Redis 캐시를 설계하고 구현하는 실전 가이드. key 설계, TTL, 무효화, Node.js 코드와 테스트까지 다룹니다.

Claude Code Redis 캐싱 실전 가이드: TTL, 무효화, Stampede 방지

Redis는 데이터를 메모리에 올려 두고 key로 빠르게 찾는 고속 key-value store입니다. 데이터베이스 집계, 외부 API 응답, 랭킹, 짧은 세션, rate limit 같은 곳에 잘 맞습니다. 하지만 Claude Code에 “Redis 캐시를 넣어줘”라고만 지시하면, 오래된 가격, 갱신되지 않는 재고, 사용자별 데이터 노출, 너무 넓은 key 삭제 같은 문제가 생기기 쉽습니다.

이 글은 Claude Code가 그대로 따라 구현할 수 있는 Redis 캐싱 절차를 정리합니다. 핵심은 캐시 정책, key 설계, TTL, invalidation, cache stampede 방지, Node.js 구현, 테스트, review 체크리스트입니다. 전체 캐시 레이어 관점은 Claude Code caching strategies를, Redis를 큐에도 쓰는 경우는 Claude Code queue system을 함께 보면 좋습니다.

flowchart LR
  Request["HTTP request"] --> Cache["Redis cache"]
  Cache -->|hit| Response["Fast response"]
  Cache -->|miss| Lock["Short lock"]
  Lock --> Loader["DB or external API"]
  Loader --> Cache
  Loader --> Response
  Admin["Update event"] --> Invalidate["Delete known keys"]
  Invalidate --> Cache

먼저 정책을 정한다

Redis 구현보다 먼저 CLAUDE.md나 작업 프롬프트에 정책을 적어 둡니다. Claude Code는 코드 흐름은 잘 읽지만, “이 값이 몇 초까지 오래되어도 괜찮은가”는 비즈니스 판단 없이는 알 수 없습니다.

데이터key 예시TTL 기준무효화 시점주의점
공개 상품 목록claudecodelab:v1:products:list:ko5분상품 수정 성공 후 목록 key 삭제가격 변경을 TTL에만 맡기지 않기
글 상세claudecodelab:v1:posts:item:{slug}10분발행, 수정, 비공개 직후초안과 미리보기 제외
관리자 통계claudecodelab:v1:analytics:daily:{date}30초보통 TTL만 사용회계 기준값으로 쓰지 않기
외부 API 결과claudecodelab:v1:exchange-rate:usd-jpy1~15분수동 갱신 또는 TTL제공자 약관 확인
로그인 사용자 데이터기본 제외0초없음공유 캐시에 개인 정보 금지

즉, 공개되고 재생성 가능한 데이터는 Redis에 어울리고, 권한, 결제, 인증, 개인 정보는 별도 보안 설계 없이는 캐시하지 않는 편이 안전합니다.

Claude Code 작업 프롬프트

이 Node.js 앱에 Redis cache-aside 계층을 추가해 주세요.

요구사항:
1. 공식 node-redis 패키지인 redis를 사용한다
2. key는 claudecodelab:v1:{domain}:{resource}:{id} 형식으로 만든다
3. TTL은 캐시 정책에서 선택하고 10% 이내 jitter를 더한다
4. update는 DB write 성공 후 알려진 관련 key만 삭제한다
5. production 코드에서 KEYS를 쓰지 않는다. 필요하면 known key, SCAN, related-key Set을 쓴다
6. 인기 key의 동시 miss에 대비해 짧은 lock을 둔다
7. node:test로 key 생성, TTL 범위, getOrSet, 동시 miss를 테스트한다

반환:
- 변경 파일
- 실행한 테스트
- 캐시하지 않은 데이터와 이유

이 프롬프트를 Claude Code code review checklist와 함께 쓰면 “빨라졌는가”뿐 아니라 “올바르게 지워지는가”까지 확인할 수 있습니다.

실행 가능한 Node.js 구현

공식 Node.js 클라이언트는 redis 패키지입니다. 연결 패턴은 node-redis guide가 기준입니다.

mkdir redis-cache-demo
cd redis-cache-demo
npm init -y
npm install redis
docker run --name redis-cache-demo -p 6379:6379 -d redis:7-alpine

먼저 key와 TTL 정책을 한 파일에 모읍니다.

// cache-policy.js
const CACHE_PREFIX = "claudecodelab";
const CACHE_VERSION = "v1";

const CACHE_POLICY = {
  productList: { ttl: 300, jitter: 30 },
  productItem: { ttl: 600, jitter: 60 },
  dailyStats: { ttl: 30, jitter: 5 },
};

function normalizePart(value) {
  const part = String(value).trim().toLowerCase();
  if (part.length === 0) {
    throw new Error("cache key part must not be empty");
  }
  return encodeURIComponent(part);
}

function cacheKey(parts) {
  if (!Array.isArray(parts) || parts.length === 0) {
    throw new Error("cacheKey requires a non-empty parts array");
  }
  return [CACHE_PREFIX, CACHE_VERSION, ...parts.map(normalizePart)].join(":");
}

function ttlWithJitter(baseSeconds, maxJitterSeconds = 30) {
  if (!Number.isInteger(baseSeconds) || baseSeconds <= 0) {
    throw new Error("base TTL must be a positive integer");
  }
  const jitter = Math.max(0, Math.floor(maxJitterSeconds));
  return baseSeconds + Math.floor(Math.random() * (jitter + 1));
}

module.exports = { CACHE_POLICY, cacheKey, ttlWithJitter };

Redis 연결은 하나의 client를 공유합니다.

// redis-client.js
const { createClient } = require("redis");

const redis = createClient({
  url: process.env.REDIS_URL || "redis://localhost:6379",
});

redis.on("error", (error) => {
  console.error("Redis Client Error", error);
});

let connecting;

async function getRedis() {
  if (redis.isOpen) return redis;
  if (!connecting) {
    connecting = redis.connect();
  }
  await connecting;
  return redis;
}

async function closeRedis() {
  if (redis.isOpen) {
    await redis.quit();
  }
  connecting = undefined;
}

module.exports = { getRedis, closeRedis };

cache-aside helper는 Redis를 먼저 읽고, miss일 때만 loader를 실행합니다. 인기 key가 동시에 만료되어 DB로 요청이 몰리는 cache stampede를 줄이기 위해 SET NX PX로 짧은 lock을 사용합니다.

// redis-cache.js
const { randomUUID } = require("node:crypto");

const UNLOCK_SCRIPT = `
if redis.call("get", KEYS[1]) == ARGV[1] then
  return redis.call("del", KEYS[1])
end
return 0
`;

function sleep(ms) {
  return new Promise((resolve) => setTimeout(resolve, ms));
}

class RedisJsonCache {
  constructor(redis, options = {}) {
    this.redis = redis;
    this.defaultTtl = options.defaultTtl || 300;
    this.lockMs = options.lockMs || 5000;
    this.waitMs = options.waitMs || 50;
    this.waitRetries = options.waitRetries || 10;
  }

  async get(key) {
    const raw = await this.redis.get(key);
    if (raw === null) return null;
    try {
      return JSON.parse(raw);
    } catch {
      await this.redis.del(key);
      return null;
    }
  }

  async set(key, value, ttlSeconds = this.defaultTtl) {
    if (!Number.isInteger(ttlSeconds) || ttlSeconds <= 0) {
      throw new Error("ttlSeconds must be a positive integer");
    }
    await this.redis.set(key, JSON.stringify(value), { EX: ttlSeconds });
  }

  async invalidate(keys) {
    const list = Array.isArray(keys) ? keys : [keys];
    if (list.length === 0) return 0;
    return this.redis.del(list);
  }

  async getOrSet(key, ttlSeconds, loader) {
    const cached = await this.get(key);
    if (cached !== null) {
      return { value: cached, cacheStatus: "hit" };
    }

    const lockKey = `${key}:lock`;
    const token = randomUUID();
    const acquired = await this.redis.set(lockKey, token, { NX: true, PX: this.lockMs });

    if (acquired === "OK") {
      try {
        const fresh = await loader();
        await this.set(key, fresh, ttlSeconds);
        return { value: fresh, cacheStatus: "miss" };
      } finally {
        await this.redis.eval(UNLOCK_SCRIPT, { keys: [lockKey], arguments: [token] });
      }
    }

    for (let attempt = 0; attempt < this.waitRetries; attempt += 1) {
      await sleep(this.waitMs);
      const afterWait = await this.get(key);
      if (afterWait !== null) {
        return { value: afterWait, cacheStatus: "hit-after-wait" };
      }
    }

    const fallback = await loader();
    await this.set(key, fallback, Math.max(5, Math.floor(ttlSeconds / 3)));
    return { value: fallback, cacheStatus: "miss-after-timeout" };
  }
}

module.exports = { RedisJsonCache };

데모에서는 메모리 DB를 사용합니다. 실제 앱에서는 loadProductsFromDb()를 Prisma, Supabase, REST API로 바꾸면 됩니다. 관련 글은 Prisma ORM with Claude CodeSupabase integration with Claude Code를 참고하세요.

// demo-products.js
const { CACHE_POLICY, cacheKey, ttlWithJitter } = require("./cache-policy");
const { getRedis, closeRedis } = require("./redis-client");
const { RedisJsonCache } = require("./redis-cache");

const db = {
  products: [
    { id: "p1", locale: "ko", name: "CLAUDE.md 템플릿", price: 9, published: true },
    { id: "p2", locale: "ko", name: "Claude Code 교육", price: 199, published: true },
  ],
};

async function loadProductsFromDb(locale) {
  await new Promise((resolve) => setTimeout(resolve, 80));
  return db.products.filter((product) => product.locale === locale && product.published);
}

async function listPublishedProducts(cache, locale) {
  const key = cacheKey(["products", "list", locale]);
  const ttl = ttlWithJitter(CACHE_POLICY.productList.ttl, CACHE_POLICY.productList.jitter);
  return cache.getOrSet(key, ttl, () => loadProductsFromDb(locale));
}

async function main() {
  const redis = await getRedis();
  const cache = new RedisJsonCache(redis);
  const first = await listPublishedProducts(cache, "ko");
  const second = await listPublishedProducts(cache, "ko");
  console.log({ firstStatus: first.cacheStatus, secondStatus: second.cacheStatus, products: second.value });
  await cache.invalidate([cacheKey(["products", "list", "ko"])]);
  await closeRedis();
}

main().catch(async (error) => {
  console.error(error);
  await closeRedis();
  process.exitCode = 1;
});
node demo-products.js

테스트

Redis 서버 없이도 핵심 분기를 테스트할 수 있습니다.

// redis-cache.test.js
const test = require("node:test");
const assert = require("node:assert/strict");
const { cacheKey, ttlWithJitter } = require("./cache-policy");
const { RedisJsonCache } = require("./redis-cache");

class FakeRedis {
  constructor() {
    this.store = new Map();
  }

  async get(key) {
    const entry = this.store.get(key);
    if (!entry) return null;
    if (entry.expiresAt && entry.expiresAt <= Date.now()) {
      this.store.delete(key);
      return null;
    }
    return entry.value;
  }

  async set(key, value, options = {}) {
    if (options.NX && (await this.get(key)) !== null) return null;
    const ttlMs = options.PX || (options.EX ? options.EX * 1000 : 0);
    this.store.set(key, { value, expiresAt: ttlMs ? Date.now() + ttlMs : 0 });
    return "OK";
  }

  async del(keys) {
    const list = Array.isArray(keys) ? keys : [keys];
    let deleted = 0;
    for (const key of list) {
      if (this.store.delete(key)) deleted += 1;
    }
    return deleted;
  }

  async eval(_script, options) {
    const [key] = options.keys;
    const [token] = options.arguments;
    if ((await this.get(key)) === token) return this.del(key);
    return 0;
  }
}

test("cacheKey encodes dynamic parts", () => {
  assert.equal(cacheKey(["Products", "List", "ko/KR"]), "claudecodelab:v1:products:list:ko%2Fkr");
});

test("ttlWithJitter stays inside the expected range", () => {
  for (let i = 0; i < 50; i += 1) {
    const ttl = ttlWithJitter(300, 30);
    assert.ok(ttl >= 300);
    assert.ok(ttl <= 330);
  }
});

test("getOrSet caches the first loader result", async () => {
  const cache = new RedisJsonCache(new FakeRedis());
  let loads = 0;
  const first = await cache.getOrSet("products:list", 60, async () => {
    loads += 1;
    return [{ id: "p1" }];
  });
  const second = await cache.getOrSet("products:list", 60, async () => {
    loads += 1;
    return [{ id: "p2" }];
  });
  assert.equal(first.cacheStatus, "miss");
  assert.equal(second.cacheStatus, "hit");
  assert.equal(loads, 1);
  assert.deepEqual(second.value, [{ id: "p1" }]);
});

test("getOrSet waits instead of running duplicate loaders", async () => {
  const cache = new RedisJsonCache(new FakeRedis(), { waitMs: 5, waitRetries: 20 });
  let loads = 0;
  const loader = async () => {
    loads += 1;
    await new Promise((resolve) => setTimeout(resolve, 20));
    return { total: 42 };
  };
  const results = await Promise.all([
    cache.getOrSet("analytics:daily", 30, loader),
    cache.getOrSet("analytics:daily", 30, loader),
  ]);
  assert.equal(loads, 1);
  assert.deepEqual(results[0].value, { total: 42 });
  assert.deepEqual(results[1].value, { total: 42 });
});
node --test redis-cache.test.js

실제 유스케이스

첫째, 공개 카탈로그와 글 목록입니다. 모든 사용자가 같은 결과를 보므로 캐시하기 쉽고, 수정 후 관련 key를 삭제하면 됩니다.

둘째, 관리자 대시보드의 집계입니다. PV, 전환율, 매출 미리보기는 짧게 캐시할 수 있지만 권한과 결제 상태는 제외합니다.

셋째, 외부 API 응답입니다. 환율, 날씨, SaaS 플랜, 공개 GitHub 메타데이터는 짧은 TTL로 rate limit 부담을 줄일 수 있습니다.

넷째, rate limit입니다. Redis의 INCREXPIRE는 짧은 시간창 카운터에 적합하지만 인증 데이터는 별도 보안 검토가 필요합니다.

흔한 함정

key가 불완전하면 잘못된 응답이 재사용됩니다. 언어, 통화, tenant, role, query, version처럼 결과를 바꾸는 조건은 key에 넣어야 합니다.

DB write 전에 캐시를 지우면 실패 시 이상한 상태가 됩니다. 순서는 DB write 성공, Redis key 삭제, 필요하면 CDN purge입니다.

production에서 KEYS user:*는 피합니다. known key, related-key Set, SCAN 기반 maintenance command를 사용하세요.

null 값도 정해야 합니다. 위 구현은 null을 miss로 처리하므로 negative cache가 필요하면 { found: false }처럼 감싸서 저장합니다.

Review 체크리스트

  • 개인 정보, 권한, 결제 데이터가 제외되었는가
  • key에 locale, tenant, role, query, version이 필요한 만큼 들어갔는가
  • TTL이 업무상 신선도 기준으로 설명되는가
  • update가 DB 성공 후 관련 key를 삭제하는가
  • production 코드에서 KEYS를 쓰지 않는가
  • lock, jitter, fallback으로 stampede를 줄이는가
  • 테스트가 key, TTL, hit, concurrent miss를 다루는가
  • hit rate와 fallback을 로그나 metric으로 볼 수 있는가

공식 자료와 다음 단계

Claude Code 작업에는 Redis documentation, node-redis guide, Redis patterns, Redis optimization 링크를 함께 넣는 것이 좋습니다.

팀용 CLAUDE.md, prompt, review checklist까지 정리하려면 ClaudeCodeLab products and templates에서 시작할 수 있습니다. 실제 서비스의 Redis, CDN, DB update, monitoring 연결을 설계해야 한다면 Claude Code training and consultation을 활용하세요.

Masa가 작은 Node.js demo로 확인했을 때 첫 요청은 loader를 호출했고, 두 번째 요청은 Redis hit가 되었으며, 동시 miss 테스트에서도 loader는 한 번만 실행되었습니다. Redis 자체보다 key, TTL, invalidation, review 조건을 먼저 쓴 것이 가장 큰 효과였습니다.

#Claude Code #Redis #캐시 #Node.js #성능
무료

무료 PDF: Claude Code 치트시트

이메일을 입력하면 명령, 리뷰 습관, 안전한 워크플로를 정리한 PDF를 받을 수 있습니다.

개인정보를 안전하게 관리하며 스팸을 보내지 않습니다.

Masa

작성자 소개

Masa

Claude Code 실무 워크플로와 팀 도입을 검증하는 엔지니어입니다.