Problem
Problem
콘서트 예매는 동일 좌석 중복 예약, 대기열 입장 제어, 미결제 좌석 만료 복구가 한 흐름에서 맞물리는 도메인입니다.
Decision
Decision
한 가지 락 전략을 정답으로 두지 않고 비관적 락, 낙관적 락, Redis 분산 락을 같은 시나리오에서 비교해 경합 패턴별 비용을 확인했습니다.
Build
Implementation
- Redis Sorted Set과 SSE로 대기 순번을 전달하고, 입장 토큰은 1회 사용과 TTL을 전제로 설계했습니다.
- 예약 구간은 Redis 재고 차감, Redisson MultiLock, DB 좌석 검증을 함께 두어 분산 환경의 중복 진입을 줄였습니다.
- 결제 완료, 취소, 만료는 Outbox table과 relay scheduler로 Kafka 발행 실패 경계를 줄이고, 실패 이벤트는 retry/backoff 후 DEAD 또는 DLT manual replay로 격리했습니다.
락 선택은 구현 취향이 아니라 경합 위치와 실패 경계의 문제이며, 예매 도메인에서는 처리량과 복구 흐름을 함께 설명할 수 있어야 한다는 점을 정리했습니다.
Proof
Verification
docs/PERF_RESULT.md의 k6 시나리오에서 Hot Seat overselling 0건, Distributed Reservation 50/50 성공, Mixed Load 1,005 RPS 결과를 확인했습니다.
local Docker Compose, k6 200 VU 혼합 트래픽
Hot seat contention에서 초과 예약 없이 1건만 성공
중복 요청, 결제 idempotency, Outbox retry와 DLT replay 검증
1,005 RPS
Mixed Load
- Scenario
- 읽기와 예매 요청이 섞인 콘서트 예매 트래픽
- Method
- k6 Scenario C에서 Redis 분산 락 기준으로 측정
- Result
- local Docker Compose, k6 200 VU, 45초 기준으로 읽기 p95 7ms, 쓰기 p95 6ms와 함께 측정했습니다.
성공 1건 / overselling 0건
Hot Seat
- Scenario
- 동일 좌석에 100명 동시 예매 요청
- Method
- 비관적 락, 낙관적 락, Redis 분산 락을 동일 조건으로 비교
- Result
- 세 전략 모두 정확히 1건만 성공하고 초과 예약은 발생하지 않음
정책 검증
Idempotency / Outbox / DLT
- Scenario
- 중복 요청, 결제/취소/만료 race, DB commit 이후 Kafka publish 실패
- Method
- ReservationIdempotencyIntegrationTest, PaymentIdempotencyIntegrationTest, OutboxIntegrationTest, KafkaDltReplayIntegrationTest로 검증
- Result
- Idempotency-Key, Outbox retry/backoff/DEAD, ROLE_ADMIN 기반 DLT manual replay utility를 확인했습니다.
D/E/F pending
Pending k6
- Scenario
- Payment Expiration Race, Duplicate Request / Idempotency, Queue Token Abuse k6 시나리오
- Method
- script added, 정식 부하 결과 전
- Result
- PERF_RESULT.md에 Pending으로 분리되어 있어 측정 수치로 사용하지 않습니다.
Boundaries
Trade-offs & Limitations
Trade-offs
- 낙관적 락은 충돌이 적은 구간에서는 단순하지만, schedule 버전 충돌이 많은 좌석 예매에서는 성공률이 낮았습니다.
- Redis 분산 락은 처리량이 좋지만 Redis 장애와 락 TTL 만료 경계 처리를 별도로 설계해야 했습니다.
Limitations
- 결제 PG 연동은 모의 결제 흐름으로 검증했습니다.
- 성능 수치는 로컬 Docker Compose와 M4 개발 장비 기준입니다.
- DLT replay는 운영용 자동 복구가 아니라 ROLE_ADMIN 기반 manual utility입니다.
아키텍처
전체 아키텍처▼
architecture
전체 아키텍처
Mermaid 원본을 파싱해 텍스트는 DOM으로 렌더링합니다.
Client
사용자 진입과 실시간 상태 확인
Client Browser
Application
API, 도메인 로직, 워커 처리
App Instance 1
App
App Instance 2
App
Data / Messaging
상태 저장, 캐시, 이벤트 전달
Redis
PostgreSQL
Kafka KRaft
핵심 연결 흐름
- 1. Client Browser → App Instance 1 · REST API + SSE
- 2. Client Browser → App Instance 2 · REST API + SSE
- 3. App Instance 1 → Redis · Redisson Lock
- 4. App Instance 2 → Redis · Redisson Lock
- 5. App Instance 1 → PostgreSQL
- 6. App Instance 2 → PostgreSQL
- 7. App Instance 1 → Kafka KRaft
- 8. App Instance 2 → Kafka KRaft
- 9. Redis → App Instance 1 · Queue / Hold / Token
- 10. Redis → App Instance 2 · Queue / Hold / Token
원본 Mermaid 보기
flowchart LR
Client[Client Browser]
subgraph App[Spring Boot Cluster]
App1[App Instance 1]
App2[App Instance 2]
end
Redis[Redis]
PG[(PostgreSQL)]
Kafka[Kafka KRaft]
Client -->|REST API + SSE| App1
Client -->|REST API + SSE| App2
App1 -->|Redisson Lock| Redis
App2 -->|Redisson Lock| Redis
App1 --> PG
App2 --> PG
App1 --> Kafka
App2 --> Kafka
Redis -->|Queue / Hold / Token| App1
Redis -->|Queue / Hold / Token| App2ERD
전체 ERD▼
erd
전체 ERD
Mermaid 원본을 파싱해 텍스트는 DOM으로 렌더링합니다.
users
- bigserial
- id
- PK
- varchar
- UK
- varchar
- password
- varchar
- nickname
- timestamp
- created_at
concerts
- bigserial
- id
- PK
- varchar
- title
- text
- description
- varchar
- venue
- varchar
- artist
- timestamp
- created_at
concert_schedules
- bigserial
- id
- PK
- bigint
- concert_id
- FK
- date
- schedule_date
- time
- start_time
- int
- total_seats
- int
- available_seats
+ 2개 필드
seats
- bigserial
- id
- PK
- bigint
- schedule_id
- FK
- varchar
- section
- int
- row_number
- int
- seat_number
- int
- price
+ 3개 필드
reservations
- bigserial
- id
- PK
- uuid
- reservation_key
- UK
- bigint
- user_id
- FK
- bigint
- schedule_id
- FK
- varchar
- status
- int
- total_amount
+ 2개 필드
reservation_seats
- bigserial
- id
- PK
- bigint
- reservation_id
- FK
- bigint
- seat_id
- FK
payments
- bigserial
- id
- PK
- uuid
- payment_key
- UK
- bigint
- reservation_id
- FK
- int
- amount
- varchar
- status
- timestamp
- created_at
관계 요약
- #1 users ||--o{ reservations · 예매
- #2 concerts ||--o{ concert_schedules · 스케줄
- #3 concert_schedules ||--o{ seats · 좌석
- #4 concert_schedules ||--o{ reservations · 예매
- #5 reservations ||--o{ reservation_seats · 좌석 배정
- #6 seats ||--o{ reservation_seats · 예매됨
- #7 reservations ||--o| payments · 결제
원본 Mermaid 보기
erDiagram
users ||--o{ reservations : "예매"
concerts ||--o{ concert_schedules : "스케줄"
concert_schedules ||--o{ seats : "좌석"
concert_schedules ||--o{ reservations : "예매"
reservations ||--o{ reservation_seats : "좌석 배정"
seats ||--o{ reservation_seats : "예매됨"
reservations ||--o| payments : "결제"
users {
bigserial id PK
varchar email UK
varchar password
varchar nickname
timestamp created_at
}
concerts {
bigserial id PK
varchar title
text description
varchar venue
varchar artist
timestamp created_at
}
concert_schedules {
bigserial id PK
bigint concert_id FK
date schedule_date
time start_time
int total_seats
int available_seats
bigint version
timestamp created_at
}
seats {
bigserial id PK
bigint schedule_id FK
varchar section
int row_number
int seat_number
int price
varchar status
bigint version
timestamp created_at
}
reservations {
bigserial id PK
uuid reservation_key UK
bigint user_id FK
bigint schedule_id FK
varchar status
int total_amount
timestamp expires_at
timestamp created_at
}
reservation_seats {
bigserial id PK
bigint reservation_id FK
bigint seat_id FK
}
payments {
bigserial id PK
uuid payment_key UK
bigint reservation_id FK
int amount
varchar status
timestamp created_at
}시퀀스 다이어그램
대기열 진입▼
sequence
대기열 진입
Mermaid 원본을 파싱해 텍스트는 DOM으로 렌더링합니다.
Participants
- 1요청
Client → QueueController
POST /api/queue/enter
- 2요청
QueueController → QueueService
enter(scheduleId, userId)
- 3처리
QueueService → Redis
ZADD queue:schedule NX
- 4응답
QueueService → Client
대기열 등록
- 5요청
Client → QueueController
GET /api/queue/events
- 6요청
QueueController → QueueService
subscribe()
- 7loop 순번 갱신
control
loop 순번 갱신
조건: loop 순번 갱신
- 8loop 순번 갱신
QueueService → Redis
ZRANK / ZCARD
조건: loop 순번 갱신
+ 4개 단계는 아래 상세 메시지에서 확인할 수 있습니다.
전체 메시지 상세
| Step | From → To | Message | Condition |
|---|---|---|---|
| 1 | Client → QueueController | POST /api/queue/enter | - |
| 2 | QueueController → QueueService | enter(scheduleId, userId) | - |
| 3 | QueueService → Redis | ZADD queue:schedule NX | - |
| 4 | QueueService → Client | 대기열 등록 | - |
| 5 | Client → QueueController | GET /api/queue/events | - |
| 6 | QueueController → QueueService | subscribe() | - |
| 7 | control | loop 순번 갱신 | loop 순번 갱신 |
| 8 | QueueService → Redis | ZRANK / ZCARD | loop 순번 갱신 |
| 9 | QueueService → Client | SSE position update | loop 순번 갱신 |
| 10 | control | alt 100등 이내 | alt 100등 이내 |
| 11 | QueueService → Redis | 입장 토큰 저장(TTL 5분) | alt 100등 이내 |
| 12 | QueueService → Client | SSE ready + token | alt 100등 이내 |
원본 Mermaid 보기
sequenceDiagram
participant C as Client
participant API as QueueController
participant QS as QueueService
participant R as Redis
C->>API: POST /api/queue/enter
API->>QS: enter(scheduleId, userId)
QS->>R: ZADD queue:schedule NX
QS-->>C: 대기열 등록
C->>API: GET /api/queue/events
API->>QS: subscribe()
loop 순번 갱신
QS->>R: ZRANK / ZCARD
QS-->>C: SSE position update
end
alt 100등 이내
QS->>R: 입장 토큰 저장(TTL 5분)
QS-->>C: SSE ready + token
end예매 & 결제▼
sequence
예매 & 결제
Mermaid 원본을 파싱해 텍스트는 DOM으로 렌더링합니다.
Participants
- 1요청
Client → ReservationController
POST /api/reservations
- 2검증
ReservationController → QueueTokenInterceptor
queue token validate
- 3검증
QueueTokenInterceptor → ReservationController
통과
- 4처리
ReservationController → ReservationService
reserve(seatIds)
- 5처리
ReservationService → Redis
stock DECR
- 6처리
ReservationService → Redis
Redisson MultiLock
- 7저장
ReservationService → PostgreSQL
SELECT schedule / seats FOR UPDATE
- 8저장
ReservationService → PostgreSQL
reservation 저장
+ 6개 단계는 아래 상세 메시지에서 확인할 수 있습니다.
전체 메시지 상세
| Step | From → To | Message | Condition |
|---|---|---|---|
| 1 | Client → ReservationController | POST /api/reservations | - |
| 2 | ReservationController → QueueTokenInterceptor | queue token validate | - |
| 3 | QueueTokenInterceptor → ReservationController | 통과 | - |
| 4 | ReservationController → ReservationService | reserve(seatIds) | - |
| 5 | ReservationService → Redis | stock DECR | - |
| 6 | ReservationService → Redis | Redisson MultiLock | - |
| 7 | ReservationService → PostgreSQL | SELECT schedule / seats FOR UPDATE | - |
| 8 | ReservationService → PostgreSQL | reservation 저장 | - |
| 9 | ReservationService → Redis | unlock | - |
| 10 | ReservationService → Client | reservationId | - |
| 11 | Client → PaymentService | POST /api/payments | - |
| 12 | PaymentService → PostgreSQL | payment 저장 | - |
| 13 | PaymentService → Kafka | reservation.completed | - |
| 14 | PaymentService → Client | 결제 성공 | - |
원본 Mermaid 보기
sequenceDiagram
participant C as Client
participant API as ReservationController
participant QI as QueueTokenInterceptor
participant RS as ReservationService
participant R as Redis
participant DB as PostgreSQL
participant PS as PaymentService
participant K as Kafka
C->>API: POST /api/reservations
API->>QI: queue token validate
QI-->>API: 통과
API->>RS: reserve(seatIds)
RS->>R: stock DECR
RS->>R: Redisson MultiLock
RS->>DB: SELECT schedule / seats FOR UPDATE
RS->>DB: reservation 저장
RS->>R: unlock
RS-->>C: reservationId
C->>PS: POST /api/payments
PS->>DB: payment 저장
PS->>K: reservation.completed
PS-->>C: 결제 성공만료 좌석 해제▼
sequence
만료 좌석 해제
Mermaid 원본을 파싱해 텍스트는 DOM으로 렌더링합니다.
Participants
- 1처리
ExpirationScheduler → PostgreSQL
만료된 PENDING 예약 조회
- 2이벤트
ExpirationScheduler → Kafka
reservation.cancelled 발행
- 3이벤트
Kafka → SeatReleaseConsumer
consume
- 4처리
SeatReleaseConsumer → PostgreSQL
reservation 상태 EXPIRED
- 5처리
SeatReleaseConsumer → PostgreSQL
seat 상태 AVAILABLE
- 6처리
SeatReleaseConsumer → Redis
재고 복구 + hold key 정리
전체 메시지 상세
| Step | From → To | Message | Condition |
|---|---|---|---|
| 1 | ExpirationScheduler → PostgreSQL | 만료된 PENDING 예약 조회 | - |
| 2 | ExpirationScheduler → Kafka | reservation.cancelled 발행 | - |
| 3 | Kafka → SeatReleaseConsumer | consume | - |
| 4 | SeatReleaseConsumer → PostgreSQL | reservation 상태 EXPIRED | - |
| 5 | SeatReleaseConsumer → PostgreSQL | seat 상태 AVAILABLE | - |
| 6 | SeatReleaseConsumer → Redis | 재고 복구 + hold key 정리 | - |
원본 Mermaid 보기
sequenceDiagram
participant S as ExpirationScheduler
participant DB as PostgreSQL
participant K as Kafka
participant C as SeatReleaseConsumer
participant R as Redis
S->>DB: 만료된 PENDING 예약 조회
S->>K: reservation.cancelled 발행
K->>C: consume
C->>DB: reservation 상태 EXPIRED
C->>DB: seat 상태 AVAILABLE
C->>R: 재고 복구 + hold key 정리