MSA로 선착순 티켓팅 시스템 만들기: 전체 개요와 진화 로드맵
콘서트 티켓팅을 MSA로 학습하기 위한 사이드 프로젝트 노트. 도착지 다이어그램, 핵심 패턴 7개, 1→5단계 진화 순서, 그리고 '동작하는 최소 → 측정 → 한계 → 진화' 메타 원칙까지 정리했다.
MSA로 선착순 티켓팅 시스템 만들기: 전체 개요
다이어그램을 그대로 베끼지 않는다. "동작하는 최소 → 측정 → 한계 → 진화" 사이클로, 한 부품씩 만들면서 "왜 이게 거기 있는가"를 직접 체감하는 게 목적이다.
들어가며
이 글은 내가 진행 중인 사이드 프로젝트 — 선착순 예매(티켓팅) 시스템을 MSA로 만들기 — 의 전체 개요다. 시리즈의 첫 글이며, 도착지 다이어그램·핵심 패턴·진화 순서·메타 학습 원칙을 한 번에 잡아둔다.
후속 글들에서는 각 결정의 근거(왜 MSA? 왜 CQRS? 왜 같은 DB로 시작? 왜 Bulkhead?)를 따로 깊게 다룬다. 이 글은 "지도"이고, 나머지가 "골목길"이다.
이 프로젝트는 주니어와 토론하기 위해 만든 학습용 노트에서 출발했다. 그래서 모든 결정에 "왜 이 옵션을 안 골랐는가"의 트레이드오프가 따라온다.
1. 우리가 만드는 것
선착순 예매(티켓팅) 시스템. 콘서트 티켓 예매 같은 도메인이다.
대규모 트래픽이 동시에 몰리고(수만~수십만 동접), 좌석은 한정되어 있어 정확히 한 사람만 한 좌석을 가져가야 한다. 동시성 + throughput + 정확성 셋이 다 어려운 도메인이다.
학습 목표는 두 가지다.
- 인프라부터 직접 구현 — Redis, Kafka 같은 핵심 부품을 외부 매니지드 서비스 대신 Kotlin/Netty로 직접 짜본 뒤 그 위에 도메인을 얹는다. 동작 원리를 체득하는 게 목표다
- 점진적 진화 — 처음부터 다이어그램 통째로 구현하지 않는다. "동작하는 최소 → 측정 → 한계 발견 → 진화" 사이클을 돈다
2. 도착지 다이어그램 (참고만)
┌────────────────────┐
│ Client (Browser) │
│ polling 5s → 1s │
└─────────┬──────────┘
│
┌─────────▼──────────┐
│ API Gateway │
│ URL routing │
└─────────┬──────────┘
┌─────────────┼──────────────────┐
│ │ │
Queue Server Group Booking Server Group
(자원 격리) (자원 격리)
│ │
┌───────▼───────┐ ┌────────▼───────┐
│ Queue API × N │ │ Booking API × N│
│ UUID·JWT·순번 │ │ 토큰검증·좌석·결제│
└───────┬───────┘ └────────┬───────┘
│ │
┌───────▼──────────┐ ┌───────▼──────────┐
│ 대기열 Redis │ │ 잔여좌석 Redis │
│ ZSET [UUID:ts] │ │ Seat Counter Lua │
│ Active User Set │ │ Seat Lock SETNX │
└──────────────────┘ └────────┬─────────┘
│
┌─────────────▼───────┐
│ Message Queue │ ← Kafka
│ (events) │
└─────────┬───────────┘
│ consume
┌───────▼───────┐
│ Worker │
│ (MQ → DB) │
└───────┬───────┘
│ INSERT
┌───────▼───────┐
│ RDB │ ← Postgres
│ Primary/Replica
└───────────────┘
이건 최종 도착지다. 우리는 한 부품씩 만들어가며 "왜 이게 거기 있는가"를 직접 체감하는 게 목적이다.
다이어그램을 그대로 베끼지 않는 이유는 단순하다. 그렇게 하면 부품이 왜 필요한지 모르고 만들게 된다. Worker를 먼저 만들면 "API 폭주가 DB 폭주로 직결되는" 고통을 겪지 못한 채 비동기를 도입하게 되고, 그 결정의 무게를 알 수 없다. 한계가 드러나야 부품의 존재 이유가 보인다.
3. 핵심 패턴 한 줄씩
다이어그램에 박혀 있는 패턴 7개를 한 줄로 요약하면 이렇다.
| 패턴 | 어디 적용 | 본질 |
|---|---|---|
| CQRS | command service / query service 분리 | 읽기/쓰기 스케일링 곡선이 다르니 모델·서비스도 따로 |
| Bulkhead | Queue tier ↔ Booking tier 자원 격리 | 한 쪽이 무너져도 다른 쪽이 살아있게 |
| Polling 가변 주기 | Client 5s → 1s | 순번 가까울수록 빨라짐. 서버 부담 vs UX 균형 |
| Sorted Set 순번 관리 | 대기열 Redis (ZADD/ZRANK/ZPOPMIN) | "순서"를 데이터 구조 자체에 위임 |
| 분산락 (SETNX) + Lua atomic | 잔여좌석 Redis | DB락 대신 인메모리, 처리량 ↑ |
| MQ 비동기 영속화 | Booking API → Kafka → Worker → DB | API 응답 빠르게, DB 부담 완충 |
| DB Primary/Replica | command writes Primary, query reads Replica | 조회 부하 분산. replica lag 트레이드오프 |
각 패턴이 왜 거기 있는지는 후속 글에서 결정별로 다룬다.
4. 지금까지 진행 상황
완성된 것
- MyRedis — Netty + RESP 직접 구현. 캐시/세션 동작
- MyKafka MVP — 단일 브로커,
PRODUCE/FETCH/CREATE_TOPIC/COMMIT_OFFSET/FETCH_OFFSET5개 ApiKey, sparse offset index, segment rolling, batch all-or-none. 클라이언트 SDK는 아직 없음 - auth-service — Spring Boot 4 + JWT + Postgres + MyRedis token store
- frontend — Next.js, queue/seats/payment/confirmation 페이지
- ticket-command-service — port 8082, 예약 생성/확정/취소. 비관락 +
@Version - ticket-query-service — port 8083, 읽기 전용. Hikari
read-only: true+@Transactional(readOnly = true). validate 모드 - mock-data.sql — Event 1 / Section 3 / Seat 292 시드. 멱등
- 부하 테스트 시나리오 A, B — k6 작성. baseline 측정
아직 미구현
- gateway (뼈대만)
- Queue API (대기열 진입)
- Scheduler (1초마다 ZPOPMIN 100)
- Worker (MQ → DB INSERT)
- MyKafka client SDK
- DB Primary/Replica 분리
다음 한 걸음
부하 테스트에서 cold/warm 분리, 또는 시나리오 C(점진 증가)를 진행할 차례다. 그 결과로 Worker 도입 트리거를 직접 확인하는 게 다음 마일스톤이다.
5. 컴포넌트 매핑표
다이어그램 부품과 실제 구현이 어떻게 대응되는지 표로 정리하면 진행 정도가 한눈에 보인다.
| 다이어그램 부품 | 우리 구현 | 상태 |
|---|---|---|
| API Gateway | gateway/ | 뼈대만 |
| Queue API | (없음) | 미구현 |
| Booking API | ticket-command-service | DONE ★ |
| (다이어그램 외) Read service | ticket-query-service | DONE ★ |
| Scheduler | (없음) | 미구현 |
| 대기열 Redis | MyRedis 공용 | ZSET/SETNX/Lua 지원 확인 필요 |
| 잔여좌석 Redis | MyRedis 공용 | 위와 동일 |
| MQ | MyKafka | MVP DONE, client SDK 없음 |
| Worker | (없음) | 미구현 |
| RDB | Postgres ticket_db | 단일 (replica 미분리) |
6. 진화 순서 (현재 위치)
각 단계는 이전 단계에서 측정된 한계가 드러난 뒤 진행한다. 다음 단계의 도입 시점은 부하 테스트가 결정한다.
[1단계 · 현재] command 동기 DB 쓰기 (비관락)
↓ 부하 테스트로 한계 측정 ← 지금 여기
[2단계] command → MyKafka publish → Worker → DB INSERT (비동기)
↓ "API 폭주에도 안 죽음" 학습
[3단계] DB Primary/Replica → query는 replica
↓ "조회 부하 분산 + replica lag" 학습
[4단계] Queue API + Scheduler 도입 (대기열 throttling)
↓ "예매 진입 자체를 제어" 학습
[5단계] Gateway 완성 (JWT 검증 + 라우팅)
이 순서가 왜 이 순서인지는 후속 글의 §8에서 따로 다룬다. 짧게 말하면, 각 단계가 직전 단계에서 발생한 병목을 해결하도록 설계되어 있어 역순이 거의 불가능하다.
7. 메타 학습 원칙
이 프로젝트 내내 의식적으로 따르는 규칙 5개다. 어떤 결정에 부딪혔을 때 "이 원칙이 어느 쪽을 가리키는가"로 판단의 기준이 된다.
- 학습은 한 번에 한 변수. 새 개념과 새 도구를 동시에 도입하지 않는다
- 동작하는 최소 → 측정 → 한계 → 진화. 다이어그램 그대로 베끼지 않는다
- 공유 모델로 묶지 않는다 (지금은). CQRS의 자유도 보존
- 권한을 미리 잠가둔다. 미래 분리가 쉬워지는 방향
- 인프라 설치는 사용자가 직접. 도구 자동화는 안내까지만
특히 1번은 자주 깨진다. 시나리오 B 측정 중에 JIT warm-up이라는 숨은 변수가 끼어들어 "비관락이 잡혔는데 latency가 줄어드는" 역설적 결과를 본 적이 있다. 사후에 발견했고, 이게 또 좋은 학습거리가 됐다.
마치며: 이 시리즈의 다음 글
이 글은 "지도"다. 다음 글들에서 각 결정의 골목길로 들어간다.
- §1. 왜 MSA인가 — 모놀리스가 아니라
- §2. 왜 CQRS-ish 분리인가 — ticket-command / ticket-query 두 서비스
- §3. 왜 같은 DB로 시작했나 — CQRS면 보통 DB도 갈라야 한다는데
- §4. 왜 Bulkhead — 대기열 ↔ 예매 자원 격리
- §5. 왜 Gateway — 단일 진입점의 가치와 비용
- §6. 왜 직접 인프라를 구현했나 — MyRedis, MyKafka
- §7. 왜 비관락 + @Version 둘 다
- §8. 진화 순서가 왜 이 순서인가
각 §은 "상황 / 옵션 / 선택 / 왜 / 트레이드오프 / 토론거리" 5단 구조로 정리한다. "왜 그 옵션은 안 택했어요?"에 답할 수 있도록.
MSA는 만드는 것보다 운영하는 게 어렵다. 그리고 운영 복잡도를 모르고 도입한 MSA는 모놀리스보다 못한 분산 모놀리식으로 빠지기 쉽다. 그래서 이 프로젝트는 단계마다 "여기서 어디가 부서지는가"를 직접 보고 다음 단계로 간다. 다이어그램을 보고 처음부터 베끼지 않는 이유가 그것이다.