프로젝트 목록으로 돌아가기
Recruiter Briefconcert-booking

Concert Booking

동일 좌석 경합, 중복 요청, 결제/만료 race, Outbox/Kafka 실패 경계를 검증한 고동시성 예매 프로젝트입니다.

Role
백엔드 설계 및 구현
Period
2026
Team
개인 프로젝트
Theme
Concurrency
Java 21Spring Boot 3Spring SecuritySpring Data JPAJWTPostgreSQLRedisRedisson

30초 요약

문제
동일 좌석 경합, 중복 요청, 결제/만료 race에서 좌석 정합성이 깨질 수 있었습니다.
해결
락 전략 비교, Idempotency-Key, Outbox/Kafka, Redis stock reconciliation으로 실패 경계를 나눴습니다.
검증
동일 좌석 100명 동시 요청에서 성공 1건, overselling 0건을 검증했습니다.

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 결과를 확인했습니다.

1,005 RPS
Measured
Mixed Load

local Docker Compose, k6 200 VU 혼합 트래픽

성공 1건
Verified
Overselling

Hot seat contention에서 초과 예약 없이 1건만 성공

100%
Verified
Idempotency / Outbox

중복 요청, 결제 idempotency, Outbox retry와 DLT replay 검증

Measured

1,005 RPS

Mixed Load

Scenario
읽기와 예매 요청이 섞인 콘서트 예매 트래픽
Method
k6 Scenario C에서 Redis 분산 락 기준으로 측정
Result
local Docker Compose, k6 200 VU, 45초 기준으로 읽기 p95 7ms, 쓰기 p95 6ms와 함께 측정했습니다.
Verified

성공 1건 / overselling 0건

Hot Seat

Scenario
동일 좌석에 100명 동시 예매 요청
Method
비관적 락, 낙관적 락, Redis 분산 락을 동일 조건으로 비교
Result
세 전략 모두 정확히 1건만 성공하고 초과 예약은 발생하지 않음
Verified

정책 검증

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를 확인했습니다.
Pending

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. 1. Client Browser → App Instance 1 · REST API + SSE
  2. 2. Client Browser → App Instance 2 · REST API + SSE
  3. 3. App Instance 1 → Redis · Redisson Lock
  4. 4. App Instance 2 → Redis · Redisson Lock
  5. 5. App Instance 1 → PostgreSQL
  6. 6. App Instance 2 → PostgreSQL
  7. 7. App Instance 1 → Kafka KRaft
  8. 8. App Instance 2 → Kafka KRaft
  9. 9. Redis → App Instance 1 · Queue / Hold / Token
  10. 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| App2

ERD

전체 ERD

erd

전체 ERD

Mermaid 원본을 파싱해 텍스트는 DOM으로 렌더링합니다.

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

+ 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. #1 users ||--o{ reservations · 예매
  2. #2 concerts ||--o{ concert_schedules · 스케줄
  3. #3 concert_schedules ||--o{ seats · 좌석
  4. #4 concert_schedules ||--o{ reservations · 예매
  5. #5 reservations ||--o{ reservation_seats · 좌석 배정
  6. #6 seats ||--o{ reservation_seats · 예매됨
  7. #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
ClientQueueControllerQueueServiceRedis
  1. 1요청

    Client → QueueController

    POST /api/queue/enter

  2. 2요청

    QueueController → QueueService

    enter(scheduleId, userId)

  3. 3처리

    QueueService → Redis

    ZADD queue:schedule NX

  4. 4응답

    QueueService → Client

    대기열 등록

  5. 5요청

    Client → QueueController

    GET /api/queue/events

  6. 6요청

    QueueController → QueueService

    subscribe()

  7. 7loop 순번 갱신

    control

    loop 순번 갱신

    조건: loop 순번 갱신

  8. 8loop 순번 갱신

    QueueService → Redis

    ZRANK / ZCARD

    조건: loop 순번 갱신

+ 4개 단계는 아래 상세 메시지에서 확인할 수 있습니다.

전체 메시지 상세
StepFrom → ToMessageCondition
1Client → QueueControllerPOST /api/queue/enter-
2QueueController → QueueServiceenter(scheduleId, userId)-
3QueueService → RedisZADD queue:schedule NX-
4QueueService → Client대기열 등록-
5Client → QueueControllerGET /api/queue/events-
6QueueController → QueueServicesubscribe()-
7controlloop 순번 갱신loop 순번 갱신
8QueueService → RedisZRANK / ZCARDloop 순번 갱신
9QueueService → ClientSSE position updateloop 순번 갱신
10controlalt 100등 이내alt 100등 이내
11QueueService → Redis입장 토큰 저장(TTL 5분)alt 100등 이내
12QueueService → ClientSSE ready + tokenalt 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
ClientReservationControllerQueueTokenInterceptorReservationServiceRedisPostgreSQLPaymentServiceKafka
  1. 1요청

    Client → ReservationController

    POST /api/reservations

  2. 2검증

    ReservationController → QueueTokenInterceptor

    queue token validate

  3. 3검증

    QueueTokenInterceptor → ReservationController

    통과

  4. 4처리

    ReservationController → ReservationService

    reserve(seatIds)

  5. 5처리

    ReservationService → Redis

    stock DECR

  6. 6처리

    ReservationService → Redis

    Redisson MultiLock

  7. 7저장

    ReservationService → PostgreSQL

    SELECT schedule / seats FOR UPDATE

  8. 8저장

    ReservationService → PostgreSQL

    reservation 저장

+ 6개 단계는 아래 상세 메시지에서 확인할 수 있습니다.

전체 메시지 상세
StepFrom → ToMessageCondition
1Client → ReservationControllerPOST /api/reservations-
2ReservationController → QueueTokenInterceptorqueue token validate-
3QueueTokenInterceptor → ReservationController통과-
4ReservationController → ReservationServicereserve(seatIds)-
5ReservationService → Redisstock DECR-
6ReservationService → RedisRedisson MultiLock-
7ReservationService → PostgreSQLSELECT schedule / seats FOR UPDATE-
8ReservationService → PostgreSQLreservation 저장-
9ReservationService → Redisunlock-
10ReservationService → ClientreservationId-
11Client → PaymentServicePOST /api/payments-
12PaymentService → PostgreSQLpayment 저장-
13PaymentService → Kafkareservation.completed-
14PaymentService → 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
ExpirationSchedulerPostgreSQLKafkaSeatReleaseConsumerRedis
  1. 1처리

    ExpirationScheduler → PostgreSQL

    만료된 PENDING 예약 조회

  2. 2이벤트

    ExpirationScheduler → Kafka

    reservation.cancelled 발행

  3. 3이벤트

    Kafka → SeatReleaseConsumer

    consume

  4. 4처리

    SeatReleaseConsumer → PostgreSQL

    reservation 상태 EXPIRED

  5. 5처리

    SeatReleaseConsumer → PostgreSQL

    seat 상태 AVAILABLE

  6. 6처리

    SeatReleaseConsumer → Redis

    재고 복구 + hold key 정리

전체 메시지 상세
StepFrom → ToMessageCondition
1ExpirationScheduler → PostgreSQL만료된 PENDING 예약 조회-
2ExpirationScheduler → Kafkareservation.cancelled 발행-
3Kafka → SeatReleaseConsumerconsume-
4SeatReleaseConsumer → PostgreSQLreservation 상태 EXPIRED-
5SeatReleaseConsumer → PostgreSQLseat 상태 AVAILABLE-
6SeatReleaseConsumer → 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 정리