Redis를 직접 만들어보며
배운 것들
"Redis를 구현하라" — 단순한 과제를 티케팅 서비스로 확장한
4인 팀 프로젝트 회고
Prologue
Redis를 쓰면서 한 번쯤은 생각해봤을 것이다.
"이거 대체 어떻게 동작하는 거야?"
크래프톤 정글에서 수요코딩회 과제로 Redis를 직접 구현하는 팀 프로젝트가 주어졌다. 과제 자체는 간단했다. Redis라는 키워드를 받았고, 그걸 구현해보라는 것. 외부 라이브러리를 가져다 쓰는 게 아니라, Redis가 하는 일을 우리 손으로 직접 만들어보라는 의미였다.
이 글은 그 과정에서 내가 직접 설계하고 구현한 것들, 팀과 병합하면서 배운 것들, 그리고 다음에 가져갈 것들을 정리한 기록이다.
시작은 크래프톤 정글 수요코딩회
크래프톤 정글은 단순한 강의 수강이 아니다. 매주 수요일 진행되는 수요코딩회에서는 실제로 무언가를 만들어야 하는 팀 과제가 주어진다.
이번 과제의 키워드는 딱 하나였다.
"Redis — 직접 구현해보세요."
외부 Redis 라이브러리를 import 해서 쓰는 게 아니라, Redis가 제공하는 기능들을 우리가 직접 처음부터 만들어야 한다는 의미였다. TCP 서버, 프로토콜 파싱, 인메모리 자료구조, TTL, 동시성 제어까지.
그런데 우리 팀은 여기서 한 가지 질문을 더 했다.
"우리가 만든 기능을 어떻게 하면
자연스럽게, 실제처럼 시연할 수 있을까?"
TTL이 있다고 말하는 것과, TTL이 터지는 순간을 화면에서 직접 보여주는 것은 다르다. LOCK이 있다고 설명하는 것과, 동시에 여러 사람이 같은 좌석을 누르는 상황에서 단 한 명만 성공하는 걸 눈으로 보여주는 것은 다르다.
그래서 우리가 선택한 것이 티케팅 서비스였다. 대기열, 좌석 선점, 예약 만료, 초과 판매 방지 — Redis의 핵심 기능들이 가장 드라마틱하게 드러나는 도메인이었기 때문이다.
팀 프로젝트: Mini Redis + 티케팅 데모
팀은 4명이었다. 각자 독립적으로 구현체를 만든 뒤 Codex를 통해 병합(Merge)하는 전략을 택했다. 내 브랜치(ChoiHyunJin)가 최종 팀 결과물(main2)의 코어 엔진 기반이 됐다.
티케팅 웹 페이지
HTTP ↔ TCP 변환
TCP + HashTable
흥미로운 설계 결정이 하나 있었다. UI는 HTTP(REST API)로 통신하지만, 핵심 엔진은 TCP(RESP 프로토콜)로만 통신한다. 이 둘을 연결하기 위한 Bridge API(client.py)를 중간에 두는 구조였다. JSON HTTP 요청이 들어오면 RESP 명령으로 변환해 server.py에 전달하는 방식.
내가 만든 것: PyMiniRedis v1.4
— Python 표준 라이브러리만 사용한 TCP Redis 구현체
내 브랜치에서 만든 PyMiniRedis는 단순히 Redis를 흉내낸 수준이 아니었다. 실제로 redis-cli를 그대로 붙여도 동작하도록 RESP 프로토콜을 지원하고, 30개 이상의 커맨드를 구현했다.
🏗️ 핵심 결정 1 — 커스텀 해시 테이블
Redis가 내부적으로 해시 테이블을 쓴다면, 우리도 직접 구현해보자는 생각으로 Python 내장 자료구조에 의존하지 않고 처음부터 만들었다. 직접 구현해보니 배울 게 정말 많았다.
단순한 Key-Value 저장을 넘어서, 각 엔트리에 만료 시각과 무효화 상태를 함께 담았다:
Entry {
data_type: str # string | hash | set | zset
value: Any
expires_at: float | None
invalidated: bool
invalidation_reason: str | None
invalidated_at: float | None
}
📡 핵심 결정 2 — RESP 프로토콜 구현
실제 Redis와 호환되는 RESP(REdis Serialization Protocol)를 지원하도록 만들었다. 이 덕분에 실제 redis-cli를 그대로 붙이거나, 인라인 텍스트 명령을 그냥 쳐도 둘 다 동작한다.
# 인라인 텍스트 방식 (직관적)
127.0.0.1:6379> SET hello world
+OK
127.0.0.1:6379> GET hello
$world
# RESP Array 방식 (실제 redis-cli 호환)
*1\r\n$4\r\nPING\r\n → +PONG
응답 포맷도 RESP 스펙을 따른다. +는 성공 마커, $는 문자열, :는 정수, -ERR은 에러.
📋 구현한 커맨드 전체 목록
⏱️ 핵심 결정 3 — Expiration & Invalidation 이중 전략
일반적인 TTL 만료 외에, Invalidation(무효화)이라는 별도 개념을 추가했다. 두 개념은 다르다:
- 시간이 지나면 자동 만료
- Lazy Check + Background Sweeper
- 만료 후 즉시 접근 불가
- 삭제가 아니라 논리적 무효화
- Grace Period 동안 메모리 잔존
- 무효화 이유(reason) 함께 저장
또한 EXPIREJITTER라는 커맨드도 직접 추가했다. TTL에 무작위 jitter를 붙여 캐시 만료가 한꺼번에 몰리는 Thundering Herd 문제를 완화하는 목적이다.
🔒 핵심 결정 4 — 동시성 제어
멀티 클라이언트 TCP 서버에서 동시성을 다루는 것이 가장 까다로운 부분이었다. 크게 세 가지 레이어로 동시성을 제어했다:
모든 스토어 읽기/쓰기는 threading.RLock으로 보호된다. INCR/DECR 같은 카운터 연산의 원자성이 여기서 보장된다.
특정 리소스(예: 좌석 번호)에 대해 소유자 기반 뮤텍스를 지원한다. TTL이 있어서 데드락 걱정 없이 사용할 수 있다.
API 남용 방지용 고정 윈도우 레이트 리미터. 응답에 남은 횟수와 윈도우 리셋 시간을 함께 반환한다.
🎫 Redis 기능을 실제로 보여주다 — 티케팅 서비스
PyMiniRedis 위에 실제 티케팅 서비스를 구현했다. Page A(티케팅 페이지), Page B(대기실), /ops(관리자 페이지) 세 화면으로 구성된다. 각 커맨드가 어떤 역할을 하는지를 눈으로 볼 수 있게 하는 게 핵심이었다.
SISMEMBER 중복 체크 → ZADD waiting-room 대기열 등록ZRANK로 현재 순위 조회 → ZPOPMIN으로 입장 처리LOCK seat:X → DECRBY stock → HSET reservation → EXPIREUNLOCK → INCRBY stock → DEL reservation🧪 테스트 55개 & 성능 측정
기능별로 테스트 파일을 철저하게 분리했다. 단순히 동작 여부 확인이 아니라 동시성 테스트(test_concurrency.py), 재시작 복구 테스트(test_persistence.py)까지 포함했다.
* Sorted Set ZADD/ZRANK은 I/O 없이 O(log N)으로 대기열 처리 → DB 풀링 전략 대비 10~20배 이상 차이
팀 병합 — 4개 브랜치를 하나로
4명이 각자 다른 방향으로 구현체를 만든 뒤 Codex를 통해 병합하는 과정이 쉽지 않았다. 팀 최종본(main2 브랜치)에는 내 PyMiniRedis 코어 엔진 위에 다른 팀원들의 UI와 Bridge API가 얹혀지는 구조가 됐다.
특히 기억에 남는 건, 문서가 실제로 팀의 소통 도구가 됐다는 점이다. 내가 미리 작성해둔 SPEC.md가 "이 커맨드는 이렇게 동작해야 해"의 기준이 됐고, 병합 과정에서 충돌이 생겼을 때 판단 근거가 됐다.
가장 어려웠던 기술적 챌린지 3가지
구현하면서 "이건 그냥 넘길 수 없다"고 느꼈던 세 가지 순간들
동시성 제어 — 단순한 INCR이 왜 위험한가
처음엔 단순하게 생각했다. 티켓 수량을 줄이는 건 그냥 DECR이면 되지 않을까? 하지만 멀티스레드 환경에서는 그게 아니었다.
# 동시에 100명이 DECR을 시도할 때
Thread-1: GET stock → 1 # 재고 읽음
Thread-2: GET stock → 1 # 동시에 재고 읽음
Thread-1: SET stock 0 # 감소
Thread-2: SET stock 0 # 중복 감소 → 초과 판매!
해결책은 전역 threading.RLock으로 스토어 전체를 감싸는 것이었다. INCR/DECR 같은 read-modify-write 연산은 반드시 하나의 임계 구역 안에서 원자적으로 실행되어야 했다. 거기서 한 발 더 나아가 LOCK/UNLOCK 커맨드를 직접 구현해 애플리케이션 레벨 뮤텍스를 만들었다. 덕분에 티켓 예약 시퀀스 전체를 하나의 임계 구역으로 묶을 수 있었다.
💡 핵심 인사이트
단일 스레드인 실제 Redis가 "원자성"을 보장하는 이유를 이 때 비로소 이해했다. 싱글 스레드 설계 자체가 락 없이 원자성을 구현하는 방법이었다.
TTL 만료 & Thundering Herd — 캐시가 동시에 죽으면 생기는 일
EXPIRE를 구현하고 나서 새로운 문제를 발견했다. 동일한 TTL로 설정된 다수의 키가 같은 시각에 일제히 만료되면, 그 순간 모든 클라이언트가 동시에 DB로 몰리는 썬더링 허드(Thundering Herd) 현상이 발생한다.
# EXPIREJITTER: TTL에 랜덤 지터 추가
EXPIREJITTER key 3600 300
# → 실제 TTL = 3600 + random(-300, +300)
# → 키들이 분산된 시간대에 만료됨
# INVALIDATE: 소프트 삭제 + 이유 기록
INVALIDATE session:user-a "logout"
해결책으로 EXPIREJITTER 커맨드를 만들었다. 기본 TTL에 ±랜덤 값을 더해 만료 시각을 의도적으로 분산시키는 것이다. 추가로 INVALIDATE 커맨드로 즉시 삭제가 아닌 "무효화" 처리를 구현해 이유를 기록하고 Grace Period를 줄 수 있게 했다.
💡 핵심 인사이트
대형 서비스에서 EXPIREJITTER가 기본값으로 쓰이는 이유가 있었다. 캐시 만료를 "정확한 시각"이 아닌 "분산된 시간대"로 설계하는 것이 시스템 안정성의 핵심이었다.
RESP 프로토콜 직접 파싱 — 바이트 스트림을 명령어로
Redis의 클라이언트-서버 통신은 RESP(Redis Serialization Protocol)로 이루어진다. 이 프로토콜을 직접 파싱하는 것이 예상보다 훨씬 까다로웠다. TCP 소켓에서 들어오는 건 그냥 바이트 스트림이라, 명령어 경계를 직접 찾아야 했다.
# RESP Array 포맷 — SET hello world
*3
# 인자 3개
$3
# 첫 번째 인자 3바이트
SET
$5
# 두 번째 인자 5바이트
hello
$5
# 세 번째 인자 5바이트
world
# 응답 포맷
+OK
# 단순 문자열
:42
# 정수
$5
# 벌크 문자열
hello
-ERR msg
# 에러
RESP Array(*N)와 인라인 텍스트 두 가지를 모두 지원해야 했다. 특히 바이너리 세이프(binary-safe) 처리를 위해 길이를 먼저 읽고 정확히 그만큼만 바이트를 읽는 방식으로 파서를 구현했다. 단순해 보이지만, 스트림이 중간에 끊기는 경우나 여러 명령어가 하나의 패킷에 붙어오는 경우를 모두 처리해야 해서 디버깅에 많은 시간이 걸렸다.
💡 핵심 인사이트
프로토콜이란 "약속"이고, 그 약속을 직접 구현하면 왜 그런 형식이 나왔는지 이해된다. 길이를 먼저 보내는 방식이 왜 텍스트 구분자 방식보다 안전한지를 직접 부딪혀 깨달았다.
배운 것들 — 직접 만들어야만 보이는 것들
"Redis를 구현하라"는 과제는 명령어만 동작하면 된다. 그런데 우리는 "이걸 어떻게 보여줄 수 있지?"라는 질문 하나를 더 했고, 그게 티케팅 서비스로 이어졌다. 질문 하나가 프로젝트의 결을 완전히 바꿨다.
직접 djb2 해시 함수 짜고, separate chaining으로 충돌 처리하고, 버킷 크기 설정하면서 "아, 이래서 O(1)이구나"를 몸으로 익히게 됐다. 배운 것과 이해한 것은 다르다.
단순히 INCR 하나도 멀티스레드에서 원자성을 보장하지 않으면 카운터가 튄다. RLock 걸고, 테스트 짜고, 실제로 레이스 컨디션이 터지는 걸 보면서야 "락이 왜 필요한가"를 진짜로 이해했다.
티케팅 대기열에 RDBMS를 쓰면 왜 느린가? DB Lock, Full Scan, I/O 레이턴시. Sorted Set(ZSet)으로 대기열을 구현하면 왜 빠른가? O(log N), 메모리, I/O 없음. 직접 두 가지를 모두 구현해보고 벤치마크하니 차이가 눈에 보였다.
SPEC.md를 먼저 쓰고 구현하는 것이 처음엔 느린 것처럼 보였다. 근데 팀 병합 단계에서 "이 커맨드는 이렇게 동작해야 해"를 SPEC.md 한 장으로 설명할 수 있었다. 문서가 있으면 토론이 짧아진다.
55개 테스트가 통과하는 상태에서 리팩토링하는 것은 두렵지 않다. 테스트가 없으면 코드 고치는 게 두렵다. 이 프로젝트에서 처음으로 "테스트를 짜는 것이 더 빠른 개발이다"를 체감했다.
발표 이후 — 피드백이 와 닿았던 순간
발표 후 받은 피드백과, 그때서야 진짜로 이해한 것들
"이번 주차 핵심 키워드는 캐시였다."
발표가 끝나고 강사님이 하신 말씀이 머릿속에 박혔다. 우리가 만든 건 Mini Redis였는데, 결국 그게 캐시의 본질이었다. TTL, 만료, 무효화, 메모리 자동 관리 — 전부 "언제까지 이 데이터를 살려둘 것인가"에 대한 이야기였고, 그게 캐시가 하는 일이었다.
📊 왜 Redis인가 — 메모리 관리 측면에서의 비교
| 항목 | 일반 서버 (RDBMS) | Redis (인메모리) |
|---|---|---|
| 삭제 방식 | 직접 삭제 (수동 쿼리) | 자동 (TTL 기반 만료) |
| 메모리 관리 | 수동 (배치/스케줄링) | 자동 (Background Sweeper) |
| 부하 | 큼 (쿼리, 디스크 I/O) | 매우 적음 (인메모리) |
| 대기열(Queue) | 전체 스캔 O(N), 락 경합 | ZSet O(log N), 락 없음 |
| 무효화 | 별도 플래그 컬럼 필요 | INVALIDATE + Grace Period |
핵심 요약 — Redis의 스케줄링 삭제(TTL Sweeper)와 무효화(INVALIDATE)는 메모리 관리 측면에서 일반 서버 대비 압도적으로 유리하다. 티켓팅처럼 임시 세션·대기열·좌석 홀드가 중요한 서비스에서 Redis가 선택받는 이유가 바로 여기에 있다.
🙋 발표 Q&A — 준비했던 답변들
'개발 > 프로젝트' 카테고리의 다른 글
| 크래프트 정글 × 바이브 프로젝트Mini SQL을 두 번 만들고 나서야보인 것들 (1) | 2026.04.16 |
|---|---|
| 크래프톤 정글 × 바이브 프로젝트Mini SQL을 두 번 만들고 나서야보인 것들 (1) | 2026.04.08 |
| 크래프톤 정글 × 수요코딩회Custom React 구현기 2편 — 과제 제약이 가르쳐준 React의 설계 원리 (0) | 2026.04.02 |
| 크래프톤 정글 × 수요코딩회"React를 구현하라" (0) | 2026.03.26 |
| 바이브코딩으로 하루 만에 팀 프로젝트 완성하기 — Clean Email 회고 (0) | 2026.03.12 |