Theme:

Redis는 싱글 스레드라서 빠르다고 하는데, 6.0부터 멀티스레딩을 지원한다면 그 장점을 포기한 건가요?

Redis 멀티스레딩이란

Redis 6.0에서 도입된 I/O 멀티스레딩은 네트워크 읽기/쓰기를 여러 스레드로 병렬 처리하는 기능입니다. 핵심은 명령 실행은 여전히 싱글 스레드라는 것입니다. 락이 필요 없는 단순한 실행 모델은 그대로 유지하면서, 네트워크 I/O 병목만 해결하는 것이 목표입니다.

왜 필요한가

Redis의 처리 과정을 단계별로 나눠보면 병목이 어디인지 보입니다.

PLAINTEXT
1. 소켓에서 데이터 읽기 (read)        ← 네트워크 I/O
2. 요청 파싱 (parse)                  ← CPU
3. 명령 실행 (execute)                ← CPU (데이터 접근)
4. 응답 생성 (format)                 ← CPU
5. 소켓으로 데이터 쓰기 (write)        ← 네트워크 I/O

단순한 GET/SET 명령에서는 1번과 5번(네트워크 I/O)이 전체 시간의 상당 부분을 차지합니다. 명령 실행 자체는 나노초 단위로 빠르기 때문입니다. 클라이언트가 수천 개 이상이면 이 I/O 처리가 싱글 스레드의 병목이 됩니다.

6.0 이전: 완전한 싱글 스레드

PLAINTEXT
Main Thread:
  read(client1) → parse → execute → write
  read(client2) → parse → execute → write
  read(client3) → parse → execute → write
  ...
  (모든 작업을 한 스레드가 순차 처리)

이벤트 루프(epoll/kqueue)로 다중 클라이언트를 처리하지만, 실제 I/O 연산은 한 스레드에서 순차적으로 수행됩니다.

6.0 이후: I/O 멀티스레딩

PLAINTEXT
I/O Thread 1: read(client1), read(client4), read(client7)...
I/O Thread 2: read(client2), read(client5), read(client8)...
I/O Thread 3: read(client3), read(client6), read(client9)...

Main Thread:
  parse + execute(client1)
  parse + execute(client2)
  parse + execute(client3)
  ...
  (명령 실행은 여전히 싱글 스레드)

I/O Thread 1: write(client1), write(client4)...
I/O Thread 2: write(client2), write(client5)...
I/O Thread 3: write(client3), write(client6)...

처리 흐름 상세

PLAINTEXT
단계 1: [I/O 스레드들] 클라이언트 소켓에서 데이터 읽기 (병렬)
         ↓ (동기화 포인트)
단계 2: [메인 스레드] 요청 파싱 + 명령 실행 (순차)
         ↓ (동기화 포인트)
단계 3: [I/O 스레드들] 응답 데이터를 소켓에 쓰기 (병렬)
         ↓ (동기화 포인트)
단계 1로 반복

핵심 포인트: 각 단계 사이에 동기화 포인트가 있어서, 스레드 간 데이터 경쟁(race condition)이 발생하지 않습니다. 메인 스레드가 명령을 실행하는 동안 I/O 스레드는 대기하고, I/O 스레드가 읽기/쓰기하는 동안 메인 스레드가 대기합니다.

설정 방법

redis.conf

BASH
# I/O 스레드 수 (메인 스레드 포함)
# 기본값: 1 (멀티스레딩 비활성화)
io-threads 4

# I/O 스레드가 읽기(파싱)도 처리할지 여부
# 기본값: no (쓰기만 병렬 처리)
io-threads-do-reads yes

런타임 설정

BASH
# 런타임에는 변경 불가 — redis.conf에서만 설정
# Redis 재시작 필요

권장 설정

BASH
# 4코어 머신
io-threads 2  # 또는 3

# 8코어 머신
io-threads 4  # 최대 6

# 규칙: CPU 코어 수의 절반 이하
# 8 이상은 거의 효과 없음

싱글 스레드를 유지하는 이유

1. 락이 필요 없음

PLAINTEXT
멀티스레드 명령 실행이라면:
Thread 1: INCR counter → read(100), 100+1=101, write(101)
Thread 2: INCR counter → read(100), 100+1=101, write(101)
→ 결과: 101 (올바른 결과는 102)
→ 락이 필요 → 성능 저하

싱글 스레드 명령 실행:
Main: INCR counter → read(100), write(101)
Main: INCR counter → read(101), write(102)
→ 결과: 102 (정확)
→ 락 불필요

2. 컨텍스트 스위칭 없음

스레드 간 전환 비용이 없으므로 마이크로초 단위의 명령 실행에서 유리합니다.

3. 원자성 보장

MULTI/EXEC, Lua 스크립트 등의 원자성이 자연스럽게 보장됩니다.

성능 향상 벤치마크

Redis 공식 벤치마크에서 I/O 멀티스레딩의 효과입니다.

PLAINTEXT
io-threads 1 (싱글): ~100,000 ops/sec
io-threads 2:        ~170,000 ops/sec  (+70%)
io-threads 4:        ~250,000 ops/sec  (+150%)
io-threads 6:        ~280,000 ops/sec  (수확 체감)

실제 효과는 워크로드, 네트워크 환경, 명령 복잡도에 따라 다릅니다.

효과가 큰 경우

  • 대량의 클라이언트 연결 (수천 개 이상)
  • 작은 값의 대량 GET/SET
  • 네트워크 대역폭이 충분하지만 I/O 처리가 병목인 경우

효과가 적은 경우

  • 클라이언트가 적은 경우 (수십 개)
  • 큰 값을 다루는 경우 (명령 실행 자체가 병목)
  • 복잡한 명령 (SORT, ZUNIONSTORE 등)
  • Lua 스크립트가 많은 경우

다른 백그라운드 스레드들

I/O 스레드 외에도 Redis는 이전부터 백그라운드 스레드를 사용하고 있었습니다.

PLAINTEXT
메인 스레드:     명령 실행, 이벤트 루프
I/O 스레드:      네트워크 읽기/쓰기 (6.0+)
Bio 스레드 1:    파일 close() 처리
Bio 스레드 2:    AOF fsync() 처리
Bio 스레드 3:    Lazy free (UNLINK, 비동기 삭제)
Fork 프로세스:   RDB 저장, AOF 리라이트

Lazy Free (4.0+)

큰 키를 삭제할 때 메인 스레드를 블로킹하지 않고 백그라운드에서 처리합니다.

BASH
# DEL 대신 UNLINK 사용
127.0.0.1:6379> UNLINK large-key  # 백그라운드에서 삭제

# 자동 Lazy Free 설정
lazyfree-lazy-eviction yes        # maxmemory eviction 시
lazyfree-lazy-expire yes          # TTL 만료 삭제 시
lazyfree-lazy-server-del yes      # RENAME 등 내부 삭제 시
lazyfree-lazy-user-del yes        # DEL도 UNLINK처럼 동작 (Redis 6.0+)

모니터링

BASH
# I/O 스레드 활동 확인
127.0.0.1:6379> INFO stats
# io_threaded_reads_processed: I/O 스레드가 처리한 읽기 수
# io_threaded_writes_processed: I/O 스레드가 처리한 쓰기 수

# 현재 설정 확인
127.0.0.1:6379> CONFIG GET io-threads
1) "io-threads"
2) "4"

Thread-Safe 클라이언트 설정

I/O 멀티스레딩은 서버 측 변경이므로 클라이언트 코드 변경은 필요 없습니다. 하지만 최대 성능을 위해 클라이언트도 적절히 설정해야 합니다.

JAVA
// Lettuce — 기본적으로 넌블로킹, 파이프라이닝 지원
// 별도 설정 없이 I/O 멀티스레딩의 효과를 누릴 수 있음

// 커넥션 풀은 불필요 (Lettuce는 단일 연결로 멀티플렉싱)
// 하지만 블로킹 명령이 있다면 풀 사용
LettucePoolingClientConfiguration clientConfig = LettucePoolingClientConfiguration.builder()
    .poolConfig(new GenericObjectPoolConfig<>())
    .build();

정리

  • Redis 6.0의 멀티스레딩은 네트워크 I/O만 병렬화하고, 명령 실행은 싱글 스레드를 유지합니다
  • 이를 통해 락 없는 단순한 실행 모델의 장점을 보존하면서 I/O 병목을 해결합니다
  • io-threads는 CPU 코어 수의 절반 이하로 설정하고, 보통 2~4가 적절합니다
  • 대량 클라이언트의 단순 명령(GET/SET)에서 가장 효과적이며, 복잡한 명령이 많으면 효과가 적습니다
  • Lazy Free, Bio 스레드 등 이전부터 존재하던 백그라운드 스레드도 함께 이해하면 Redis의 전체 스레드 모델을 파악할 수 있습니다
댓글 로딩 중...