본문으로 건너뛰기
Previous
Next
Redis 캐싱 전략 패턴 5가지 | Cache-Aside부터 Refresh-ahead까지

Redis 캐싱 전략 패턴 5가지 | Cache-Aside부터 Refresh-ahead까지

Redis 캐싱 전략 패턴 5가지 | Cache-Aside부터 Refresh-ahead까지

이 글의 핵심

Redis 캐싱 패턴(Cache-Aside~Refresh-ahead)·TTL·무효화와 Redis 단일 스레드·epoll, RDB/AOF, LRU·LFU 축출, 프로덕션 연결·관측·장애 대응까지 Node.js 실무 관점에서 정리합니다.

Redis는 메모리 기반 키·값 저장소인데, 세션이랑 레이트 리밋, 브로커로도 쓰이지만 캐시 쓰는 케이스가 진짜 많다. 그런데 솔직히 말해서, 나는 캐시는 두 번째로 어려운 문제라고 본다. (첫째는 이름 짓기다. 둘째가 캐시 무효화다 — 옛 밈이지만, 실무에선 여전히 맞다.) 캐시를 어떻게 붙이느냐에 따라 일관성·지연·장애 때 행동이 갈리는데, 그게 Cache-Aside, Read-through, Write-through, Write-behind, Refresh-ahead로 정리돼 온 것이다. 이 글은 그 흐름을 Node(ioredis 가정) 쪽 느낌으로 풀어본다. 스택은 Docker Compose로 Redis 같이 띄우고, DB 쪽은 Node DB 연동·PostgreSQL vs MySQL 정도를 이미 봤다고 가정.

무효화 한 방 실패담부터. 옛날 팀에서 상품 가격 캐시에 product:123 키를 쓰고 있었는데, 어드민에서 가격을 바꾸는 API만 새로 만들고 그 경로에만 del을 넣은 줄 알았다. 막상 CS 들어온 건 “모바일 앱이 여전히 옛가격”이었고, 알고 보니 재고·프로모 연동 쪽 또 다른 업데이트 루트UPDATE는 하는데 캐시는 안 건드리는 케이스가 남아 있었던 것. TTL이 길수록 누구는 맞고 누구는 틀리는 그림이 된다. 결국 “가격/재고에 손대는 모든 쓰기 경로”를 grep해서 목록 뽑고, 거기서만 무효화·버전 키·이벤트를 통일했다. 캐시는 넣는 건 30분이고, 빼는(무효화) 설계가 3일 걸리는 이유다.

비유 하나만. DB 매번 가는 건 창고 끝까지 매번 뛰는 거고, Redis는 라인 앞 버퍼에 가깝다. 누가 그 버퍼를 채우고 비우느냐가 패턴마다 다르다.

이 글을 읽고 나면 다섯 패턴의 책임 분리(앱 vs 캐시 vs DB), Redis 단일 스레드·epoll·RDB·AOF·maxmemory 쪽 직감, TTL·스탬피드·무효화를 실무 말로 설명할 수 있게 될 것이다. (공식 문서만으로는 안 보이는 엣지가 많다. 나도 프로덕션에서만 겪는 병목이 꽤 있었다.)

Redis 안쪽 짧게. 핵심 명령 처리는 전통적으로 단일 스레드에 가깝다(버전마다 I/O 스레드 등이 있어도 “사용자 명령이 한 줄로 간다”는 그림은 비슷). 그래서 O(N) 뻔한 KEYS나 대형 정렬이 도는 동안 다른 요청도 같이 밀린다. 네트워크 쪽은 epoll 같은 멀티플렉싱으로 많은 연결을 한 프로세스가 받고, 실제 구조체 변경은 그 단일 경로로 간다. Node도 이벤트 루프라 “직렬 구간이 여러 층에 있다”는 감각이 맞는다. ioredis로 파이프라인·클러스터 쓰면 연결 수·대기 큐·타임아웃이 Redis 쪽 병목이랑 맞물려 p99이 튀는지 봐야 한다.

패턴별 — 코드는 ioredis 기준 최소 스케치다.

// lib/redis.js
import Redis from 'ioredis';
export const redis = new Redis(process.env.REDIS_URL);

Cache-Aside는 앱이 캐시 먼저 본 뒤, 없으면 DB 읽고 넣는다. 가장 흔하다. 쓸 때 캐시를 누가 지우냐는 앱 책임이 된다.

async function getUser(id) {
  const key = `user:${id}`;
  const cached = await redis.get(key);
  if (cached) return JSON.parse(cached);
  const row = await db.query('SELECT * FROM users WHERE id = $1', [id]);
  if (!row) return null;
  await redis.set(key, JSON.stringify(row), 'EX', 300);
  return row;
}

EX로 TTL을 건다 — 300초 지나면 옛 스냅샷이 무한히 남지 않는다. 없는 id를 오래 캐시하면 캐시 포이즈닝이 될 수 있으니, 짧은 TTL이나 “없음” 전용 키를 고려한다.

Read-through는 캐시/래퍼가 미스일 때 로더를 대신 돌린다. 앱은 readThrough 같은 API만 본다.

async function readThrough(key, ttlSec, loader) {
  const hit = await redis.get(key);
  if (hit) return JSON.parse(hit);
  const value = await loader();
  if (value != null)
    await redis.set(key, JSON.stringify(value), 'EX', ttlSec);
  return value;
}

Write-through는 쓸 때 DB랑 캐시를 함께 맞춘다. 읽기 경로는 단순해지고, 쓰기는 두 번 쓰는 만큼 느려질 수 있다.

async function updateUser(id, data) {
  await db.query('UPDATE users SET ....WHERE id = $1', [id]);
  const row = await db.query('SELECT * FROM users WHERE id = $1', [id]);
  await redis.set(`user:${id}`, JSON.stringify(row[0]), 'EX', 300);
}

Write-behind는 캐시에 먼저 쓰고 DB는 뒤에 비동기로 밀어 넣는다. 쓰기는 빨라질 수 있어도 손실·복구가 꼬이기 쉬워서 결제·재고엔 잘 안 맞는다. Refresh-ahead는 만료 전에 백그라운드로 미리 갱신해서 미스를 줄인다. 핫 키에 쓰면 좋고, 중복 갱신 방지 설계가 필요하다.

패턴 vs 감각만 정리하자면: Cache-Aside는 읽기 지연이 히트면 낮고, 일관성은 TTL+무효화에 기대는 편. Read-through도 읽기는 낮고 복잡도는 로더 한곳에 달렸다. Write-through는 읽기 낮·쓰기는 다소↑·일관성은 상대적으로 나은 편. Write-behind는 쓰기는 매우 낮을 수 있어도 일관성은 잡기 어렵다. Refresh-ahead는 히트면 매우 낮고, 선로딩 타이밍이 전부다. 표로 박지 않고 이 정도 톤으로 이해하는 게 낫다(표는 “요약”일 뿐, 운영은 표 밖에서 깨진다).

TTL·무효화·스탬피드. 키에 TTL이 있으면 오래된 데이터가 자연스럽게 사라진다. 짧으면 DB 부하, 길면 불일치 기간이 길다. Cache-Aside랑 궁합이 좋은 건 쓰기 시 삭제다.

async function updateUser(id, data) {
  await db.query('UPDATE users SET ....WHERE id = $1', [id]);
  await redis.del(`user:${id}`);
}

핫 키가 만료되는 순간 DB로 몰리면 스탬피드다. 확률적 조기 만료나, 싱글 플라이트(락 키로 한 요청만 DB)로 때운다.

const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
async function getWithSingleFlight(key, ttl, loader) {
  const lockKey = `lock:${key}`;
  const cached = await redis.get(key);
  if (cached) return JSON.parse(cached);
  const got = await redis.set(lockKey, '1', 'EX', 10, 'NX');
  if (!got) {
    await sleep(50);
    return getWithSingleFlight(key, ttl, loader);
  }
  try {
    const value = await loader();
    await redis.set(key, JSON.stringify(value), 'EX', ttl);
    return value;
  } finally {
    await redis.del(lockKey);
  }
}

NX+EX는 데드락 비슷한 꼬임을 막는 데 익숙한 패턴이다.

RDB·AOF는 “캐시만”이면 휘발로 두고 재기동 시 DB에서 다시 채우는 선택도 있다. 세션·큐·카운터가 섞이면 RDB 스냅샷 + AOF(everysec 같은 타협) 둘 다 켜는 팀도 많다. AOF는 길어지면 rewrite로 압축하는데, 그때도 디스크·fork 비용이 따라온다.

maxmemory·축출. volatile-lru / volatile-lfu는 TTL 붙은 키만 희생, allkeys-lru / allkeys-lfu는 전체. 순수 캐시면 후자가 흔하다. noeviction은 한계에선 쓰기 실패 — 캐시엔 잘 안 맞는다. 큰 값 몇 개가 메모리를 잡아먹으면 LRU가 의도와 다른 키를 먼저 날릴 수 있으니 값 크기 상한은 같이 본다.

프로덕션 잡담 톤으로. 연결은 워커당 수를 PM2/클러스터랑 맞추고, 타임아웃은 멱등 읽기에만 재시도 권장. MGET·파이프라인은 왕복 줄이는데, 한 덩어리가 너무 크면 Redis 단일 처리 구간에서 막힌다. 키는 서비스:엔티티:id 류로 통일하고, 스키마 바뀌면 user:v2: 같은 버전 접두를 박는 팀이 많다. Redis가 느리면 서킷으로 DB 직통이나 기능 축소로 간다(전부 500이 나는 것보다 낫다). 핫 키는 샤딩·로컬 L1·읽기 복제 쪽. 배포 직후 캐시가 비면 DB로 몰리니 워밍·Refresh-ahead·락/큐로 중복 선로딩을 막는다. 수평 확장된 Node는 인메모리 캐시만으론 일관성이 안 맞고, Redis를 중앙에 두고 Pub/Sub·스트림으로 로컬 L1 무효화를 섞는 이중 캐시도 나온다. Pub/Sub은 유실이 있을 수 있으니 치명 경로는 TTL·버전 키로 이중 잠금한다. TLS·ACL·방화벽·VPC는 기본값.

고장 났을 때(표 대신) — 간헐적 옛 데이터면 무효화 경로 누락 의심하고 업데이트마다 del/버전 추적. 메모리 폭주는 큰 객체·키 난립·maxmemory-policy 확인. 자주 미스면 LRU/volatile 설정이랑 TTL 붙은 키/안 붙은 키 혼용을 의심. 특정 키만 느리면 핫 키·O(N) 명령. DB가 터지면 스탬피드. 타임아웃 연쇄면 Redis 지연·슬로우 로그·연결 수.

마지막 한 줄. Cache-Aside로 시작해서 필요하면 Write-through·Refresh-ahead로 키우는 팀이 많다. TTL이랑 명시적 무효화를 같이 써야 밤에 잔다. epoll·RDB·AOF·축출 정책은 “패턴”과 별개로 지연·내구·메모리를 만든다. 캐시 인스턴스에 진짜 원천 데이터를 섞을지부터 구분해라. Node 성능 가이드C++ 캐싱 글·disk 이슈는 같이 읽으면 맥락이 잡힌다.

배포 전엔 git addcommitpushnpm run deploy 정도는 습관이 되어야 한다.

내부에 같이 쓰면 좋은 글: C++ 캐싱 전략 #50-8, [Redis Caching Strategies (en)](/en/blog/redis-caching-strategies/, Linux #09 디스크.

검색: Node.js, Redis, 캐싱, Cache-Aside, Write-through, epoll, RDB, LFU.