[Redis] 아키텍처와 데이터 모델(싱글 스레드, I/O Multiplexing, 메모리 구조)
개요
Redis(Remote Dictionary Server)는 인메모리 키-값 데이터 저장소다. 캐시, 데이터베이스, 메시지 브로커, 스트리밍 엔진, 분산 락 저장소 같은 여러 역할을 동시에 수행하는 다목적 시스템이다. 단일 인스턴스에서도 초당 10만 건 이상의 명령을 평균 1ms 미만의 응답으로 처리하는 성능 특성이 있다.
Redis의 성능은 단순히 메모리 기반이라는 사실보다 메모리 접근에 최적화된 자료구조와 락 없는 싱글 스레드 이벤트 루프, 그리고 운영체제(OS) 수준의 I/O Multiplexing이 결합된다.
Redis가 다루는 작업은 대부분 메모리 내에서 처리된다. Hash 조회, Sorted Set 점수 갱신, List 삽입은 나노초에서 마이크로초 단위로 끝난다. 이 시점에서 멀티스레드를 도입하면 락 비용과 컨텍스트 스위칭 비용이 오히려 더 크다. 그래서 Redis는 명령 실행을 단일 스레드로 직렬화하고, 네트워크 I/O만 운영체제의 epoll이나 kqueue로 다중화하는 방향을 택했다. 이 결정이 원자성과 순서 보장과 구현 단순성을 동시에 가져다 준다.
핵심 키워드
| 용어 | 의미 |
|---|---|
| 이벤트 루프 | aeMain()이 epoll 대기, 파일 이벤트 처리, 시간 이벤트 처리를 반복하는 단일 스레드 루프 |
| I/O Multiplexing | 하나의 스레드가 수많은 File Descriptor를 동시에 감시하고 준비된 것만 처리하는 메커니즘(epoll, kqueue) |
| RESP | Redis Serialization Protocol. 클라이언트와 서버 간 텍스트 기반 직렬화 프로토콜 |
| robj | 모든 Redis 값을 감싸는 16바이트 구조체. type, encoding, refcount, ptr을 보유 |
| SDS | Simple Dynamic String. C 문자열 대신 사용하는 길이와 바이너리에 안전한 문자열 |
| Dict | Redis의 키 공간을 구성하는 해시 테이블. 점진적 리해싱 지원 |
| Skiplist | Sorted Set의 내부 자료구조. O(log N) 범위 쿼리를 확률적으로 제공 |
| Copy-on-Write | fork() 직후 부모와 자식이 같은 페이지를 공유하다 쓰기가 발생하면 그 페이지만 복제하는 운영체제 기법 |
왜 싱글 스레드인가
전통적 멀티스레드 모델의 한계
동시 클라이언트를 처리하는 가장 단순한 방법은 연결마다 스레드 하나를 할당하는 것이다. 클라이언트 1만 명이 연결되면 1만 개의 스레드가 떠 있고, 그중 일부는 데이터가 올 때까지 recv()에서 블로킹된 채 멈춰 있다. 이 모델은 직관적이지만 다음 세 가지 비용에서 한계가 존재한다.
- 컨텍스트 스위칭: CPU가 스레드 간 전환할 때마다 레지스터 저장과 복원, 그리고 캐시 무효화가 일어난다. 수천 개의 스레드가 떠 있으면 컨텍스트 스위칭에만 CPU 시간의 상당 부분이 소비된다.
- 스택 메모리: 스레드 하나당 기본 1MB 스택을 잡는다. 1만 개의 스레드면 스택만으로 10GB다.
- 동기화 비용: 여러 스레드가 같은 데이터에 접근하면 락이 필요하다. Redis처럼 명령 하나가 나노초 단위로 끝나는 환경에서 락 획득과 해제 비용은 명령 자체보다 크다.
Redis가 택한 방향
Redis는 하나의 스레드가 I/O Multiplexing을 통해 수만 개의 연결을 감시하고, 준비된 연결만 골라 순차적으로 처리한다. 스레드 1개당 연결 N개가 아니라, 스레드 1개가 연결 수만 개를 담당하는 모델이다.
- 원자성: 모든 명령이 다른 명령에 의해 중간에 끊기지 않고 끝까지 실행된다.
INCR counter실행 중에 다른 클라이언트의GET counter가 끼어들 수 없다. - 순서 보장: 명령은 도착 순서대로 처리된다. 같은 클라이언트의 두 명령은 순서가 뒤집힐 일이 없다.
- 단순성: 뮤텍스, 세마포어, CAS 루프, 스핀락 같은 동기화 메커니즘이 코드 안에 거의 없다. 디버깅과 유지보수가 단순해진다.
싱글 스레드의 위험: O(N) 명령
한 번에 한 개의 명령만 실행되므로, 하나의 느린 명령이 뒤에 줄 선 모든 요청을 블로킹한다. 정상 상황에서는 마이크로초 단위 명령이 빠르게 순차 처리되지만 100만 개 키를 순회하는 KEYS * 한 번이 실행되면 그동안 뒤따르는 모든 요청이 수백 밀리초에서 수 초간 정지한다.
# 잘못된 패턴: O(N) 명령으로 전체 이벤트 루프를 블로킹
redis-cli KEYS 'user:*' # 100만 키 순회, 그동안 모든 요청 정지
redis-cli DEL bigHash # 큰 객체 동기 삭제, 해제까지 블로킹
redis-cli FLUSHALL # 전체 삭제를 메인 스레드에서 동기 수행
# 올바른 패턴: 커서 기반 순회 + 백그라운드 비동기 해제
redis-cli --scan --pattern 'user:*' # SCAN 커서 순회, 매 호출 O(1) 분할
redis-cli UNLINK bigHash # 백그라운드 스레드에서 비동기 해제
redis-cli FLUSHALL ASYNC # 삭제를 백그라운드로 위임KEYS *, FLUSHALL, FLUSHDB, 거대한 Hash나 List에 대한 HGETALL이나 LRANGE 0 -1 같은 O(N) 명령은 100만 개 키 기준 수백 밀리초에서 수 초의 블로킹을 만든다.
I/O Multiplexing
블로킹 I/O와 논블로킹 I/O
recv()를 호출하면 데이터가 도착할 때까지 스레드가 완전히 멈춘다. 그동안 이 스레드는 다른 클라이언트의 요청을 처리할 수 없다. 1만 연결을 받으려면 1만 스레드가 필요하다.
논블로킹 I/O는 recv()를 호출하고, 데이터가 없어도 즉시 EWOULDBLOCK을 반환한다. 그러나 그 자체로는 끊임없이 데이터 도착 여부를 묻는 바쁜 대기(busy-wait)가 된다. CPU는 100% 사용되면서 실제 일은 하지 않는 상태가 만들어진다.
I/O Multiplexing의 아이디어
I/O Multiplexing은 하나의 스레드가 여러 소켓의 File Descriptor를 운영체제에 등록해두고, 운영체제에 준비된 File Descriptor만 골라서 알려달라고 요청하는 것이다. 10만 개 File Descriptor를 등록해두고 그중 10개만 준비되면, 운영체제는 그 10개만 돌려준다. 애플리케이션은 그 10개에 대해서만 read()를 호출하면 되고, 나머지에 대해서는 신경 쓸 필요가 없다.
여기서 File Descriptor(파일 디스크립터)는 운영체제가 열린 소켓이나 파일을 가리키기 위해 프로세스에게 발급하는 정수 번호표다. 리눅스는 소켓, 파일, 파이프를 전부 파일처럼 다루고, 프로세스가 무언가를 열면 커널이 실제 정보를 자기 테이블에 담아둔 뒤 그 항목을 가리키는 작은 정수 하나만 돌려준다. 이후 프로세스는 read(fd, ...)나 write(fd, ...)처럼 이 번호만 넘겨 I/O를 요청하고, 실제 버퍼 상태는 커널이 관리한다. 클라이언트가 TCP로 접속하면 Redis는 accept()로 File Descriptor 하나를 받으므로, 연결 1개가 곧 File Descriptor 1개다. 클라이언트 10만 명이면 소켓 File Descriptor가 10만 개 열린다.
여기서 준비됨(ready)은 지금 그 소켓에 논블로킹으로 I/O를 하면 기다리지 않고 즉시 끝난다는 커널의 보증을 뜻한다.
- 읽기 준비(readable): 커널의 수신 버퍼에 아직 읽지 않은 데이터가 도착해 있는 상태.
read()를 호출하면 블로킹 없이 즉시 그 데이터를 가져온다. 리슨 소켓의 경우 새 연결 요청이 도착하면 읽기 준비가 되고, 이때accept()로 클라이언트를 받는다. 반대로 클라이언트가 연결만 맺고 아무것도 보내지 않았으면 수신 버퍼가 비어 있어 준비되지 않은 상태이므로 건드리지 않는다. - 쓰기 준비(writable): 커널의 송신 버퍼에 빈 공간이 있어
write()가 블로킹 없이 응답을 실을 수 있는 상태. 클라이언트가 느리게 받아가 송신 버퍼가 꽉 차면 준비되지 않은 상태가 되고, 공간이 생기면 다시 준비되어 남은 응답을 마저 보낸다.
즉 10만 연결 중 대부분은 대기 중(준비 안 됨)이고, 실제로 데이터를 보냈거나 응답을 받아갈 여유가 생긴 소수만 준비됨이 된다. epoll_wait()은 이 준비된 소수만 커널에게서 받아 처리하므로, 준비 안 된 소켓에서 멈추는 일이 없다.
select에서 poll을 거쳐 epoll로의 진화
I/O Multiplexing 구현은 운영체제와 시기에 따라 다르다.
| 구분 | select | poll | epoll |
|---|---|---|---|
| FD 전달 방식 | 매 호출마다 전체 복사 | 매 호출마다 전체 복사 | 등록 1회 |
| 최대 FD 수 | 1024개 제한 | 제한 없음 | 제한 없음 |
| 준비 FD 탐색 | O(N) 순회 | O(N) 순회 | O(1) 콜백 |
| 반환 방식 | 전체 검사 | 전체 검사 | 준비된 것만 |
| 동작 모델 | 동기 블로킹 | 동기 블로킹 | 비동기 논블로킹 |
select와 poll은 매 호출마다 전체 File Descriptor를 순회한다. File Descriptor가 1만 개면 1만 번 검사한다. epoll은 다르다. 커널이 감시 대상 File Descriptor를 Red-Black Tree로 관리하고, 이벤트가 발생한 File Descriptor만 Ready List에 추가한다. epoll_wait()는 Ready List에서 꺼내기만 하면 끝이다.
Redis는 운영체제별로 가장 효율적인 구현을 선택한다. Linux에서는 epoll, macOS와 BSD에서는 kqueue, Solaris에서는 evport, 그 외 환경에서는 select로 폴백한다. ae(A simple Event library)라는 자체 추상화가 이 분기를 감싸 준다.
이벤트 루프
Redis 프로세스의 메인 스레드는 aeMain()이라는 무한 루프 안에서 동작한다. 한 사이클은 크게 네 단계로 구성된다. 먼저 beforesleep()이 클라이언트 응답 버퍼를 소켓으로 내보내고 AOF 버퍼를 파일에 기록한다. 그다음 aeApiPoll()이 epoll이나 kqueue를 호출해 준비된 소켓 이벤트만 수집한다. 이어서 준비된 File Descriptor마다 파일 이벤트를 처리하고, 마지막으로 serverCron()이 시간 이벤트를 처리한다.
| 작업 | 설명 |
|---|---|
| 만료 키 능동 삭제 | TTL이 지난 키를 샘플링해 능동적으로 제거 |
| 백그라운드 작업 상태 확인 | RDB와 AOF의 자식 프로세스 완료 여부 확인 |
| 복제 상태 관리 | 레플리카에 주기적 PING, 복제 지연 확인 |
| eviction | 메모리 사용량이 maxmemory를 넘으면 키 축출 |
| 점진적 리해싱 | 해시 테이블 리사이즈를 한 사이클에 한 스텝씩 진행 |
클라이언트 요청의 처리 흐름
SET user:1 hong이 들어오면 내부에서 일어나는 일은 시간 순서로 정리된다. 클라이언트가 TCP 연결을 맺으면 Redis가 accept()로 받아 클라이언트 구조체를 만들고 읽기 핸들러를 등록한다. 이후 명령 바이트가 도착하면 소켓에서 쿼리 버퍼로 읽어들이고, RESP 프로토콜을 파싱해 명령과 인자로 분해한 뒤 실행한다.
sequenceDiagram participant C as 클라이언트 participant EL as 이벤트 루프(메인 스레드) participant DB as Dict(키 공간) C->>EL: TCP 연결 요청 EL->>EL: accept() + 클라이언트 구조체 생성 + 읽기 핸들러 등록 C->>EL: RESP 바이트 전송 (배열 3개 SET user1 hong) EL->>EL: readQueryFromClient() 소켓에서 querybuf로 EL->>EL: processInputBuffer() RESP 파싱 EL->>EL: processCommand() 명령 조회 + 인자 검증 + ACL EL->>DB: setCommand() 키-값 저장 DB-->>EL: 저장 완료 + dirty 카운터 증가 EL->>EL: addReply(+OK) 응답 버퍼에 적재 EL-->>C: beforesleep에서 소켓 write (+OK)
명령 조회부터 저장까지는 모두 단일 스레드에서 동기적으로 실행된다. 그래서 명령 단위로 원자성이 보장된다. 다만 응답 버퍼에 쓴 다음 실제 소켓 write는 beforesleep() 또는 쓰기 이벤트가 처리한다. 명령 실행과 네트워크 write가 분리되어 있다는 점이 다음 절의 I/O 스레딩 도입을 가능하게 한다.
Redis 6.0의 I/O 스레딩
Redis 6.0에서 I/O 멀티스레딩이 도입되었다. 다만 여기서 멀티스레드가 되는 것은 네트워크 read와 write일 뿐, 명령 실행은 여전히 단일 스레드다. Redis 6.0부터 멀티스레드라는 표현은 절반만 맞고 절반은 틀리다. 처리는 세 단계로 나뉜다. 먼저 여러 I/O 스레드가 클라이언트 소켓을 병렬로 읽고, 배리어 이후 메인 스레드만 명령을 순차 실행하며, 다시 배리어 이후 여러 I/O 스레드가 응답을 병렬로 내보낸다.
핵심은 I/O 스레드와 메인 스레드가 동시에 같은 데이터에 접근하는 구간이 없다는 점이다. 그래서 락이 필요 없다. 네트워크 read와 write가 CPU 시간의 상당 부분을 차지하므로, 이 부분만 멀티스레드로 분리해도 전체 처리량은 2배 이상 향상된다.
# redis.conf
io-threads 4 # 보통 CPU 코어의 50~75%, 8 이상은 거의 효과 없음
io-threads-do-reads yes # 읽기도 멀티스레드 (기본 no)백그라운드 스레드(bio)
메인 스레드를 블로킹하면 안 되는 느린 작업은 별도의 백그라운드 스레드(bio: Background I/O)가 처리한다.
| 스레드 | 작업 |
|---|---|
BIO_CLOSE_FILE | AOF rewrite 후 이전 파일 닫기. close()가 커널에서 블로킹될 수 있음 |
BIO_AOF_FSYNC | AOF 파일 fsync. everysec 정책에서 1초마다 호출 |
BIO_LAZY_FREE | UNLINK, FLUSHDB ASYNC로 인한 큰 객체의 비동기 메모리 해제 |
# 비동기 해제 정책
lazyfree-lazy-eviction yes # eviction 시 비동기 해제
lazyfree-lazy-expire yes # TTL 만료 시 비동기 해제
lazyfree-lazy-server-del yes # RENAME 등 기존 키 비동기 삭제
lazyfree-lazy-user-del yes # DEL을 UNLINK처럼 동작영속성: RDB와 AOF
인메모리 저장소지만 Redis는 재시작 후에도 데이터를 복원할 수 있도록 디스크 영속성을 제공한다. 방식은 두 가지이고 성격이 반대다.
- RDB(Redis Database): 특정 시점의 전체 데이터를 바이너리 스냅샷으로 덤프한다. 파일이 작고 복원이 빠르지만, 마지막 스냅샷 이후 변경분은 유실될 수 있다.
- AOF(Append Only File): 쓰기 명령을 순서대로 로그에 append한다. 유실 범위가 작지만 파일이 크고 복원이 느리다.
fsync정책(always,everysec,no)으로 내구성과 성능을 조절한다.
| 항목 | RDB | AOF |
|---|---|---|
| 저장 형태 | 시점 스냅샷(바이너리) | 쓰기 명령 로그(append) |
| 유실 범위 | 마지막 스냅샷 이후 | fsync 정책에 따라 최대 1초 |
| 파일 크기 | 작음 | 큼(rewrite로 압축) |
| 복원 속도 | 빠름 | 느림 |
| 생성 방식 | BGSAVE, fork 자식 | BGREWRITEAOF, fork 자식 |
두 방식 모두 스냅샷과 rewrite 시점에 fork()로 자식 프로세스를 떠서 메인 스레드를 멈추지 않는다. 실무에서는 RDB로 주기 백업을, AOF로 최근 변경 보존을 함께 쓰는 혼합 구성이 일반적이다.
전체 아키텍처 통합
지금까지 살펴본 요소는 하나의 프로세스 안에서 결합된다. 메인 스레드가 이벤트 루프를 돌며 Dict와 Skiplist와 Quicklist 같은 자료구조를 조작하고, I/O 스레드 풀이 네트워크 읽기와 쓰기를 병렬로 담당하며, bio 스레드가 느린 파일 작업과 비동기 해제를 처리한다. RDB와 AOF 영속성은 fork로 떠낸 자식 프로세스가 담당하고, 클라이언트와의 통신은 RESP 프로토콜과 I/O Multiplexing 위에서 이뤄진다.