Skip to content

Folders and files

NameName
Last commit message
Last commit date

Latest commit

 

History

29 Commits
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 

Repository files navigation

Cinema Ticket System

콘서트 티켓 예약 시스템

목차


프로젝트 개요

프로젝트 동기

평소 좋아하는 가수의 콘서트나 스포츠 경기 티켓을 예매하면서 늘 궁금했던 점이 있었습니다. 티켓 오픈 시간에 수만 명이 동시에 접속해서 같은 좌석을 선택하려 할 때, 시스템은 어떻게 중복 예약 없이 정확히 한 명에게만 좌석을 배정할 수 있을까? 특히 인기 있는 공연의 경우 초 단위로 좌석이 매진되는데, 이렇게 높은 트래픽 상황에서도 데이터 정합성을 완벽하게 유지하는 기술이 궁금했습니다.

이러한 궁금증을 해결하기 위해 실제 티켓 예매 시스템의 핵심 로직을 직접 구현하고, JMeter를 활용한 대규모 부하 테스트를 통해 동시성 제어 메커니즘을 구현해보았습니다.

시스템 특징

이 프로젝트는 대규모 동시 접속 환경에서 콘서트 티켓 예약을 안정적으로 처리하기 위한 시스템입니다.

핵심 문제 해결:

  • 동일 좌석에 대한 중복 예약 방지
  • 대량 트래픽 환경에서의 안정적인 처리량 확보
  • 비동기 큐 기반 예약 처리로 응답성 향상

검증된 성능 (JMeter 부하 테스트):

  • 단일 좌석 경합: 100 스레드, 486 TPS, 중복 예약 0건
  • 분산 좌석 처리: 500 스레드, 2,411 TPS, 28분간 425만+ 요청 처리
  • 예약 취소/재예약: 좌석 상태 정합성 검증 완료

주요 기능

1. 콘서트 관리

  • 콘서트 생성, 조회, 수정, 삭제
  • 콘서트별 좌석 자동 생성 (A1~A100)
  • 콘서트 일시 및 장소 관리

2. 좌석 관리

  • 좌석 상태 관리 (AVAILABLE, RESERVED)
  • 좌석 번호 및 상태 조회
  • 좌석 정보 수정

3. 예약 시스템

  • 비동기 큐 기반 예약 처리

    • 즉시 응답: 예약 요청 시 queueId 즉시 반환
    • 백그라운드 처리: Worker가 큐를 비동기로 처리
    • 상태 폴링: /status/{queueId}로 처리 결과 확인
  • 예약 처리 플로우

    1. POST /api/reservations → queueId 반환 (PENDING)
    2. Worker가 큐 처리 → PROCESSING
    3. 좌석 예약 성공/실패 → SUCCESS/FAILED
    4. GET /status/{queueId} → reservationId 확인
    
  • 예약 취소

    • 확정된 예약 취소 가능
    • 좌석 상태 AVAILABLE로 복귀
    • 취소된 좌석 재예약 가능

4. 사용자 관리

  • 사용자 등록, 조회, 수정, 삭제
  • 닉네임 중복 검증

기능 상세 플로우

1. 예약 요청 전체 플로우

sequenceDiagram
    actor Client as 클라이언트
    participant API as ReservationController
    participant Service as ReservationService
    participant Queue as ReservationQueue
    participant DB as Database
    participant Worker as Worker Scheduler
    participant Processor as TaskProcessor

    Client->>API: POST /api/reservations<br/>{userId, seatId}
    API->>Service: requestReservation()

    Service->>Service: 중복 큐 요청 검증
    Service->>Queue: ReservationQueue.create()
    Queue-->>Queue: status = PENDING
    Service->>DB: save(queue)
    DB-->>Service: queueId
    Service-->>API: {queueId, status: PENDING}
    API-->>Client: 201 Created<br/>{queueId: 123}

    Note over Client,API: 클라이언트는 즉시 응답 받음

    loop 250ms 간격
        Worker->>DB: SELECT ... FOR UPDATE SKIP LOCKED<br/>LIMIT 50
        DB-->>Worker: [taskId1, taskId2, ...]

        loop 각 taskId
            Worker->>Processor: processSingleTask(taskId)
            Processor->>DB: queue.status = PROCESSING
            Processor->>DB: UPDATE seats<br/>WHERE id=? AND status='AVAILABLE'

            alt 좌석 예약 성공 (updated=1)
                Processor->>DB: INSERT reservation
                Processor->>DB: queue.status = SUCCESS<br/>queue.reservationId = ?
                Processor-->>Worker: 성공
            else 좌석 없음 (updated=0)
                Processor->>DB: queue.status = FAILED
                Processor-->>Worker: 실패
            end
        end
    end

    Client->>API: GET /status/{queueId}
    API->>Service: getStatus(queueId)
    Service->>DB: findById(queueId)
    DB-->>Service: {status: SUCCESS, reservationId}
    Service-->>API: ReservationStatusResponse
    API-->>Client: {queueId, status, reservationId}
Loading

2. 예약 취소 플로우

sequenceDiagram
    actor Client as 클라이언트
    participant API as ReservationController
    participant Service as ReservationService
    participant Reservation as Reservation Entity
    participant Seat as Seat Entity
    participant DB as Database

    Client->>API: POST /api/reservations/cancel<br/>{userId, reservationId}
    API->>Service: cancelReservation()

    Service->>DB: findById(reservationId)
    DB-->>Service: reservation

    Service->>Service: 권한 검증<br/>(userId 일치?)

    alt 권한 있음
        Service->>Reservation: cancel()
        Reservation->>Reservation: status = CANCELLED
        Service->>DB: save(reservation)

        Service->>Seat: updateStatus(AVAILABLE)
        Seat->>Seat: status = AVAILABLE
        Service->>DB: save(seat)

        Service-->>API: {reservationId, status: CANCELLED}
        API-->>Client: 200 OK
    else 권한 없음
        Service-->>API: AuthorizationException
        API-->>Client: 403 Forbidden
    end
Loading

3. Worker 스케줄러 동작 플로우

flowchart TD
    Start([Worker 시작<br/>250ms 간격]) --> Check{워커<br/>활성화?}
    Check -->|No| End([종료])
    Check -->|Yes| Lock[FOR UPDATE SKIP LOCKED<br/>배치 크기만큼 잠금]

    Lock --> Batch[taskIds 획득<br/>최대 50개]
    Batch --> Loop{각 taskId<br/>처리}

    Loop --> NewTx[새 트랜잭션 시작<br/>REQUIRES_NEW]
    NewTx --> MarkProc[status = PROCESSING]
    MarkProc --> ReserveSeat[좌석 예약 시도<br/>UPDATE WHERE status='AVAILABLE']

    ReserveSeat --> CheckUpdate{UPDATE<br/>결과}
    CheckUpdate -->|성공 1건| CreateRes[Reservation 생성]
    CreateRes --> MarkSuccess[status = SUCCESS<br/>reservationId 저장]
    MarkSuccess --> SaveSuccess[repository.save]
    SaveSuccess --> Publish[이벤트 발행<br/>ReservationSucceeded]

    CheckUpdate -->|실패 0건| MarkFailed[status = FAILED]
    MarkFailed --> SaveFailed[repository.save]

    Publish --> NextTask{다음<br/>task?}
    SaveFailed --> NextTask
    NextTask -->|Yes| Loop
    NextTask -->|No| End

    style SaveSuccess fill:#ff6b6b
    style SaveFailed fill:#ff6b6b
Loading

4. 동시성 제어 플로우

flowchart LR
    subgraph Client["100명 동시 요청"]
        C1[Client 1]
        C2[Client 2]
        C3[Client ...]
        C100[Client 100]
    end

    subgraph API["API Layer"]
        API1[POST /reservations]
    end

    subgraph Queue["Reservation Queue"]
        Q1[Queue 1: PENDING]
        Q2[Queue 2: PENDING]
        Q3[Queue ...: PENDING]
        Q100[Queue 100: PENDING]
    end

    subgraph Worker["Worker Processing"]
        W[FOR UPDATE<br/>SKIP LOCKED]
        W --> Batch["배치로 처리<br/>(50개씩)"]
    end

    subgraph DB["Database - 좌석"]
        Seat["Seat ID: 1<br/>status: AVAILABLE"]
    end

    subgraph Result["처리 결과"]
        R1[Queue 1: SUCCESS ]
        R2[Queue 2: FAILED ]
        R3[Queue ...: FAILED ]
        R100[Queue 100: FAILED ]
    end

    C1 & C2 & C3 & C100 --> API1
    API1 --> Q1 & Q2 & Q3 & Q100
    Q1 & Q2 & Q3 & Q100 --> W
    Batch --> Seat
    Seat --> R1
    Seat --> R2
    Seat --> R3
    Seat --> R100

    style R1 fill:#51cf66
    style R2 fill:#ff6b6b
    style R3 fill:#ff6b6b
    style R100 fill:#ff6b6b
Loading

5. 좌석 예약 경쟁 상황 (Optimistic Locking)

sequenceDiagram
    participant W1 as Worker Thread 1
    participant W2 as Worker Thread 2
    participant DB as MySQL Database
    participant Seat as seats 테이블

    Note over Seat: seat_id=1, status='AVAILABLE'

    par 동시 처리
        W1->>DB: BEGIN TRANSACTION
        W2->>DB: BEGIN TRANSACTION
    end

    par 좌석 예약 시도
        W1->>Seat: UPDATE seats SET status='RESERVED'<br/>WHERE id=1 AND status='AVAILABLE'
        W2->>Seat: UPDATE seats SET status='RESERVED'<br/>WHERE id=1 AND status='AVAILABLE'
    end

    Note over Seat: 먼저 도착한 UPDATE만 적용됨

    Seat-->>W1: affected rows = 1 
    Seat-->>W2: affected rows = 0 

    W1->>DB: INSERT INTO reservations ...
    W1->>DB: UPDATE queue SET status='SUCCESS'
    W1->>DB: COMMIT

    W2->>DB: UPDATE queue SET status='FAILED'
    W2->>DB: COMMIT

    Note over W1,W2: 중복 예약 방지 성공
Loading

아키텍처

계층 구조

┌─────────────────────────────────────────┐
│         API Layer (Controller)          │  ← REST API 엔드포인트
├─────────────────────────────────────────┤
│   Application Layer (Service)           │  ← 비즈니스 로직, 트랜잭션
├─────────────────────────────────────────┤
│      Domain Layer (Entity)              │  ← 도메인 모델, 비즈니스 규칙
├─────────────────────────────────────────┤
│  Infrastructure Layer (Repository)      │  ← 데이터 영속성
└─────────────────────────────────────────┘

          ┌──────────────────┐
          │ Worker Scheduler │  ← 비동기 큐 처리
          └──────────────────┘

핵심 컴포넌트

1. 예약 큐 시스템

  • ReservationQueue: 예약 요청을 큐로 관리
    • PENDING: 대기 중
    • PROCESSING: 처리 중
    • SUCCESS: 예약 성공
    • FAILED: 예약 실패 (좌석 없음)

2. Worker Scheduler

  • ReservationQueueService: 250ms마다 큐 처리
  • ReservationApplicationService: 배치 단위로 큐 잠금 및 처리
  • ReservationTaskProcessor: 개별 예약 트랜잭션 처리

3. 동시성 제어

  • Optimistic Locking: 좌석 예약 시 조건부 업데이트
    UPDATE seats SET status = 'RESERVED'
    WHERE seat_id = ? AND status = 'AVAILABLE'
  • FOR UPDATE SKIP LOCKED: 큐 처리 시 행 잠금
  • Transaction Isolation: REQUIRES_NEW로 독립 트랜잭션

기술 스택

Backend

  • Java 17
  • Spring Boot
  • Spring Data JPA

Database

  • MySQL 8.0

Build Tool

  • Gradle

Testing

  • JUnit 5
  • Apache JMeter (부하 테스트)

동시성 제어

1. 좌석 예약 동시성

문제: 동일 좌석에 여러 요청이 동시에 들어올 때 중복 예약 방지

해결책: Optimistic Locking (낙관적 락)

// SeatRepository
@Modifying
@Query("UPDATE Seat s SET s.status = 'RESERVED', s.updatedAt = CURRENT_TIMESTAMP " +
       "WHERE s.id = :seatId AND s.status = 'AVAILABLE'")
int reserveIfAvailable(@Param("seatId") Long seatId);

검증 결과 (JMeter 시나리오 1):

  • 100개 동시 요청 → 1개 성공, 99개 실패
  • 중복 예약 0건

2. 큐 처리 동시성

문제: 여러 Worker 인스턴스에서 동일 큐 항목을 동시에 처리하는 문제

해결책: FOR UPDATE SKIP LOCKED

// ReservationQueueRepository
@Query(value = "SELECT id FROM reservation_queue " +
               "WHERE status = 'PENDING' " +
               "ORDER BY created_at ASC " +
               "LIMIT :size " +
               "FOR UPDATE SKIP LOCKED",
       nativeQuery = true)
List<Long> findPendingTaskIdsForUpdate(@Param("size") int size);

장점:

  • 잠금 경합 최소화
  • 다른 Worker가 잠긴 행을 건너뛰고 다음 항목 처리
  • 병렬 처리 효율 극대화

3. 트랜잭션 격리

문제: 큐 처리 실패 시 전체 배치가 롤백되는 문제

해결책: REQUIRES_NEW propagation

@Transactional(propagation = Propagation.REQUIRES_NEW)
public void processSingleTask(Long taskId) {
    // 각 작업이 독립적인 트랜잭션으로 실행
    // 한 작업 실패해도 다른 작업에 영향 없음
}

부하 테스트

테스트 환경

  • 도구: Apache JMeter 5.6.3
  • 서버: Spring Boot (JVM: -Xms512m -Xmx1024m)
  • 데이터베이스: MySQL 8.0
  • 테스트 데이터: 콘서트 1개, 좌석 100개, 사용자 500명

테스트 시나리오

시나리오 1: 단일 좌석 경합 테스트

목적: 동일 좌석에 대한 동시 예약 요청 시 중복 예약 방지 검증

항목
스레드 수 100
Ramp-up 5초
타겟 좌석 1개
총 요청 수 203,881
테스트 시간 7분
평균 TPS 486/s
평균 응답시간 1ms
에러율 0%

결과:

  • ✅ SUCCESS: 1개
  • ✅ FAILED: 99개
  • ✅ 중복 예약 0건

시나리오 2: 분산 좌석 처리량 테스트

목적: 여러 좌석에 대한 동시 예약 요청 시 시스템 처리량 측정

항목
스레드 수 500
Ramp-up 10초
타겟 좌석 100개
총 요청 수 4,254,619
테스트 시간 28분
평균 TPS 2,411/s
평균 응답시간 3ms
에러율 0%

결과:

  • ✅ SUCCESS: 100개 (모든 좌석 예약)
  • ✅ FAILED: 400개
  • ✅ 데이터 정합성 100% (좌석 100 = 예약 100)

시나리오 3: 예약 취소 및 재예약 테스트

목적: 예약 취소 시 좌석 복귀 및 재예약 가능 여부 검증

플로우:

  1. 10개 예약 생성 → ✅ 성공
  2. 5개 예약 취소 → ✅ CANCELLED 상태
  3. 좌석 상태 확인 → ✅ 5개 AVAILABLE 복귀
  4. 재예약 요청 → ✅ 큐 생성 성공

결과: 취소 및 좌석 복귀 로직 정상 작동

부하 테스트 실행 가이드

빠른 시작:

# 데이터베이스 초기화
mysql -u concert -pticket concert << EOF
DELETE FROM reservations;
DELETE FROM reservation_queue;
UPDATE seats SET status = 'AVAILABLE';
EOF

# 서버 실행
export JAVA_OPTS="-Xms512m -Xmx1024m"
./gradlew bootRun

# 시나리오 1 실행
jmeter -n -t docs/jmeter/scenario1-contention.jmx \
  -l docs/jmeter/results/scenario1-results.jtl \
  -e -o docs/jmeter/results/scenario1-report

# 결과 확인
open docs/jmeter/results/scenario1-report/index.html

테스트 결과 문서


데이터베이스 스키마

주요 테이블

concerts

CREATE TABLE concerts (
  concert_id BIGINT PRIMARY KEY AUTO_INCREMENT,
  concert_title VARCHAR(255) NOT NULL,
  concert_venue VARCHAR(255) NOT NULL,
  concert_date DATETIME NOT NULL,
  created_at DATETIME NOT NULL,
  updated_at DATETIME
);

seats

CREATE TABLE seats (
  seat_id BIGINT PRIMARY KEY AUTO_INCREMENT,
  concert_id BIGINT,
  seat_number VARCHAR(50) NOT NULL,
  status VARCHAR(20) NOT NULL,  -- AVAILABLE, RESERVED
  created_at DATETIME NOT NULL,
  updated_at DATETIME,
  FOREIGN KEY (concert_id) REFERENCES concerts(concert_id)
);

CREATE INDEX idx_seats_concert ON seats(concert_id);
CREATE INDEX idx_seats_status ON seats(status);

reservations

CREATE TABLE reservations (
  reservation_id BIGINT PRIMARY KEY AUTO_INCREMENT,
  user_id BIGINT,
  concert_id BIGINT,
  seat_id BIGINT,
  status VARCHAR(20) NOT NULL,  -- CONFIRMED, CANCELLED
  created_at DATETIME NOT NULL,
  updated_at DATETIME,
  FOREIGN KEY (user_id) REFERENCES users(user_id),
  FOREIGN KEY (concert_id) REFERENCES concerts(concert_id),
  FOREIGN KEY (seat_id) REFERENCES seats(seat_id)
);

CREATE INDEX idx_reservations_user ON reservations(user_id);
CREATE INDEX idx_reservations_status ON reservations(status);

reservation_queue

CREATE TABLE reservation_queue (
  id BIGINT PRIMARY KEY AUTO_INCREMENT,
  user_id BIGINT NOT NULL,
  seat_id BIGINT NOT NULL,
  status VARCHAR(20) NOT NULL,  -- PENDING, PROCESSING, SUCCESS, FAILED
  reservation_id BIGINT,
  created_at DATETIME NOT NULL,
  updated_at DATETIME
);

CREATE INDEX idx_queue_status ON reservation_queue(status);
CREATE INDEX idx_queue_created ON reservation_queue(created_at);

users

CREATE TABLE users (
  user_id BIGINT PRIMARY KEY AUTO_INCREMENT,
  nickname VARCHAR(100) NOT NULL UNIQUE,
  password VARCHAR(255) NOT NULL,
  created_at DATETIME NOT NULL,
  updated_at DATETIME
);

프로젝트 구조

ticket/
├── src/
│   ├── main/
│   │   ├── java/com/project/ticket/
│   │   │   ├── api/                    
│   │   │   │   ├── ConcertController.java
│   │   │   │   ├── ReservationController.java
│   │   │   │   ├── SeatController.java
│   │   │   │   └── UserController.java
│   │   │   ├── application/          
│   │   │   │   ├── concert/
│   │   │   │   ├── reservation/
│   │   │   │   │   ├── ReservationApplicationService.java
│   │   │   │   │   ├── ReservationQueueService.java  (Worker)
│   │   │   │   │   └── ReservationTaskProcessor.java
│   │   │   │   ├── seat/
│   │   │   │   └── user/
│   │   │   ├── domain/            
│   │   │   │   ├── concert/
│   │   │   │   │   └── Concert.java
│   │   │   │   ├── seat/
│   │   │   │   │   └── Seat.java
│   │   │   │   ├── reservation/
│   │   │   │   │   ├── Reservation.java
│   │   │   │   │   └── ReservationQueue.java
│   │   │   │   ├── user/
│   │   │   │   │   └── User.java
│   │   │   │   ├── status/           
│   │   │   │   │   ├── SeatStatus.java
│   │   │   │   │   ├── ReservationStatus.java
│   │   │   │   │   └── QueueStatus.java
│   │   │   │   └── exception/         
│   │   │   └── infrastructure/       
│   │   │       ├── concert/
│   │   │       ├── reservation/
│   │   │       ├── seat/
│   │   │       └── user/
│   │   │             
│   │   └── resources/
│   │       └── application.yml
│   └── test/                          
├── docs/
│   └── jmeter/                      
│       ├── README.md
│       ├── SCENARIOS.md
│       ├── SCENARIO1_TEST_REPORT.md
│       ├── SCENARIO2_TEST_REPORT.md
│       ├── SCENARIO3_TEST_REPORT.md
│       ├── scenario1-contention.jmx
│       ├── scenario2-distributed.jmx
│       └── data/
│           ├── users-contention.csv
│           └── users-distributed.csv
├── build.gradle
└── README.md

About

Cinema

Resources

Stars

0 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages