캐시 적용 시 문제점과 해결방안
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으로 만드는 것이 아니라 캐시 장애가 서비스 전체 장애로 승격되지 않게 하는 것이다. 캐시는 없어도 서비스가 느려질 뿐이어야 하고, 멈춰서는 안 된다.
Hot Key
문제상황
콘서트 티켓 오픈 시각, 수십만 명이 동시에 같은 공연 페이지를 연다. 공연 정보는 이미 캐시에 올라가 있고 히트율은 99.9%다. 그런데 응답이 느려지기 시작하더니 결국 장애가 난다. 캐시가 제대로 동작하고 있는데도 벌어지는 일이다.
Hot Key는 소수의 특정 키에 요청이 몰려서, 그 키를 담당하는 캐시 노드 한 대가 병목이 되는 현상이다. 앞의 세 문제와는 무너지는 지점 자체가 다르다.
1 2
Stampede / Penetration / Crash : 캐시 MISS ─▶ DB 가 무너진다 Hot Key : 캐시 HIT ─▶ 캐시 노드가 무너진다
왜 노드를 늘려도 해결되지 않을까?
Redis Cluster는 키 이름을 해시해서 담당 노드를 정한다(
CRC16(key) mod 16384로 슬롯 번호를 구하고, 그 슬롯을 노드들이 나눠 갖는다). 계산식보다 중요한 건 그 결과다. 키 이름이 같으면 결과도 항상 같다. 즉post:hot:1001이라는 키는 언제나 정해진 노드 한 대에만 존재한다.그래서 노드를 4대로 늘려도 이 키의 요청은 전부 그중 한 대로만 간다.
1 2 3 4 5 6
전체 100,000 rps node-1 : 92,000 rps ← post:hot:1001 요청이 전부 여기로 CPU 100% (병목) node-2 : 3,000 rps node-3 : 2,500 rps node-4 : 2,500 rps CPU 3% (유휴)
샤딩은 키가 많을 때 그 키들을 여러 노드에 나눠 담는 장치다. 키 하나가 뜨거울 때는 나눌 대상이 없으니 아무 역할도 하지 못한다.
노드 한 대의 한계는 어디서 오나? 두 가지다.
- CPU: Redis는 명령을 한 번에 하나씩 처리한다(싱글 스레드). 코어가 아무리 많아도 노드 하나의 초당 처리량에는 상한이 있고, 핫키가 그 상한을 혼자 다 써버린다.
- 네트워크: 10KB짜리 값을 초당 10만 번 내보내면 그것만으로 약 1GB/s다. CPU에 여유가 있어도 랜카드가 먼저 막힌다. 값이 클수록(= Big Key) 이 한계에 훨씬 빨리 도달한다.
어떤 키가 핫키가 되는지는 대체로 정해져 있다.
- 이벤트성 집중: 티켓팅 좌석 정보, 한정 쿠폰 재고처럼 특정 시각에 모두가 같은 키를 두드리는 경우
- 공통 조회 키: 실시간 인기글, 랭킹 보드처럼 거의 모든 페이지가 함께 읽는 키
- 전역 키: 설정값, 피처 플래그처럼 모든 요청이 무조건 한 번씩 읽는 키
- 쓰기 집중: 조회수·좋아요 카운터처럼
INCR가 한 키에 몰리는 경우. 읽기가 아니라 쓰기라서 뒤에 나올 복제본으로도 나눌 수 없다
피해는 그 키 하나로 끝나지 않는다. 노드는 여러 키를 함께 담고 있기 때문에, 핫키 때문에 노드가 느려지면 같은 노드에 얹혀 있던 무관한 키들까지 전부 같이 느려진다.
1 2 3 4 5 6
post:hot:1001 폭주 ─▶ node-1 지연 ─▶ node-1 에 있는 다른 키도 함께 지연 (핫키와 무관한 API 까지 영향) ─▶ 타임아웃 후 재시도가 얹히며 부하 가중 ─▶ node-1 다운 ─▶ 그 샤드의 키 전체가 MISS ─▶ Cache Crash + Stampede 로 확대
게다가 평균 지표로는 보이지 않는다. 노드 4대 중 1대만 CPU 100%여도 클러스터 평균은 27%라 정상으로 보인다. 노드별 편차를 따로 봐야 드러나므로,
redis-cli --hotkeys(LFU 정책 필요)나MONITOR샘플링, 프록시·클라이언트 계층의 키별 호출 카운팅처럼 키 단위로 분포를 관측하는 수단을 미리 갖춰 두어야 한다.해결방안
방향은 세 가지뿐이다. 요청 수를 줄이거나(로컬 캐시), 부하를 여러 노드로 나누거나(키 분할), 쓰기를 합치거나(배치 집계).
로컬 캐시(L1) 도입 — Redis까지 가는 요청 수를 줄인다
핫키는 개수가 적고 히트율이 극단적으로 높다. 그래서 애플리케이션 프로세스 안에 짧은 TTL(수백 ms ~ 수 초)로 들고 있으면, 메모리는 거의 안 쓰면서 Redis로 나가는 요청이 급감한다. 대부분의 요청이 애플리케이션 안에서 처리되고, 캐시가 만료됐을 때만 Redis로 나간다.
1 2 3
before req ─▶ [app] ────────────────▶ [redis node-1] 100,000 rps after req ─▶ [app] ─▶ [L1 cache] ──▶ [redis node-1] 50 rps hit 99.9% (miss only)TTL이 1초이고 앱 인스턴스가 50대라면, Redis는 초당 50번만 조회를 받는다. 10만 건이 50건이 되는 것이다.
대가는 정합성이다. 인스턴스마다 값이 달라질 수 있으므로 잠깐의 지연을 허용할 수 있는 데이터여야 한다. 또 모든 인스턴스의 L1이 같은 순간에 만료되면 그때 Redis로 요청이 한꺼번에 몰리므로, 여기에도 TTL 지터가 필요하다(앞의 Stampede와 같은 문제가 한 계층 위에서 반복된다). 값이 바뀌는 즉시 반영돼야 한다면 Pub/Sub으로 무효화 신호를 뿌리거나, Redis 6의 클라이언트 사이드 캐싱(
CLIENT TRACKING)으로 서버가 직접 알려주게 한다.키 분할(Key Splitting) — 부하를 여러 노드로 나눈다
키 이름이 같으면 항상 같은 노드로 간다는 게 문제였으니, 키 이름을 일부러 여러 개로 만들면 서로 다른 노드로 흩어진다. 같은 값을 담은 사본을 N개 만들어 두고, 조회할 때 그중 하나를 무작위로 고르는 방식이다.
1 2 3 4 5 6 7 8
# 저장: 같은 값을 사본 10개에 모두 기록 SET post:hot:1001#0 {data} EX ... SET post:hot:1001#1 {data} EX ... ... SET post:hot:1001#9 {data} EX ... # 조회: 요청마다 사본 하나를 무작위 선택 GET post:hot:1001#{rand(0,9)} → 읽기 부하가 최대 10개 노드로 분산노드 단위로 부하를 실제로 쪼갤 수 있는 거의 유일한 방법이다. 대신 쓸 때마다 사본 N개를 모두 갱신해야 하므로 쓰기 비용이 N배가 되고, 갱신 도중에는 사본끼리 값이 다를 수 있다. 그리고 키 이름에 해시태그(
{})를 잘못 넣으면 사본이 전부 같은 노드로 몰려 아무 효과도 없으니 주의해야 한다.더 간단한 대안으로 읽기 복제본에 조회를 분산할 수도 있다(Cluster에서는
READONLY필요). 구성은 쉽지만 복제가 비동기라 아주 잠깐 옛날 값이 읽힐 수 있고, 복제본을 늘릴수록 마스터가 복제본에 데이터를 보내는 부담이 커져 무한정 늘리지는 못한다.배치 집계 — 쓰기 횟수를 합친다
조회수 증가처럼 쓰기가 한 키에 몰리는 경우는 위의 두 방법이 통하지 않는다. 읽기가 아니라서 로컬 캐시로 막을 수 없고, 복제본은 쓰기를 받지 않기 때문이다.
이때는 각 앱 인스턴스가 로컬 카운터에 먼저 쌓아두고, 1초에 한 번씩 모아서
INCRBY로 반영한다.INCR를 1만 번 보내는 대신INCRBY 10000을 한 번 보내는 것이다. 실시간 정확도를 조금 포기하는 대신 쓰기 횟수 자체를 수천 분의 일로 줄인다. 반영 전에 인스턴스가 죽으면 그 구간의 카운트는 사라지므로, 조회수처럼 약간 틀려도 되는 값에만 쓴다.
방법 줄이는 대상 잘 맞는 경우 대가 로컬 캐시(L1) Redis로 가는 요청 수 읽기 위주, 짧은 지연 허용 인스턴스 간 값 불일치 키 분할 노드 한 대가 받는 부하 비율 읽기가 압도적으로 많을 때 쓰기 N배, 사본 간 불일치 배치 집계 Redis로 가는 쓰기 횟수 INCR같은 카운터반영 지연, 장애 시 유실 핫키는 없앨 수 있는 대상이 아니다. 트래픽이 한 키에 몰린다는 건 그 콘텐츠가 인기 있다는 뜻이고, 그건 서비스가 잘 되고 있다는 신호다. 그러니 목표는 발생을 막는 것이 아니라 그 부하가 노드 한 대에 집중되지 않게 하는 것이다. 참고로 모든 사용자에게 똑같은 응답을 주는 데이터라면, 애초에 CDN·엣지 캐시로 요청이 애플리케이션에 도달하기 전에 끊는 것이 가장 저렴하다.
Big Key
문제상황
Big Key는 값 하나가 지나치게 큰 키를 말한다. 절대적인 기준은 없지만 보통 String 값이 10KB를 넘거나, List·Hash·Set·ZSet의 요소가 5,000개를 넘으면 경계 대상으로 본다.
여기서 오해하기 쉬운 지점이 있다. 문제는 크기 자체가 아니라 한 번에 다루는 양이다. 1MB짜리 String을
GET하는 건 값을 통째로 내보내기만 하면 되므로 그럭저럭 견딜 만하다. 하지만 요소가 100만 개인 Hash에HGETALL을 쓰면 100만 개를 전부 순회해 직렬화해야 한다. 크기에 비례해 처리 시간이 늘어나는 명령(O(N))이 진짜 위험이다.왜 치명적인가 — Redis는 명령을 하나씩 처리한다
Redis는 싱글 스레드다. 명령 하나를 처리하는 동안 나머지 요청은 줄을 서서 기다린다. 평소에는 명령 하나가 0.1ms 안에 끝나므로 문제가 되지 않는다. 그런데 Big Key 명령 하나가 800ms를 잡아먹으면, 그 800ms 동안 그 노드로 들어온 모든 요청이 멈춘다.
1 2 3 4 5 6
before during Big Key command ────── ────────────────────── GET a 0.1 ms HGETALL big:key ─── 800 ms ───▶ GET b 0.1 ms GET a (blocked) GET c 0.1 ms GET b (blocked) GET d 0.1 ms GET c (blocked)
Hot Key가 “노드 하나에 부하가 몰려 느려지는” 문제였다면, Big Key는 명령 한 번으로 노드 전체가 멈추는 문제다. 느려지는 게 아니라 정지에 가깝다는 점에서 성격이 더 나쁘다. 애플리케이션 입장에서는 평소 1ms 걸리던 무관한 조회가 갑자기 타임아웃되므로 원인을 찾기도 어렵다.
블로킹 외에도 부작용이 겹친다.
- 네트워크 대역폭: 1MB짜리 값을 초당 1,000번 내보내면 그것만으로 1GB/s다. Hot Key와 Big Key가 겹치면 노드가 가장 빨리 무너지는 조합이 된다.
- 메모리 편중: 클러스터에서도 키 하나는 쪼갤 수 없으므로 그 키가 있는 노드만 메모리를 많이 쓴다.
maxmemory에 먼저 도달해 그 노드에서만 eviction이 발생하고, 애먼 정상 키들이 밀려난다. - 삭제와 만료도 비싸다: 큰 컬렉션을
DEL하는 것 자체가 O(N)이라 삭제하는 순간 다시 블로킹된다. TTL로 만료될 때도 마찬가지여서, 정리하려다 장애를 내는 경우가 실제로 많다. - 복제·영속화 부담: RDB 저장이나 AOF 재작성 때 큰 키를 직렬화하는 비용이 크고, 복제본으로 보내는 버퍼가 한도를 넘으면 복제가 끊긴 뒤 전체 재동기화를 반복하는 상태에 빠질 수 있다.
- 슬롯 이동 실패: 노드를 추가해 리밸런싱할 때 슬롯을 옮기는
MIGRATE는 키를 통째로 옮기는 원자적 연산이라, 큰 키가 있으면 그 시간 동안 블로킹되거나 타임아웃으로 실패한다.
어떻게 만들어지나 — 대부분 서서히 자란다
Big Key의 특징은 처음에는 멀쩡하다는 것이다. 만들 때는 요소가 100개였는데 1년 뒤 100만 개가 되어 있는 식이다.
- 상한 없이 계속 쌓는 컬렉션: 사용자별 활동 로그나 알림 목록에
LPUSH만 하고 오래된 항목을 지우지 않는 경우 - 전체 목록을 한 덩어리로 캐싱: “전체 카테고리”, “전 상품 목록”을 JSON 하나로 만들어
SET하는 경우. 데이터가 늘면 값도 같이 커진다 - 객체를 통째로 직렬화: 세션이나 사용자 정보에 필요 없는 필드까지 전부 담아 저장하는 경우
- 애초에 캐시에 넣을 게 아닌 데이터: 이미지, 파일, 대용량 리포트 결과 등
그래서 주기적인 점검이 필요하다.
redis-cli --bigkeys로 자료구조별 최대 키를 훑고,MEMORY USAGE <key>로 실제 메모리 사용량을 확인한다. 운영 중인 인스턴스에서는KEYS가 아니라SCAN으로 순회하면서STRLEN·LLEN·HLEN·SCARD·ZCARD로 크기를 수집하고, 부하를 주고 싶지 않다면 RDB 파일을 오프라인으로 분석한다.해결방안
방향은 세 가지다. 값을 여러 키로 나누거나, 한 번에 다 읽지 않거나, 애초에 크지 않게 유지하거나.
값을 여러 키로 나누기
큰 컬렉션 하나를 여러 개의 작은 키로 쪼갠다. Hot Key의 키 분할이 같은 값의 사본을 여러 개 만드는 것이었다면, 여기서는 다른 값을 여러 키에 나눠 담는다는 점이 다르다.
1 2
before user:1000:activities → 1 key, 1,000,000 fields, 240 MB after user:1000:activities:0..99 → 100 keys, 10,000 fields each
나누는 기준은 조회 패턴이다. 여기서 먼저 물어야 할 질문은 “이 값을 정말 한 번에 전부 필요로 하는가”다. 대부분은 아니다.
- Hash: 필드 이름을 해시해
key:{n}버킷으로 분산하고, 조회는 필요한 버킷에만HGET - List: 페이지 단위로
feed:1000:page:0,:page:1… 로 끊어 저장 - String(JSON 덩어리): 필드별 Hash로 바꿔 필요한 필드만
HMGET. 프로필 전체를 받아 이름 하나만 쓰는 코드가 의외로 많다
쪼개면 명령 하나가 다루는 양이 줄어 블로킹이 사라지고, 키가 여러 개가 되므로 클러스터에서 여러 노드로 분산되는 효과도 따라온다. 대신 여러 키를 조합해야 하는 조회는 애플리케이션 코드가 복잡해지고, 클러스터에서는 키들이 다른 노드에 흩어져 한 번의 멀티키 명령으로 묶을 수 없다는 제약이 생긴다.
- Hash: 필드 이름을 해시해
한 번에 다 읽지 않기
구조를 바꾸기 어렵다면, 최소한 전체를 한 번에 훑는 명령부터 없앤다. 커서 기반 명령이나 범위 지정으로 바꾸면 큰 키를 여러 번에 나눠 처리하게 되어 이벤트 루프를 오래 붙잡지 않는다.
1 2 3 4
HGETALL big:hash ─▶ HSCAN big:hash 0 COUNT 100 커서로 나눠 받기 LRANGE big:list 0 -1 ─▶ LRANGE big:list 0 99 페이지 단위로 SMEMBERS big:set ─▶ SSCAN big:set 0 COUNT 100 커서로 나눠 받기 DEL big:key ─▶ UNLINK big:key 백그라운드 해제
특히 삭제는 반드시
UNLINK를 쓴다.DEL은 메모리 해제까지 그 자리에서 끝내지만,UNLINK는 키를 키 공간에서 즉시 떼어낸 뒤 실제 해제는 백그라운드 스레드에 맡긴다. 설정에서lazyfree-lazy-expire,lazyfree-lazy-eviction등을 켜두면 TTL 만료나 eviction으로 큰 키가 지워질 때도 같은 방식으로 동작해, 정리 과정에서 서비스가 멈추는 일을 막을 수 있다.애초에 크지 않게 유지하기
가장 확실한 대응은 커지고 나서 대응하는 게 아니라 커지지 못하게 막는 것이다. Big Key는 서서히 자라므로, 쓰기 시점에 상한을 강제해 두면 문제 자체가 생기지 않는다.
1 2
LPUSH feed:1000 {item} # 새 항목 추가 LTRIM feed:1000 0 999 # 항상 최근 1,000개만 유지ZSet이라면
ZREMRANGEBYRANK로 같은 일을 한다. 이미지·파일·대용량 리포트처럼 애초에 캐시에 담을 것이 아닌 데이터는 오브젝트 스토리지에 두고 Redis에는 그 참조(URL, 키)만 저장한다. 그리고 크기 상한을 정했다면 모니터링에 함께 걸어, 특정 키가 기준을 넘기 시작할 때 알림을 받도록 해 둔다.
자료구조 위험한 명령 대체 상한 유지 String 큰 값 자체를 GET필드를 Hash로 분리 후 HMGET저장 전 크기 검증 List LRANGE 0 -1LRANGE 0 99(페이지)LTRIM 0 999Hash HGETALLHSCAN/HMGET버킷 키로 분할 Set SMEMBERSSSCAN버킷 키로 분할 ZSet ZRANGE 0 -1ZRANGE 0 99ZREMRANGEBYRANKHot Key와 Big Key는 짝을 이루는 문제다. Hot Key는 요청이 한 키에 몰리는 것이고 Big Key는 값 하나가 큰 것인데, 둘이 겹치면 큰 값을 높은 빈도로 내보내게 되어 노드가 가장 빠르게 무너진다. 다만 대응 방식은 정반대에 가깝다. Hot Key는 트래픽이 몰린다는 사실 자체를 막을 수 없어 사후에 분산시켜야 하지만, Big Key는 설계 단계에서 상한만 정해두면 애초에 생기지 않는다. 컬렉션에 데이터를 넣는 코드를 작성할 때 “이게 최대 몇 개까지 늘어날 수 있는가”를 한 번 묻는 것이, 나중에 몇 백 MB짜리 키를 서비스 중단 없이 지우는 방법을 고민하는 것보다 훨씬 싸다.