Redis 캐시 스탬피드와 무효화 전략: TTL 지터·SingleFlight·Stale Cache로 DB 지키기

캐시는 빨랐지만 장애는 더 빨리 왔다
캐시는 백엔드 성능 최적화에서 가장 먼저 떠올리는 도구입니다. Redis를 앞에 두면 데이터베이스 조회를 줄이고, p95 응답 시간을 낮추고, 트래픽 피크를 흡수할 수 있습니다. 그런데 캐시는 제대로 설계하지 않으면 장애를 막는 장치가 아니라 장애를 증폭시키는 장치가 됩니다. 특히 트래픽이 높은 서비스에서 특정 키가 동시에 만료되는 순간, 수천 개의 요청이 한꺼번에 데이터베이스로 쏟아지는 캐시 스탬피드(cache stampede)는 생각보다 자주 발생합니다.
우리 팀도 비슷한 사고를 겪었습니다. 메인 화면의 추천 상품 API는 평소 Redis hit ratio가 98% 이상이었습니다. 그래서 모두가 안심했습니다. 하지만 정각 배치 직후 인기 카테고리 키들이 같은 TTL로 동시에 만료됐고, 수만 명의 사용자가 새로고침하는 시간대와 겹쳤습니다. Redis는 정상인데 DB CPU가 100%로 치솟았고, connection pool 대기열이 길어지면서 캐시와 무관한 API까지 느려졌습니다. 장애 원인은 "캐시가 없는 것"이 아니라 "캐시가 동시에 사라지는 것"이었습니다.
이 글은 Redis 캐시를 운영할 때 반드시 챙겨야 할 스탬피드 방어, TTL 지터, stale-while-revalidate, single-flight lock, hot key 보호, 무효화 이벤트 설계를 다룹니다. 단순히 SETEX를 쓰는 법이 아니라, 캐시가 실패해도 데이터베이스를 지키는 운영 패턴에 초점을 맞춥니다.
1. 캐시 스탬피드는 왜 생기는가
캐시 스탬피드는 많은 요청이 같은 캐시 키의 만료를 동시에 관측하고, 모두가 원본 데이터 소스로 재계산을 시도할 때 발생합니다. 키 하나가 만료됐을 뿐인데 데이터베이스에는 수천 개의 동일 쿼리가 몰립니다. 조회 비용이 낮다면 잠깐의 부하로 끝나지만, 조인과 집계가 섞인 API라면 순식간에 connection pool을 채웁니다.
스탬피드가 위험한 이유는 캐시 hit ratio 평균이 문제를 숨기기 때문입니다. 하루 평균 hit ratio가 98%여도 특정 30초 구간에는 hot key miss가 폭발할 수 있습니다. 평균만 보면 서비스는 안정적으로 보이지만, 사용자는 바로 그 30초에 장애를 겪습니다. 캐시 운영에서는 평균 hit ratio보다 hot key별 miss burst, 원본 조회 동시성, oldest refresh latency가 더 중요할 때가 많습니다.
대표적인 원인은 세 가지입니다. 첫째, 동일 TTL입니다. 많은 키를 ttl=300처럼 고정값으로 넣으면 생성 시점이 비슷한 키들이 같은 시각에 만료됩니다. 둘째, lazy loading만 사용하는 구조입니다. 요청이 와야 캐시를 채우는 방식은 단순하지만, 만료 순간 첫 요청들이 원본을 때립니다. 셋째, hot key를 일반 키와 똑같이 취급하는 것입니다. 메인 화면 설정, 인기 상품 랭킹, 환율, feature flag처럼 조회량이 압도적인 키는 별도 보호가 필요합니다.
2. TTL 지터: 동시에 죽지 않게 만든다
가장 간단하고 효과적인 방어는 TTL에 지터(jitter)를 넣는 것입니다. 300초 고정 TTL 대신 300초 ± 60초처럼 랜덤 범위를 둡니다. 이렇게 하면 많은 키가 한꺼번에 만료되는 현상을 줄일 수 있습니다. 캐시는 정확히 5분 뒤 사라져야 하는 계약이 아니라, 대체로 신선한 데이터를 빠르게 제공하기 위한 장치입니다. 비즈니스 요구가 허용한다면 TTL은 분산되는 편이 안전합니다.
TTL 지터는 키 생성 시점이 몰리는 배치성 데이터에서 특히 중요합니다. 매일 자정에 수십만 개의 상품 캐시를 재생성하는 작업이 있다면, 고정 TTL은 다음 날 같은 시각 장애 예약과 같습니다. TTL을 base + random(0, spread)로 잡아 만료 시간을 넓게 펴야 합니다. 중요한 것은 모든 키에 무작위성을 넣되, 너무 넓게 잡아 데이터 신선도 계약을 깨지 않는 것입니다.
지터는 스탬피드를 완전히 없애지 못합니다. hot key 하나가 만료될 때 몰리는 동시 요청은 여전히 존재합니다. 따라서 지터는 1차 방어선이고, hot key에는 single-flight와 stale cache 전략을 함께 적용해야 합니다.
3. SingleFlight: 재계산은 한 명만 한다
SingleFlight는 같은 키에 대한 원본 조회를 동시에 하나만 허용하는 패턴입니다. 캐시 miss가 발생하면 첫 요청만 lock을 잡고 데이터베이스를 조회합니다. 뒤따라온 요청들은 lock 결과를 기다리거나, stale 데이터를 반환하거나, 짧은 backoff 후 캐시를 다시 확인합니다. Go의 singleflight 패키지가 유명하지만, 개념은 어떤 언어에서도 구현할 수 있습니다.
분산 환경에서는 Redis 기반 lock을 사용할 수 있습니다. SET lock:key value NX PX 3000처럼 짧은 TTL을 가진 lock을 잡고, 성공한 워커만 원본을 조회합니다. Lock TTL은 원본 조회의 p99보다 약간 길게 잡되, 너무 길면 lock holder가 죽었을 때 복구가 늦어집니다. Lock value에는 랜덤 토큰을 넣고, unlock 시 토큰이 일치할 때만 삭제해야 다른 프로세스의 lock을 지우는 사고를 막을 수 있습니다.
다만 lock은 만능이 아닙니다. 원본 조회가 느려져 lock TTL을 초과하면 두 번째 워커가 다시 원본 조회를 시작할 수 있습니다. Redis 장애 시 lock 자체가 동작하지 않을 수도 있습니다. 그래서 lock을 "정확히 한 번 보장" 도구로 이해하면 위험합니다. 목적은 원본 동시성을 제한하고, 최악의 순간에 데이터베이스를 보호하는 것입니다.
4. Stale-While-Revalidate: 조금 낡은 데이터를 빠르게 준다
모든 캐시 miss가 사용자 요청을 기다리게 할 필요는 없습니다. 데이터가 몇 초 또는 몇 분 낡아도 괜찮은 도메인이라면 stale-while-revalidate가 훨씬 안정적입니다. 캐시에 fresh TTL과 stale TTL을 나눠 저장합니다. Fresh 기간에는 그대로 반환합니다. Fresh는 지났지만 stale 기간 안이면 사용자에게 기존 값을 반환하고, 백그라운드에서 갱신을 시도합니다. Stale 기간까지 지나면 그때는 원본 조회 실패를 사용자에게 노출하거나 fallback을 사용합니다.
예를 들어 추천 상품 목록은 1분 단위 정확도가 필요하지 않습니다. Fresh TTL을 60초, stale TTL을 10분으로 두면 Redis 키가 완전히 사라지는 순간을 줄일 수 있습니다. 원본 DB가 잠깐 느려져도 사용자는 이전 추천 목록을 받습니다. 운영 관점에서 "약간 낡은 정상 응답"은 "최신 데이터를 기다리다 5초 후 실패"보다 훨씬 나은 경우가 많습니다.
Stale 전략은 명시적인 데이터 계약이 필요합니다. 결제 금액, 재고 차감, 권한 확인처럼 최신성이 중요한 값에는 쓰면 안 됩니다. 반대로 홈 화면 구성, 랭킹, 통계 카드, 추천 목록, 지역별 설정처럼 읽기 중심이고 약간의 지연이 허용되는 데이터에는 적극적으로 적용할 수 있습니다. 캐시 정책은 기술이 아니라 도메인 계약입니다.
5. 무효화 전략: 삭제는 쉽고 일관성은 어렵다
캐시에서 가장 어려운 문제는 저장이 아니라 무효화입니다. TTL만 믿으면 데이터가 최대 TTL만큼 낡을 수 있습니다. 변경 이벤트마다 캐시를 삭제하면 신선도는 좋아지지만, 이벤트 누락과 순서 문제가 생깁니다. Write-through, write-behind, cache-aside 중 어떤 패턴을 쓰는지에 따라 실패 모드가 달라집니다.
Cache-aside는 가장 흔합니다. 애플리케이션이 먼저 Redis를 보고, miss면 DB에서 읽어 캐시에 넣습니다. 데이터 변경 시에는 관련 키를 삭제합니다. 단순하지만 race condition이 있습니다. 한 요청이 DB에서 예전 값을 읽고 캐시에 쓰는 동안, 다른 요청이 DB를 새 값으로 업데이트하고 캐시를 삭제할 수 있습니다. 그 뒤 첫 요청이 예전 값을 다시 캐시에 넣으면 stale 값이 살아납니다.
이를 줄이려면 version 또는 updated_at을 캐시 값에 포함하고, 캐시 write 시 현재 버전보다 오래된 값은 버리도록 설계할 수 있습니다. 또는 변경 이벤트 기반으로 삭제 후 짧은 지연을 두고 한 번 더 삭제하는 delayed double delete를 쓰기도 합니다. 완벽한 해법은 아니지만, 읽기/쓰기 경쟁이 잦은 키에서는 효과가 있습니다.
무효화 범위도 중요합니다. 상품 하나가 바뀌었는데 카테고리 랭킹, 검색 결과, 추천 카드, 판매자 페이지 캐시를 모두 지워야 할 수 있습니다. 이 관계를 코드 곳곳에 흩뿌리면 반드시 누락됩니다. 캐시 키 네이밍과 태그 기반 무효화 목록을 중앙에서 관리해야 합니다. Next.js의 revalidateTag처럼 태그 단위 무효화 개념을 백엔드 캐시에도 도입하면 운영 사고가 줄어듭니다.
6. Hot Key 보호: Redis도 한 점으로 몰리면 병목이 된다
Redis는 빠르지만 무한하지 않습니다. 특정 키 하나에 요청이 몰리면 Redis cluster에서도 해당 슬롯을 가진 노드에 부하가 집중됩니다. Hot key가 작은 문자열 값이면 네트워크와 CPU가 병목이 되고, 큰 JSON이면 직렬화와 전송 비용이 문제됩니다. 캐시가 DB를 지키는 대신 Redis 자체가 병목이 되는 상황입니다.
Hot key 보호 방법은 여러 가지입니다. 첫째, 로컬 in-memory cache를 짧게 둡니다. 각 애플리케이션 인스턴스가 1~5초 정도만 hot key를 메모리에 보관해도 Redis RPS가 크게 줄어듭니다. 둘째, key sharding을 사용합니다. 동일 값을 여러 물리 키에 복제하고 요청이 랜덤 shard를 읽게 하면 읽기 부하가 분산됩니다. 셋째, 값 크기를 줄입니다. 화면에 필요한 필드만 캐싱하고, 거대한 JSON blob을 매번 가져오지 않도록 합니다.
Hot key는 관측해야 합니다. Redis monitor를 운영에서 상시 켜는 것은 위험하지만, slowlog, keyspace hit/miss, 클라이언트 측 metric, 샘플링된 command 통계를 통해 상위 키를 추정할 수 있습니다. 애플리케이션 레벨에서 cache key별 hit/miss와 latency를 집계하면 어떤 키가 장애 위험인지 더 빨리 볼 수 있습니다.
7. 장애 모드별 대응
캐시 장애 대응은 "Redis가 죽으면 DB로 가면 된다"로 끝나면 안 됩니다. Redis 전체 장애에서 모든 트래픽이 DB로 우회하면 DB가 같이 죽습니다. 이를 cache bypass storm이라고 볼 수 있습니다. Redis 장애 시에는 read-through fallback을 제한하고, 핵심 API만 DB 조회를 허용하며, 비핵심 API는 stale local cache나 degraded response를 반환해야 합니다.
DB 장애 중 캐시가 살아 있는 경우도 있습니다. 이때 stale cache가 있다면 사용자 경험을 어느 정도 유지할 수 있습니다. 하지만 캐시 miss가 난 요청은 DB를 계속 두드려 장애를 악화시킬 수 있습니다. Circuit breaker를 두고 원본 조회 실패율이 높아지면 일정 시간 refresh를 멈추는 전략이 필요합니다. 이때 stale TTL이 길수록 서비스는 더 오래 버팁니다.
배포 직후 캐시 key schema가 바뀌는 경우도 위험합니다. 새 버전이 전혀 다른 키를 사용하면 전체 트래픽이 한꺼번에 cold start를 겪습니다. 대규모 키 변경은 점진적으로 해야 합니다. 새 키를 읽되 miss면 옛 키를 읽어 새 키로 채우는 migration 기간을 두거나, 배포 전에 주요 키를 prewarm합니다.
실무 체크리스트
- 고정 TTL 대신 도메인별 TTL 지터를 적용했는가
- Hot key에 single-flight 또는 분산 lock을 적용했는가
- 약간 낡아도 되는 데이터에 stale-while-revalidate를 적용했는가
- Redis 장애 시 모든 요청이 DB로 우회하지 않도록 제한했는가
- Cache key별 hit/miss, refresh latency, lock wait, stale serve count를 보고 있는가
- 데이터 변경 시 어떤 캐시 키와 태그가 무효화되는지 중앙에서 관리하는가
- 큰 JSON blob을 캐싱해 네트워크와 직렬화 비용을 키우고 있지 않은가
- 배포 시 cache key schema 변경으로 cold start가 발생하지 않게 prewarm 또는 migration 경로를 뒀는가
결론: 캐시는 성능 장치이면서 장애 격리 장치다
Redis 캐시는 빠른 응답을 만드는 도구입니다. 하지만 운영 관점에서는 그보다 더 중요한 역할이 있습니다. 원본 데이터베이스를 갑작스러운 트래픽과 장애 전파에서 격리하는 완충 장치입니다. TTL 지터, single-flight, stale cache, hot key 보호는 모두 같은 목표를 가집니다. "캐시가 사라지는 순간에도 원본을 한꺼번에 때리지 않는다"는 것입니다.
좋은 캐시 설계는 hit ratio 숫자 하나로 평가되지 않습니다. 캐시 miss가 몰릴 때 DB 동시성을 얼마나 제한하는지, Redis 장애 시 서비스가 어떻게 degrade되는지, 데이터 변경 시 stale window를 얼마나 통제하는지까지 봐야 합니다. 캐시는 언젠가 miss가 납니다. 운영자는 그 순간을 전제로 설계해야 합니다.