콘서트 티켓 예약 시스템
평소 좋아하는 가수의 콘서트나 스포츠 경기 티켓을 예매하면서 늘 궁금했던 점이 있었습니다. 티켓 오픈 시간에 수만 명이 동시에 접속해서 같은 좌석을 선택하려 할 때, 시스템은 어떻게 중복 예약 없이 정확히 한 명에게만 좌석을 배정할 수 있을까? 특히 인기 있는 공연의 경우 초 단위로 좌석이 매진되는데, 이렇게 높은 트래픽 상황에서도 데이터 정합성을 완벽하게 유지하는 기술이 궁금했습니다.
이러한 궁금증을 해결하기 위해 실제 티켓 예매 시스템의 핵심 로직을 직접 구현하고, JMeter를 활용한 대규모 부하 테스트를 통해 동시성 제어 메커니즘을 구현해보았습니다.
이 프로젝트는 대규모 동시 접속 환경에서 콘서트 티켓 예약을 안정적으로 처리하기 위한 시스템입니다.
핵심 문제 해결:
- 동일 좌석에 대한 중복 예약 방지
- 대량 트래픽 환경에서의 안정적인 처리량 확보
- 비동기 큐 기반 예약 처리로 응답성 향상
검증된 성능 (JMeter 부하 테스트):
- 단일 좌석 경합: 100 스레드, 486 TPS, 중복 예약 0건
- 분산 좌석 처리: 500 스레드, 2,411 TPS, 28분간 425만+ 요청 처리
- 예약 취소/재예약: 좌석 상태 정합성 검증 완료
- 콘서트 생성, 조회, 수정, 삭제
- 콘서트별 좌석 자동 생성 (A1~A100)
- 콘서트 일시 및 장소 관리
- 좌석 상태 관리 (AVAILABLE, RESERVED)
- 좌석 번호 및 상태 조회
- 좌석 정보 수정
-
비동기 큐 기반 예약 처리
- 즉시 응답: 예약 요청 시
queueId즉시 반환 - 백그라운드 처리: Worker가 큐를 비동기로 처리
- 상태 폴링:
/status/{queueId}로 처리 결과 확인
- 즉시 응답: 예약 요청 시
-
예약 처리 플로우
1. POST /api/reservations → queueId 반환 (PENDING) 2. Worker가 큐 처리 → PROCESSING 3. 좌석 예약 성공/실패 → SUCCESS/FAILED 4. GET /status/{queueId} → reservationId 확인 -
예약 취소
- 확정된 예약 취소 가능
- 좌석 상태 AVAILABLE로 복귀
- 취소된 좌석 재예약 가능
- 사용자 등록, 조회, 수정, 삭제
- 닉네임 중복 검증
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}
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
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
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
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: 중복 예약 방지 성공
┌─────────────────────────────────────────┐
│ API Layer (Controller) │ ← REST API 엔드포인트
├─────────────────────────────────────────┤
│ Application Layer (Service) │ ← 비즈니스 로직, 트랜잭션
├─────────────────────────────────────────┤
│ Domain Layer (Entity) │ ← 도메인 모델, 비즈니스 규칙
├─────────────────────────────────────────┤
│ Infrastructure Layer (Repository) │ ← 데이터 영속성
└─────────────────────────────────────────┘
┌──────────────────┐
│ Worker Scheduler │ ← 비동기 큐 처리
└──────────────────┘
- ReservationQueue: 예약 요청을 큐로 관리
PENDING: 대기 중PROCESSING: 처리 중SUCCESS: 예약 성공FAILED: 예약 실패 (좌석 없음)
- ReservationQueueService: 250ms마다 큐 처리
- ReservationApplicationService: 배치 단위로 큐 잠금 및 처리
- ReservationTaskProcessor: 개별 예약 트랜잭션 처리
- Optimistic Locking: 좌석 예약 시 조건부 업데이트
UPDATE seats SET status = 'RESERVED' WHERE seat_id = ? AND status = 'AVAILABLE'
- FOR UPDATE SKIP LOCKED: 큐 처리 시 행 잠금
- Transaction Isolation:
REQUIRES_NEW로 독립 트랜잭션
- Java 17
- Spring Boot
- Spring Data JPA
- MySQL 8.0
- Gradle
- JUnit 5
- Apache JMeter (부하 테스트)
문제: 동일 좌석에 여러 요청이 동시에 들어올 때 중복 예약 방지
해결책: 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건
문제: 여러 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가 잠긴 행을 건너뛰고 다음 항목 처리
- 병렬 처리 효율 극대화
문제: 큐 처리 실패 시 전체 배치가 롤백되는 문제
해결책: 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명
목적: 동일 좌석에 대한 동시 예약 요청 시 중복 예약 방지 검증
| 항목 | 값 |
|---|---|
| 스레드 수 | 100 |
| Ramp-up | 5초 |
| 타겟 좌석 | 1개 |
| 총 요청 수 | 203,881 |
| 테스트 시간 | 7분 |
| 평균 TPS | 486/s |
| 평균 응답시간 | 1ms |
| 에러율 | 0% |
결과:
- ✅ SUCCESS: 1개
- ✅ FAILED: 99개
- ✅ 중복 예약 0건
목적: 여러 좌석에 대한 동시 예약 요청 시 시스템 처리량 측정
| 항목 | 값 |
|---|---|
| 스레드 수 | 500 |
| Ramp-up | 10초 |
| 타겟 좌석 | 100개 |
| 총 요청 수 | 4,254,619 |
| 테스트 시간 | 28분 |
| 평균 TPS | 2,411/s |
| 평균 응답시간 | 3ms |
| 에러율 | 0% |
결과:
- ✅ SUCCESS: 100개 (모든 좌석 예약)
- ✅ FAILED: 400개
- ✅ 데이터 정합성 100% (좌석 100 = 예약 100)
목적: 예약 취소 시 좌석 복귀 및 재예약 가능 여부 검증
플로우:
- 10개 예약 생성 → ✅ 성공
- 5개 예약 취소 → ✅ CANCELLED 상태
- 좌석 상태 확인 → ✅ 5개 AVAILABLE 복귀
- 재예약 요청 → ✅ 큐 생성 성공
결과: 취소 및 좌석 복귀 로직 정상 작동
빠른 시작:
# 데이터베이스 초기화
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.htmlCREATE 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
);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);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);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);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