Redis 아키텍처 — 싱글 스레드와 이벤트 루프의 비밀
Redis는 싱글 스레드인데, 어떻게 초당 수십만 건의 요청을 처리할 수 있을까요?
개념 정의
Redis는 인메모리 데이터 스토어 로, 핵심 명령어 처리를 단일 스레드에서 수행합니다. 그런데 벤치마크를 보면 초당 10만~30만 건 이상의 처리가 가능합니다. 이 비밀은 이벤트 루프(Event Loop) 와 I/O 멀티플렉싱(I/O Multiplexing) 에 있습니다.
왜 싱글 스레드인가
멀티스레드가 무조건 빠를 것 같지만, Redis의 상황에서는 다릅니다. 핵심은 어디가 병목인가 입니다.
- Redis는 인메모리 로 동작합니다. 대부분의 명령어가 마이크로초(μs) 단위에 완료됩니다.
- CPU 연산이 이렇게 빠르면, 진짜 병목은 CPU가 아니라 네트워크 I/O 입니다.
- 네트워크 대기만 효율적으로 처리하면 되므로, 멀티스레드가 필요 없습니다.
- 오히려 멀티스레드를 쓰면 컨텍스트 스위칭 비용 과 락(Lock) 오버헤드 가 추가됩니다.
그래서 Redis는 명령어 실행을 싱글 스레드로 유지하고, 네트워크 대기를 이벤트 루프 + I/O 멀티플렉싱 으로 해결하는 전략을 택했습니다.
정리하면, "인메모리라 CPU가 아닌 네트워크가 병목이고, epoll 기반 이벤트 루프로 하나의 스레드가 수천 개 연결을 동시에 관리하기 때문에 빠르다"가 핵심입니다.
이벤트 루프의 동작 원리
Redis의 이벤트 루프는 두 가지 유형의 이벤트를 처리합니다.
파일 이벤트 (File Events)
네트워크 소켓에서 발생하는 이벤트입니다.
- AE_READABLE: 클라이언트가 데이터를 보냈을 때
- AE_WRITABLE: 클라이언트에게 응답을 보낼 수 있을 때
시간 이벤트 (Time Events)
주기적으로 실행되는 작업입니다.
- 만료된 키 정리
- 통계 정보 갱신
- 클라이언트 타임아웃 검사
- AOF/RDB 관련 백그라운드 작업 모니터링
루프 한 바퀴의 흐름
이 루프가 ** 무한히 반복 **되면서 Redis가 동작합니다. 각 단계를 하나씩 살펴보겠습니다.
1단계 — beforeSleep(): 루프가 시작되기 전에 밀린 집안일을 처리합니다. 만료된 키를 일부 정리하고, AOF 버퍼에 쌓인 쓰기 명령을 디스크에 동기화(fsync)하고, 이전 루프에서 만들어둔 응답을 클라이언트에게 보낼 준비를 합니다.
2단계 — aeProcessEvents(): 이벤트 루프의 핵심입니다. epoll_wait()를 호출해서 "지금 데이터를 보낸 클라이언트가 있는가?"를 확인합니다. 아무도 보내지 않았으면 여기서 잠시 대기하고, 누군가 보냈으면 바로 깨어납니다. 이 한 번의 호출로 수천 개의 클라이언트 연결을 동시에 감시할 수 있습니다.
**3단계 — 파일 이벤트 처리 **: epoll_wait()가 "이 소켓에 데이터가 왔다"고 알려주면, 해당 소켓에서 데이터를 읽고(read), RESP 프로토콜을 파싱하고, 명령어를 실행합니다. SET key value 같은 명령어가 여기서 처리됩니다.
**4단계 — 시간 이벤트 처리 **: 주기적으로 해야 하는 작업을 수행합니다. TTL이 만료된 키를 정리하거나, 서버 통계를 갱신하거나, 클라이언트 타임아웃을 검사합니다.
5단계 — afterSleep(): Redis 6.0 이상에서 I/O 스레드를 사용하는 경우, 별도 스레드가 처리한 네트워크 읽기/쓰기 결과를 메인 스레드가 수거합니다. 그리고 다시 1단계로 돌아갑니다.
이 루프 전체가 ** 하나의 스레드 **에서 돌아갑니다. 클라이언트가 1000명이어도 스레드는 1개입니다.
epoll_wait()한 번으로 "누가 보냈는지"를 파악하고, 보낸 사람의 명령어만 순서대로 처리합니다.
I/O 멀티플렉싱 — epoll과 kqueue
Redis는 운영체제가 제공하는 I/O 멀티플렉싱 API를 활용합니다.
플랫폼별 선택
| 플랫폼 | 멀티플렉싱 API | 특징 |
|---|---|---|
| Linux | epoll | O(1) 이벤트 감지, 대규모 연결에 효율적 |
| macOS/BSD | kqueue | epoll과 유사한 성능 |
| 기타 | select/poll | 폴백(fallback), 연결 수 제한 있음 |
epoll의 동작 방식
// Redis 내부 구현의 핵심 흐름 (단순화)
int epfd = epoll_create(1024);
// 클라이언트 소켓 등록
struct epoll_event ev;
ev.events = EPOLLIN; // 읽기 이벤트 감시
ev.data.fd = client_fd;
epoll_ctl(epfd, EPOLL_CTL_ADD, client_fd, &ev);
// 이벤트 대기 — 여러 소켓을 한 번에 감시
int nfds = epoll_wait(epfd, events, MAX_EVENTS, timeout);
for (int i = 0; i < nfds; i++) {
// 준비된 소켓만 순차적으로 처리
handleEvent(events[i]);
}
핵심은 epoll_wait() 한 번의 호출로 ** 수천 개의 소켓 중 준비된 것만** 효율적으로 골라낸다는 점입니다.
명령어 처리 파이프라인
클라이언트의 요청이 처리되는 전체 흐름을 살펴봅니다.
| 단계 | 처리 내용 | 병목 여부 |
|---|---|---|
| 1 | 소켓에서 읽기 (read) | ** 네트워크 I/O** |
| 2 | RESP 프로토콜 파싱 | 빠름 |
| 3 | 명령어 조회 (command table) | 빠름 |
| 4 | 인자 검증 | 빠름 |
| 5 | 명령어 실행 (메모리 조작) | 빠름 (μs) |
| 6 | 응답 버퍼에 결과 쓰기 | 빠름 |
| 7 | 소켓으로 응답 전송 (write) | ** 네트워크 I/O** |
2~6번은 모두 인메모리 연산이라 마이크로초 단위입니다. 진짜 병목은 1번과 7번의 네트워크 I/O이고, 이것을 이벤트 루프가 효율적으로 관리합니다.
Redis 6.0 — I/O 스레딩
Redis 6.0부터는 네트워크 읽기/쓰기를 별도 스레드로 분리할 수 있습니다.
# redis.conf
io-threads 4 # I/O 스레드 수 (기본: 1, 비활성)
io-threads-do-reads yes # 읽기도 멀티스레드로 처리
동작 방식
┌─────────────┐ ┌─────────────┐ ┌─────────────┐
│ I/O 스레드 1 │ │ I/O 스레드 2 │ │ I/O 스레드 3 │
│ read/write │ │ read/write │ │ read/write │
└──────┬───────┘ └──────┬───────┘ └──────┬───────┘
│ │ │
└─────────────────┼─────────────────┘
│
┌──────────▼──────────┐
│ 메인 스레드 │
│ 명령어 실행 (순차) │
└─────────────────────┘
중요한 점은 ** 명령어 실행은 여전히 싱글 스레드 **라는 것입니다. I/O 스레딩은 네트워크 처리만 병렬화하므로 데이터 정합성 문제가 없습니다.
파이프라이닝 (Pipelining)
네트워크 RTT(Round-Trip Time)를 줄이는 기법입니다.
일반적인 방식 (RTT 3회)
| 순서 | 클라이언트 | 방향 | Redis | 네트워크 왕복 |
|---|---|---|---|---|
| 1 | SET a 1 | --> | 수신 | |
| 2 | <-- | +OK | RTT 1 | |
| 3 | SET b 2 | --> | 수신 | |
| 4 | <-- | +OK | RTT 2 | |
| 5 | SET c 3 | --> | 수신 | |
| 6 | <-- | +OK | RTT 3 |
매 명령어마다 응답을 기다려야 하므로, 3개 명령어에 3번의 네트워크 왕복이 발생합니다.
파이프라이닝 (RTT 1회)
| 순서 | 클라이언트 | 방향 | Redis | 비고 |
|---|---|---|---|---|
| 1 | SET a 1 | --> | 큐잉 | |
| 2 | SET b 2 | --> | 큐잉 | 응답 안 기다리고 연속 전송 |
| 3 | SET c 3 | --> | 큐잉 | |
| 4 | <-- | +OK, +OK, +OK | RTT 1회로 끝 |
응답을 기다리지 않고 명령어를 연속 전송한 뒤, 응답을 한 번에 수신합니다.
Python redis-py에서는 pipeline()으로 간단히 사용할 수 있습니다.
# Python redis-py 파이프라이닝 예제
import redis
r = redis.Redis()
pipe = r.pipeline()
# 파이프라인에 명령어 추가 (아직 전송하지 않음)
pipe.set('user:1:name', 'Alice')
pipe.set('user:1:age', 30)
pipe.get('user:1:name')
# 한 번에 전송하고 결과 수신
results = pipe.execute()
# [True, True, b'Alice']
싱글 스레드의 주의점
싱글 스레드이기 때문에 발생하는 문제도 있습니다.
느린 명령어가 전체를 블로킹
싱글 스레드이므로 ** 하나의 느린 명령어가 다른 모든 요청을 막습니다.** 실제로 KEYS *를 프로덕션에서 실행했다가 수 초간 전체 서비스가 멈추는 장애가 흔하게 발생합니다. 키가 100만 개면 KEYS *가 끝날 때까지 GET/SET 등 모든 요청이 대기합니다.
# KEYS *는 모든 키를 순회하므로 위험
# 키가 100만 개라면 수 초간 블로킹 발생
KEYS * # 프로덕션에서 사용 금지
# 대신 SCAN 사용
SCAN 0 COUNT 100 # 점진적으로 키 탐색
위험한 명령어 목록
| 명령어 | 위험도 | 이유 |
|---|---|---|
KEYS * | 매우 높음 | 전체 키 스캔 |
FLUSHALL | 높음 | 모든 키 삭제 |
SORT (대규모) | 높음 | O(N+M*log(M)) |
LRANGE 0 -1 | 중간 | 리스트 전체 조회 |
slowlog로 모니터링
# 10밀리초 이상 걸린 명령어 기록
CONFIG SET slowlog-log-slower-than 10000
# 최근 느린 명령어 확인
SLOWLOG GET 10
성능 벤치마크
Redis 공식 벤치마크 도구로 측정한 대략적인 수치입니다.
# 벤치마크 실행 예제
redis-benchmark -t set,get -n 1000000 -q -P 16
# 파이프라이닝 없이: ~100,000 ops/sec
# 파이프라이닝(16): ~500,000+ ops/sec
성능에 영향을 미치는 요소는 다음과 같습니다.
- **네트워크 지연 **: 로컬 소켓 > TCP loopback > 원격
- ** 명령어 복잡도 **: O(1) 명령어(GET/SET) vs O(N) 명령어(LRANGE)
- ** 값의 크기 **: 작은 값일수록 처리량 증가
- ** 파이프라이닝 **: RTT 제거로 처리량 대폭 증가
정리
| 항목 | 핵심 | 주의점 |
|---|---|---|
| ** 왜 빠른가** | 인메모리 연산(μs) + 이벤트 루프(epoll) | CPU가 아닌 네트워크가 병목 |
| ** 이벤트 루프** | 파일 이벤트(네트워크) + 시간 이벤트(만료 키 등) | 루프 한 바퀴 안에서 모든 것 처리 |
| I/O 멀티플렉싱 | epoll(Linux) / kqueue(macOS) | 수천 연결을 O(1)로 감시 |
| 6.0 I/O 스레딩 | 네트워크 read/write만 병렬화 | ** 명령어 실행은 여전히 싱글 스레드** |
| ** 파이프라이닝** | RTT를 줄여 처리량 수 배↑ | 명령어 자체는 순차 실행 |
| ** 위험한 명령어** | KEYS *, FLUSHALL, 대규모 SORT | 싱글 스레드라 전체 블로킹 |