홈시리즈

© 2026 Ki Chang. All rights reserved.

본 블로그의 콘텐츠는 CC BY-NC-SA 4.0 라이선스를 따릅니다.

☕후원하기소개JSON Formatter러닝 대기질개인정보처리방침이용약관

© 2026 Ki Chang. All rights reserved.

콘텐츠: CC BY-NC-SA 4.0

☕후원하기
소개|JSON Formatter|러닝 대기질|개인정보처리방침|이용약관

Redis가 싱글 스레드인데 왜 빠른가 — I/O 멀티플렉싱부터 6.0 스레드 모델까지

정기창·2026년 3월 10일

"싱글 스레드"라는 말의 오해

"Redis는 싱글 스레드입니다." 면접에서 이 말만 하면 절반만 맞는 답이 됩니다. 정확히는 클라이언트 명령을 처리하는 메인 루프가 싱글 스레드입니다. Redis 프로세스 전체가 단일 스레드로 돌아가는 것은 아닙니다.

Redis 내부에는 여러 백그라운드 스레드가 존재합니다.

  • bio_close_file: 파일 디스크립터를 비동기로 닫음
  • bio_aof_fsync: AOF 파일의 fsync를 비동기로 수행
  • bio_lazy_free: 큰 키 삭제 시 메모리 해제를 백그라운드에서 처리 (UNLINK, FLUSHDB ASYNC)

하지만 이 스레드들은 명령 실행과 무관합니다. SET, GET, ZADD 같은 명령을 실제로 처리하는 것은 단 하나의 메인 스레드입니다. 이것이 "모든 명령이 원자적"이라는 Redis의 핵심 특성을 만듭니다.

왜 싱글 스레드를 선택했는가

Redis 창시자 Salvatore Sanfilippo(antirez)의 설계 철학은 단순했습니다. 병목이 CPU가 아니라 네트워크와 메모리라면, 멀티스레드는 복잡성만 더한다는 것입니다.

이 판단의 근거를 하나씩 살펴보겠습니다.

1. Redis 명령은 CPU를 거의 안 씁니다

대부분의 Redis 명령은 해시 테이블 조회(O(1))이거나 skiplist 탐색(O(log N))입니다. 하나의 명령이 소비하는 CPU 시간은 수 마이크로초에 불과합니다. 초당 수십만 건의 명령을 처리해도 CPU 코어 하나를 다 쓰지 않습니다.

일반적인 Redis 명령 실행 시간:
GET/SET         → ~1μs
HGET/HSET       → ~1μs
ZADD/ZRANGE     → ~2-5μs (log N)
LRANGE 100개    → ~10-20μs

비교: MySQL SELECT (인덱스 히트) → ~100-500μs
비교: 디스크 랜덤 I/O             → ~5,000-10,000μs

이 수치를 보면, 명령 처리 자체보다 네트워크에서 요청을 받고 응답을 보내는 시간이 훨씬 긴 것을 알 수 있습니다.

2. 멀티스레드의 비용

만약 Redis가 멀티스레드로 명령을 처리한다면, 공유 데이터 구조에 대한 락이 필요합니다.

멀티스레드 Redis (가상):
Thread A: LOCK(hash_table) → GET key1 → UNLOCK
Thread B: LOCK(hash_table) → SET key2 → UNLOCK  ← Thread A가 끝날 때까지 대기

싱글 스레드 Redis (실제):
Main Loop: GET key1 → SET key2 → ...  ← 락 없이 순차 실행

락의 오버헤드는 명령 자체의 실행 시간(~1μs)과 비슷하거나 더 클 수 있습니다. Fine-grained locking으로 최적화할 수 있지만, 코드 복잡성이 기하급수적으로 증가합니다. 명령이 이미 충분히 빠르다면, 락을 넣어서 병렬화하는 것보다 락 없이 순차 실행하는 것이 더 빠릅니다.

3. 원자성이 공짜로 따라옵니다

싱글 스레드의 부산물이지만 매우 강력한 특성입니다. INCR, LPUSH, SETNX 같은 명령이 별도의 동기화 없이 원자적으로 실행됩니다. 분산 락(RedLock)이나 메시지 큐(Bull Queue)에서 Redis를 쓸 수 있는 것도 이 원자성 덕분입니다.

I/O 멀티플렉싱: 하나의 스레드로 수천 연결 처리

싱글 스레드가 성능을 포기하지 않는 핵심 비결은 I/O 멀티플렉싱(I/O Multiplexing)입니다.

문제: 전통적인 블로킹 I/O

일반적인 소켓 프로그래밍에서 read()는 데이터가 올 때까지 블로킹됩니다. 싱글 스레드에서 이렇게 하면, 한 클라이언트가 느리면 다른 모든 클라이언트가 멈춥니다.

블로킹 I/O (문제):
클라이언트 A: read() → [3ms 대기] → 데이터 도착 → 처리
클라이언트 B:                                        → read() → [대기...]
클라이언트 C:                                                        → [대기...]
→ A가 끝나야 B 시작, B가 끝나야 C 시작

해결: epoll/kqueue

I/O 멀티플렉싱은 "데이터가 준비된 소켓만 알려달라"고 커널에 요청하는 것입니다. Linux에서는 epoll, macOS에서는 kqueue, 과거에는 select/poll이 이 역할을 합니다.

I/O 멀티플렉싱 (해결):
epoll_wait() → "A, C에 데이터 준비됨"
→ A 읽기 + 명령 처리 + 응답
→ C 읽기 + 명령 처리 + 응답
→ epoll_wait() → "B에 데이터 준비됨"
→ B 읽기 + 명령 처리 + 응답
→ 대기 중인 소켓 없으면 다른 작업 수행

핵심은 대기(waiting) 시간을 겹치는 것입니다. 클라이언트 A의 다음 요청을 기다리는 동안, B와 C의 요청을 처리할 수 있습니다.

epoll이 select보다 빠른 이유

select와 poll은 매번 전체 파일 디스크립터 목록을 커널에 넘기고, 커널이 전부 스캔합니다. 연결이 10,000개면 매번 10,000개를 확인합니다.

epoll은 관심 있는 이벤트를 커널에 등록해두고, 이벤트가 발생한 것만 반환합니다. 연결이 10,000개여도 이벤트가 발생한 5개만 처리합니다.

select/poll: O(N) — N = 전체 연결 수 (매번 스캔)
epoll:       O(1) — 이벤트 등록은 1회, 이후 발생한 이벤트만 반환

이것이 Redis가 수만 개의 동시 연결에서도 성능이 저하되지 않는 이유입니다.

Redis 이벤트 루프의 구조

Redis의 메인 루프는 ae.c(A simple Event library)에 구현되어 있습니다. Node.js의 libuv와 개념적으로 유사하지만, Redis 전용으로 훨씬 가볍습니다.

Redis 이벤트 루프 (aeMain):
┌─────────────────────────────────────┐
│  1. aeProcessEvents()               │
│     ├─ beforesleep()                │
│     │   ├─ AOF 버퍼 플러시          │
│     │   ├─ 만료 키 처리 (일부)      │
│     │   └─ 클러스터 메시지 처리     │
│     │                               │
│     ├─ epoll_wait() / kqueue()      │
│     │   → 준비된 소켓 이벤트 수집   │
│     │                               │
│     ├─ 파일 이벤트 처리             │
│     │   ├─ 클라이언트 연결 수락     │
│     │   ├─ 명령 읽기 + 파싱         │
│     │   ├─ 명령 실행                │
│     │   └─ 응답 쓰기               │
│     │                               │
│     └─ 시간 이벤트 처리             │
│         ├─ serverCron() (100ms 주기)│
│         │   ├─ 메모리 사용량 체크   │
│         │   ├─ 만료 키 정리         │
│         │   ├─ RDB/AOF 백그라운드   │
│         │   └─ Replication 관리     │
│         └─ 기타 예약 작업           │
│                                     │
│  → 다시 1로 반복                    │
└─────────────────────────────────────┘

이 루프가 초당 수십만 번 반복됩니다. 각 반복에서 준비된 이벤트만 처리하고, 나머지는 다음 반복으로 미룹니다. 이것이 하나의 스레드로 높은 처리량을 달성하는 원리입니다.

명령 파이프라이닝과의 시너지

클라이언트가 명령을 하나씩 보내면, 매 명령마다 네트워크 왕복(RTT)이 발생합니다. 파이프라이닝은 여러 명령을 한꺼번에 보내고 응답을 한꺼번에 받는 기법입니다.

파이프라이닝 없이:
SET a 1 → [RTT] → OK
SET b 2 → [RTT] → OK
SET c 3 → [RTT] → OK
총 시간: 3 × RTT + 3 × 명령실행

파이프라이닝:
SET a 1 / SET b 2 / SET c 3 → [RTT] → OK / OK / OK
총 시간: 1 × RTT + 3 × 명령실행

이벤트 루프 관점에서, 파이프라이닝된 명령들은 한 번의 read()로 읽혀서 순차적으로 실행됩니다. 네트워크 대기가 줄어들므로 처리량이 5~10배 향상될 수 있습니다.

Redis 6.0: I/O 스레드의 도입

Redis 6.0에서 가장 큰 변화는 Threaded I/O의 도입입니다. 하지만 이것이 "Redis가 멀티스레드가 되었다"는 의미는 아닙니다.

무엇이 바뀌었는가

명령 실행은 여전히 메인 스레드에서만 이루어집니다. 변경된 것은 네트워크 I/O입니다.

Redis 6.0 이전:
메인 스레드: [소켓 읽기] → [파싱] → [명령 실행] → [응답 쓰기] → ...

Redis 6.0 이후 (io-threads 활성화):
I/O 스레드들: [소켓 읽기 + 파싱] 또는 [응답 쓰기]
메인 스레드:  [명령 실행]

처리 흐름:
1. 메인 스레드가 준비된 클라이언트를 I/O 스레드에 분배
2. I/O 스레드들이 병렬로 소켓 데이터 읽기 + 파싱
3. 메인 스레드가 순차적으로 명령 실행 (싱글 스레드 유지)
4. I/O 스레드들이 병렬로 응답 데이터 쓰기

설정 방법

io-threads 4              # I/O 스레드 수 (CPU 코어 수에 맞춤)
io-threads-do-reads yes   # 읽기도 멀티스레드로 (기본은 쓰기만)

언제 효과가 있는가

I/O 스레드는 네트워크 I/O가 병목일 때 효과가 있습니다. 대부분의 경우 Redis는 네트워크보다 먼저 한계에 도달하지 않으므로, 일반적인 워크로드에서는 큰 차이가 없을 수 있습니다.

효과가 큰 경우는 다음과 같습니다.

  • 초당 수십만 건 이상의 요청을 처리할 때
  • MGET, LRANGE 등 응답 크기가 큰 명령이 많을 때
  • 클라이언트 연결 수가 수천 개 이상일 때

Redis 공식 문서에서도 "최소 4코어 이상의 머신에서, 높은 처리량이 필요한 경우에만" I/O 스레드를 권장합니다. 무조건 켜는 것이 아닙니다.

성능의 실체: 벤치마크로 보기

redis-benchmark로 측정한 일반적인 수치를 살펴보겠습니다.

redis-benchmark -t set,get -n 1000000 -c 50 -P 16
일반적인 벤치마크 결과 (단일 노드, 로컬):
SET: ~500,000 ops/sec (파이프라이닝 16)
GET: ~600,000 ops/sec (파이프라이닝 16)

파이프라이닝 없이:
SET: ~80,000 ops/sec
GET: ~100,000 ops/sec

파이프라이닝만으로 5~6배 차이가 나는 것을 볼 수 있습니다. 이것이 "네트워크가 진짜 병목"이라는 증거입니다.

주의할 점: O(N) 명령

싱글 스레드이므로, 하나의 느린 명령이 전체를 블로킹합니다. 이것이 Redis에서 가장 조심해야 할 부분입니다.

위험한 명령:
KEYS *          → O(N), 프로덕션에서 절대 사용 금지
SMEMBERS        → O(N), 큰 Set에서 주의
HGETALL         → O(N), 큰 Hash에서 주의
SORT            → O(N+M*log(M))
FLUSHDB         → O(N), 동기 모드에서 전체 블로킹

안전한 대안:
KEYS * → SCAN (커서 기반, 점진적 순회)
SMEMBERS → SSCAN
HGETALL → HSCAN

프로덕션에서 KEYS *를 실행하면, 키가 수백만 개일 때 수 초간 Redis 전체가 멈출 수 있습니다. 이 시간 동안 모든 클라이언트의 모든 명령이 대기합니다. 이것은 싱글 스레드 아키텍처의 가장 큰 트레이드오프입니다.

Node.js와의 유사성, 그리고 차이

Redis의 이벤트 루프는 Node.js의 이벤트 루프와 구조적으로 유사합니다. 둘 다 싱글 스레드 이벤트 루프 + I/O 멀티플렉싱 모델입니다. 하지만 결정적 차이가 있습니다.

Node.js:
- 이벤트 루프: libuv (범용, 파일 I/O는 스레드 풀)
- CPU 바운드 작업에 취약 (Worker Threads로 보완)
- 비동기 I/O 대기 시간이 긴 작업에 강점 (HTTP, DB 쿼리)

Redis:
- 이벤트 루프: ae.c (자체 구현, Redis 전용)
- 모든 연산이 인메모리 → CPU 바운드 자체가 거의 없음
- 명령 하나가 μs 단위에 끝남 → 비동기가 불필요

Node.js에서 Redis를 쓸 때 Bull Queue의 concurrency: 4가 잘 동작하는 것도 이 유사성 때문입니다. Node.js의 await 시점(I/O 대기)에서 다른 Job을 처리하고, Redis의 이벤트 루프에서 명령을 즉시 처리하고 — 두 이벤트 루프가 서로의 대기 시간을 채워주는 구조입니다.

정리: 면접에서 이 질문에 답한다면

"Redis가 싱글 스레드인데 왜 빠른가?"에 대한 답을 정리하면 이렇습니다.

첫째, Redis 명령은 인메모리 연산이라 하나당 마이크로초 단위에 끝납니다. CPU가 병목이 아닙니다.

둘째, I/O 멀티플렉싱(epoll/kqueue)으로 수천 연결의 네트워크 대기 시간을 겹칩니다. 하나의 스레드가 준비된 이벤트만 처리하므로 블로킹이 없습니다.

셋째, 싱글 스레드이므로 락이 필요 없습니다. 컨텍스트 스위칭도 없습니다. 이 비용 제거가 오히려 멀티스레드보다 빠른 결과를 만듭니다.

다만, O(N) 명령 하나가 전체를 블로킹할 수 있다는 트레이드오프가 있으며, Redis 6.0부터 네트워크 I/O에 한해 멀티스레드를 도입하여 이 한계를 일부 완화했습니다.

Redis의 설계는 "범용적으로 빠른 것"이 아니라, "Redis가 하는 일에 최적화된 것"입니다. 인메모리 키-값 연산이라는 특수한 워크로드에서, 싱글 스레드 + I/O 멀티플렉싱이 가장 효율적인 선택이라는 것을 이해하는 것이 핵심입니다.

Redis싱글 스레드I/O 멀티플렉싱이벤트 루프성능면접 준비

관련 글

Redis 내부 원리 총정리 — 싱글 스레드부터 클러스터까지

Redis를 캐시로만 쓰다가 면접에서 "싱글 스레드인데 왜 빠른가?"라는 질문에 막힌 경험이 있습니다. 싱글 스레드 모델, 자료구조별 시간복잡도, RDB/AOF 영속성, 메모리 관리, Sentinel과 Cluster까지 — Redis 내부 원리를 한 글에 정리했습니다.

관련도 94%

Bull Queue와 RedLock으로 배운 Redis — 이커머스 대량 처리 실전기

이커머스 플랫폼에서 대량 주문 취소를 처리하며 Redis를 메시지 브로커(Bull Queue)와 분산 락(RedLock)으로 활용한 경험입니다. concurrency 설정, 중복 방지, 실패 처리, Redis 장애 대응까지 — 실전에서 배운 것들을 정리했습니다.

관련도 90%

Redis 영속성 전략 — RDB vs AOF, 그리고 혼합 방식의 트레이드오프

Redis는 인메모리 데이터베이스이지만, 데이터를 디스크에 저장하는 두 가지 방식이 있습니다. RDB 스냅샷의 fork()와 COW 메커니즘, AOF의 fsync 정책, 그리고 Redis 4.0의 혼합 방식까지. 실무 선택 기준을 정리했습니다.

관련도 89%