"Redis는 왜 빠른가요?" 흔히 나오는 의문이다. 대부분 "인메모리라서요", "싱글 스레드라서 락이 없어서요"로 끝난다. 틀린 말은 아니지만, 이 대답은 결과를 원인처럼 설명하고 있다. Memcached도 인메모리인데 왜 Redis와 성능 특성이 다를까? 싱글 스레드인데 어떻게 수십만 QPS가 나올까? 진짜 답은 훨씬 아래 계층에 있다. 이 글은 뜬금없이 궁금해서 Claude Code와 함께 그 아래를 파고든 기록이다.
- Redis의 빠름은 "메모리에 뒀다"가 아니라 "CPU 사이클을 낭비하는 모든 요소를 제거한 설계" 에서 온다.
- 싱글 스레드는 원인이 아니라 epoll 기반 이벤트 루프 선택의 귀결이다.
- 자료구조(
listpack,skiplist), 할당자(jemalloc), 프로토콜(RESP)까지 모든 계층이 CPU 캐시 친화적으로 정렬돼 있다. - Redis 6+의 "멀티스레드"는 I/O만 분산이지 명령 실행은 여전히 메인 스레드 단독 — 원자성이 바로 여기서 나온다.
1. 흔한 오답부터 시작하자
"Redis가 왜 빠른가요?"에 대한 전형적 대답 두 가지
- "인메모리라서요."
- "싱글 스레드라서 락이 없어요."
둘 다 사실이지만, 이 둘만 가지고는 다음 질문에 답할 수 없다.
- Memcached도 인메모리인데 왜 특정 워크로드에서 Redis가 더 빠른가?
- 인메모리 DB 중에도 Redis만큼 빠르지 않은 게 많은 이유는?
- 싱글 스레드로 어떻게 수만 개 커넥션을 처리하는가? 하나씩 순차 처리하는 것 아닌가?
진짜 답은 "메모리에 뒀을 때 CPU 사이클을 어떻게 아끼느냐" 에 있다.
2. 진짜 이유 - CPU 관점에서 다시 보기
현대 서버에서 가장 비싼 비용 세 가지를 먼저 짚자.
| 비용 | 대략적 지연 | 발생 조건 |
|---|---|---|
| CPU 캐시 미스 (L3 → RAM) | ~100 ns | 포인터 체이싱, 흩어진 메모리 접근 |
| 컨텍스트 스위칭 | ~1-10 µs | 스레드 전환 |
| 락 경합 (uncontended vs contended) | ns → µs → ms | 멀티스레드 공유 자료구조 |
Redis는 이 셋을 구조적으로 회피했다. 하나씩 보자.
3. epoll과 이벤트 루프 - 싱글 스레드의 진짜 무기
싱글 스레드 자체가 빠른 게 아니다. 싱글 스레드인데도 수십만 QPS가 나오는 이유는 I/O Multiplexing 덕분이다.

Linux의 epoll, BSD의 kqueue는 "수천 개 소켓 중 I/O 준비된 것만 골라주는 커널 API" 다. Redis 메인 스레드는 이 목록만 순회하며 처리한다.
결과적으로
- 연결당 스레드를 만들지 않으니 컨텍스트 스위칭 비용이 0
- 스레드 간 락/뮤텍스 경합이 아예 없음
- 같은 코어에서 계속 실행 → L1/L2 캐시 hit률이 높게 유지
멀티스레드 DB에서 락 경합이 얼마나 성능을 깎아먹는지 경험해봤다면 이 구조의 강점이 보인다. Redis는 그 문제를 해결한 게 아니라 회피한 것이다.
4. 싱글 스레드 = 원자성 공짜
싱글 스레드에서 명령이 하나씩 순차 실행된다는 건 곧 개별 명령이 태생적으로 원자적이라는 뜻이다. INCR, LPUSH, SETNX에 내부 락이 필요 없다.
분산 락 패턴을 떠올려보자.
# 이 한 줄이 원자적이라서 분산 락이 성립한다
SET lock:order:42 "owner-node-1" NX PX 30000
멀티스레드였다면 내부적으로 락이 필요했을 테고, 그러면 Redis로 분산 락을 만드는 설계 자체가 불가능했을 것이다. 싱글 스레드는 단순한 성능 선택이 아니라 API 디자인의 기반이다.
💡 Thundering Herd
캐시 만료 순간 다수 요청이 동시에 miss를 만나 원본 DB로 몰려드는 현상. 소 떼(herd)가 천둥(thunder)에 놀라 한 방향으로 돌진하는 모습에서 유래. 위SET NX가 바로 이 방어의 기본 빌딩블록이다.
5. 자료구조가 CPU 캐시를 의식한다
Redis의 숨은 강점은 값의 크기에 따라 내부 표현을 자동으로 바꾼다는 점이다.
| 타입 | 작을 때 | 클 때 |
|---|---|---|
| String | int / embstr |
raw (SDS) |
| List | listpack |
quicklist |
| Hash | listpack |
hashtable |
| Set | intset / listpack |
hashtable |
| Sorted Set | listpack |
skiplist + hashtable |
listpack은 연속 메모리에 밀어넣은 인코딩이다. 포인터를 따라가지 않으므로 CPU 캐시가 한 줄 읽어올 때 필요한 데이터가 이미 다 들어있다. 반면 Java의 HashMap은 Entry 객체마다 포인터를 따라가야 해서 캐시 미스가 잦다.
데이터가 커지면 hashtable 같은 구조로 승격해 O(1) 접근을 유지한다. "작으면 캐시 친화적, 크면 접근 복잡도 유지" 의 이중 전략.
직접 확인해보자
127.0.0.1:6379> HSET user:1 name "ramos" age 30
127.0.0.1:6379> OBJECT ENCODING user:1
"listpack" # 작으므로 listpack
127.0.0.1:6379> CONFIG SET hash-max-listpack-entries 2
127.0.0.1:6379> HSET user:1 email "x@y.com" phone "..." addr "..."
127.0.0.1:6379> OBJECT ENCODING user:1
"hashtable" # 커져서 승격
6. Sorted Set에 Skip List를 쓴 이유

흔히 "Red-Black Tree로 구현하면 더 빠르지 않나?" 생각할 수 있다. Redis는 의도적으로 Skip List를 선택했다.
- 구현이 단순하다 → 버그가 적고 유지보수가 쉽다.
- 범위 쿼리(
ZRANGEBYSCORE,ZREVRANGE)가 자연스럽다 → 최하위 레벨이 그냥 연결 리스트라서 순차 순회가 저렴하다. - 확률적 균형이라 재균형 비용이 평균적으로 낮다.
랭킹 보드, 타임라인 같은 범위 스캔 중심 워크로드에 Red-Black Tree보다 적합했던 것이다.
7. RESP - 파싱 비용을 거의 0으로
RESP는 REdis Serialization Protocol의 약자. Redis 클라이언트↔서버 통신 규격이다.
*3\r\n
$3\r\nSET\r\n
$3\r\nfoo\r\n
$3\r\nbar\r\n
*3→ 3개짜리 배열$3→ 다음 문자열은 3바이트
길이 prefix가 있으니 파서가 토큰을 스캔할 필요가 없다. 숫자 읽고, 그만큼 바이트 건너뛰면 끝. JSON처럼 이스케이프 처리나 토큰 분리 같은 비용이 없다.
여기에 파이프라이닝까지 쓰면 RTT도 압축된다

RTT가 50µs이고 10개 명령이라면: 500µs → 50µs + α. 이건 Redis 자체가 빠른 게 아니라 프로토콜이 그런 최적화를 허용하도록 설계된 것이다.
8. jemalloc - 할당자 수준의 최적화
glibc malloc은 범용/멀티스레드용이라 락과 메타데이터 오버헤드가 있다. Redis는 jemalloc을 번들링해서 쓴다.
- 크기별 arena로 단편화 최소화
- 메타데이터가 작음
MEMORY USAGE같은 명령으로 정확한 추적 가능
127.0.0.1:6379> INFO memory
# mem_fragmentation_ratio:1.05 # 1.0에 가까울수록 좋음
# mem_allocator:jemalloc-5.x.x
장기간 운영하는 캐시에서 메모리 단편화는 치명적이다. mem_fragmentation_ratio가 1.5 이상이면 실제 메모리의 절반 이상이 낭비되고 있다는 뜻. 이걸 할당자 레벨에서 관리한다는 게 Redis의 마감 완성도다.
9. Redis 6+ 멀티스레드 - 흔한 오해
"Redis 6부터 멀티스레드 됐다"는 말을 자주 듣는다. 틀린 설명이다.

실제로는 소켓 read/write와 RESP 파싱만 여러 스레드로 분산되고, 명령 실행은 여전히 메인 스레드 독점이다. 즉 원자성 보장은 그대로 유지하면서 네트워크 I/O 병목만 해소한 거다.
설정은 아래와 같이.
# redis.conf
io-threads 4
io-threads-do-reads yes
코어 4개 이상일 때만 효과 있다. 무작정 늘리면 오히려 느려진다.
10. Kotlin/Spring Boot로 체감하기
이제 이론을 체감해볼 코드 세 가지. Spring Data Redis + Kotlin.
10.1 원자적 분산 락 (싱글 스레드 원자성의 직접 수혜)
@Service
class OrderLockService(
private val redis: StringRedisTemplate,
) {
companion object {
private const val LOCK_PREFIX = "lock:order:"
private val LOCK_TTL = Duration.ofSeconds(30)
private val UNLOCK_SCRIPT = DefaultRedisScript(
"""
if redis.call('get', KEYS[1]) == ARGV[1] then
return redis.call('del', KEYS[1])
else
return 0
end
""".trimIndent(),
Long::class.java,
)
}
fun tryLock(orderId: Long, owner: String): Boolean =
redis.opsForValue()
.setIfAbsent("$LOCK_PREFIX$orderId", owner, LOCK_TTL) == true
fun unlock(orderId: Long, owner: String): Boolean =
redis.execute(UNLOCK_SCRIPT, listOf("$LOCK_PREFIX$orderId"), owner) == 1L
}
SET NX PX가 원자적이라는 건 싱글 스레드 구조의 직접적 결과물이다. 해제는 Lua로 묶어야 GET → DEL 사이 레이스를 막는다.
10.2 파이프라이닝 (RESP + 싱글 RTT)
fun getUsersBulk(ids: List<Long>): Map<Long, String?> {
val results = redis.executePipelined(
RedisCallback { conn ->
val c = conn as StringRedisConnection
ids.forEach { id -> c.get("user:$id") }
null
}
)
return ids.mapIndexed { i, id -> id to (results[i] as String?) }.toMap()
}
N번의 네트워크 왕복이 1번으로 접힌다. RESP가 길이 prefix라서 서버가 파싱도 싸게 해준다.
10.3 Sorted Set 랭킹 (Skip List 범위 쿼리)
@Service
class RankingService(
private val redis: StringRedisTemplate,
) {
fun topN(boardKey: String, n: Int): List<String> =
redis.opsForZSet()
.reverseRange(boardKey, 0, (n - 1).toLong())
?.toList()
.orEmpty()
fun addScore(boardKey: String, userId: String, delta: Double): Double? =
redis.opsForZSet().incrementScore(boardKey, userId, delta)
}
ZREVRANGE가 Skip List 최하위 레벨의 역방향 순회라 O(log N + n)에 끝난다.
11. 운영에서 잊지 말아야 할 것
이론이 아름다워도 운영에서 무너질 수 있다. 싱글 스레드 구조의 그림자는 다음과 같다.
11.1 단 하나의 느린 명령이 전체를 블록한다
# 절대 운영에서 하면 안 되는 것
KEYS *
# 수백만 키 환경에서 수 초간 Redis 전체 정지
싱글 스레드라는 건 한 요청이 느리면 뒤에 쌓인 모두가 같이 느려진다는 뜻이다. 반드시 SCAN으로 대체. SLOWLOG로 10ms 이상 걸린 명령을 주기 모니터링해야 한다.
redis-cli> CONFIG SET slowlog-log-slower-than 5000 # 5ms 이상 기록
redis-cli> SLOWLOG GET 128
11.2 big key와 hot key
- big key: 단일 키 1MB 이상. 삭제/만료가 O(N)이라 그 시간 동안 Redis 블록.
UNLINK로 비동기 삭제하거나 설계 단계에서 쪼개기. - hot key: 특정 키에 QPS 쏠림. Cluster여도 단일 키는 단일 노드에서만 처리되므로 수평 확장 효과 0.
redis-cli --bigkeys # big key 탐지
redis-cli --hotkeys # hot key 탐지 (LFU 필요)
11.3 RDB fork와 COW 메모리 급증
RDB 스냅샷은 fork() + Copy-On-Write로 동작한다. 스냅샷 중 쓰기가 많으면 메모리 사용량이 최대 2배까지 치솟아 OOM 발생 가능. vm.overcommit_memory=1 설정 필수.
11.4 AOF everysec - 현실적 타협점
| 정책 | 손실 가능성 | 성능 영향 |
|---|---|---|
always |
없음 | 큼 (매 쓰기 fsync) |
everysec |
최대 1초 | 작음 |
no |
수 초~수십 초 | 거의 없음 |
대부분의 서비스엔 everysec이 맞다. 금융/결제급 내구성이면 always + SSD 별도 디스크 조합.
12. 정리 - "인메모리 + 싱글 스레드"의 진짜 의미
빠른 이유를 한 문장으로 압축하면,
"현대 CPU에서 가장 비싼 비용(캐시 미스, 컨텍스트 스위칭, 락)을 구조적으로 피한 설계."
"인메모리 + 싱글 스레드"는 결과적 특징이지 근본 원인이 아니다. 근본은 이렇다.
| 층위 | 설계 결정 | 아낀 비용 |
|---|---|---|
| I/O | epoll 기반 이벤트 루프 | 컨텍스트 스위칭, 연결당 스레드 |
| 실행 모델 | 싱글 스레드 명령 실행 | 락/뮤텍스 경합 |
| 자료구조 | listpack / skiplist / 동적 인코딩 | CPU 캐시 미스 |
| 메모리 | jemalloc | 할당 오버헤드, 단편화 |
| 프로토콜 | RESP + 길이 prefix | 파싱 비용, 이스케이프 처리 |
| 네트워크 | 파이프라이닝 | RTT |
각 계층이 같은 방향(CPU 사이클 절약)으로 정렬돼 있다는 게 Redis의 본질이다. 하나만 잘한 게 아니라 전 계층이 한 방향을 보고 있다.
13. 정리하자면?
Q: "Redis는 왜 빠른가요?"
"인메모리라서 I/O 대기가 없는 건 필요조건이지만 충분조건은 아닙니다. Redis의 본질은 'CPU 사이클을 낭비하는 모든 요소를 제거한 설계' 에 있습니다.
구체적으로는 epoll 기반 이벤트 루프로 연결당 스레드 없이 수천 커넥션을 처리하고, 싱글 스레드 실행 모델로 락 경합을 원천 제거했습니다. 자료구조는 크기에 따라
listpack처럼 연속 메모리 구조를 써서 CPU 캐시 친화적으로 설계되었고, 프로토콜(RESP)도 길이 prefix 기반이라 파싱 비용이 극단적으로 낮습니다.그래서 '싱글 스레드'는 느려질 원인이 아니라 원자성과 성능을 동시에 얻기 위한 의도된 선택이고, 그 원자성 덕분에 Redis 위에서 분산 락 같은 패턴이 성립할 수 있게 된 것입니다."
더 파볼 주제
- Skip List vs B-Tree vs RB-Tree - 선택 근거 비교
- epoll edge-triggered vs level-triggered 와 Redis의 선택
- Redis Cluster의 gossip 프로토콜과 슬롯 재할당
- Memcached와의 벤치마크 차이 원인 분해
여기서부터는 또 다른 글이 될 것 같다.
참고
- Redis 공식 문서 - https://redis.io/docs/latest
- redis/src/ae.c - 이벤트 루프 구현. 읽어보면 설계 의도가 잘 드러난다.
- redis/src/t_zset.c - Sorted Set 구현. Skip List 코드 자체가 교과서 수준.
'Redis' 카테고리의 다른 글
| 지표 하나에서 시작된 Valkey Sentinel 구조 개선기 (1) | 2026.06.03 |
|---|---|
| 단일 VM Valkey 를 K8s Sentinel HA 로 옮긴 기록 (0) | 2026.05.04 |