싱글 스레드인데 왜 빠른가 (Redis 이벤트 루프, io-threads, 그리고 멈추는 순간)

Redis가 빠른 이유는 싱글 스레드라서가 아니라 병목이 CPU가 아니기 때문입니다. 이벤트 루프 한 바퀴를 따라가면서 io-threads가 무엇을 나누고 무엇을 나누지 않는지, 그리고 제가 쓴 Lua가 서버를 얼마나 붙잡는지 정리했어요.

[배경 - 원자적이라는 말의 대가를 몰랐다]

39번 글에서 재고 확인과 차감을 Lua 스크립트 한 덩어리로 묶었습니다. 27번 글의 토큰 버킷도 Lua였고, 2번 글의 델타 차감도 Lua였어요. 세 번 다 이유는 같았습니다. “Redis는 싱글 스레드라 스크립트가 원자적으로 실행된다.”

맞는 말입니다. 그런데 어느 날 이 문장을 뒤집어 읽어봤어요.

스크립트가 도는 동안 Redis는 다른 아무것도 하지 못합니다.

원자성은 공짜로 얻은 게 아니라, 그 시간 동안 서버 전체를 붙잡아서 산 거였어요. 제 스크립트가 10ms 걸리면 그 10ms 동안 다른 모든 클라이언트가 대기합니다. 그때는 이걸 대가로 인식하지 않았습니다.

그래서 Redis 안으로 들어가 봤어요. 이벤트 루프가 한 바퀴 도는 동안 무슨 일이 일어나는지, 어디가 진짜 싱글 스레드이고 어디는 아닌지, 그리고 언제 멈추는지를 봤습니다.

미리 밝혀둘게요. 이 글에는 제가 잰 숫자가 없습니다. 벤치마크를 돌리지 않았어요. 나오는 값은 Redis의 기본 설정값과 소스에 있는 상수이고, 그렇다는 걸 표시해뒀습니다.

[문제 상황 분석 - 이벤트 루프 한 바퀴]

싱글 스레드가 아니라 싱글 이벤트 루프입니다

먼저 표현을 정확히 할 필요가 있어요. Redis는 명령을 실행하는 스레드가 하나입니다. 프로세스 전체가 스레드 하나라는 뜻이 아니에요. 뒤에서 볼 백그라운드 스레드들이 따로 있습니다.

명령 실행 경로의 중심은 ae.c 의 이벤트 루프입니다. 구조는 이래요.

// 아주 단순화한 골자
while (!stop) {
    beforeSleep();                 // 루프에 들어가기 직전에 할 일
    numevents = aeApiPoll(tvp);    // epoll_wait / kevent 로 대기
    for (j = 0; j < numevents; j++) {
        // 읽기 준비된 소켓 → readQueryFromClient()
        // 쓰기 준비된 소켓 → writeToClient()
    }
    processTimeEvents();           // serverCron 등
}

aeApiPoll 이 플랫폼에 따라 갈립니다. Linux면 epoll, macOS와 BSD면 kqueue, Solaris면 evport, 그것도 없으면 select 예요. I/O 멀티플렉싱이라고 부르는 그것입니다.

핵심은 이겁니다. 커넥션이 1만 개여도 스레드는 하나이고, 커널에게 “이 1만 개 중에 준비된 것만 알려줘”라고 한 번 물어봅니다. epoll_wait 시스템 콜 한 번으로 준비된 소켓 목록을 받아와요. 커넥션마다 스레드를 띄우는 모델과 비교하면 컨텍스트 스위치와 스택 메모리가 통째로 없습니다.

명령 하나가 지나가는 길

클라이언트가 GET foo 를 보내면 이런 순서로 처리됩니다.

epoll_wait 가 소켓을 깨움
   ↓
readQueryFromClient()     소켓에서 바이트를 읽어 querybuf 에 넣는다
   ↓
processInputBuffer()      RESP 프로토콜 파싱. 명령 하나가 완성됐는지 본다
   ↓
processCommand()          명령 조회, 권한, maxmemory, 클러스터 리다이렉트 검사
   ↓
call()                    실제 명령 함수 실행 (getCommand)
   ↓
addReply()                응답을 클라이언트 출력 버퍼에 쌓는다  ← 아직 소켓에 안 쓴다
   ↓
   ...  다른 이벤트들 처리 ...
   ↓
beforeSleep()
   └ handleClientsWithPendingWrites()
        └ writeToClient()   여기서 비로소 소켓에 쓴다

주목할 부분은 응답이 즉시 소켓으로 나가지 않는다는 점이에요. addReply 는 메모리 버퍼에 담을 뿐이고, 실제 write 는 루프가 다시 대기에 들어가기 직전(beforeSleep)에 몰아서 합니다.

이 설계가 파이프라이닝의 효과를 만들어요. 클라이언트가 명령 100개를 한 번에 밀어 넣으면, Redis는 그걸 한 번의 read 로 받아 100개를 처리하고 한 번의 write 로 답합니다. 시스템 콜이 200번에서 2번으로 줄어요.

그래서 왜 빠른가

“싱글 스레드인데 왜 빠른가”의 답은 사실 순서가 거꾸로입니다. 빠른 이유가 싱글 스레드여서가 아니라, 병목이 CPU가 아니라서 싱글 스레드로도 충분한 겁니다.

Redis가 GET 하나를 처리하는 실제 연산은 해시 테이블 조회 한 번이에요. 마이크로초도 안 걸립니다. 시간을 먹는 건 그게 아니라 이쪽이에요.

  • 네트워크 왕복 지연
  • 커널과 유저 공간 사이의 데이터 복사
  • 시스템 콜 오버헤드

그러니 코어를 더 준다고 빨라지지 않습니다. 대신 싱글 스레드를 골라서 얻는 게 큽니다.

락이 필요 없습니다. 자료구조에 뮤텍스가 없어요. 락 경합도, 데드락도, 메모리 배리어 비용도 없습니다. 이게 성능만이 아니라 코드의 단순함까지 만들어줘요.

컨텍스트 스위치가 없습니다. 스레드가 하나이니 스케줄러가 개입할 일이 없습니다.

그리고 모든 명령이 원자적입니다. 이게 제가 Lua를 믿고 쓴 근거였어요. INCR 이 원자적인 이유는 특별한 장치가 있어서가 아니라, 실행 중에 끼어들 다른 스레드가 없기 때문입니다.

자료구조도 같은 철학입니다

Redis가 메모리를 아끼려고 쓰는 인코딩 전환도 이 그림의 일부예요. 작은 컬렉션은 배열처럼 촘촘한 구조에 담고, 커지면 본격적인 자료구조로 바꿉니다.

타입작을 때커지면전환 기준 (기본값)
Hashlistpackhashtable필드 128개, 값 64바이트
Listlistpackquicklistlistpack 크기 제한
Setintset 또는 listpackhashtable정수 512개, 요소 128개
ZSetlistpackskiplist와 hashtable요소 128개, 값 64바이트

작을 때 배열 형태를 쓰는 이유는 캐시 지역성입니다. 요소가 몇십 개면 포인터를 따라다니는 것보다 연속된 메모리를 선형 탐색하는 게 빨라요. 대신 커지면 O(n)이 부담이 되니 전환합니다.

여기서 조심할 게 하나 있어요. 인코딩 전환은 되돌아가지 않습니다. hashtable로 승격된 해시는 필드를 다시 줄여도 listpack으로 돌아오지 않아요. 순간적으로 커진 컬렉션이 계속 큰 메모리를 쓰는 원인이 됩니다.

[io-threads - 무엇을 나누고 무엇을 나누지 않는가]

병목이 네트워크라면 거기를 나눈다

앞에서 병목이 CPU가 아니라 I/O라고 했습니다. Redis 6.0의 io-threads 는 정확히 그 진단에서 나온 기능이에요.

명령 실행은 여전히 메인 스레드 혼자 합니다. 나뉘는 건 소켓에서 읽고 쓰는 부분, 그리고 RESP 프로토콜을 파싱하는 부분이에요.

이벤트 루프 한 바퀴에서 병렬로 도는 구간 병렬 구간 1 소켓 read 와 RESP 파싱 · io-threads-do-reads yes 일 때만 나뉜다 직렬 구간 메인 스레드 혼자 processCommand → call() → 자료구조 변경 → addReply() Lua 스크립트 실행, MULTI 트랜잭션, 만료 처리, eviction 도 전부 여기 병렬 구간 2 출력 버퍼를 소켓에 write · beforeSleep 에서 io-threads 로 분배 io-threads 를 늘려도 직렬 구간은 그대로다. 느린 명령 하나는 여전히 서버 전체를 멈춘다. 이 기능이 노리는 것은 처리량이지 지연이 아니다. 값이 크고 커넥션이 많을수록 효과가 크다. Redis 6, 7 기준 기본값은 io-threads 1, io-threads-do-reads no 이다.

기본값은 io-threads 1 입니다. 그러니까 켜지 않으면 아무 일도 일어나지 않아요. 그리고 io-threads-do-reads 도 기본이 no 라서, 켜더라도 처음에는 쓰기만 나뉩니다.

Redis 문서는 코어 수보다 적게, 4코어 미만이면 켜지 말라고 안내합니다. 실행이 직렬인 이상 I/O 스레드를 늘려도 어느 지점부터는 이득이 없고, 스레드 사이 조율 비용만 늘어요.

중요한 건 이 기능이 지연을 줄여주지 않는다는 겁니다. 명령 하나가 느리면 io-threads를 8로 올려도 그 명령이 도는 동안 전부 멈춥니다. 늘어나는 건 초당 처리할 수 있는 명령 수예요. 값이 크거나(네트워크 복사가 큼) 커넥션이 많을 때 효과가 납니다.

Redis 8 계열에서 이 부분이 더 개선됐다고 알고 있는데, 저는 운영해보지 않았으니 여기까지만 적을게요.

진짜로 따로 도는 것들

명령 실행 말고, 오래 걸리는 작업을 메인 스레드에서 빼려는 장치들이 따로 있습니다.

백그라운드 I/O 스레드(bio). close() 와 fsync() 처럼 블로킹될 수 있는 시스템 콜을 전담합니다. AOF의 fsync 를 메인 스레드가 직접 하면 디스크가 느릴 때 서버가 통째로 멈추니까요.

lazy free. 큰 컬렉션을 지울 때 메모리 해제 자체가 오래 걸립니다. 요소가 100만 개인 Set을 DEL 하면 그걸 하나씩 free하는 시간 동안 서버가 멈춰요. UNLINK 는 키를 네임스페이스에서만 떼어내고 실제 해제는 백그라운드 스레드에 넘깁니다. 같은 동작을 자동으로 하게 하는 설정도 있어요.

lazyfree-lazy-eviction    no
lazyfree-lazy-expire      no
lazyfree-lazy-server-del  no
replica-lazy-flush        no

기본이 전부 no 입니다. 큰 컬렉션을 다루는 서비스라면 이 값들을 보고 넘어갈 만해요.

자식 프로세스. BGSAVE 와 BGREWRITEAOF 는 스레드가 아니라 fork() 로 자식 프로세스를 만듭니다. 자식이 메모리 스냅샷을 디스크에 쓰는 동안 부모는 계속 요청을 처리해요.

여기에 운영에서 자주 물리는 함정이 있습니다. fork는 copy-on-write에 기댑니다. 처음에는 메모리 페이지를 공유하다가, 부모가 쓰기를 하는 페이지만 복사해요. 그러니 스냅샷 중에 쓰기가 많으면 메모리 사용량이 최대 두 배까지 갈 수 있습니다.

그리고 THP(Transparent Huge Pages)를 끄라는 권고가 여기서 나와요. THP가 켜져 있으면 페이지 단위가 4KB가 아니라 2MB입니다. 4KB짜리 쓰기 하나에 2MB를 복사하니 메모리도 지연도 폭증해요. Redis가 기동할 때 로그로 경고를 띄우는 항목입니다.

[멈추는 순간들 - 직렬 구간에 무엇이 들어가는가]

직렬 구간이 성능의 전부라면, 거기에 뭐가 들어가는지가 실무의 핵심입니다.

느린 명령 하나

가장 유명한 건 KEYS * 예요. 키 공간 전체를 훑는 O(n) 명령이고, 그동안 서버가 멈춥니다. 키가 100만 개면 그만큼 걸려요.

그래서 SCAN 이 있습니다. 커서를 들고 조금씩 훑는 방식이라 한 번의 호출이 짧아요. 대신 순회 중에 추가된 키를 보장하지 않고 같은 키를 두 번 볼 수도 있습니다. 정확성을 포기하고 지연을 산 설계예요.

비슷한 것들이 많습니다.

명령위험
KEYS pattern키 공간 전체 스캔
SMEMBERS, HGETALL, LRANGE 0 -1큰 컬렉션 전체를 직렬화
DEL (큰 컬렉션)요소마다 free. UNLINK 로 대체
FLUSHALL, FLUSHDB기본이 동기. ASYNC 옵션이 있다
SORT (BY, GET)정렬 비용이 직렬 구간에 들어간다
ZRANGEBYSCORE (넓은 범위)결과 크기만큼 걸린다

slowlog 가 이걸 잡는 도구입니다. slowlog-log-slower-than 기본값은 10000 마이크로초, 그러니까 10ms예요. 여기 뭔가 찍힌다면 그 시간만큼 서버 전체가 멈췄다는 뜻입니다.

한 가지 덧붙이면, slowlog에 기록되는 시간은 명령 실행 시간뿐입니다. 네트워크로 응답을 보내는 시간은 안 들어가요. 그래서 LRANGE 로 큰 결과를 뽑으면 slowlog는 짧게 나오는데 클라이언트 체감은 느릴 수 있습니다.

제가 쓴 Lua 스크립트

이제 처음 질문으로 돌아옵니다. Lua 스크립트는 통째로 직렬 구간에 들어갑니다.

스크립트가 도는 동안 다른 클라이언트의 명령은 하나도 처리되지 않아요. 그래서 스크립트 안에서 절대 하면 안 되는 것들이 있습니다.

  • 반복 횟수가 데이터 크기에 비례하는 루프
  • 큰 컬렉션을 통째로 읽는 명령
  • 블로킹 명령

busy-reply-threshold(예전 이름은 lua-time-limit) 기본값은 5000ms입니다. 이 시간을 넘기면 Redis가 다른 클라이언트에게 BUSY 에러로 답하기 시작해요. 주의할 건 스크립트가 중단되는 게 아니라는 점입니다. 계속 돕니다. 다만 그때부터 SCRIPT KILL 을 받을 수 있게 될 뿐이에요.

그리고 이미 쓰기를 한 스크립트는 SCRIPT KILL 로 죽일 수 없습니다. 중간에 끊으면 원자성이 깨지니까요. 남은 방법은 SHUTDOWN NOSAVE 뿐입니다. 데이터를 버리고 서버를 내리는 거예요.

제 스크립트들을 이 기준으로 다시 봤습니다. 39번의 재고 차감은 GET 과 DECRBY 몇 개라 상수 시간이고, 27번의 토큰 버킷도 마찬가지예요. 2번의 델타 차감도 키 하나를 다룹니다. 다행히 셋 다 문제될 형태는 아니었어요.

다만 그때 이걸 기준으로 판단한 게 아니라 결과적으로 안전했던 것뿐입니다. Lua를 쓸 때 “원자적이니까 좋다”만 생각했지 “그동안 서버가 멈춘다”를 계산에 넣지 않았어요. 스크립트에 루프를 하나만 넣었어도 달랐을 겁니다.

만료 처리

TTL이 지난 키를 Redis가 어떻게 지우는지도 직렬 구간의 일부예요. 두 가지를 같이 씁니다.

게으른 만료. 키에 접근할 때 만료됐는지 확인하고, 그렇다면 그때 지웁니다. 아무도 안 찾는 키는 계속 메모리에 남아요.

능동적 만료. serverCron 이 주기적으로 돌면서 만료 후보를 표본으로 뽑아 지웁니다. hz 기본값은 10이니 초당 10회 돌아요. 한 번에 TTL이 걸린 키 20개를 무작위로 뽑고, 그중 25%보다 많이 만료돼 있으면 같은 과정을 반복합니다.

이 반복이 지연 스파이크를 만들 수 있어요. 같은 시각에 만료되는 키가 대량으로 있으면 능동 만료 루프가 오래 돌면서 그동안 명령 처리가 밀립니다. TTL을 일괄로 거는 대신 약간의 무작위를 섞으라는 조언이 여기서 나옵니다.

eviction

maxmemory 에 도달하면 정책에 따라 키를 지웁니다. 기본 정책은 noeviction 이라서, 설정을 안 바꿨다면 메모리가 차는 순간부터 쓰기 명령이 OOM 에러로 실패해요.

여기서 흥미로운 건 LRU 구현입니다. Redis는 진짜 LRU를 쓰지 않아요. 모든 키를 연결 리스트로 관리하려면 키마다 포인터 두 개가 더 필요하고, 접근할 때마다 리스트를 조작해야 하니까요. 메모리 서버로서는 받아들이기 어려운 비용입니다.

대신 근사 LRU를 씁니다. 무작위로 표본을 뽑아서 그중 가장 오래된 걸 지워요. maxmemory-samples 기본값은 5입니다. 이 값을 올리면 진짜 LRU에 가까워지지만 CPU를 더 씁니다.

allkeys-lfu 는 접근 빈도를 봅니다. 한 번 몰려서 접근된 키가 LRU에서 오래 살아남는 문제를 줄여줘요. 캐시 워크로드라면 LRU보다 나은 경우가 많습니다.

영속화와 fsync

AOF의 appendfsync 는 세 가지예요.

값동작대가
always쓰기 명령마다 fsync디스크 지연이 곧 명령 지연
everysec초당 한 번 fsync (기본값)최대 1초치 유실
noOS 에 맡김유실 구간이 커진다

everysec 이 기본인 이유가 여기 있습니다. always 는 안전하지만 매 쓰기가 디스크를 기다려요. 그리고 everysec 이어도 완전히 자유롭지는 않습니다. 이전 fsync 가 아직 안 끝났는데 다음 쓰기가 오면 메인 스레드가 기다리게 되는 구간이 있어요. 디스크가 느린 환경에서 Redis 지연이 튀는 원인 중 하나입니다.

느린 클라이언트

마지막 하나. 출력 버퍼는 Redis 메모리입니다. 클라이언트가 응답을 안 읽어가면 그게 계속 쌓여요.

client-output-buffer-limit normal 0 0 0
client-output-buffer-limit slave  256mb 64mb 60
client-output-buffer-limit pubsub 32mb 8mb 60

normal 이 0 0 0, 그러니까 제한 없음입니다. 일반 클라이언트가 LRANGE 로 거대한 결과를 요청하고 안 읽어가면 그만큼 Redis 메모리를 먹어요.

Pub/Sub은 32mb 제한이 있습니다. 구독자가 느리면 32MB를 넘는 순간 연결이 끊겨요. Pub/Sub으로 대량 메시지를 뿌리는 구조에서 구독자가 조용히 사라지는 원인이 대개 이겁니다.

[실무 적용 - 이 구조에서 나오는 규칙]

정리하면 규칙은 단순해집니다. 직렬 구간을 짧게 유지한다.

1. O(n) 명령을 프로덕션 경로에서 뺍니다. KEYS 대신 SCAN, DEL 대신 UNLINK. 컬렉션 전체를 읽는 명령은 범위를 자릅니다.

2. Lua 스크립트를 상수 시간으로 유지합니다. 데이터 크기에 비례하는 루프를 넣지 않아요. 여러 키를 다뤄야 하면 스크립트를 쪼개는 쪽을 먼저 생각합니다.

3. 큰 값을 넣지 않습니다. 값 하나가 1MB면 그걸 복사하고 직렬화하는 시간이 전부 직렬 구간이에요. 쪼개거나 다른 저장소로 옮깁니다.

4. 파이프라이닝을 씁니다. 명령 100개를 따로 보내면 왕복이 100번입니다. 묶으면 1번이에요. 지연이 지배적인 워크로드에서 가장 확실한 개선입니다.

5. TTL에 무작위를 섞습니다. 같은 시각 대량 만료를 피합니다.

6. maxmemory 와 정책을 명시합니다. 기본이 noeviction 이라 안 정하면 어느 날 쓰기가 전부 실패해요.

7. THP를 끕니다. fork 기반 스냅샷과 상성이 나쁩니다.

8. io-threads는 마지막에 봅니다. 위 항목들을 안 고친 상태에서 켜도 직렬 구간은 그대로예요.

[결론]

“싱글 스레드인데 왜 빠른가”라는 질문의 답은 뒤집어야 정확했습니다. 병목이 CPU가 아니기 때문에 싱글 스레드로 충분한 것이고, 그 선택으로 락과 컨텍스트 스위치를 통째로 없앤 게 이득이었어요.

그리고 그 이득의 반대편에 대가가 있었습니다. 모든 것이 하나의 직렬 구간을 지나갑니다. 원자성도, 느린 명령도, Lua 스크립트도, 만료 처리도 전부 같은 줄에 서요. io-threads는 그 줄의 앞뒤를 넓힐 뿐 줄 자체를 나누지 않습니다.

제 코드에서 바뀐 건 Lua를 보는 눈이었어요. 원자성이 필요할 때 Lua를 쓰는 판단은 그대로 유효하지만, 이제 스크립트의 실행 시간을 서버 전체의 정지 시간으로 읽습니다. 그 관점이 없으면 언젠가 루프가 하나 들어가고 서버가 멈출 거예요.

한계도 적어둘게요.

첫째, 측정이 없습니다. io-threads를 켰을 때 얼마나 좋아지는지, 제 Lua 스크립트가 실제로 몇 마이크로초인지 재보지 않았어요. redis-benchmark 와 SLOWLOG 로 확인할 수 있는 것들인데 아직 안 했습니다.

둘째, Redis 8의 스레딩 변화를 모릅니다. 문서로 개선됐다는 것만 알고 운영해본 적이 없어요. 여기서 더 말하면 지어내는 게 됩니다.

셋째, 클러스터 모드를 다루지 않았습니다. 이 글은 단일 노드 기준이에요. 클러스터에서는 슬롯 이동과 리다이렉트가 추가되고, 여러 키를 다루는 Lua 스크립트에 해시 태그 제약이 생깁니다. 제가 클러스터를 운영해본 적이 없어서 별도로 다루지 않았어요.

넷째, 제 판단이 결과적으로 안전했던 것과 근거를 갖고 안전했던 것은 다릅니다. 39번과 27번의 Lua는 다행히 상수 시간이었지만, 그건 제가 그 기준으로 설계해서가 아니었어요. 이걸 알고 나서야 그 코드를 다시 읽을 수 있었습니다.