Cache Aside
읽을 때 캐시를 먼저 확인하고 MISS면 DB 조회 후 저장한다. 구현이 단순하고 읽기 중심 API에 잘 맞는다.
대부분의 일반 조회 API, 상품 목록, 프로필, 설정 조회분류: Layer 6 - 운영 심화: 관측성 & 복원력 | 작성일: 2026-04-01
Redis 내부 자료구조, RDB/AOF 영속화, 클러스터, ElastiCache 튜닝은 L8/redis-internals.mdx에서 다룬다. 이 문서는 Redis를 캐시와 장애 격리 장치로 사용하는 복원력 패턴에 집중한다.
Redis는 데이터를 메모리에 저장하는 Key-Value 스토어다. 이 문서에서 Redis의 핵심 역할은 “빠른 저장소” 자체가 아니라, 반복 조회를 흡수해 DB를 보호하고 장애가 하위 시스템으로 번지는 경로를 줄이는 공유 캐시 레이어다.
캐시는 원본 데이터베이스의 대체물이 아니라 복사본이다. 그래서 캐시를 이해할 때는 항상 세 질문을 같이 봐야 한다.
여기서 Key는 Redis가 값을 찾는 이름이고, Value는 그 이름에 매달린 실제 payload다. Redis는 key-value 구조라서 product:123처럼 접근은 빠르지만, 같은 이름으로 어떤 원본 데이터를 복사했는지 애플리케이션이 기억해야 한다. TTL과 Eviction은 “이 복사본을 언제 버릴 것인가”를 정하는 두 축이고, Fallback과 Circuit Breaker는 “버리거나 느려졌을 때 원본 시스템을 어떻게 보호할 것인가”를 정하는 축이다.
단일 서버에서는 프로세스 안의 Map이나 메모리 캐시만으로도 반복 조회를 줄일 수 있다. 문제는 서비스가 여러 pod이나 인스턴스로 늘어나는 순간부터다. 각 인스턴스가 자기 메모리에만 캐시를 들고 있으면 같은 상품, 같은 설정, 같은 세션을 여러 번 따로 저장한다.
이 구조는 세 가지 한계를 만든다.
Redis 분산 캐시는 캐시 상태를 별도 네트워크 노드에 두고 모든 애플리케이션 인스턴스가 공유하게 만든다. 이 선택은 hit rate와 일관성을 올리지만, Redis 자체가 느려지거나 장애가 나면 전체 서비스의 병목이 될 수 있다. 이 문서의 lineage는 여기에 있다. 로컬 캐시의 분산 한계를 Redis로 풀고, Redis가 만든 새 장애점을 Fallback, Circuit Breaker, TTL Jitter, Eviction 정책으로 다시 격리한다.
숫자로 보면 역할이 더 분명하다. NestJS API 10대가 7K RPS를 받고, Redis cache hit rate가 95%라면 DB로 내려가는 조회는 약 350 RPS다. 같은 상황에서 Redis가 사라지면 DB는 7K RPS를 직접 받아야 하므로 20배 부하를 맞는다. 그래서 Redis는 “응답을 빠르게 하는 도구”이기도 하지만, L6 관점에서는 DB connection pool과 p99 latency를 보호하는 완충 레이어다.
캐시(Cache)는 자주 쓰는 계산 결과나 조회 결과를 더 가까운 저장소에 보관하는 복사본이다. 캐시에서 값을 찾으면 Cache HIT, 찾지 못하면 Cache MISS라고 부른다. 캐시 hit rate가 높을수록 DB는 덜 호출되지만, 캐시 값이 원본보다 오래될 가능성도 함께 관리해야 한다.
캐시의 핵심 철학은 “모든 값을 영원히 빠르게 보관한다”가 아니다. 비싼 경로를 줄이되, 오래된 값과 장애 전파를 허용 가능한 범위 안에 묶는다가 더 정확하다.
Cache Aside는 애플리케이션이 캐시와 DB 사이의 흐름을 직접 제어하는 방식이다. 먼저 Redis를 조회하고, MISS일 때만 DB를 읽은 뒤 결과를 TTL과 함께 Redis에 저장한다. 쓰기나 수정이 일어나면 관련 캐시 키를 삭제하거나 갱신한다.
flowchart TD
Request["요청"] --> Cache["Redis 조회"]
Cache --> Hit{"HIT?"}
Hit -->|"예"| Cached["캐시 값 반환"]
Hit -->|"아니오"| DB["DB 조회"]
DB --> Save["Redis에 TTL과 함께 저장"]
Save --> Fresh["DB 조회 결과 반환"] async function getProduct(id: number): Promise<Product> { const key = `product:${id}`; const cached = await redis.get(key); if (cached) return JSON.parse(cached);
const product = await productRepository.findOneBy({ id }); await redis.set(key, JSON.stringify(product), "EX", 600); return product;}이 코드는 짧지만 중요한 경계를 담고 있다. productRepository가 원본이고, Redis는 읽기 비용을 줄이는 복사본이다. EX 600은 “10분 뒤 이 복사본을 더 이상 신뢰하지 않는다”는 시간 계약이다.
update -> DEL 무효화Cache Aside에서 읽기 코드만 있으면 절반만 이해한 것이다. 원본 DB를 수정한 뒤 기존 캐시를 지우는 무효화 경로가 있어야 다음 읽기가 새 값을 다시 채운다.
async function updateProductName(id: number, name: string): Promise<Product> { const updated = await productRepository.updateAndReturn(id, { name }); await redis.del(`product:${id}`); return updated;}순서가 중요하다. DEL -> update로 처리하면 캐시 삭제 직후 들어온 읽기 요청이 아직 수정 전인 DB 값을 다시 캐시에 채울 수 있다. update -> SET cache도 항상 더 안전한 것은 아니다. DB commit, 캐시 write, 동시 읽기 사이의 순서를 정확히 묶기 어렵고, 여러 쓰기 경로가 같은 키를 갱신하면 더 늦은 값이 더 이른 값으로 덮일 수 있다. 그래서 일반적인 Cache Aside에서는 원본을 먼저 바꾸고 캐시는 삭제한다. 삭제 후 첫 MISS가 DB에서 새 값을 읽어 다시 채우게 만드는 것이다.
이 방식도 완전한 일관성을 주지는 않는다. DB 수정은 성공했지만 DEL이 실패하면 stale 값은 TTL이 끝날 때까지 남을 수 있다. 그래서 수정 빈도가 높거나 stale 허용 폭이 좁은 데이터는 TTL을 짧게 잡고, 캐시 삭제 실패를 로그와 재시도 작업으로 남기거나, 이벤트 기반 무효화 경로를 별도로 둔다. 핵심 판단은 “캐시가 원본을 대신 최신 상태로 보장한다”가 아니라 “stale이 남아도 허용 가능한 시간 안에 사라진다”는 계약이다.
읽을 때 캐시를 먼저 확인하고 MISS면 DB 조회 후 저장한다. 구현이 단순하고 읽기 중심 API에 잘 맞는다.
대부분의 일반 조회 API, 상품 목록, 프로필, 설정 조회쓰기 시 DB와 캐시를 함께 갱신한다. 읽기 최신성은 좋아지지만 쓰기 경로가 느려지고 실패 처리가 복잡해진다.
쓰기 직후 같은 값을 자주 읽고 stale 허용 폭이 좁을 때캐시에 먼저 쓰고 DB에는 나중에 반영한다. 쓰기 부하는 줄지만 Redis 장애 시 데이터 유실 위험이 커진다.
조회수, 카운터처럼 일부 손실을 허용할 수 있는 고빈도 쓰기Cache Aside가 기본 선택인 이유는 실패 경계가 비교적 명확하기 때문이다. Redis 저장에 실패해도 DB 원본은 이미 읽었다. 반대로 Write Back은 Redis가 사실상 임시 원본이 되므로, 영속화와 복구 전략을 먼저 검토해야 한다.
Write Back은 “쓰기 요청을 Redis에 모아 두고 DB에는 나중에 반영한다”는 선택이다. 가장 이해하기 쉬운 예시는 조회수 카운터다. 요청마다 DB row를 update하면 write QPS가 너무 높으니 Redis에서 먼저 원자 증가시키고, 주기적으로 DB에 flush한다.
async function recordProductView(productId: number): Promise<void> { await redis.incr(`views:product:${productId}:pending`);}
async function flushProductViews(productId: number): Promise<void> { const key = `views:product:${productId}:pending`; const pending = Number((await redis.getdel(key)) ?? 0); if (pending === 0) return;
await productRepository.increment({ id: productId }, "viewCount", pending);}이 예제에서 Redis는 단순 캐시가 아니라 “아직 DB에 반영되지 않은 증가분”을 들고 있다. GETDEL은 pending 값을 읽고 지우는 원자 연산이라 중복 flush를 줄여주지만, DB increment가 실패하면 방금 지운 증가분이 사라질 수 있다. 그래서 Write Back은 결제 금액, 재고 차감, 권한 변경처럼 유실이 치명적인 값에는 기본 선택이 아니다. 조회수, 노출 수, rate-limit 카운터처럼 일부 손실을 감수할 수 있거나, flush 실패 시 재적재할 durable queue가 있을 때만 검토한다.
숫자로 보면 장점도 분명하다. 상품 1개가 초당 5K 조회를 받을 때 DB에 매 요청 update를 보내면 DB write가 5K QPS가 된다. Redis INCR로 모으고 10초마다 한 번 flush하면 DB write는 대략 0.1 QPS로 줄어든다. 대신 “최근 10초 증가분은 Redis 장애 시 잃을 수 있다”는 위험을 명시적으로 받아들이는 셈이다.
TTL(Time To Live)은 키가 Redis에 남아 있을 수 있는 최대 시간이다. TTL은 단순한 만료 타이머가 아니라, 캐시 값의 신뢰 기간과 메모리 회수 시점을 함께 정하는 계약이다.
TTL이 필요한 이유는 두 가지다.
TTL은 짧을수록 안전한 것이 아니다. 너무 짧으면 MISS가 자주 발생해 DB 부하가 늘고, 너무 길면 stale 데이터가 오래 보인다. 그래서 TTL은 데이터의 변경 빈도와 사용자에게 허용되는 stale 시간을 기준으로 정한다.
| 데이터 종류 | TTL 판단 기준 | 주의할 점 |
|---|---|---|
| 세션/토큰 블랙리스트 | 보안 정책과 만료 시간 | TTL이 원본 토큰 만료보다 짧으면 안 된다 |
| 상품/목록 조회 | 변경 빈도와 stale 허용 시간 | 수정 시 DEL 또는 갱신 이벤트가 필요하다 |
| 설정/코드성 데이터 | 변경이 드물고 stale 영향이 작은지 | 장애 대응 중 즉시 반영이 필요한 값은 주의한다 |
| 랭킹/카운터 | 실시간성 요구와 계산 비용 | 짧은 TTL은 stampede를 만들 수 있다 |
TTL을 정할 때는 “몇 분이 적당해 보인다”보다 두 숫자를 먼저 잡는 편이 낫다. 첫째, 사용자가 오래된 값을 봐도 되는 최대 시간이다. 상품 설명이 5분 늦게 바뀌어도 괜찮다면 TTL 300초는 후보가 되지만, 재고 수량이 5초만 늦어도 주문 실패를 만들면 같은 TTL은 위험하다. 둘째, MISS가 원본에 주는 비용이다. MISS 1건이 DB primary index lookup 한 번이면 짧은 TTL도 버틸 수 있지만, 외부 API 호출이나 큰 join이면 TTL을 짧게 줄이는 순간 원본 비용이 폭증한다.
작은 계산으로 감을 잡아보자. 어떤 목록 API가 2K RPS를 받고 hit rate가 90%면 DB 조회는 200 RPS다. TTL을 10분에서 1분으로 줄여 hit rate가 70%까지 떨어지면 DB 조회는 600 RPS가 된다. stale 허용 폭을 줄인 대가로 DB QPS가 3배가 된 것이다. 반대로 데이터 변경 시 update -> DEL 무효화가 잘 동작한다면 TTL은 “평상시 최신성”보다 “무효화 누락 시 오래된 값이 남는 최악 시간”에 가깝다. 그래서 TTL은 무효화 전략, MISS 비용, stale 허용 시간을 함께 놓고 정해야 한다.
같은 시간에 생성한 많은 키에 같은 TTL을 주면 같은 시각에 함께 만료된다. 이때 트래픽이 높은 키들이 동시에 MISS가 나면 DB 요청이 한 번에 몰린다. TTL Jitter는 기본 TTL에 작은 랜덤 편차를 더해 만료 시점을 분산한다.
function ttlWithJitter(baseSeconds: number, jitterSeconds = 60): number { return baseSeconds + Math.floor(Math.random() * jitterSeconds);}
await redis.set(key, payload, "EX", ttlWithJitter(600));TTL Jitter의 철학은 “정확히 10분 캐시”가 아니라 “대략 10분 캐시를 여러 시점에 나눠 만료”시키는 것이다. 사용자 입장에서 600초와 647초의 차이가 중요하지 않다면, Jitter는 매우 싼 비용으로 stampede 위험을 낮춘다.
숫자로 보면 효과가 더 분명하다. 배포 직후 12,000개 키가 동시에 만들어지고 모두 TTL 600초를 받으면 10분 뒤 같은 초에 12,000개 키가 만료될 수 있다. 여기에 0~120초 Jitter를 더하면 만료 시점이 2분 폭으로 퍼지고, 평균적으로는 초당 약 100개 키가 만료된다. 물론 트래픽이 hot key 몇 개에만 몰리면 평균 분산만으로 충분하지 않으므로 Mutex Lock, Early Refresh, Stale While Revalidate가 필요하다.
Jitter는 모든 TTL에 붙이는 만능 장식이 아니다. 토큰 블랙리스트처럼 보안 만료 시각이 정확해야 하거나, 법적/정책적 만료 시간이 명확한 값에는 임의 연장을 붙이면 안 된다. Jitter가 자연스러운 곳은 “대략 이 정도 시간 동안 복사본을 써도 된다”는 캐시성 데이터다.
Eviction은 Redis 메모리가 maxmemory에 가까워졌을 때 어떤 키를 삭제할지 정하는 정책이다. TTL 만료와 Eviction은 다르다. TTL 만료는 시간이 지나서 키가 사라지는 것이고, Eviction은 메모리 압박 때문에 Redis가 살아남기 위해 키를 버리는 것이다.
캐시 시스템에서 Eviction은 실패가 아니라 정상적인 압력 해소 장치다. 하지만 어떤 데이터를 Redis에 넣었는지에 따라 정책 선택이 완전히 달라진다.
메모리가 가득 차면 새 쓰기를 거부한다. 데이터가 자동 삭제되지 않지만 캐시 저장 실패가 애플리케이션 오류로 보일 수 있다.
Redis에 임시 캐시가 아니라 유실되면 안 되는 큐/상태가 섞여 있을 때전체 키 중 최근 덜 쓰였거나 사용 빈도가 낮은 키를 삭제한다. 순수 캐시 용도에 가장 자연스럽다.
Redis를 DB 앞 읽기 캐시로 쓰고, MISS 시 DB 재조회가 가능할 때TTL이 있는 키 중에서만 삭제한다. TTL 없는 영구 키를 보호하지만, TTL 없는 키가 많으면 메모리 회수가 제한된다.
일부 영구 키를 Redis에 함께 두지만 캐시 키에는 TTL을 강제할 때Eviction을 이해할 때의 경계는 명확하다. Redis를 캐시로 쓴다면 키가 사라져도 원본에서 다시 만들 수 있어야 한다. 키가 사라지면 결제 상태, 작업 큐, 사용자 권한이 복구 불가능하게 깨진다면 그것은 단순 캐시가 아니며 L8의 영속화와 클러스터 설계까지 포함해 판단해야 한다.
메모리 압박은 보통 평균 value 크기와 key 개수의 곱에서 시작한다. 예를 들어 JSON payload가 평균 2KB인 상품 캐시를 100만 개 저장하면 값만 2GB다. 여기에 key 문자열, Redis object overhead, allocator fragmentation, replication buffer 같은 내부 오버헤드가 붙으므로 실제 used_memory는 단순 payload 합보다 커진다. allocator fragmentation은 메모리 할당 단위가 딱 맞지 않아 남는 조각이고, replication buffer는 replica로 데이터를 보내기 위해 잠깐 쌓아 두는 버퍼다. maxmemory를 “인스턴스 RAM 전체”에 가깝게 잡으면 OS와 Redis 내부 버퍼가 숨 쉴 공간이 없어지고, 너무 낮게 잡으면 eviction이 과도하게 일어나 hit rate가 떨어진다.
정책 선택의 반례도 중요하다. 순수 캐시라고 생각하고 volatile-lru를 골랐는데 코드 일부가 TTL 없이 SET을 쓰면, TTL 없는 키는 eviction 후보가 아니어서 메모리 회수가 제한된다. 반대로 allkeys-lru는 모든 키를 버릴 수 있으므로 큐, 락, idempotency 결과처럼 “없어지면 의미가 깨지는 값”을 같은 Redis에 섞었을 때 위험하다. 캐시 키와 상태 키가 섞이기 시작하면 eviction 정책 하나로 둘 다 만족시키기 어렵기 때문에 Redis 역할 분리까지 검토해야 한다.
Eviction 실패 신호는 다음과 같다.
used_memory가 maxmemory에 계속 붙어 있다.evicted_keys가 빠르게 증가하고 cache hit rate가 떨어진다.Cache Stampede는 인기 키가 만료되거나 삭제된 직후 많은 요청이 동시에 MISS를 만나 DB로 몰리는 현상이다. 캐시가 정상일 때는 DB를 보호하지만, 만료 시점이 겹치면 오히려 DB 부하를 증폭한다.
flowchart TD HotKey["인기 키 TTL 만료"] --> MissMany["다수 요청이 동시에 MISS"] MissMany --> DBBurst["각 요청이 DB 조회 시도"] DBBurst --> Pool["DB connection pool 포화"] Pool --> Latency["API p99 지연 증가"] Latency --> Retry["상위 재시도 증가"] Retry --> DBBurst
Stampede 방지의 핵심은 “모든 요청이 동시에 원본을 재생성하지 못하게 한다”는 것이다.
| 방법 | 해결 메커니즘 | 적합한 경우 |
|---|---|---|
| TTL Jitter | 키별 만료 시점을 분산한다. | 여러 키가 비슷한 시점에 생성되는 일반 캐시 |
| Mutex Lock / Single Flight | MISS가 나도 한 요청만 DB를 조회하고 나머지는 기다린다. | DB 조회 비용이 큰 hot key |
| Probabilistic Early Refresh | 만료 직전에 일부 요청이 미리 갱신한다. | 지속 트래픽이 있는 매우 인기 있는 키 |
| Stale While Revalidate | 오래된 값을 잠깐 반환하면서 백그라운드로 갱신한다. | 약간 stale이어도 사용자 경험이 중요한 조회 |
Probabilistic Early Refresh는 TTL이 아직 남아 있어도 만료가 가까워질수록 일부 요청만 먼저 갱신하게 만든다. 모든 요청이 만료 순간까지 기다렸다가 동시에 MISS를 내는 대신, 지속 트래픽 중 일부가 미리 새 값을 만들어 둔다. Stale While Revalidate는 반대로 “이미 조금 낡은 값”을 잠깐 반환하고, 사용자 요청 경로 밖에서 새 값을 채우는 방식이다.
Stale While Revalidate(SWR)를 쓸 때는 보통 두 시간을 나눈다. 예를 들어 freshTtl = 60초, staleTtl = 300초라면 60초 안의 값은 그대로 반환하고, 60~300초 사이의 값은 사용자에게 일단 반환하되 백그라운드 갱신을 시도한다. 300초를 넘은 값은 더 이상 반환하지 않고 MISS처럼 다룬다. 이 구조는 추천 목록, 공지, 랭킹처럼 “조금 오래된 응답이 느린 응답보다 낫다”는 조회에 잘 맞는다. 반대로 권한, 재고, 결제 상태처럼 잘못된 stale 값이 사용자 행동을 오염시키는 경로에서는 stale 반환 대신 빠른 실패나 원본 조회를 선택해야 한다.
SWR의 실패 신호는 “오래된 값이 계속 보인다”가 아니라 “갱신 작업이 실패해 stale 창 끝까지 밀린다”에 가깝다. 그래서 stale 응답률, background refresh 실패율, 값의 age를 같이 봐야 한다. stale 응답률이 높고 refresh 실패가 계속되면 사용자는 정상 응답을 받는 것처럼 보이지만, 실제로는 캐시가 조용히 낡아가는 중이다.
분산 락을 쓸 때는 SET key value NX EX seconds처럼 TTL이 있는 락이어야 한다. TTL 없는 락은 작업자 장애 시 영구 잠금이 된다. 또한 락 해제 시에는 내가 잡은 락인지 확인해야 다른 인스턴스의 락을 지우지 않는다.
const lockValue = crypto.randomUUID();const acquired = await redis.set(`lock:${key}`, lockValue, "NX", "EX", 10);
if (acquired === "OK") { try { const fresh = await fetchFromDb(); await redis.set(key, JSON.stringify(fresh), "EX", ttlWithJitter(600)); } finally { await releaseOnlyIfOwner(`lock:${key}`, lockValue); }}락을 얻지 못한 요청은 무조건 재귀 재시도하지 않는다. DB 비용이 매우 크고 응답 최신성이 중요하면 짧게 대기한 뒤 다시 캐시를 확인할 수 있다. stale 값을 허용할 수 있으면 오래된 값을 반환하는 편이 DB 보호에 낫다. 결제나 권한처럼 stale이 위험한 경로라면 빠른 실패가 더 안전하다.
Hot Key는 특정 키 하나에 요청이 몰려 Redis나 DB 병목을 만드는 키다. 공지, 인기 상품, 전역 설정처럼 대부분의 요청이 같은 값을 읽으면 Redis 자체도 단일 지점으로 뜨거워진다. 이때는 Redis를 L2 캐시로 두고 각 애플리케이션 인스턴스에 짧은 TTL의 L1 in-process 캐시를 얹거나, Early Refresh와 Mutex Lock으로 재생성 요청 수를 제한한다.
Hot Key를 2단계 캐시로 흡수하는 감각은 숫자로 보면 쉽다. API pod 10대가 global:notice 키 하나를 합쳐 7K RPS로 읽는다면, L2 Redis는 초당 7K GET을 받는다. 각 pod에 TTL 2초짜리 L1 캐시를 두면 같은 키에 대한 Redis 재조회는 pod당 2초에 한 번, 전체로는 대략 초당 5회 수준까지 내려갈 수 있다. L1 값이 2초 stale이어도 괜찮은 공지, 추천, feature flag 조회라면 Redis hot key를 매우 싸게 식힐 수 있다. 반대로 권한, 재고, 결제 상태처럼 1~2초 stale도 위험한 값이면 L1 캐시보다 빠른 실패, 더 좁은 TTL, 원본 조회 정책을 먼저 검토한다.
Early Refresh는 “만료된 뒤 모두가 MISS를 맞는 것”을 피한다. 예를 들어 TTL이 600초인 인기 키에서 남은 TTL이 30초 이하가 되면 요청의 5%만 백그라운드 갱신을 시도하게 만들 수 있다. 그러면 대부분의 요청은 기존 값을 그대로 받고, 소수 요청만 새 값을 준비한다. 이 방식은 지속 트래픽이 있는 키에 잘 맞고, 트래픽이 거의 없다가 갑자기 들어오는 키에는 Mutex Lock이나 Stale While Revalidate가 더 직접적이다.
Stampede의 실패 신호는 cache hit rate 하락 하나로 끝나지 않는다. Redis MISS 증가 직후 DB QPS, DB connection wait, API p99, 상위 재시도가 함께 뛰면 캐시 만료가 장애 증폭기로 바뀐 것이다.
Redis 장애는 두 형태로 온다. 하나는 연결 실패처럼 명확한 장애이고, 다른 하나는 Redis가 살아 있지만 응답이 느려지는 silent degradation이다. 두 번째가 더 위험하다. 애플리케이션은 Redis를 계속 기다리고, 대기 중인 요청은 Node.js 이벤트 루프와 DB fallback 경로를 함께 압박한다.
Silent degradation은 “Redis가 죽었는가”보다 “요청 경로가 Redis를 기다리느라 막히는가”로 판단한다. 숫자는 서비스마다 다르지만, 캐시 조회를 사용자 요청 경로 안에서 수행한다면 아래 정도를 출발점으로 삼을 수 있다.
| Redis 지연 신호 | 해석 | 대응 방향 |
|---|---|---|
| 평균 1~3ms, p99 한 자리 ms | 네트워크 캐시로 자연스러운 범위 | hit rate와 DB QPS를 함께 관찰한다. |
| 평균 10ms 이상 또는 p99 50ms 이상 | 캐시가 응답 시간의 주요 구성요소가 되기 시작함 | timeout, 큰 키, 블로킹 커맨드, hot key를 본다. |
| max 100ms 이상 burst 또는 timeout | 요청 대기가 쌓이고 fallback이 동시에 열릴 수 있음 | Circuit Breaker와 fallback budget을 확인한다. |
| Redis timeout + DB QPS 급증 동시 발생 | 캐시 장애가 DB 장애로 전파되는 중 | 제한 없는 DB fallback을 즉시 의심한다. |
여기서 중요한 점은 평균만 보지 않는 것이다. Redis 평균 latency가 낮아도 1분에 몇 번씩 200ms 이상 멈추면 Node.js API의 p99와 timeout이 흔들릴 수 있다. Redis command timeout은 “느린 캐시를 무한정 기다리지 않겠다”는 상한이고, Circuit Breaker는 “느린 캐시를 계속 호출하지 않겠다”는 호출량 상한이다.
Fallback은 Redis 조회에 실패했을 때 DB를 직접 조회하는 대체 경로다. 이 패턴은 서비스 기능을 유지할 수 있지만, 캐시가 평소 흡수하던 트래픽이 한 번에 DB로 향한다.
캐시 hit rate가 H라면 캐시 장애 시 DB 트래픽 증폭은 대략 1 / (1 - H)로 본다.
| 평소 cache hit rate | Redis 장애 시 DB 증폭 | 판단 |
|---|---|---|
| 80% | 5배 | 제한된 fallback을 검토할 수 있다. |
| 95% | 20배 | DB headroom 없이는 fallback이 위험하다. |
| 99% | 100배 | 제한 없는 fallback은 거의 장애 전파다. |
따라서 “Redis가 죽으면 DB로 가면 된다”는 말은 DB가 그 순간의 부하를 감당할 수 있을 때만 맞다. DB headroom이 부족하면 빠른 실패, 부분 기능 제한, rate limit, stale 응답이 더 나은 선택일 수 있다.
fallback 판단은 hit rate보다 DB headroom이 더 직접적이다. 예를 들어 전체 조회 트래픽이 7K RPS이고 평소 hit rate가 95%라면 DB 조회는 350 RPS다. 여기에 쓰기와 다른 조회를 합친 DB 현재 부하가 850 RPS이고, 장애 중 안전하게 버틸 수 있는 상한이 2K RPS라면 headroom은 1,150 RPS뿐이다. Redis 장애 시 전체 7K RPS를 DB로 넘기는 full fallback은 불가능하다. 이 경우 fallback으로 허용할 수 있는 조회는 대략 1,150 / 7,000 = 16%이고, 나머지는 stale 응답, 중요도 낮은 endpoint 차단, 빠른 실패 중 하나로 보내야 한다.
이 계산은 정확한 용량 산정이 아니라 사고방식이다. “hit rate가 높으니 캐시가 잘 된다”에서 끝내지 않고, “캐시가 사라진 5분 동안 DB가 몇 RPS까지 버틸 수 있는가”를 정해야 fallback이 복원력 패턴이 된다. 이 숫자가 없으면 fallback은 안전장치가 아니라 장애 증폭기다.
Circuit Breaker는 일정 비율 이상 실패하거나 timeout이 누적되면 Redis 호출을 잠시 차단한다. retry-backoff-idempotency.mdx에서 다룬 Closed → Open → Half-Open 상태 기계를 Redis 레이어에 적용하는 것이다.
flowchart TD Closed["Closed: Redis 호출 허용"] -->|"timeout 또는 오류율 증가"| Open["Open: Redis 우회"] Open -->|"reset timeout 경과"| HalfOpen["Half-Open: 소량 probe"] HalfOpen -->|"성공"| Closed HalfOpen -->|"실패"| Open
Circuit Breaker가 의미 있으려면 Redis 클라이언트 timeout이 먼저 있어야 한다. commandTimeout이 없으면 느린 Redis 호출이 실패로 바뀌지 않고, breaker는 열리지 않는다.
new Redis({ host: process.env.REDIS_HOST, commandTimeout: 200, maxRetriesPerRequest: 1,});Redis용 Circuit Breaker는 Redis를 고치는 장치가 아니라 요청 경로를 보호하는 장치다. 예를 들어 최근 30초 동안 Redis 호출의 timeout 비율이 50%를 넘으면 Open으로 바꾸고, 10~30초 뒤 Half-Open에서 소량 요청만 통과시킬 수 있다. Open 상태에서 모든 요청을 DB fallback으로 보내면 breaker가 Redis 호출만 줄이고 DB 장애는 키우는 꼴이 된다. 그래서 Open 상태의 행동은 endpoint 중요도별로 나누어야 한다. stale 응답이 가능한 조회는 stale을 반환하고, 중요도가 낮은 추천/랭킹은 빠르게 degrade하며, 원본 조회가 꼭 필요한 경로만 rate limit 안에서 DB fallback을 허용한다.
Half-Open도 “복구됐는지 한번 크게 몰아본다”가 아니다. probe 요청 수를 작게 제한하고, 성공률과 latency가 안정될 때 Closed로 돌아가야 한다. 이 구간을 느슨하게 잡으면 Redis가 회복 직후 다시 과부하를 맞거나, 애플리케이션이 Open/Closed를 빠르게 오가며 p99를 흔들 수 있다.
Redis가 실패했을 때 모든 요청을 DB로 보내면 fallback이 DB 장애를 만든다. 그래서 fallback 경로에는 rate limit, endpoint별 차단, 중요도 기반 degrade가 같이 있어야 한다.
Redis rate limit의 가장 작은 모델은 INCR + EXPIRE다. 현재 1분 창을 나타내는 키를 만들고, 요청마다 원자적으로 증가시키며, 첫 요청일 때만 TTL을 붙인다.
async function allowRequest(userId: string, limit = 100): Promise<boolean> { const minute = Math.floor(Date.now() / 60_000); const key = `rate:${userId}:${minute}`; const count = await redis.incr(key);
if (count === 1) { await redis.expire(key, 60); }
return count <= limit;}이 방식은 fixed-window라서 분 경계에 요청이 몰리면 부정확할 수 있다. 하지만 fallback 경로를 무제한으로 열어 DB를 무너뜨리는 것보다는 훨씬 낫다. 더 정확한 제어가 필요하면 Sorted Set으로 sliding window를 만들지만, 학습 관점에서는 먼저 “Redis의 원자 증가 + TTL이 호출량 상한을 만든다”는 메커니즘을 이해하면 된다.
fixed-window의 반례는 경계 시점에 있다. limit = 100/min일 때 어떤 사용자가 12:00:59에 100번, 12:01:00에 100번 요청하면 두 요청 묶음은 서로 다른 분 키를 쓰므로 2초 안에 200번이 허용될 수 있다. 로그인 시도 제한이나 fallback DB 보호처럼 짧은 burst가 치명적이면 fixed-window만으로는 부족하다. Sliding window는 Sorted Set에 요청 timestamp를 넣고, 현재 시각에서 60초보다 오래된 항목을 지운 뒤 남은 개수를 세는 방식으로 이 경계 burst를 줄인다. 대신 요청마다 ZADD, ZREMRANGEBYSCORE, ZCARD가 필요하므로 구현과 Redis 비용이 더 크다.
따라서 rate limit 전략도 데이터와 같다. “대략적인 분당 상한”이면 INCR + EXPIRE가 단순하고 싸다. “어느 60초 구간에서도 정확히 N개 이하”가 필요하면 sliding window나 token bucket을 검토한다. Redis 장애 시 fallback 안전핀으로는 먼저 단순한 fixed-window를 두고, 실제 burst 문제가 관측될 때 더 정교한 방식으로 올리는 편이 운영 리스크가 낮다.
원본 DB를 직접 조회한다. 기능은 유지되지만 DB 부하가 급증한다.
DB headroom이 충분하고 요청량을 제한할 수 있을 때조금 오래된 캐시나 로컬 값을 반환한다. 정확도보다 가용성이 중요할 때 쓴다.
상품 목록, 추천, 비핵심 설정처럼 stale 허용 폭이 있을 때Redis와 DB를 더 압박하지 않고 503 또는 제한 응답을 반환한다.
결제, 권한, 재고처럼 잘못된 응답이 더 위험할 때운영 절차를 외우기 전에 어떤 현상이 어떤 개념의 실패를 뜻하는지 연결해야 한다.
클러스터 관련 신호는 여기서 한 줄만 읽으면 된다. Redis Cluster는 key를 여러 hash slot으로 나누어 노드에 분산하는 구성이고, CROSSSLOT은 한 번의 multi-key 명령이 서로 다른 slot의 key를 함께 다룰 때 나는 오류다. 관련 key를 같은 slot에 묶는 표기인 key tag 같은 세부 디버깅은 L8 범위다.
| 관측 신호 | 의미하는 실패 | 먼저 의심할 개념 |
|---|---|---|
| CacheHitRate 급락 + DB QPS 급증 | 캐시가 DB 보호 역할을 잃었다. | TTL, 무효화, Stampede |
| Redis latency 증가 + API p99 증가 | Redis가 살아 있지만 느려져 요청 경로를 막고 있다. | command timeout, Circuit Breaker |
evicted_keys 증가 + hit rate 하락 | 메모리 압박으로 필요한 키가 계속 밀려난다. | Eviction 정책, maxmemory, 키 크기 |
| OOM/write error | Redis가 새 키를 저장하지 못한다. | noeviction, TTL 누락, 메모리 sizing |
blocked_clients 증가 | 블로킹 커맨드나 긴 작업이 Redis를 붙잡고 있다. | KEYS, 큰 SMEMBERS, 느린 Lua/script |
| fallback 호출 증가 | Redis 장애가 DB 경로로 전파되고 있다. | Fallback budget, Rate Limit |
| Circuit Open 빈발 | Redis 장애 또는 timeout 기준이 실제 부하와 충돌한다. | timeout, breaker threshold, Redis latency |
CROSSSLOT 오류 증가 | Redis Cluster에서 여러 키 명령이 서로 다른 slot을 건드린다. | key tag, multi-key command 경계 |
이 신호들은 따로 보지 않는다. Redis hit rate만 낮은지, DB QPS와 p99가 함께 뛰는지, circuit open 로그가 latency burst와 같은 시간대에 있는지를 묶어 봐야 원인이 보인다.
Redis 캐시를 설계할 때는 코드부터 쓰기보다 다음 순서로 판단한다.
캐시하지 않는 선택도 설계다. 원본 조회가 이미 싸고, 같은 키를 반복 조회하지 않으며, stale 허용 폭이 거의 없다면 Redis를 넣어도 복잡도만 늘 수 있다. 예를 들어 사용자별로 한 번만 보는 일회성 export 상태를 Redis에 캐시하면 hit rate는 낮고 키 수만 늘어난다. 반대로 모든 요청이 읽는 전역 설정을 TTL 없이 Redis에 넣으면 hit rate는 높아도 변경 즉시 반영과 eviction 정책이 어려워진다. 좋은 캐시 후보는 반복 조회가 많고, MISS 비용이 크며, stale 허용 폭과 무효화 경로를 설명할 수 있는 데이터다.
| 키워드 | 설명 |
|---|---|
| Redis | 메모리 기반 Key-Value 스토어. 캐시, 세션, rate limit, 큐 등에 사용한다. |
| Cache | 원본 데이터나 계산 결과의 복사본을 더 빠른 위치에 저장하는 계층 |
| Cache HIT / MISS | 캐시에 값이 있으면 HIT, 없어서 원본을 조회해야 하면 MISS |
| Cache Aside | 읽기 시 캐시를 확인하고 MISS일 때 DB 조회 후 캐시에 저장하는 전략 |
| Write Through | 쓰기 시 DB와 캐시를 함께 갱신하는 전략 |
| Write Back | 캐시에 먼저 쓰고 DB에는 비동기로 반영하는 전략 |
| TTL | Time To Live. 캐시 키가 유효한 최대 시간 |
| TTL Jitter | 동시 만료를 줄이기 위해 TTL에 랜덤 편차를 더하는 방식 |
| Eviction | 메모리 압박 시 Redis가 키를 삭제하는 정책 |
| Cache Stampede | 캐시 만료 직후 다수 요청이 동시에 DB로 몰리는 현상 |
| Hot Key | 특정 키에 요청이 집중되어 Redis 또는 DB 병목을 만드는 키 |
| Mutex Lock / SET NX | 키가 없을 때만 설정하는 Redis 명령으로 단일 갱신자만 허용하는 방식 |
| Circuit Breaker | 실패율 또는 timeout이 높을 때 호출을 잠시 차단하는 상태 기계 |
| Fallback | 주 경로 실패 시 대체 경로로 응답하는 전략 |
| Bulkhead | 장애가 전체 시스템으로 번지지 않도록 경로와 자원을 격리하는 설계 |
| Rate Limiting | 호출 횟수나 속도를 제한해 하위 시스템을 보호하는 패턴 |
| Stale While Revalidate | 오래된 값을 잠시 반환하면서 백그라운드로 새 값을 갱신하는 방식 |
| L1 / L2 Cache | 애플리케이션 내부 짧은 캐시와 Redis 공유 캐시를 함께 쓰는 2단계 구조 |
| Redis Cluster | 키를 hash slot으로 나누어 여러 Redis 노드에 분산하는 구성 |
| CROSSSLOT | Redis Cluster에서 multi-key 명령의 키들이 같은 slot에 없을 때 나는 오류 |
| ioredis | Node.js에서 Redis에 연결할 때 자주 쓰는 클라이언트 라이브러리 |
| ElastiCache | AWS의 관리형 Redis/Memcached 서비스 |
본문 이해에 꼭 필요하지 않은 실습과 on-call 절차는 여기서 접어 둔다. 실전에서 Redis를 확인해야 할 때만 펼쳐서 본다.
docker run -d --name redis-test -p 6379:6379 redis:7-alpinedocker exec -it redis-test redis-cliSET greeting "Hello Redis"GET greetingSETEX session:abc 60 "userId:42"TTL session:abcINCR counter:api:1INCR counter:api:1확인 포인트는 세 가지다.
GET greeting은 TTL 없는 키라 명시적으로 지우기 전까지 남는다.SETEX session:abc 60 ...은 60초 뒤 자연 만료된다.INCR는 원자적으로 증가하므로 fixed-window rate limit의 기본 재료가 된다.먼저 Redis 자체가 느린지, Redis MISS 때문에 DB가 느린지 분리한다.
redis-cli -h <endpoint> --latency-history -i 1redis-cli -h <endpoint> INFO clientsredis-cli -h <endpoint> SLOWLOG GET 10판단 기준:
commandTimeout과 Circuit Breaker 이벤트를 확인한다.blocked_clients가 증가하면 블로킹 커맨드나 큰 키 조회를 의심한다.KEYS, 큰 SMEMBERS, 긴 Lua script가 보이면 해당 호출을 SCAN 또는 작은 단위 조회로 바꾼다.redis-cli -h <endpoint> INFO memoryredis-cli -h <endpoint> INFO statsredis-cli -h <endpoint> --bigkeys판단 기준:
used_memory가 maxmemory에 붙어 있으면 메모리 상한과 키 크기를 같이 본다.evicted_keys가 계속 증가하면 eviction 정책이 사용 목적과 맞는지 확인한다.SETEX 또는 SET ... EX를 강제한다.즉각 조치는 “키 몇 개 삭제”보다 정책 확인이 우선이다. 순수 캐시라면 allkeys-lru나 allkeys-lfu가 자연스럽지만, 큐·세션·락처럼 사라지면 안 되는 키가 섞여 있으면 Redis 역할 분리부터 검토한다.
redis-cli -h <endpoint> GET "product:123"redis-cli -h <endpoint> TTL "product:123"확인 순서:
-1이면 만료 계약이 잘못된 것이다.DEL product:123 또는 Write Through 갱신이 빠졌는지 확인한다.재발 방지의 핵심은 수동 삭제가 아니라 “쓰기 경로가 캐시 무효화를 소유한다”는 규칙을 코드에 넣는 것이다.
Redis Cluster는 key를 hash slot으로 나누어 여러 노드에 분산한다. MGET a b, Lua script, transaction처럼 여러 key를 한 번에 다루는 명령은 관련 key가 같은 slot에 있어야 한다. 서로 다른 slot의 key를 한 명령에서 다루면 CROSSSLOT Keys in request don't hash to the same slot 오류가 난다.
캐시 문서에서 이 오류를 길게 다루지 않는 이유는 클러스터 설계가 L8 범위이기 때문이다. 다만 L6 운영 신호로는 중요하다. 단일 Redis에서는 잘 되던 multi-key cache invalidation이 Cluster 전환 후 실패하면, 캐시 삭제가 누락되어 stale 데이터가 남거나 배치 조회가 장애로 바뀔 수 있다. 짧은 대응 방향은 관련 key에 {product:123} 같은 hash tag를 써서 같은 slot에 묶을지, multi-key 명령을 key별 단일 명령으로 풀지 결정하는 것이다.