IQ Lab
← all posts
DEV 2026.04.27 · 13 min read Intermediate

Redis 운영의 모든 병목은 단일 스레드에서 시작된다

SLOWLOG 진단부터 Lua 원자성, 메모리 인코딩, 모니터링 지표, OOM·복제·fork 장애 패턴까지, 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은 매 호출마다 이벤트 루프를 짧게 사용하고 커서를 반환해 다음 호출로 넘긴다. 전체 완료까지 수 초가 걸려도 서비스에 영향을 주지 않는다. commandstatsusec_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 스크립트와 이벤트 루프

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.85eviction 임박
mem_fragmentation_ratio> 2.0 또는 < 1.0단편화 또는 스왑
rdb_last_bgsave_statuserr데이터 손실 위험
connected_slaves0 (Master)복제 단절
latest_fork_usec> 200000BGSAVE 지연

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를 안정적으로 운영한다는 것은 단일 스레드라는 설계 결정을 이해하고, 그 제약 안에서 각 도구를 의도적으로 선택하는 것이다.