캐시 적용 시 문제점과 해결방안
Cache Stampede
문제상황
Cache Stampede(= Thundering Herd, Dog-piling)는 캐시 미스가 동시에 대량으로 발생해 그 요청이 전부 DB로 몰리는 현상이다. 캐시는 “미스가 나면 원본을 조회해서 다시 채운다”는 전제로 동작하는데, 그 미스가 서로 다른 시점에 흩어져서 발생한다는 암묵적 가정이 깨지는 순간 캐시는 DB를 보호하지 못하고 오히려 부하를 한 시점에 몰아주는 증폭기가 된다.
발생 경로는 크게 두 가지다.
- 인기 키 하나의 만료: 조회수가 초당 수천 건인 키(예:
post:hot:1001) 하나가 TTL로 사라지면, 캐시가 다시 채워지기 전까지 들어온 모든 요청이 동시에 미스 판정을 받고 각자 DB를 조회한다. 한 번이면 충분한 쿼리가 수천 번 중복 실행된다. 다수 키의 동시 만료: 배포 직후 캐시 워밍처럼 여러 키를 한꺼번에 적재하면서 동일한 TTL(예: 전부
EX 3600)을 부여하면, 정확히 1시간 뒤 그 키들이 같은 초에 일제히 만료된다. 서로 다른 키인데도 만료 시점이 동기화되어 스탬피드가 발생한다.1 2 3 4 5 6 7
정상 상황 Stampede 발생 ───────── ───────────── req ─▶ [cache HIT] ─▶ resp req ─┐ req ─▶ [cache HIT] ─▶ resp req ─┼─▶ [cache MISS] ─┬─▶ DB query req ─▶ [cache MISS] ─▶ DB req ─┤ (TTL 만료) ├─▶ DB query req ─┘ └─▶ DB query ... × N
문제는 여기서 끝나지 않고 연쇄된다. DB로 동일 쿼리가 N번 들어오면 → DB CPU/IO 포화 → 쿼리 응답이 느려짐 → 각 요청이 커넥션을 오래 점유 → 커넥션 풀 고갈 → 캐시와 무관한 다른 API까지 같이 느려지거나 실패한다. 게다가 느려진 요청이 타임아웃 후 재시도되면 부하가 다시 얹히면서 스스로 회복하지 못하는 상태에 빠질 수 있다. 캐시 미스율은 1%도 안 되는데 장애는 전면적으로 발생하는 이유가 이것이다.
- 인기 키 하나의 만료: 조회수가 초당 수천 건인 키(예:
해결방안
TTL 시간을 랜덤으로 설정
만료 시점 자체를 흩뿌려서 “여러 키가 같은 초에 만료되는” 경로를 차단한다. 고정 TTL 대신 기준값에 랜덤 지터(jitter)를 더해 저장한다.
1 2 3 4 5 6 7
# 고정 TTL: 60분 뒤 전부 동시 만료 SET post:1001 {data} EX 3600 SET post:1002 {data} EX 3600 # 지터 적용: 60분 ± 5분 구간으로 만료가 분산 SET post:1001 {data} EX 3712 # 3600 + rand(0, 300) SET post:1002 {data} EX 3541 # 3600 - rand(0, 300)구현이 한 줄이고 추가 인프라도 필요 없어 가장 먼저 적용할 수 있는 방어책이다. 다만 이건 “다수 키의 동시 만료”만 해결한다. 인기 키 하나가 만료되어 그 순간 요청이 몰리는 경우는 TTL을 어떻게 흩어놓아도 막지 못하므로, 지터는 필요조건일 뿐 충분조건이 아니다.
Lock 제어
인기 키 문제를 해결하려면 “미스가 나도 원본 조회는 한 번만 수행한다”를 보장해야 한다. 캐시 미스 시 곧바로 DB로 가지 않고, 분산 락 획득에 성공한 하나의 요청만 DB를 조회해 캐시를 채우고 나머지는 그 결과를 기다린다.
1 2 3 4
1. GET post:1001 → nil (miss) 2. SET lock:post:1001 {owner} NX EX 3 → 성공한 요청만 DB 조회 3-a. 락 획득 O : DB 조회 → SET post:1001 {data} EX ... → 락 해제(Lua로 GET+DEL 원자화) 3-b. 락 획득 X : 짧게 대기 후 GET 재시도 (or 직전 stale 값 반환)SET ... NX EX는 “값이 없을 때만 설정”을 단일 원자 연산으로 처리하므로 동시에 수천 개 요청이 들어와도 정확히 하나만 락을 획득한다(이전 글의 Distributed Lock과 같은 패턴이다).EX로 TTL을 걸어두면 락을 쥔 프로세스가 죽어도 자동으로 풀려 영구 데드락을 막을 수 있고, 값에 소유자 식별자를 넣어 해제 시 자기 것일 때만DEL하도록 하면 남의 락을 실수로 푸는 것도 방지된다.주의할 점은 락 획득에 실패한 요청을 어떻게 처리하느냐다. 무한정 대기시키면 DB 부하는 막아도 응답 지연과 스레드 점유가 그대로 남으므로, 대기 시간에 상한을 두고 그 안에 캐시가 채워지지 않으면 만료 직전 값(stale)을 반환하거나 실패로 끊는 정책을 함께 정해야 한다.
논리적 만료(Logical Expiration)
락 방식은 대기하는 요청이 생긴다는 게 근본적인 한계다. 이를 없애려면 Redis의 TTL로 키를 지우지 않고, 값 안에 만료 시각을 필드로 함께 저장한다.
1
SET post:1001 {"data": ..., "expireAt": 1756598400} # TTL 미설정조회 시
expireAt이 지났으면 일단 만료된 값을 그대로 반환하고, 갱신은 락을 획득한 하나의 요청이 백그라운드 스레드로 수행한다. 키 자체는 항상 존재하므로 캐시 미스 구간이 아예 생기지 않아 어떤 요청도 대기하지 않는다. 대신 갱신이 끝나기 전까지는 오래된 값이 응답되므로, 랭킹·조회수처럼 잠깐의 정합성 저하를 허용할 수 있는 데이터에 적합하다.
세 가지는 배타적인 선택지가 아니라 층으로 쌓는 방어다. TTL 지터로 만료 시점을 분산시켜 스탬피드 발생 확률 자체를 낮추고, 그럼에도 인기 키에서 미스가 겹치면 락으로 원본 조회를 1회로 수렴시키며, 지연조차 허용되지 않는 핵심 키에는 논리적 만료를 적용해 대기 자체를 제거하는 식이다.
Cache Penetration
문제상황
Cache Penetration은 캐시에도 없고 DB에도 없는 데이터를 반복 조회해서, 모든 요청이 캐시를 그대로 통과(penetrate)해 DB까지 도달하는 현상이다. Cache Stampede와 증상은 닮았지만 원인은 정반대다. 스탬피드는 “있던 값이 만료로 잠깐 사라진” 일시적 문제이므로 누군가 캐시를 다시 채우면 상황이 종료된다. 반면 페네트레이션은 애초에 캐시에 채워 넣을 값이 존재하지 않는다. 조회 → 미스 → DB 조회 → 결과 없음 → 캐시에 넣을 것이 없음 → 다음 요청도 똑같이 미스. 이 루프가 무한히 반복되면서 해당 키의 히트율은 영구히 0%에 고정되고, 캐시 계층은 요청을 그대로 흘려보내는 통로가 된다.
1 2 3 4 5 6 7
1st req ─▶ [cache MISS] ─▶ DB ─▶ 0 rows ─▶ nothing to cache ─▶ 404 2nd req ─▶ [cache MISS] ─▶ DB ─▶ 0 rows ─▶ nothing to cache ─▶ 404 3rd req ─▶ [cache MISS] ─▶ DB ─▶ 0 rows ─▶ nothing to cache ─▶ 404 ... Nth req ─▶ [cache MISS] ─▶ DB ─▶ 0 rows ─▶ nothing to cache ─▶ 404 cache hit rate for this key: 0% (forever)
발생 경로는 두 가지다.
- 악의적 트래픽: 존재하지 않는 ID를 무작위로 대입한 요청(
/posts/-1,/posts/999999999, 랜덤 UUID)을 대량으로 보낸다. 캐시를 우회해 DB를 직접 때리는 것이 목적이므로 매 요청의 키가 서로 다르게 만들어진다. - 정상 트래픽: 이미 삭제된 리소스를 가리키는 외부 링크, 오래된 URL을 계속 재방문하는 크롤러, 클라이언트 버그로
id=null·id=0이 그대로 전달되는 경우처럼 악의가 없어도 충분히 발생한다.
피해가 커지는 이유는 “없는 행”을 찾는 조회가 오히려 비싸기 때문이다. 존재하는 행은 인덱스에서 찾는 즉시 반환되지만, 없는 키는 탐색을 끝까지 수행하고도 빈손으로 돌아온다. 여기에 인덱스를 타지 못하는 조건이나 조인이 얹히면 요청당 비용이 정상 조회보다 커지고, 그대로 커넥션 풀 고갈과 전면 지연으로 번진다.
중요한 건 Cache Stampede의 해결책이 여기서는 통하지 않는다는 점이다. TTL 지터는 만료 시점을 흩뿌리는 기법이지만 페네트레이션에는 만료될 값 자체가 없다. 분산 락도 “같은 키에 몰린 동시 요청”을 하나로 수렴시키는 장치이므로, 매 요청이 서로 다른 키인 무작위 대입에는 락이 각각 따로 잡히며 아무것도 막지 못한다. 캐시 계층 내부의 최적화가 아니라 “없는 키”를 판별해 걸러내는 별도의 장치가 필요하다.
- 악의적 트래픽: 존재하지 않는 ID를 무작위로 대입한 요청(
해결방안
즉시 빈 값 반환(Null Caching)
DB 조회 결과가 없을 때 “캐시에 넣을 게 없다”며 그냥 넘기지 말고, 없다는 사실 자체를 캐시에 저장한다. 두 번째 요청부터는 캐시에서 “없음”을 확인하고 DB를 건드리지 않은 채 404를 반환하므로, 반복 조회가 DB까지 도달하는 경로가 끊긴다.
1 2 3 4 5 6 7
# 1st req GET post:99999 → nil (miss) SELECT * FROM post WHERE id=99999 → 0 rows SET post:99999 "<NULL>" EX 60 → "없음"을 짧은 TTL로 캐싱 # 2nd req 이후 GET post:99999 → "<NULL>" → DB 조회 없이 404
주의할 점이 세 가지다. 첫째, TTL을 정상 값보다 짧게 잡아야 한다. 지금 없는 데이터가 곧 생성될 수 있는데(예: 아직 등록 전인 리소스), 1시간짜리 null을 캐싱하면 이미 생성된 데이터를 1시간 동안 없다고 응답한다. 둘째, 데이터가 실제로 생성되는 시점에 null 캐시를 명시적으로 무효화(
DEL) 해 두면 짧은 TTL로도 남는 공백을 없앨 수 있다. 셋째, 빈 문자열이나 빈 리스트를 넣으면 정상 데이터와 구분이 되지 않으므로"<NULL>"같은 전용 센티널 값을 쓰고 조회 코드에서 “캐시 미스”와 “없음이 캐싱된 상태”를 분리해 처리해야 한다.한계는 무작위 키 공격이다. 매 요청의 키가 다르면 첫 조회는 여전히 DB로 나가므로 방어 효과가 없고, 오히려 불필요한 null 키가 캐시 메모리를 잠식해 정상 키를 evict 시킬 수 있다. null 전용 TTL을 짧게 두는 것이 이 부작용을 줄이는 최소한의 장치다.
Bloom Filter
존재하는 키의 집합을 확률적 자료구조로 미리 만들어 두고, 캐시 조회 이전에 “이 키는 확실히 없다”를 걸러낸다. 블룸 필터는 false positive(없는데 있다고 답함)는 발생하지만 false negative(있는데 없다고 답함)는 구조적으로 발생하지 않는다. 즉 “없다”는 답은 100% 신뢰할 수 있어 그 자리에서 404로 끊어도 안전하다.
1 2
req ─▶ [Bloom Filter] ─┬─ definitely NOT exists ─▶ 404 (cache/DB 미접근) └─ maybe exists ─▶ [cache] ─▶ [DB]Redis라면 RedisBloom 모듈의
BF.ADD/BF.EXISTS로 바로 구성할 수 있고, 수백만 개의 키를 수 MB 수준의 비트 배열로 표현하므로 메모리 비용도 작다. 무작위 대입을 캐시 앞단에서 차단하기 때문에 null 캐싱이 못 막는 경로를 정확히 메워 준다. 대신 일반 블룸 필터는 삭제를 지원하지 않아 데이터가 지워져도 필터에서 제거할 수 없다. 삭제가 잦으면 카운팅 블룸 필터를 쓰거나 필터를 주기적으로 재구성해야 하고, 도입 시점에 전체 키를 한 번 적재하는 초기 비용도 감안해야 한다.입력값 검증과 레이트 리밋
형식이 명백히 잘못된 요청(음수 ID, 범위를 벗어난 값, UUID 형식 불일치)은 애플리케이션 진입 단계에서 거른다. 비용이 거의 없고 캐시·DB·블룸 필터를 전부 건드리지 않으므로 가장 앞단에 두는 것이 이득이다. 다만 형식은 정상인데 존재하지 않는 ID는 걸러낼 수 없으므로, 동일 클라이언트가 짧은 시간에 미스를 반복하면 IP·토큰 단위로 레이트 리밋을 걸어 공격 트래픽 자체를 억제하는 방어를 함께 둔다.
Cache Stampede와 마찬가지로 층으로 쌓는 방어다. 입력값 검증으로 형식 오류를 먼저 떨어내고, 블룸 필터로 존재하지 않는 키를 캐시 앞에서 차단하며, 그래도 통과한 미스는 null 캐싱으로 반복 조회를 1회로 수렴시킨다. 트래픽 대부분이 정상이고 삭제된 리소스 재조회가 문제라면 null 캐싱만으로 충분하고, 무작위 대입이 실제 위협 수준이라면 블룸 필터와 레이트 리밋까지 올리는 판단이 필요하다.
Cache Crash
문제상황
Cache Crash는 캐시 서버 자체가 응답하지 못하게 되면서 평소 캐시가 흡수하던 트래픽 전량이 한꺼번에 DB로 넘어가는 현상이다. 앞의 두 문제는 “특정 키”의 미스였다. Stampede는 인기 키 하나 또는 동시에 만료된 다수의 키, Penetration은 존재하지 않는 키였다. 반면 크래시는 모든 키가 동시에 미스가 된다. 캐시 계층이 사라진 것과 같으므로 히트율은 0%로 떨어지고, DB는 애초에 감당하도록 사이징되지 않은 부하를 그대로 받는다.
증폭 배수는 평소 히트율로 계산된다. 히트율이
h일 때 DB로 가는 트래픽은1/(1-h)배가 된다. 히트율 99%인 시스템은 캐시가 죽는 순간 DB 부하가 100배가 된다.1 2
hit rate 99% ─▶ cache absorbs 9,900 rps ─▶ DB receives 100 rps cache down ─▶ cache absorbs 0 rps ─▶ DB receives 10,000 rps (x100)
발생 경로는 다양하다.
- 프로세스 레벨 장애:
maxmemory초과로 인한 OOM, Big Key 조회로 인한 이벤트 루프 블로킹, 프로세스 크래시 - 인프라 레벨 장애: 노드 하드웨어 장애, 네트워크 단절, AZ 전체 장애
- 운영 실수:
FLUSHALL, 배포 과정에서 캐시 키 프리픽스가 변경되어 사실상 전체 무효화되는 경우 - 재기동: 영속화(RDB/AOF) 없이 인스턴스를 재시작하면 데이터가 전부 사라지고, 영속화가 있어도 적재가 끝나기 전까지는 콜드 캐시 구간이 존재한다
피해가 특히 큰 이유는 두 가지 연쇄 때문이다. 첫째, 캐시가 SPOF로 승격된다. 캐시 조회는 보통 짧은 타임아웃을 걸고 호출하지만, 캐시가 죽어 응답이 없으면 매 요청이 그 타임아웃(예: 1초)만큼 스레드를 붙잡고 기다린다. 요청량이 초당 수천이면 스레드 풀과 커넥션 풀이 순식간에 마르고, 캐시를 전혀 쓰지 않는 API까지 함께 실패한다. 캐시는 성능 최적화 수단이었을 뿐인데 그 장애가 서비스 전체 장애가 되는 것이다.
둘째, 스스로 회복하지 못한다. 캐시가 복구되어도 내용이 비어 있으므로 워밍이 끝나기 전까지 미스가 계속되고, 그 사이 DB는 이미 포화 상태다. 느려진 요청이 타임아웃 후 재시도되며 부하가 다시 얹히면 캐시를 채울 여유조차 생기지 않는다.
- 프로세스 레벨 장애:
해결방안
고가용성 캐시 클러스터 적용
“캐시가 완전히 사라지는” 사건 자체의 확률을 낮추는 접근이다. 단일 인스턴스 구성은 그 인스턴스가 곧 전체 캐시이므로 장애 = 100% 미스지만, 복제와 샤딩을 적용하면 장애의 영향 범위를 좁힐 수 있다.
- 복제 + 자동 failover: 마스터에 복제본을 붙이고 Redis Sentinel(마스터 감시 후 복제본 승격) 또는 Redis Cluster를 구성해 노드 장애 시 사람의 개입 없이 승격되도록 한다.
- 샤딩으로 blast radius 축소: 전체 키를 N개 샤드로 나누면 한 샤드가 죽어도 미스는 전체의
1/N에 그친다. 100% 미스와 10% 미스는 DB 입장에서 완전히 다른 사건이다. - 물리적 분산: 노드를 서로 다른 AZ·랙에 배치해 단일 인프라 장애가 전체를 삼키지 않게 한다.
- 영속화(RDB/AOF): 재기동 시 콜드 캐시 구간을 줄여 준다.
다만 이것만으로는 부족하다. 자동 failover에도 장애 감지 + 승격 + 클라이언트 토폴로지 갱신에 수 초에서 수십 초가 걸리고, 그 구간의 미스는 전부 DB로 향한다. 즉 클러스터는 장애 확률과 지속 시간을 줄이지만 “캐시 없는 구간”을 0으로 만들지는 못한다. 애플리케이션 측 방어가 반드시 함께 있어야 한다.
Circuit Breaker 적용
캐시 호출 실패·타임아웃이 임계치를 넘으면 회로를 열어 이후 캐시 호출을 시도조차 하지 않고 즉시 실패 처리한다. 매 요청이 타임아웃까지 기다리며 스레드를 점유하는 경로를 끊는 것이 핵심이다. 죽은 캐시를 계속 두드리는 것은 어차피 성공하지 않으므로, 빠르게 포기하고 대체 경로로 넘기는 편이 낫다.
1 2 3 4
CLOSED ─▶ OPEN : failure rate over threshold → cache call fails fast OPEN ─▶ HALF_OPEN : after wait duration → probe request allowed HALF_OPEN ─▶ CLOSED : probe succeeded → normal traffic restored HALF_OPEN ─▶ OPEN : probe failed → wait again
HALF_OPEN상태가 중요하다. 회로가 열린 뒤 일정 시간이 지나면 소수의 탐색 요청만 통과시켜 캐시가 살아났는지 확인하고, 성공하면 정상 상태로 복귀한다. 이렇게 하지 않으면 캐시가 복구되어도 회로가 계속 열려 있거나, 반대로 전량을 한 번에 다시 흘려보내 갓 복구된 캐시를 재차 무너뜨린다.회로를 여는 것만으로 끝나지 않고 회로가 열린 동안의 fallback 정책을 반드시 정해야 한다. 캐시를 건너뛰고 DB 직행을 허용하면 결과적으로 DB가 100배 부하를 받으므로, DB 쪽에도 동시 요청 수 제한(bulkhead)과 타임아웃을 함께 걸어 상한을 둔다. 그 상한을 넘는 요청은 오래된 로컬 캐시 값을 반환하거나, 기능을 축소(graceful degradation)하거나, 빠르게 에러를 돌려준다. 전부 실패시키는 편이 DB까지 잃는 것보다 낫다는 판단이 여기서 필요하다.
로컬 캐시와 점진적 워밍
원격 캐시 앞에 짧은 TTL의 프로세스 내 로컬 캐시(L1)를 두면, Redis가 완전히 사라진 구간에도 인기 키 트래픽 상당 부분을 애플리케이션 안에서 흡수할 수 있다. 인스턴스마다 값이 달라 정합성은 느슨해지지만, 장애 구간에서 DB에 도달하는 절대량을 줄이는 데는 가장 확실한 수단이다.
복구 직후에는 콜드 캐시 구간을 관리해야 한다. 캐시가 비어 있는 상태로 전량 트래픽을 받으면 그대로 Stampede가 되므로, 핵심 키를 미리 적재하는 워밍업을 수행하고 워밍이 끝날 때까지는 유입량을 스로틀링해 DB가 회복할 여유를 만든다.
세 가지는 목적이 서로 다르다. 클러스터는 캐시가 사라지는 확률과 지속 시간을 줄이고, 서킷 브레이커는 그 구간에 애플리케이션 스레드가 캐시에 묶여 죽는 것을 막고, 로컬 캐시와 스로틀링은 DB에 도달하는 절대량을 제한한다. 목표는 캐시 장애를 0으로 만드는 것이 아니라 캐시 장애가 서비스 전체 장애로 승격되지 않게 하는 것이다. 캐시는 없어도 서비스가 느려질 뿐이어야 하고, 멈춰서는 안 된다.