Redis는 싱글 스레드인데, 어떻게 초당 수십만 건의 요청을 처리할 수 있을까요?

개념 정의

Redis는 인메모리 데이터 스토어 로, 핵심 명령어 처리를 단일 스레드에서 수행합니다. 그런데 벤치마크를 보면 초당 10만~30만 건 이상의 처리가 가능합니다. 이 비밀은 이벤트 루프(Event Loop) 와 I/O 멀티플렉싱(I/O Multiplexing) 에 있습니다.

왜 싱글 스레드인가

멀티스레드가 무조건 빠를 것 같지만, Redis의 상황에서는 다릅니다. 핵심은 어디가 병목인가 입니다.

  1. Redis는 인메모리 로 동작합니다. 대부분의 명령어가 마이크로초(μs) 단위에 완료됩니다.
  2. CPU 연산이 이렇게 빠르면, 진짜 병목은 CPU가 아니라 네트워크 I/O 입니다.
  3. 네트워크 대기만 효율적으로 처리하면 되므로, 멀티스레드가 필요 없습니다.
  4. 오히려 멀티스레드를 쓰면 컨텍스트 스위칭 비용 과 락(Lock) 오버헤드 가 추가됩니다.

그래서 Redis는 명령어 실행을 싱글 스레드로 유지하고, 네트워크 대기를 이벤트 루프 + I/O 멀티플렉싱 으로 해결하는 전략을 택했습니다.

정리하면, "인메모리라 CPU가 아닌 네트워크가 병목이고, epoll 기반 이벤트 루프로 하나의 스레드가 수천 개 연결을 동시에 관리하기 때문에 빠르다"가 핵심입니다.

이벤트 루프의 동작 원리

Redis의 이벤트 루프는 두 가지 유형의 이벤트를 처리합니다.

파일 이벤트 (File Events)

네트워크 소켓에서 발생하는 이벤트입니다.

  1. AE_READABLE: 클라이언트가 데이터를 보냈을 때
  2. 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특징
LinuxepollO(1) 이벤트 감지, 대규모 연결에 효율적
macOS/BSDkqueueepoll과 유사한 성능
기타select/poll폴백(fallback), 연결 수 제한 있음

epoll의 동작 방식

C
// 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**
2RESP 프로토콜 파싱빠름
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부터는 네트워크 읽기/쓰기를 별도 스레드로 분리할 수 있습니다.

PLAINTEXT
# redis.conf
io-threads 4           # I/O 스레드 수 (기본: 1, 비활성)
io-threads-do-reads yes # 읽기도 멀티스레드로 처리

동작 방식

PLAINTEXT
┌─────────────┐  ┌─────────────┐  ┌─────────────┐
│ 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네트워크 왕복
1SET a 1-->수신
2<--+OKRTT 1
3SET b 2-->수신
4<--+OKRTT 2
5SET c 3-->수신
6<--+OKRTT 3

매 명령어마다 응답을 기다려야 하므로, 3개 명령어에 3번의 네트워크 왕복이 발생합니다.

파이프라이닝 (RTT 1회)

순서클라이언트방향Redis비고
1SET a 1-->큐잉
2SET b 2-->큐잉응답 안 기다리고 연속 전송
3SET c 3-->큐잉
4<--+OK, +OK, +OKRTT 1회로 끝

응답을 기다리지 않고 명령어를 연속 전송한 뒤, 응답을 한 번에 수신합니다.

Python redis-py에서는 pipeline()으로 간단히 사용할 수 있습니다.

PYTHON
# 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 등 모든 요청이 대기합니다.

BASH
# KEYS *는 모든 키를 순회하므로 위험
# 키가 100만 개라면 수 초간 블로킹 발생
KEYS *          # 프로덕션에서 사용 금지

# 대신 SCAN 사용
SCAN 0 COUNT 100  # 점진적으로 키 탐색

위험한 명령어 목록

명령어위험도이유
KEYS *매우 높음전체 키 스캔
FLUSHALL높음모든 키 삭제
SORT (대규모)높음O(N+M*log(M))
LRANGE 0 -1중간리스트 전체 조회

slowlog로 모니터링

BASH
# 10밀리초 이상 걸린 명령어 기록
CONFIG SET slowlog-log-slower-than 10000

# 최근 느린 명령어 확인
SLOWLOG GET 10

성능 벤치마크

Redis 공식 벤치마크 도구로 측정한 대략적인 수치입니다.

BASH
# 벤치마크 실행 예제
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싱글 스레드라 전체 블로킹
댓글 로딩 중...