티스토리 뷰
Redis는 클라이언트 요청을 이벤트 루프 기반으로 처리하고, Redis 자료구조를 직접 읽고 쓰는 명령 실행은 메인 쓰레드 중심으로 동작한다. 하지만 파일 닫기, AOF fsync, 메모리 해제 같은 일부 느린 작업은 백그라운드 쓰레드로 넘긴다.
그래서 Redis를 정확히 이해하려면 다음 두 가지를 구분해야 한다.
1. Redis 명령 실행 경로
- GET, SET, INCR, HSET 같은 명령 실행
- 메인 쓰레드 중심
2. Redis 프로세스 전체
- 메인 쓰레드 외에도 백그라운드 쓰레드 존재
- 파일 닫기, AOF fsync, lazy free 등 처리
1. Redis가 싱글 쓰레드라는 말의 의미
Redis가 싱글 쓰레드라고 할 때 핵심은 데이터를 읽고 쓰는 명령 실행 경로가 기본적으로 하나의 메인 쓰레드에서 처리된다는 뜻이다.
예를 들어 다음과 같은 명령이 있다고 하자.
SET user:1 "kim"
GET user:1
INCR view:post:100
LPUSH queue:order 1001
이 명령들은 Redis 내부의 메인 이벤트 루프에서 순차적으로 실행된다.
즉, 여러 클라이언트가 동시에 요청을 보내더라도 Redis는 내부적으로 명령을 하나씩 꺼내 처리한다.
Client A -> SET user:1
Client B -> GET user:1
Client C -> INCR view:post:100
Redis Main Thread
1. SET user:1 처리
2. GET user:1 처리
3. INCR view:post:100 처리
이 구조 덕분에 Redis는 명령 실행 중에 여러 쓰레드가 같은 자료구조를 동시에 수정하는 문제를 피할 수 있다.
멀티 쓰레드 구조라면 다음과 같은 고민이 필요하다.
Thread 1: user:1 수정
Thread 2: user:1 조회
Thread 3: user:1 삭제
이 경우 동시성 제어를 위해 lock이 필요하다.
하지만 Redis는 핵심 명령 실행을 단일 쓰레드에서 처리하기 때문에, 내부 자료구조 접근에 대해 복잡한 lock 경합을 크게 줄일 수 있다.
이게 Redis가 단순하면서도 빠른 이유 중 하나다.
2. 그럼 Redis 프로세스에는 쓰레드가 하나만 있을까?
그건 아니다.
Redis에는 메인 쓰레드 외에도 백그라운드 작업을 처리하기 위한 쓰레드들이 존재한다.
책에서 본 것처럼 Redis는 표면적으로는 싱글 쓰레드처럼 설명되지만, 실제로는 메인 쓰레드 외에 백그라운드 쓰레드가 함께 동작한다.
대표적으로 Redis의 background I/O, 즉 BIO 쓰레드는 다음과 같은 작업을 담당한다.
Main Thread
- 클라이언트 연결 처리
- 명령 파싱
- 명령 실행
- 응답 생성
Background I/O Threads
- BIO_CLOSE_FILE
- BIO_AOF_FSYNC
- BIO_LAZY_FREE
여기서 중요한 점은 백그라운드 쓰레드가 Redis의 핵심 데이터 명령을 병렬로 실행하는 것이 아니라는 점이다.
예를 들어 GET, SET, INCR, HSET 같은 명령을 여러 쓰레드가 동시에 나눠 실행하는 구조가 아니다.
백그라운드 쓰레드는 주로 메인 쓰레드가 직접 처리하면 오래 걸릴 수 있는 작업을 뒤로 넘겨서 처리한다.
3. Redis의 3가지 백그라운드 쓰레드
Redis의 백그라운드 I/O 쓰레드는 역할별로 나누어 볼 수 있다.
대표적으로 다음 3가지가 있다.
1. BIO_CLOSE_FILE
- 파일 닫기 작업 처리
2. BIO_AOF_FSYNC
- AOF 파일 fsync 처리
3. BIO_LAZY_FREE
- 큰 객체의 메모리 해제 작업 처리
각 쓰레드는 Redis 명령을 대신 실행하는 것이 아니라, 메인 쓰레드가 오래 붙잡고 있으면 위험한 보조 작업을 비동기적으로 처리한다.
3-1. BIO_CLOSE_FILE
BIO_CLOSE_FILE은 이름 그대로 파일을 닫는 작업을 담당한다.
일반적으로 파일을 닫는 close() 작업은 매우 빠를 것처럼 보인다. 하지만 운영체제나 파일 시스템 상황에 따라 파일을 닫는 과정이 예상보다 오래 걸릴 수 있다.
예를 들어 Redis가 AOF 파일을 교체하거나, 임시 파일을 사용한 뒤 닫아야 하는 상황이 있다고 하자.
1. Main Thread: 파일 작업 완료
2. Main Thread: 파일 닫기 작업을 BIO_CLOSE_FILE 큐에 등록
3. BIO_CLOSE_FILE Thread: 실제 close 작업 수행
4. Main Thread: 클라이언트 요청 계속 처리
만약 메인 쓰레드가 직접 파일 닫기를 처리하다가 지연되면, Redis는 그동안 다른 클라이언트 명령을 처리하지 못한다.
나쁜 흐름
Main Thread
1. SET user:1 처리
2. 파일 close 처리 시작
3. close 작업 지연
4. GET user:2 대기
5. INCR view:1 대기
그래서 Redis는 파일 닫기처럼 가끔 지연될 수 있는 작업을 백그라운드 쓰레드로 넘긴다.
개선된 흐름
Main Thread
1. SET user:1 처리
2. close 작업을 백그라운드 큐에 등록
3. GET user:2 처리
4. INCR view:1 처리
BIO_CLOSE_FILE Thread
1. 실제 파일 close 수행
핵심은 파일 닫기가 Redis 데이터 명령 실행과 직접 관련된 작업은 아니라는 점이다. 따라서 백그라운드로 넘겨도 Redis의 명령 처리 일관성을 해치지 않는다.
3-2. BIO_AOF_FSYNC
BIO_AOF_FSYNC는 AOF(Append Only File)의 fsync 작업을 담당한다.
Redis에서 AOF를 사용하면 쓰기 명령이 AOF 파일에 기록된다.
예를 들어 다음 명령을 실행했다고 하자.
SET user:1 "kim"
INCR view:post:100
AOF를 켜두면 Redis는 이 쓰기 명령들을 파일에 append 한다.
appendonly.aof
SET user:1 "kim"
INCR view:post:100
그런데 파일에 썼다는 것과 디스크에 안전하게 반영됐다는 것은 다르다.
운영체제는 성능을 위해 파일 쓰기를 메모리 버퍼에 잠시 보관할 수 있다. 이때 fsync는 “버퍼에 있는 내용을 실제 디스크에 반영해라”라고 요청하는 작업이다.
문제는 fsync가 디스크 I/O 작업이기 때문에 느려질 수 있다는 점이다.
Main Thread가 fsync까지 직접 처리하는 경우
1. SET user:1 처리
2. AOF 파일에 기록
3. fsync 수행
4. 디스크 응답 대기
5. 다음 명령 처리
이 흐름에서는 디스크가 느려지는 순간 Redis 전체 응답 시간이 흔들릴 수 있다.
그래서 appendfsync everysec 설정에서는 보통 fsync를 매 명령마다 하지 않고, 약 1초 단위로 백그라운드에서 처리한다.
Main Thread
1. SET user:1 처리
2. AOF 버퍼 또는 파일에 append
3. 클라이언트에 응답
4. 다음 명령 처리
BIO_AOF_FSYNC Thread
1. 주기적으로 AOF 파일 fsync 수행
2. 디스크 반영 처리
이 방식의 장점은 메인 쓰레드가 디스크 fsync 때문에 오래 멈추는 상황을 줄일 수 있다는 것이다.
다만 트레이드오프도 있다.
appendfsync always
- 매 쓰기마다 fsync
- 내구성 강함
- 성능 저하 가능성 큼
appendfsync everysec
- 보통 1초 단위 fsync
- 성능과 내구성의 균형
- 장애 시 최근 약 1초 데이터 유실 가능
appendfsync no
- fsync를 운영체제에 맡김
- 성능은 좋을 수 있음
- 내구성 보장은 약함
즉, BIO_AOF_FSYNC는 Redis의 명령 실행을 빠르게 유지하면서도 AOF 내구성을 확보하기 위한 백그라운드 작업이라고 볼 수 있다.
3-3. BIO_LAZY_FREE
BIO_LAZY_FREE는 큰 객체의 메모리 해제를 백그라운드에서 처리한다.
Redis에서 key를 삭제할 때 실제로는 두 가지 일이 발생한다.
1. keyspace에서 key 제거
2. 해당 value가 사용하던 메모리 해제
작은 문자열 key라면 이 작업은 매우 빠르다.
DEL user:1
하지만 value가 매우 큰 List, Set, Sorted Set, Hash라면 이야기가 달라진다.
DEL big:list
예를 들어 big:list에 수백만 개의 요소가 들어 있다면, key를 제거하는 것보다 내부 요소들의 메모리를 해제하는 작업이 오래 걸릴 수 있다.
DEL big:list
Main Thread
1. keyspace에서 big:list 찾기
2. key 제거
3. list 내부 요소 메모리 해제
4. 해제가 끝날 때까지 다른 명령 대기
이 문제를 줄이기 위해 Redis는 UNLINK를 제공한다.
UNLINK big:list
UNLINK는 key를 먼저 keyspace에서 제거한다. 그러면 이후 Redis 명령에서는 해당 key에 접근할 수 없다.
그리고 실제 메모리 해제 작업은 BIO_LAZY_FREE 쓰레드로 넘긴다.
UNLINK big:list
Main Thread
1. keyspace에서 big:list 제거
2. 메모리 해제 작업을 BIO_LAZY_FREE 큐에 등록
3. 바로 다음 명령 처리
BIO_LAZY_FREE Thread
1. big:list 내부 요소 메모리 해제
2. 사용하던 메모리 반환
이 구조가 안전한 이유는 메인 쓰레드가 먼저 keyspace에서 key를 제거하기 때문이다.
1. Main Thread가 keyspace에서 key 제거
2. 이후 어떤 Redis 명령도 그 key에 접근할 수 없음
3. Background Thread가 실제 value 메모리 해제
즉, 백그라운드 쓰레드가 Redis 자료구조를 마음대로 수정하는 것이 아니라, 이미 접근 불가능해진 객체의 메모리를 정리하는 역할을 한다.
BIO_LAZY_FREE는 UNLINK뿐 아니라 설정에 따라 만료, eviction, FLUSHDB ASYNC, FLUSHALL ASYNC 같은 작업에서도 사용될 수 있다.
UNLINK big:key
FLUSHDB ASYNC
FLUSHALL ASYNC
관련 설정으로는 다음과 같은 것들이 있다.
lazyfree-lazy-eviction yes
lazyfree-lazy-expire yes
lazyfree-lazy-server-del yes
replica-lazy-flush yes
이 설정들은 eviction, expire, 서버 내부 삭제, replica flush 같은 상황에서 메모리 해제를 가능한 한 백그라운드로 넘길지 결정한다.
정리하면 BIO_LAZY_FREE는 Redis의 싱글 쓰레드 구조에서 가장 체감하기 쉬운 백그라운드 쓰레드다. 큰 key 삭제나 전체 flush 작업이 메인 쓰레드를 오래 막지 않도록 도와주기 때문이다.
4. 왜 백그라운드 쓰레드가 필요할까?
Redis는 메인 쓰레드가 클라이언트 요청을 순차적으로 처리한다.
이 구조에서는 하나의 작업이 오래 걸리면 뒤에 있는 요청들이 모두 기다려야 한다.
예를 들어 아주 큰 key를 삭제한다고 해보자.
DEL big:list
만약 big:list가 매우 큰 리스트라면, 삭제 과정에서 메모리를 해제하는 작업이 오래 걸릴 수 있다.
Redis 메인 쓰레드가 이 작업을 끝까지 직접 처리하면 그동안 다른 요청 처리가 지연될 수 있다.
1. DEL big:list 처리 시작
2. 큰 메모리 해제 작업 수행
3. 작업이 끝날 때까지 다른 요청 대기
4. 이후 GET, SET 처리
이 문제를 줄이기 위해 Redis는 UNLINK 같은 명령을 제공한다.
UNLINK big:list
UNLINK는 key를 논리적으로 제거한 뒤, 실제 메모리 해제 작업은 백그라운드에서 처리할 수 있게 한다.
1. Main Thread: key를 keyspace에서 제거
2. BIO_LAZY_FREE Thread: 실제 메모리 해제
3. Main Thread: 다음 요청 계속 처리
즉, Redis가 백그라운드 쓰레드를 사용하는 이유는 핵심 명령 실행을 병렬화하기 위해서라기보다, 메인 이벤트 루프가 오래 막히지 않도록 느린 작업을 분리하기 위해서라고 보는 편이 맞다.
5. Redis 6부터는 I/O Thread도 있다
Redis 6부터는 I/O Thread 기능이 추가되었다.
여기서 다시 헷갈릴 수 있다.
“Redis 6부터 멀티 쓰레드라면 이제 명령도 병렬로 실행되는 건가?”
그렇지는 않다.
Redis 6의 I/O Thread는 주로 네트워크 I/O를 병렬화하기 위한 기능이다.
Redis 요청 처리를 단순화하면 다음과 같다.
1. 클라이언트 요청 읽기
2. 명령 파싱
3. 명령 실행
4. 응답 쓰기
기존에는 이 흐름 대부분을 메인 쓰레드가 처리했다.
Main Thread
Read -> Parse -> Execute -> Write
Redis 6 이후에는 설정에 따라 일부 네트워크 읽기/쓰기 작업을 I/O Thread가 도와줄 수 있다.
I/O Thread
- socket read
- socket write
- command parsing 일부
Main Thread
- command execute
- Redis data structure access
- reply 생성
즉, Redis 6 이후에도 핵심은 동일하다.
네트워크 I/O 일부는 멀티 쓰레드 가능
하지만 Redis 자료구조를 변경하는 명령 실행은 메인 쓰레드 중심
여기서 Redis의 I/O Thread와 앞에서 설명한 BIO 쓰레드는 역할이 다르다.
BIO Thread
- 파일 닫기
- AOF fsync
- lazy free
- 메인 쓰레드가 오래 막힐 수 있는 내부 작업 처리
I/O Thread
- 클라이언트 socket read/write 보조
- 네트워크 I/O 병목 완화
즉, 둘 다 Redis 프로세스 내부의 추가 쓰레드지만 목적이 다르다.
BIO Thread는 Redis 내부의 느린 작업을 백그라운드로 넘기기 위한 쓰레드이고, I/O Thread는 클라이언트 네트워크 I/O 처리량을 높이기 위한 쓰레드다.
6. Redis가 싱글 쓰레드인데도 빠른 이유
Redis가 빠른 이유를 단순히 “메모리를 사용하기 때문”이라고만 보면 부족하다.
물론 Redis는 대부분의 데이터를 메모리에 두기 때문에 디스크 기반 DB보다 빠른 접근이 가능하다.
하지만 그 외에도 몇 가지 이유가 있다.
1) 명령 실행이 단순하다
Redis 명령은 대부분 짧고 빠르게 끝난다.
GET user:1
SET token:abc "..."
INCR view:post:1
이런 명령은 일반적인 RDBMS 쿼리처럼 복잡한 실행계획을 세우거나, 여러 테이블을 조인하거나, 디스크 블록을 많이 읽는 구조가 아니다.
대부분 메모리의 자료구조에 직접 접근한다.
2) I/O Multiplexing을 사용한다
Redis는 하나의 쓰레드로도 여러 클라이언트 연결을 다룰 수 있다.
이때 사용하는 방식이 I/O Multiplexing이다.
쉽게 말하면, 클라이언트마다 쓰레드를 하나씩 만드는 것이 아니라, 하나의 이벤트 루프가 여러 소켓을 감시하다가 준비된 요청만 처리하는 방식이다.
Client 1
Client 2
Client 3
Client 4
↓
Event Loop
↓
Ready 된 요청만 처리
이 구조는 Node.js의 이벤트 루프와도 비슷하게 이해할 수 있다.
3) Lock 경합이 적다
멀티 쓰레드로 명령을 동시에 실행하면 공유 데이터에 대한 lock이 필요하다.
하지만 Redis는 핵심 명령 실행을 단일 쓰레드에서 순차적으로 처리하기 때문에 lock 경합 비용이 작다.
멀티 쓰레드 방식
- 동시에 실행 가능
- lock 필요
- context switching 비용 발생
- 동시성 버그 가능성 증가
Redis 방식
- 명령 실행은 순차 처리
- lock 경합 감소
- 구현 단순
- 예측 가능한 실행 흐름
Redis는 이 단순한 구조를 선택한 대신, 오래 걸리는 작업을 피하거나 백그라운드로 넘기는 방식으로 성능을 유지한다.
7. 싱글 쓰레드 구조에서 조심해야 할 명령
Redis가 빠르다고 해서 모든 명령이 항상 안전한 것은 아니다.
싱글 쓰레드 구조에서는 오래 걸리는 명령 하나가 전체 요청 처리에 영향을 줄 수 있다.
대표적인 예가 KEYS다.
KEYS *
이 명령은 전체 keyspace를 순회한다.
운영 환경에서 key가 많다면 메인 쓰레드가 오랫동안 이 작업을 수행하게 되고, 그동안 다른 요청이 밀릴 수 있다.
개선된 방식은 SCAN이다.
SCAN 0 MATCH user:* COUNT 100
SCAN은 전체를 한 번에 훑는 것이 아니라 cursor 기반으로 조금씩 순회한다.
KEYS
- 전체 key를 한 번에 조회
- 메인 쓰레드가 오래 점유될 수 있음
- 운영 환경에서 위험
SCAN
- cursor 기반으로 나눠 조회
- 한 번에 처리하는 양을 조절 가능
- 운영 환경에서 상대적으로 안전
큰 key 삭제도 마찬가지다.
DEL big:key
큰 객체를 삭제할 때는 다음처럼 UNLINK를 고려할 수 있다.
UNLINK big:key
DEL
- key 삭제와 메모리 해제를 동기적으로 처리할 수 있음
- 큰 key에서는 지연 발생 가능
UNLINK
- keyspace에서는 빠르게 제거
- 실제 메모리 해제는 BIO_LAZY_FREE가 백그라운드 처리
AOF를 사용하는 환경에서는 appendfsync 설정도 중요하다.
appendonly yes
appendfsync everysec
보통은 everysec가 성능과 내구성의 균형점으로 많이 사용된다.
appendfsync always
- 쓰기마다 디스크 동기화
- 안전하지만 느려질 수 있음
appendfsync everysec
- 약 1초 단위로 fsync
- BIO_AOF_FSYNC가 백그라운드에서 처리
- 일반적인 운영 환경에서 많이 사용
appendfsync no
- 운영체제 정책에 맡김
- 빠를 수 있지만 장애 시 유실 범위가 커질 수 있음
8. Redis를 “싱글 쓰레드”라고만 말하면 부족한 이유
Redis를 이해할 때는 관점을 나눠야 한다.
1. 명령 실행 관점
- 대부분 싱글 쓰레드
- Redis 자료구조 접근은 메인 쓰레드 중심
2. Redis 프로세스 관점
- 메인 쓰레드 외 백그라운드 쓰레드 존재
- BIO_CLOSE_FILE, BIO_AOF_FSYNC, BIO_LAZY_FREE 등 처리
3. Redis 6 이후 네트워크 I/O 관점
- I/O Thread 사용 가능
- socket read/write 일부를 병렬 처리 가능
4. Persistence 관점
- AOF fsync는 백그라운드 쓰레드가 도울 수 있음
- RDB snapshot, AOF rewrite는 별도 프로세스가 관여할 수 있음
따라서 정확히 표현하면 다음과 같다.
Redis는 클라이언트 명령 실행과 데이터 접근은 주로 싱글 쓰레드로 처리하지만, 백그라운드 I/O 작업과 네트워크 I/O 최적화를 위해 여러 쓰레드 또는 별도 프로세스를 사용할 수 있다.
9. 실제 운영에서는 어떻게 봐야 할까?
Redis를 운영할 때는 “싱글 쓰레드니까 CPU 코어 하나만 보면 된다”라고 생각하면 안 된다.
물론 명령 실행 자체는 메인 쓰레드 병목이 될 수 있다.
그래서 Redis는 보통 하나의 인스턴스가 하나의 CPU 코어를 강하게 사용하는 형태로 나타날 수 있다.
하지만 다음 요소들도 함께 봐야 한다.
- 메인 쓰레드 CPU 사용률
- 네트워크 I/O 병목
- 큰 key 삭제 여부
- AOF fsync 지연
- RDB snapshot 또는 AOF rewrite 시점
- slowlog
- latency monitor
- lazy free pending object 증가 여부
예를 들어 Redis가 느려졌을 때는 단순히 CPU만 볼 게 아니라 다음 명령들을 같이 확인할 수 있다.
SLOWLOG GET 10
INFO stats
INFO commandstats
INFO persistence
INFO memory
INFO memory에서는 lazy free와 관련된 지표를 확인할 수 있다.
lazyfree_pending_objects
이 값이 계속 증가한다면 백그라운드에서 해제해야 할 객체가 많이 쌓이고 있다는 의미로 볼 수 있다.
또 AOF를 사용하는 경우에는 INFO persistence를 통해 AOF rewrite, fsync, persistence 관련 상태를 함께 확인하는 것이 좋다.
10. 마무리
Redis는 흔히 싱글 쓰레드라고 설명된다.
이 말은 틀린 말은 아니지만, 정확히는 Redis의 핵심 명령 실행 경로가 싱글 쓰레드에 가깝다는 의미로 이해해야 한다.
Redis 프로세스 전체를 보면 메인 쓰레드 외에도 백그라운드 쓰레드가 존재하고, Redis 6 이후에는 네트워크 I/O를 위한 I/O Thread도 사용할 수 있다.
정리하면 다음과 같다.
Redis는 완전한 의미의 단일 쓰레드 프로그램은 아니다.
하지만 Redis의 핵심 데이터 명령 실행은 여전히 메인 쓰레드 중심으로 동작한다.
BIO_CLOSE_FILE은 파일 닫기 작업을 백그라운드에서 처리한다.
BIO_AOF_FSYNC는 AOF fsync 작업을 백그라운드에서 처리한다.
BIO_LAZY_FREE는 큰 객체의 메모리 해제를 백그라운드에서 처리한다.
Redis 6 이후의 I/O Thread도 명령 실행을 병렬화하는 것이 아니라 네트워크 I/O 병목을 줄이기 위한 기능이다.
결국 Redis의 싱글 쓰레드 모델은 단순한 제약이 아니라, 빠른 메모리 접근과 이벤트 루프, lock 없는 명령 실행을 조합한 설계 선택이라고 볼 수 있다.
Redis를 사용할 때 중요한 것은 “싱글 쓰레드냐, 멀티 쓰레드냐”를 외우는 것이 아니라, 어떤 작업이 메인 쓰레드를 막고 어떤 작업이 백그라운드로 분리될 수 있는지 이해하는 것이다.
참고 자료
- Redis 공식 문서 - Latency optimization
- Redis 공식 문서 - INFO command
- Redis 공식 블로그 - The little-known feature of Redis 4.0 that will speed up your applications
- Redis 공식 블로그 - Redis 8.0-M03 is out. Even more performance & new features
- Redis GitHub source -
src/bio.c,src/bio.h
'Database' 카테고리의 다른 글
| 인덱스 풀스캔(Index Full Scan) 훑어보기 (0) | 2026.04.18 |
|---|---|
| count(*) / count(1) / count(컬럼) 차이 (0) | 2022.04.15 |
