Redis 유의사항 — 캐시 stampede·핫키·빅키, 단일 스레드의 함정

Redis는 빠르다. 그런데 그 빠름의 전제는 “커맨드 하나가 짧게 끝난다”는 것이다. 커맨드 실행은 단일 스레드라서, 느린 커맨드 하나가 들어오면 그 뒤에 줄 선 모든 요청이 함께 멈춘다. 메모리도 마찬가지다. 무한정 빠른 게 아니라 maxmemory라는 천장이 있고, 그 천장에 닿는 순간 동작이 바뀐다. 여기서는 실제로 장애를 만드는 운영 함정들을 ‘왜 생기나 → 증상 → 어떻게 피하나’ 순서로 정리한다.


Q1. 운영에서 KEYS를 쓰면 왜 위험한가?

급하게 특정 패턴의 키를 찾아야 한다. KEYS user:*를 운영 Redis에 날렸다.

왜 생기나. Redis는 커맨드를 단일 스레드로 처리한다. KEYS는 전체 키 공간을 한 번에 순회한다. 키가 1,000만 개면 1,000만 개를 다 훑을 때까지 다른 커맨드는 한 줄도 처리하지 못한다.

증상. KEYS가 도는 수백 ms ~ 수초 동안 모든 요청이 대기한다. 캐시 조회가 막히면 그 뒤 API들이 줄줄이 타임아웃을 내고, 커넥션 풀이 고갈된다. 평소 1ms로 응답하던 Redis가 갑자기 멈춘 것처럼 보인다. 단 한 번의 KEYS *가 서비스 장애가 된다.

어떻게 피하나. 운영에서는 KEYS를 금지한다. 대신 커서 기반의 SCAN으로 잘게 나눠 순회한다.

# 금지
KEYS user:*

# 대체: 커서가 0을 반환할 때까지 반복
SCAN 0 MATCH user:* COUNT 100

SCANCOUNT만큼만 보고 커서를 돌려주므로 한 번의 호출이 짧다. 그 사이 다른 커맨드가 끼어들 수 있다. 단, COUNT를 과하게 키우면(예: 10만) SCAN 한 호출 자체가 길어져 KEYS와 비슷한 블로킹이 생긴다. 100~1,000 수준에서 시작한다. 같은 이유로 HGETALL 대신 HSCAN, SMEMBERS 대신 SSCAN을 쓴다.

SCAN은 순회 도중 키가 추가·삭제될 수 있어 정확한 스냅샷을 보장하지 않는다. “그 시점에 정확히 몇 개”가 아니라 “블로킹 없이 대략 훑기”가 목적이라는 점을 기억한다.


Q2. 빅키(big key) 하나가 왜 전체 요청을 멈추나?

장바구니를 List 하나에, 활성 유저를 Set 하나에 계속 쌓았다. 어느 순간부터 그 키를 건드리는 커맨드가 느려진다.

왜 생기나. 단일 키에 수십만~수백만 개의 원소가 쌓이면 그 키를 다루는 O(N) 커맨드의 N이 커진다. 단일 스레드라 이 커맨드 하나가 도는 동안 나머지 요청은 전부 대기한다. 대표적인 O(N) 패턴은 다음과 같다.

  • LRANGE big-list 0 -1 — List 전체를 직렬화해 네트워크로 전송
  • HGETALL big-hash — 큰 Hash의 모든 필드를 한 번에 반환
  • SMEMBERS big-set — 큰 Set 전체 반환
  • DEL big-key — 큰 키를 삭제할 때 모든 원소의 메모리를 동기적으로 해제

마지막 DEL이 특히 함정이다. 삭제는 “그냥 지우는 것”처럼 보이지만, 원소가 100만 개인 컬렉션을 DEL하면 100만 개의 메모리 해제가 메인 스레드에서 동기로 일어난다. 지우는 동안 Redis가 멈춘다.

증상. 특정 키에 대한 커맨드만 슬로우 로그에 찍힌다. 평소 1ms인데 그 키만 수십~수백 ms. 빅키를 삭제하거나 만료시키는 순간 응답 지연이 튄다.

어떻게 피하나. 먼저 빅키를 찾아낸다.

# 키별 최대 크기 샘플링 (자료구조별 가장 큰 키 보고)
redis-cli --bigkeys

# 특정 키의 메모리 사용량과 원소 수 확인
redis-cli MEMORY USAGE cart:123
redis-cli LLEN cart:123
redis-cli HLEN session:123

대응은 두 갈래다.

  1. 삭제는 UNLINK로. DEL 대신 UNLINK를 쓰면 키를 키스페이스에서 즉시 떼어내고, 실제 메모리 해제는 백그라운드 스레드가 비동기로 처리한다. 메인 스레드는 거의 멈추지 않는다. 만료·eviction 시에도 비동기 해제가 되도록 lazyfree 옵션을 켠다.
# 큰 키 삭제는 비동기로
UNLINK cart:123

# 만료/eviction/플러시 시 비동기 메모리 해제
redis-cli CONFIG SET lazyfree-lazy-expire yes
redis-cli CONFIG SET lazyfree-lazy-eviction yes
redis-cli CONFIG SET lazyfree-lazy-server-del yes
  1. 애초에 빅키를 만들지 않는다. 큰 컬렉션은 잘게 쪼갠다. 전체를 한 번에 읽지 말고 범위로 끊어 읽는다.
# 전체 조회 금지
LRANGE big-list 0 -1

# 청크 단위로
LRANGE big-list 0 999
LRANGE big-list 1000 1999

자료구조 인코딩 전환(listpack → hashtable 등)으로 인한 메모리 급증도 빅키와 함께 나타난다. 인코딩 임계값과 전환의 단방향성은 → Redis 자료구조 심화에서 다룬다.


Q3. 핫키(hot key)는 왜 문제이고, 클러스터에서 더 심각한가?

특정 인기 상품 하나에 트래픽의 절반이 몰린다. 클러스터로 분산했는데도 노드 하나만 CPU가 100%다.

왜 생기나. Redis는 키 단위로 노드(슬롯)에 배치된다. 한 키에 트래픽이 집중되면 그 키가 사는 노드 하나가 모든 부하를 받는다. 클러스터는 데이터를 16384개 슬롯으로 나눠 여러 Master에 분산하지만, 트래픽이 한 키에 쏠리면 그 키의 슬롯을 가진 Master 한 대만 일한다. 나머지 노드는 유휴 상태다. 샤드를 늘려도 핫키 한 개의 부하는 분산되지 않는다.

증상. 클러스터 전체 평균 부하는 낮은데 특정 노드만 CPU·네트워크가 포화된다. redis-cli --hotkeys(LFU eviction 정책일 때) 또는 슬로우 로그·MONITOR 샘플링에서 같은 키가 반복적으로 등장한다.

# 핫키 탐지 (maxmemory-policy가 *-lfu일 때)
redis-cli --hotkeys

어떻게 피하나.

  1. 로컬 캐시로 겹쳐 막기. 진짜 핫한 소수의 키는 애플리케이션 프로세스 내 로컬 캐시(Caffeine 등)에 짧은 TTL로 올린다. 대부분의 조회가 네트워크를 타지 않고 끝나서 Redis 노드로 가는 트래픽 자체가 줄어든다. 멀티레이어 캐시 구성과 인스턴스 간 무효화는 → Redis 캐싱 전략에서 다룬다.

  2. 키 분산(샤딩). 값이 같아도 되는 읽기 전용 핫키라면, 키를 hotitem:1#0 ~ hotitem:1#N처럼 N개 복제로 쪼개고 요청 때 무작위 하나를 읽는다. 부하가 N개 슬롯으로 흩어진다. 쓰기는 N개를 모두 갱신해야 하므로 거의 안 바뀌는 데이터에만 쓴다.

클러스터의 슬롯 배치, HashTag로 인한 의도치 않은 슬롯 편중(hotspot)은 → Redis 운영 심화에서 다룬다.


Q4. 캐시 stampede가 뭔가? 한 줄로 막을 수 없나?

인기 상품 캐시가 동시에 만료되는 순간 DB가 터진다.

왜 생기나. 캐시 miss 직후 DB를 읽어 캐시를 채우기까지 수십~수백 ms의 공백이 있다. 이 공백에 같은 키에 대한 동시 요청 수백 건이 전부 miss를 내면 전부 DB로 직행한다(stampede). 여러 키가 같은 TTL로 동시에 만료되면 이 현상이 키 개수만큼 증폭된다.

증상. TTL 만료 시점에 DB QPS가 순간적으로 폭발하고 커넥션 풀이 고갈된다. 캐시가 없을 때보다 오히려 나쁜 부하가 한꺼번에 몰린다.

어떻게 피하나. 핵심만 요약하면, ① Mutex Lock으로 첫 요청만 DB를 읽게 하고 나머지는 캐시가 채워질 때까지 대기, ② TTL Jitter로 동시 만료를 분산, ③ TTL이 길고 재계산 비용이 크면 Probabilistic Early Expiry를 추가한다. 코드 레벨 구현과 선택 기준은 → Redis 캐싱 전략에서 자세히 다룬다.


Q5. penetration과 avalanche는 stampede와 뭐가 다른가?

stampede는 막았는데도 가끔 DB가 출렁인다. 다른 종류의 문제다.

세 가지는 비슷해 보이지만 원인이 다르다.

Cache Penetration(캐시 관통). 존재하지 않는 키를 반복 조회하는 경우다. 캐시에 없고(miss) DB에도 없으니 캐시에 채울 것도 없다. 매 요청이 캐시 miss → DB 조회로 흘러간다. 잘못된 ID를 던지는 버그나, 없는 키를 대량으로 찌르는 공격에서 DB가 직격된다.

  • 대응: “없음”이라는 결과 자체를 짧은 TTL로 캐시한다(negative cache). 단 TTL을 너무 길게 잡으면 실제로 키가 생겼을 때 한동안 “없음”으로 보인다. 대량의 무작위 없는 키에는 Bloom filter로 “확실히 없는 키”를 캐시 앞단에서 걸러내는 방법도 있다. negative cache 구현은 → Redis 캐싱 전략 참고.

Cache Avalanche(캐시 눈사태). 많은 키가 거의 동시에 만료되거나, Redis 자체가 잠깐 내려갔다 복구되어 캐시가 통째로 비는 경우다. 그 직후 모든 트래픽이 DB로 쏟아진다. stampede가 “한 키”의 동시 만료라면, avalanche는 “다수 키”의 동시 만료에 가깝다.

  • 대응: TTL에 Jitter를 줘서 만료 시점을 흩뜨린다. 모든 키가 정확히 같은 시각에 만료되지 않게 하는 것이 핵심이다.
// ±10% 랜덤 편차로 동시 만료 분산
double jitterFactor = 0.9 + ThreadLocalRandom.current().nextDouble() * 0.2; // 0.9 ~ 1.1
long ttlSeconds = (long) (baseTtlSeconds * jitterFactor);

Redis 장애로 인한 캐시 전면 공백(cold start 포함)에 대한 워밍업·fallback 전략은 → Redis 캐싱 전략에서 다룬다.


Q6. maxmemory eviction 때문에 TTL 전에 키가 사라지는 게 정상인가?

세션을 2시간 TTL로 저장했다. 그런데 30분 만에 사라진 세션이 나온다. “TTL 안 끝났으니 있겠지”라고 가정한 코드가 깨진다.

왜 생기나. Redis는 maxmemory에 도달하면 설정된 eviction 정책에 따라 기존 키를 강제로 제거하고 공간을 만든다. allkeys-lru 같은 정책이면 TTL이 남아있어도 메모리 압박만 있으면 키가 제거된다. 즉 TTL은 만료의 한 가지 경로일 뿐, 키 존재를 보장하지 않는다. eviction이 다른 경로로 키를 먼저 지울 수 있다.

증상. TTL이 충분히 남은 키가 조회되지 않는다. INFO statsevicted_keys가 0이 아니라 계속 증가한다. “캐시에 있겠지”를 전제로 한 로직(예: 캐시에만 의존한 분산 락, 캐시에만 저장한 단계별 상태)이 간헐적으로 실패한다.

# 강제 eviction 발생 여부 — 0이 아니면 메모리 압박 중
redis-cli INFO stats | grep evicted_keys

어떻게 피하나.

  • 캐시 데이터는 언제든 사라질 수 있다고 가정한다. 캐시 miss는 정상 흐름이고, 항상 DB 같은 원본에서 복구 가능해야 한다.
  • 사라지면 안 되는 데이터와 캐시를 같은 인스턴스에 섞지 않는다. 세션·재고처럼 휘발되면 안 되는 데이터는 별도 인스턴스로 분리하고 noeviction을 쓰거나, 최소한 volatile-* 정책으로 TTL 있는 캐시 키만 제거 대상으로 둔다.

다만 noeviction은 반대편 함정이 있다. 메모리가 꽉 차면 모든 쓰기 커맨드가 OOM command not allowed when used memory > 'maxmemory' 에러를 반환한다. 읽기는 되지만 세션 갱신·재고 차감 같은 쓰기가 전부 실패한다. 그래서 noeviction을 쓸 때는 메모리 사용률 알림이 필수다. 정책별 적합 케이스(LRU vs LFU, volatile vs allkeys)와 선택 기준은 → Redis 운영 심화에서 다룬다.


Q7. TTL을 안 걸면 어떻게 되나?

캐시를 SET key value로만 저장했다. TTL을 안 줬다. 한동안 잘 돌다가 몇 주 뒤 메모리가 천장에 닿는다.

왜 생기나. TTL 없는 키는 명시적으로 지우지 않는 한 영원히 남는다. 캐시처럼 계속 새 키가 들어오는데 만료가 없으면 메모리는 단조 증가한다. 결국 maxmemory에 도달한다. 그 다음은 Q6의 두 갈래로 갈린다. eviction 정책이 있으면 의도치 않은 키까지 제거되고, noeviction이면 쓰기가 막힌다.

증상. used_memory가 시간에 따라 우상향한다. INFO keyspace에서 keys는 많은데 expires(TTL 있는 키 수)가 그에 비해 적다.

redis-cli INFO keyspace
# db0:keys=8000000,expires=120000,avg_ttl=0
#         ^전체 800만             ^TTL 있는 키 12만뿐 → 대부분 TTL 없음

어떻게 피하나.

  • 캐시 용도의 키에는 반드시 TTL을 건다. SET key value EX 600처럼 저장과 동시에 만료를 지정한다.
  • 이미 TTL 없이 쌓인 키는 SCAN으로 패턴을 훑어 EXPIRE를 부여한다(KEYS 말고 SCAN).
  • TTL을 거는 게 구조적으로 어려운 데이터(예: 영구 보관 데이터)라면 그건 애초에 캐시가 아니라 원본 저장소다. eviction 정책을 noeviction으로 두고 용도를 분리하는 게 맞다.

TTL이 끝나도 키가 즉시 삭제되지 않고 lazy·active expiration의 조합으로 지워진다는 점, 그래서 만료가 몰리면 active expiration 부하가 커진다는 점은 → Redis 캐싱 전략에서 다룬다.


Q8. 이런 함정들을 사후가 아니라 사전에 잡으려면 뭘 봐야 하나?

장애가 터지고 나서야 슬로우 로그를 본다. 미리 보고 있었어야 했다.

운영 중 상시로 봐야 하는 지표는 세 가지다.

1. slowlog — 느린 커맨드. 빅키 O(N) 커맨드, 잘못 들어온 KEYS, 긴 Lua 스크립트가 여기 다 찍힌다.

# 임계값을 10ms로 낮춰 의심 커맨드를 기록
redis-cli CONFIG SET slowlog-log-slower-than 10000   # 단위: 마이크로초
redis-cli CONFIG SET slowlog-max-len 128

redis-cli SLOWLOG GET 20   # 최근 20개
redis-cli SLOWLOG LEN

2. latency — 지연 스파이크의 원인. fork(BGSAVE), 만료 폭주, 느린 커맨드 등 어떤 이벤트가 지연을 유발했는지 분류해준다.

redis-cli --latency             # 실시간 평균/최대 지연
redis-cli LATENCY LATEST        # 최근 지연 이벤트
redis-cli LATENCY RESET

3. INFO memory / stats — 메모리와 eviction 추세. 천장에 닿기 전에 알아챈다.

# 메모리 사용량과 단편화 비율 (1.5 이상이면 단편화 주의)
redis-cli INFO memory | grep -E "used_memory:|used_memory_rss:|mem_fragmentation_ratio"

# 강제 eviction과 만료 추세 (evicted_keys가 늘면 메모리 압박)
redis-cli INFO stats | grep -E "evicted_keys|expired_keys|keyspace_hits|keyspace_misses"

이 세 가지를 알림과 연동한다. evicted_keys 증가, 메모리 사용률 85% 초과, 슬로우 로그 급증을 임계로 잡으면 대부분의 함정은 장애가 되기 전에 손에 잡힌다. 메모리 단편화의 메커니즘과 Active Defragmentation 대응은 → Redis 운영 심화에서 다룬다.


함정 요약

함정왜 위험한가핵심 대응
KEYS / 큰 SCAN COUNT단일 스레드 전체 블로킹SCAN 커서, COUNT 100~1,000
빅키 + O(N) 커맨드느린 커맨드 하나가 전체 정지키 쪼개기, 범위 조회, UNLINK/lazyfree
핫키 편중클러스터에서도 노드 하나만 과부하로컬 캐시 겹치기, 읽기 키 분산
캐시 stampede동시 만료 시 DB 폭격Lock + TTL Jitter → 캐싱 전략 글
penetration / avalanche없는 키 반복, 동시 대량 만료negative cache, TTL Jitter
maxmemory evictionTTL 전에 키 증발캐시는 휘발 가정, 용도별 인스턴스 분리
TTL 누락메모리 무한 증가저장 시 항상 EX, 캐시/원본 분리
모니터링 부재사후 대응만 가능slowlog · latency · INFO memory 상시