Redis 운영의 모든 병목은 단일 스레드에서 시작된다
SLOWLOG 진단부터 Lua 원자성, 메모리 인코딩, 모니터링 지표, OOM·복제·fork 장애 패턴까지, Redis 운영 지식의 공통 뿌리를 추적한다.
- 01 Redis의 모든 선택은 하나의 질문에서 나온다
- 01 Redis는 왜 같은 데이터를 다르게 저장하는가
- 01 Redis 영속성은 어떻게 설계하는가
- 01 Redis 운영의 모든 병목은 단일 스레드에서 시작된다
- 01 Redis 복제는 왜 데이터를 잃는가
- 01 Spring + Redis 통합에서 직렬화가 모든 것을 결정한다
Redis 운영에서 마주치는 문제들 — 응답 시간 폭등, Race Condition, 메모리 낭비, 장애 진단 지연 — 은 겉으로 보면 전혀 다른 이슈처럼 보인다. 하지만 하나의 공통 구조에서 나온다. Redis는 단일 스레드로 모든 명령어를 직렬 처리한다. 이 사실을 이해하지 못하면, 각 문제를 개별 버그로 오해하게 된다. 왜 KEYS * 하나가 서비스를 멈추고, Lua 스크립트가 Race Condition을 원천 차단하며, fork() 350ms가 수백 개 타임아웃을 만드는가?
이벤트 루프가 곧 병목의 지도다
Redis의 단일 이벤트 루프는 모든 병목 분석의 출발점이다. 한 명령어가 실행되는 동안 다른 모든 요청은 큐에서 기다린다. 이 구조에서 O(N) 명령어는 단순히 느린 게 아니라 전체 서비스를 N에 비례하는 시간 동안 정지시킨다.
SLOWLOG는 이 구조를 데이터로 증명하는 도구다. 측정 대상은 명령어 파싱부터 실행 완료까지의 시간이다. 소켓 I/O 대기나 클라이언트 처리 시간은 포함되지 않는다.
redis-cli CONFIG SET slowlog-log-slower-than 1000 # 1ms 이상 기록
redis-cli SLOWLOG GET 20
출력의 세 번째 필드가 실행 시간(마이크로초)이다. 450234가 나왔다면 이벤트 루프가 450ms 동안 독점됐다는 의미다. 이 시간 동안 다른 모든 클라이언트는 대기한다. 기본 임계값(10ms)은 중간 수준 병목을 놓친다. 1ms로 낮추면 2~5ms짜리 누적 문제도 잡힌다.
KEYS *를 SCAN으로 교체하는 것도 같은 이유다. SCAN은 매 호출마다 이벤트 루프를 짧게 사용하고 커서를 반환해 다음 호출로 넘긴다. 전체 완료까지 수 초가 걸려도 서비스에 영향을 주지 않는다. commandstats의 usec_per_call이 높은 명령어를 찾아 SCAN 계열로 교체하는 것이 병목 제거의 첫 번째 루틴이다.
Lua 스크립트 — 단일 스레드의 역설적 활용
단일 스레드는 약점이지만 동시에 원자성의 근거다. Lua 스크립트가 실행되는 동안 다른 어떤 명령어도 끼어들 수 없다.
“잔액 확인 후 차감”을 두 번의 별도 명령어로 구현하면 GET과 DECRBY 사이에 다른 클라이언트가 개입해 음수 잔액이 생긴다. Lua 스크립트는 이 두 단계를 하나의 원자적 단위로 실행한다.
local balance = tonumber(redis.call('GET', KEYS[1]))
if balance == nil then return -1 end
if balance >= tonumber(ARGV[1]) then
redis.call('DECRBY', KEYS[1], ARGV[1])
return 1
end
return 0
EVALSHA는 네트워크 최적화다. SCRIPT LOAD로 스크립트를 한 번 등록하면 SHA1 해시(40자)만 전송해 실행한다. 2KB짜리 스크립트를 매번 전송하는 대신 50바이트만 쓴다.
Lua 스크립트도 이벤트 루프를 독점한다. 스크립트 안에서 KEYS * 같은 O(N) 명령어를 호출하면 이중 블로킹이 발생한다. lua-time-limit(기본 5000ms)을 초과해도 즉시 중단되지 않으므로, Lua 스크립트는 항상 짧고 O(1) 명령어만 사용해야 한다.
메모리 인코딩 — 설계 단계의 결정이 운영 비용을 바꾼다
Redis 자료구조는 크기에 따라 내부 인코딩을 자동으로 바꾼다. Hash가 hash-max-listpack-entries(기본 128) 이하면 listpack으로 저장된다. 임계값을 초과하는 순간 hashtable로 전환되고 메모리가 3~8배 증가한다.
redis-cli OBJECT ENCODING myhash # listpack 또는 hashtable
redis-cli MEMORY USAGE myhash # 실제 RAM 바이트
필드 10개짜리 Hash 100만 개를 listpack으로 유지하면 약 300MB다. hashtable로 전환되면 1.5GB가 된다. 설계 단계에서 임계값 이내로 데이터를 유지하는 전략(Hash Sharding 등)이 인프라 비용을 직접 결정한다.
인코딩 전환은 단방향이다. hashtable로 전환된 키는 임계값을 올려도 자동으로 listpack으로 돌아오지 않는다. 재시작 시 RDB에서 로드할 때 현재 임계값 기준으로 재결정된다.
INFO 지표 — 장애 신호는 항상 먼저 수치로 나타난다
Redis 장애는 대부분 예고 없이 오지 않는다. 수치가 먼저 움직인다.
| 지표 | 경고 기준 | 의미 |
|---|---|---|
used_memory / maxmemory | > 0.85 | eviction 임박 |
mem_fragmentation_ratio | > 2.0 또는 < 1.0 | 단편화 또는 스왑 |
rdb_last_bgsave_status | err | 데이터 손실 위험 |
connected_slaves | 0 (Master) | 복제 단절 |
latest_fork_usec | > 200000 | BGSAVE 지연 |
evicted_keys가 증가한다면 maxmemory에 근접해 데이터가 삭제되고 있다는 신호다. mem_fragmentation_ratio < 1.0은 Redis 데이터 일부가 디스크 스왑으로 내려갔다는 의미로, 즉각 대응이 필요한 긴급 상황이다.
redis_exporter + Prometheus + Grafana(대시보드 ID 11835) 조합이 이 지표들을 자동으로 수집하고 시각화한다.
트레이드오프
단일 스레드 구조의 모든 트레이드오프는 하나의 문장으로 요약된다 — Redis가 빠른 이유와 느려질 수 있는 이유가 동일하다. SLOWLOG 임계값을 낮추면 더 많은 병목을 잡지만 로그가 빠르게 가득 찬다. Lua 스크립트는 Race Condition을 막지만 긴 스크립트는 서비스 전체를 멈춘다. listpack 유지는 메모리를 아끼지만 O(N) 탐색을 허용한다. repl-backlog-size를 크게 잡으면 Partial Resync 범위가 늘어나지만 메모리를 점유한다. Master에서 BGSAVE를 끄고 Replica에서 실행하면 Master의 fork() 지연이 사라지지만 RDB 파일이 최신 데이터를 반영하지 않을 수 있다. 각 결정은 단일 스레드가 만드는 제약 안에서의 균형점 선택이다.
정리
- 모든 Redis 병목의 뿌리는 단일 이벤트 루프다. O(N) 명령어는 N에 비례한 시간 동안 모든 요청을 멈춘다.
SLOWLOG는 이벤트 루프 독점 시간을 측정한다. 1ms 임계값으로 중간 수준 병목까지 잡아라.- Lua 스크립트의 원자성은 단일 스레드 덕분이다. Read-Modify-Write 패턴은 Lua로만 안전하게 구현된다.
- 인코딩 임계값은 설계 단계의 결정이다. 전환은 단방향이므로 사후 수정은 재생성이 필요하다.
- 장애 신호는 항상 수치로 먼저 나타난다.
evicted_keys,fragmentation_ratio,latest_fork_usec에 알림을 설정하라.
Redis를 안정적으로 운영한다는 것은 단일 스레드라는 설계 결정을 이해하고, 그 제약 안에서 각 도구를 의도적으로 선택하는 것이다.