개발/프로젝트

크래프톤 정글 × 수요코딩회 - 자체제작 Redis, 타겟팅 서비스

cedis 2026. 3. 19. 14:37

 

크래프톤 정글 × 수요코딩회

Redis를 직접 만들어보며
배운 것들

"Redis를 구현하라" — 단순한 과제를 티케팅 서비스로 확장한
4인 팀 프로젝트 회고

👥 4인 팀 🐍 Python Only 📦 PyMiniRedis 🎫 Ticketing Service

Prologue

Redis를 쓰면서 한 번쯤은 생각해봤을 것이다.
"이거 대체 어떻게 동작하는 거야?"

크래프톤 정글에서 수요코딩회 과제로 Redis를 직접 구현하는 팀 프로젝트가 주어졌다. 과제 자체는 간단했다. Redis라는 키워드를 받았고, 그걸 구현해보라는 것. 외부 라이브러리를 가져다 쓰는 게 아니라, Redis가 하는 일을 우리 손으로 직접 만들어보라는 의미였다.

이 글은 그 과정에서 내가 직접 설계하고 구현한 것들, 팀과 병합하면서 배운 것들, 그리고 다음에 가져갈 것들을 정리한 기록이다.

· · ·
🌿

시작은 크래프톤 정글 수요코딩회

크래프톤 정글은 단순한 강의 수강이 아니다. 매주 수요일 진행되는 수요코딩회에서는 실제로 무언가를 만들어야 하는 팀 과제가 주어진다.

이번 과제의 키워드는 딱 하나였다.

"Redis — 직접 구현해보세요."

외부 Redis 라이브러리를 import 해서 쓰는 게 아니라, Redis가 제공하는 기능들을 우리가 직접 처음부터 만들어야 한다는 의미였다. TCP 서버, 프로토콜 파싱, 인메모리 자료구조, TTL, 동시성 제어까지.

그런데 우리 팀은 여기서 한 가지 질문을 더 했다.

"우리가 만든 기능을 어떻게 하면
자연스럽게, 실제처럼 시연할 수 있을까?"

TTL이 있다고 말하는 것과, TTL이 터지는 순간을 화면에서 직접 보여주는 것은 다르다. LOCK이 있다고 설명하는 것과, 동시에 여러 사람이 같은 좌석을 누르는 상황에서 단 한 명만 성공하는 걸 눈으로 보여주는 것은 다르다.

그래서 우리가 선택한 것이 티케팅 서비스였다. 대기열, 좌석 선점, 예약 만료, 초과 판매 방지 — Redis의 핵심 기능들이 가장 드라마틱하게 드러나는 도메인이었기 때문이다.

 
01

팀 프로젝트: Mini Redis + 티케팅 데모

팀은 4명이었다. 각자 독립적으로 구현체를 만든 뒤 Codex를 통해 병합(Merge)하는 전략을 택했다. 내 브랜치(ChoiHyunJin)가 최종 팀 결과물(main2)의 코어 엔진 기반이 됐다.

🖥️
PRESENTATION
HTML/CSS UI
티케팅 웹 페이지
🔌
APPLICATION
Bridge API
HTTP ↔ TCP 변환
⚙️
DATA / CORE
PyMiniRedis
TCP + HashTable

흥미로운 설계 결정이 하나 있었다. UI는 HTTP(REST API)로 통신하지만, 핵심 엔진은 TCP(RESP 프로토콜)로만 통신한다. 이 둘을 연결하기 위한 Bridge API(client.py)를 중간에 두는 구조였다. JSON HTTP 요청이 들어오면 RESP 명령으로 변환해 server.py에 전달하는 방식.

🎯 왜 티케팅 서비스였나
대기열 Sorted Set의 ZADD/ZRANK/ZPOPMIN이 실시간으로 동작하는 걸 화면에서 바로 확인
좌석 선점 LOCK/UNLOCK으로 동시 접근 제어 — 두 명이 같은 좌석을 눌러도 한 명만 성공
예약 만료 TTL로 예약 유효시간 관리 — 10초 후 미결제시 자동 반납
재고 관리 DECR/INCR의 원자성 — 과다 판매(Over-selling) 없이 정확한 재고 차감
 
02

내가 만든 것: PyMiniRedis v1.4

— Python 표준 라이브러리만 사용한 TCP Redis 구현체

내 브랜치에서 만든 PyMiniRedis는 단순히 Redis를 흉내낸 수준이 아니었다. 실제로 redis-cli를 그대로 붙여도 동작하도록 RESP 프로토콜을 지원하고, 30개 이상의 커맨드를 구현했다.

🐍 Python 3 (stdlib only) 🔌 TCP / RESP Protocol 🔒 Threading.RLock 💾 AOF Persistence 🧪 55 Tests

🏗️ 핵심 결정 1 — 커스텀 해시 테이블

Redis가 내부적으로 해시 테이블을 쓴다면, 우리도 직접 구현해보자는 생각으로 Python 내장 자료구조에 의존하지 않고 처음부터 만들었다. 직접 구현해보니 배울 게 정말 많았다.

해시 테이블 설계
해시 함수 djb2-style 문자열 해시
충돌 해결 Separate Chaining (연결 리스트)
초기 버킷 수 16개
시간 복잡도 평균 O(1) SET/GET/DEL

단순한 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은 에러.

📋 구현한 커맨드 전체 목록

STRING / BASIC
PING SET SET NX GET DEL EXISTS QUIT
EXPIRATION / TTL
EXPIRE TTL EXPIREJITTER INVALIDATE
ATOMIC COUNTER
INCR DECR INCRBY DECRBY
CONCURRENCY
LOCK UNLOCK RATECHECK
HASH
HSET HGET HDEL HGETALL
SET / SORTED SET
SADD SISMEMBER SREM SMEMBERS ZADD ZRANK ZRANGE ZREM ZPOPMIN ZCARD

⏱️ 핵심 결정 3 — Expiration & Invalidation 이중 전략

일반적인 TTL 만료 외에, Invalidation(무효화)이라는 별도 개념을 추가했다. 두 개념은 다르다:

EXPIRE / TTL
  • 시간이 지나면 자동 만료
  • Lazy Check + Background Sweeper
  • 만료 후 즉시 접근 불가
INVALIDATE
  • 삭제가 아니라 논리적 무효화
  • Grace Period 동안 메모리 잔존
  • 무효화 이유(reason) 함께 저장

또한 EXPIREJITTER라는 커맨드도 직접 추가했다. TTL에 무작위 jitter를 붙여 캐시 만료가 한꺼번에 몰리는 Thundering Herd 문제를 완화하는 목적이다.

🔒 핵심 결정 4 — 동시성 제어

멀티 클라이언트 TCP 서버에서 동시성을 다루는 것이 가장 까다로운 부분이었다. 크게 세 가지 레이어로 동시성을 제어했다:

1. RLock — 전역 스토어 보호

모든 스토어 읽기/쓰기는 threading.RLock으로 보호된다. INCR/DECR 같은 카운터 연산의 원자성이 여기서 보장된다.

2. LOCK/UNLOCK — 뮤텍스 스타일 리소스 잠금

특정 리소스(예: 좌석 번호)에 대해 소유자 기반 뮤텍스를 지원한다. TTL이 있어서 데드락 걱정 없이 사용할 수 있다.

3. RATECHECK — 고정 윈도우 Rate Limiting

API 남용 방지용 고정 윈도우 레이트 리미터. 응답에 남은 횟수와 윈도우 리셋 시간을 함께 반환한다.

🎫 Redis 기능을 실제로 보여주다 — 티케팅 서비스

PyMiniRedis 위에 실제 티케팅 서비스를 구현했다. Page A(티케팅 페이지), Page B(대기실), /ops(관리자 페이지) 세 화면으로 구성된다. 각 커맨드가 어떤 역할을 하는지를 눈으로 볼 수 있게 하는 게 핵심이었다.

USER FLOW
1
입장 신청
SISMEMBER 중복 체크 → ZADD waiting-room 대기열 등록
2
순서 대기 (Page B)
ZRANK로 현재 순위 조회 → ZPOPMIN으로 입장 처리
3
좌석 예약 (Page A)
LOCK seat:XDECRBY stockHSET reservationEXPIRE
취소
취소 / 타임아웃
UNLOCKINCRBY stockDEL reservation

🧪 테스트 55개 & 성능 측정

55
테스트 케이스
19.5K
req/s (INCR)
13.5K
req/s (SET/GET)
16개
테스트 파일

기능별로 테스트 파일을 철저하게 분리했다. 단순히 동작 여부 확인이 아니라 동시성 테스트(test_concurrency.py), 재시작 복구 테스트(test_persistence.py)까지 포함했다.

RDBMS vs PyMiniRedis (동시 100 유저 시뮬레이션)
MySQL + Node.js
 
~500 TPS
PyMiniRedis
 
~13,500 TPS

* Sorted Set ZADD/ZRANK은 I/O 없이 O(log N)으로 대기열 처리 → DB 풀링 전략 대비 10~20배 이상 차이

 
03

팀 병합 — 4개 브랜치를 하나로

4명이 각자 다른 방향으로 구현체를 만든 뒤 Codex를 통해 병합하는 과정이 쉽지 않았다. 팀 최종본(main2 브랜치)에는 내 PyMiniRedis 코어 엔진 위에 다른 팀원들의 UI와 Bridge API가 얹혀지는 구조가 됐다.

MERGE STRATEGY
기준 선정 각 브랜치의 핵심 기여물을 먼저 파악하고, 중복 구현은 가장 완성도 높은 것 기준으로 통합
Bridge API HTTP ↔ TCP 변환 레이어를 중간에 두어 UI 팀과 엔진 팀의 인터페이스를 분리
문서화 SPEC.md, PROJECT_PLAN.md, REPORT.md 등 내가 작성한 문서들이 합의 기준이 됨

특히 기억에 남는 건, 문서가 실제로 팀의 소통 도구가 됐다는 점이다. 내가 미리 작성해둔 SPEC.md가 "이 커맨드는 이렇게 동작해야 해"의 기준이 됐고, 병합 과정에서 충돌이 생겼을 때 판단 근거가 됐다.

가장 어려웠던 기술적 챌린지 3가지

구현하면서 "이건 그냥 넘길 수 없다"고 느꼈던 세 가지 순간들

🔒
CHALLENGE 01

동시성 제어 — 단순한 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가 "원자성"을 보장하는 이유를 이 때 비로소 이해했다. 싱글 스레드 설계 자체가 락 없이 원자성을 구현하는 방법이었다.

⏱️
CHALLENGE 02

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가 기본값으로 쓰이는 이유가 있었다. 캐시 만료를 "정확한 시각"이 아닌 "분산된 시간대"로 설계하는 것이 시스템 안정성의 핵심이었다.

🔌
CHALLENGE 03

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) 처리를 위해 길이를 먼저 읽고 정확히 그만큼만 바이트를 읽는 방식으로 파서를 구현했다. 단순해 보이지만, 스트림이 중간에 끊기는 경우나 여러 명령어가 하나의 패킷에 붙어오는 경우를 모두 처리해야 해서 디버깅에 많은 시간이 걸렸다.

💡 핵심 인사이트

프로토콜이란 "약속"이고, 그 약속을 직접 구현하면 왜 그런 형식이 나왔는지 이해된다. 길이를 먼저 보내는 방식이 왜 텍스트 구분자 방식보다 안전한지를 직접 부딪혀 깨달았다.

 
05

배운 것들 — 직접 만들어야만 보이는 것들

LESSON 01
과제 그대로 하면 과제로 끝난다

"Redis를 구현하라"는 과제는 명령어만 동작하면 된다. 그런데 우리는 "이걸 어떻게 보여줄 수 있지?"라는 질문 하나를 더 했고, 그게 티케팅 서비스로 이어졌다. 질문 하나가 프로젝트의 결을 완전히 바꿨다.

LESSON 02
O(1)이 당연하지 않다

직접 djb2 해시 함수 짜고, separate chaining으로 충돌 처리하고, 버킷 크기 설정하면서 "아, 이래서 O(1)이구나"를 몸으로 익히게 됐다. 배운 것과 이해한 것은 다르다.

LESSON 03
동시성은 언제나 생각보다 어렵다

단순히 INCR 하나도 멀티스레드에서 원자성을 보장하지 않으면 카운터가 튄다. RLock 걸고, 테스트 짜고, 실제로 레이스 컨디션이 터지는 걸 보면서야 "락이 왜 필요한가"를 진짜로 이해했다.

LESSON 04
자료구조 선택이 곧 시스템 설계다

티케팅 대기열에 RDBMS를 쓰면 왜 느린가? DB Lock, Full Scan, I/O 레이턴시. Sorted Set(ZSet)으로 대기열을 구현하면 왜 빠른가? O(log N), 메모리, I/O 없음. 직접 두 가지를 모두 구현해보고 벤치마크하니 차이가 눈에 보였다.

LESSON 05
문서가 코드보다 먼저다

SPEC.md를 먼저 쓰고 구현하는 것이 처음엔 느린 것처럼 보였다. 근데 팀 병합 단계에서 "이 커맨드는 이렇게 동작해야 해"를 SPEC.md 한 장으로 설명할 수 있었다. 문서가 있으면 토론이 짧아진다.

LESSON 06
테스트는 두려움을 없애준다

55개 테스트가 통과하는 상태에서 리팩토링하는 것은 두렵지 않다. 테스트가 없으면 코드 고치는 게 두렵다. 이 프로젝트에서 처음으로 "테스트를 짜는 것이 더 빠른 개발이다"를 체감했다.

 
🎤

발표 이후 — 피드백이 와 닿았던 순간

발표 후 받은 피드백과, 그때서야 진짜로 이해한 것들

💬
INSTRUCTOR FEEDBACK

"이번 주차 핵심 키워드는 캐시였다."

발표가 끝나고 강사님이 하신 말씀이 머릿속에 박혔다. 우리가 만든 건 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 — 준비했던 답변들

Q.해시테이블은 어떻게 작동하나요? dict를 직접 구현한다면?

해시테이블은 세 단계로 작동합니다.

1

해시 함수 (Hash Function)

"user:1" 같은 키를 입력받아 특정 숫자(해시 값)를 생성. 저는 djb2 알고리즘 사용 — hash = 33 * hash + ord(c)

2

인덱스 생성

해시 값을 버킷 수(16)로 나눈 나머지(%) → 실제 저장될 배열 인덱스 결정

3

저장 및 조회 — O(1)

해당 버킷에 바로 접근. 충돌 시 Separate Chaining(연결 리스트)으로 처리. 데이터가 아무리 많아도 평균 한 번에 찾기 가능

# 직접 구현한 HashTable (dict 없이, List만으로)
class HashTable:
    def __init__(self, capacity=16):
        self.buckets = [[] for _ in range(capacity)]  # 빈 버킷 16개
        self.capacity = capacity

    def _hash(self, key):                            # djb2
        h = 5381
        for c in key:
            h = (h * 33 + ord(c)) & 0xFFFFFFFF
        return h % self.capacity

    def set(self, key, value):
        idx = self._hash(key)
        for node in self.buckets[idx]:
            if node.key == key:
                node.value = value; return      # 업데이트
        self.buckets[idx].append(_Node(key, value)) # 삽입

Q.값을 그냥 문자열이 아니라 Entry 객체로 감싼 이유가 뭔가요?

단순 문자열만 저장하면 TTL, 무효화, 스냅샷 복구에 필요한 정보를 어디에도 붙일 수 없습니다. Entry 객체는 값 자체 + 그 값에 대한 메타데이터를 하나로 묶기 위한 설계였습니다.

@dataclass
class Entry:
    data_type:    str          # "string" | "hash" | "set" | "zset"
    value:        Any          # 실제 값
    expires_at:   float | None # TTL 만료 시각 (unix timestamp)
    invalidated:  bool = False # INVALIDATE 여부
    invalidated_at: float|None = None
    invalidation_reason: str|None = None  # 무효화 이유 기록

⏱ TTL 지원

expires_at으로 만료 시각 추적 → Background Sweeper가 자동 정리

🚫 무효화

즉시 삭제 없이 invalidated=True + 이유 기록 → Grace Period 가능

💾 AOF 복구

Entry 전체를 JSON 직렬화 → 재시작 시 완전한 상태 복원

🎯핵심 질문 — 티켓팅 서비스에서 왜 Redis를 써야 하는가?

과제로 주어졌지만, 직접 구현하고 나서야 왜 Redis여야 하는지가 보였다. 티켓팅은 본질적으로 짧은 시간에 수천 명이 동일한 자원(좌석)에 접근하는 구조다.

인메모리 → 마이크로초 단위 응답

디스크를 거치지 않아 대기열 조회(ZRANK)·좌석 확인(HGET)이 수십 마이크로초 안에 끝난다. RDBMS는 디스크 I/O만으로도 수 밀리초.

🗂

ZSet = 순서 보장 대기열 (O log N)

ZADD로 입장 → ZRANK로 내 순서 조회 → ZPOPMIN으로 1등 추출. RDBMS에서는 SELECT FOR UPDATE + 풀스캔으로 같은 기능을 구현하면 락 경합과 I/O가 폭발한다.

TTL = 자동 좌석 홀드 해제

예약 후 10초 안에 결제 미완료 시 EXPIRE reservation:xxx 10 하나로 자동 반납. RDBMS라면 별도 스케줄러 + 배치 잡이 필요하다.

🔒

LOCK/UNLOCK = 초과 판매 방지

좌석 선점 구간을 단일 뮤텍스로 감싸 Race Condition을 원천 차단. RDBMS의 SELECT FOR UPDATE보다 훨씬 가볍고 빠르다.

💬 강사님 피드백의 핵심

"Redis를 왜 쓰는지 알고 써야 한다." — 도구를 쓸 때 어떤 문제를 해결하는 도구인지를 알고 쓰는 것과 그냥 쓰는 것은 다르다. 이번 프로젝트에서 Redis를 직접 만들면서, 그 도구가 어떤 문제를 왜 그런 방식으로 해결하는지를 코드로 이해한 것이 가장 큰 수확이었다.

Redis를 쓰던 사람이
Redis를 만든 사람이 됐다.

완성도 면에서 진짜 Redis에 비하면 한없이 부족하다. 하지만 이제 Redis 소스코드를 열면 낯설지 않다. 구조가 보이고, 왜 그런 선택을 했는지가 읽힌다.
그게 이 프로젝트에서 얻은 가장 큰 것이다.