성진혁
← Projects

공연 좌석 예약 — 대기열 · 락 전략 · 이벤트 전달

좌석 예약 시스템

기간
2026.02 – 2026.05
역할
설계 · 구현 · 측정 전체
참여 인력
개인 프로젝트
Java 21Spring Boot 3.4.1PostgreSQL 16Redis 7 · Redisson 3.40.2Apache Kafka (cp 7.6.0)JPAFlywayTestcontainersk6 v1.5.0
GitHub에서 코드 보기 ↗

서비스

관객이 콘서트를 고르고, 대기열을 거쳐 좌석을 선택하고, 결제까지 마치는 예매 서비스입니다.

예매는 같은 좌석에 사람이 몰리는 순간에만 어려워집니다. 그래서 좌석 하나를 두고 경합이 일어나는 구간에 검증을 집중했습니다.

결제는 mock 즉시 성공 구조이며 실제 결제는 일어나지 않습니다.

  1. 가입
  2. 콘서트 목록
  3. 대기열
  4. 좌석 선택
  5. 결제
  6. 예매 내역
맨 위에 야간 공연장 일러스트 배너가 있고 그 위에 공연명·공연장·기간과 예매하기 버튼, 아래쪽에 공연 여섯 개의 썸네일이 있다. 배너 아래로는 예매 중인 공연 6건이 포스터 카드 격자로 놓여 있다
공연 목록 — 배너 비율·전환 속도·썸네일 크기는 NOL 티켓을 실측해 맞췄다. 다만 그쪽 캐러셀은 정지 버튼이 없고 썸네일이 div라 키보드로 못 쓴다. 정지·키보드 이동을 더했고, 움직임을 줄이는 설정에서는 자동 전환을 아예 시작하지 않는다 (이 화면이 그 상태다)
무대 아래로 VIP·R 구역이 이어지는 좌석표. 회색 칸은 이미 팔린 자리이고 VIP 1열 1번이 검게 선택돼 있다. 하단 막대에 1석 선택, VIP 1열 1번, 15만원이 표시돼 있다
좌석 선택 — 구역은 무대에서 가까운 순으로 서고, 회색은 이미 팔린 자리다. 고르면 하단에 등급·열·번호와 금액이 잡힌다
대기실 화면 위쪽에 종이비행기 공연의 포스터와 공연장·회차가 있고, 가운데에 현재 내 순서로 입장이 크게 표시된다. 좌석을 선택할 차례이며 토큰이 5분간 유효하다는 안내가 있다
대기열 — 순번은 SSE로 내려온다. 무엇을 기다리는지 먼저 적는다. 데모는 대기가 없어 바로 입장이며, 페이지를 벗어나도 순서가 유지된다
데모 UI
React · TypeScript · Vite · Playwright e2e
직접 띄워보기
docker compose -f docker-compose.yml -f docker-compose.demo.yml uplocalhost:4173

e2e가 화면에서 재현하는 것

  • 두 브라우저가 한 좌석을 경쟁하면 한 쪽만 성공하고 진 쪽은 복구된다
  • 대기열 토큰 응답이 유실돼 재요청해도 예매는 한 건만 생긴다

위 화면은 로컬 데모 실행 결과입니다. 운영 중인 서비스가 아닙니다.

요약

01좌석 예약 · 동시성 제어

동시 예매 중복 판매를 막는 락 3종을 같은 조건에서 비교 — oversell 0건

동일 좌석 경합 시 비관적 락·낙관적 락·Redis 분산 락 각각의 커밋 경로와 차단 지점을 비교한 구조도
전략별 차단 지점 — 락 대기 / 커밋 시점 버전 검사 / Redis 재고 선차감

문제 원인

  1. 좌석이 남았는지 보는 것과 예약을 남기는 것이 한 번에 처리되지 않으면, 두 요청이 같은 "잔여 있음"을 읽고 둘 다 예약을 기록
  2. 이 실패는 예외를 남기지 않고 두 요청 모두 정상 응답으로 종료되어 로그로 탐지 불가
  3. "충돌이 잦으면 비관적 락, 드물면 낙관적 락"이라는 통념만으로는 이 도메인에 어느 쪽이 맞는지 판단 근거 없음

해결 과정

  1. 비관적 락 · 낙관적 락 · Redis 분산 락을 같은 예약 도메인 위에 전략 패턴으로 구현하고 실행 시 플래그로 교체
  2. k6 시나리오를 전략만 바꿔 동일 조건으로 실행 (100 VU · 동일 좌석 1개 · VU당 1회)
  3. 매 실행 전 좌석·Redis 재고·대기열을 같은 상태로 되돌리고, 끝난 뒤 성공·실패 건수를 실제 좌석 상태와 대조

결과

  1. 세 전략 모두 성공 1건 / 실패 99건 / oversell 0건 — 동일 좌석 경합에서 중복 판매 차단 확인
  2. p95는 낙관 106ms · Redis 145ms · 비관 215ms로 벌어져 락 대기 비용이 응답 시간에 직결됨을 확인
  3. 결제·만료 race와 멱등 replay, 대기열 토큰 우회까지 같은 시나리오에 넣어 3전략 × 3회 반복 — 체크 594/594 통과
02좌석 예약 · 낙관적 락 충돌 원인 규명

다른 좌석인데 예매가 서로 실패하던 원인을 찾아 성공률 40% → 100%

서로 다른 좌석 row를 대상으로 한 예약들이 공통으로 감소시키는 잔여석 카운터 row에서 버전 충돌이 발생하는 구조도
충돌 지점은 좌석 row가 아니라 모든 예약이 함께 감소시키는 잔여석 카운터 row

문제 원인

  1. 좌석이 서로 달라 충돌이 없어야 하는 조건인데 낙관적 락만 성공률 40%(20/50)로 하락
  2. 원인은 좌석 row가 아니라 모든 예약이 함께 감소시키는 잔여석 카운터 단일 row
  3. 서로 다른 좌석을 사도 이 카운터 한 줄은 같이 고치게 되고, 낙관적 락이 그 충돌을 잡아 나머지를 되돌림

해결 과정

  1. 50 VU가 서로 다른 좌석 50개를 예매하는 시나리오를 전략만 바꿔 돌려 낙관적 락에서만 재현되는 것을 확인하고, 충돌 대상을 좌석에서 잔여석 카운터 한 줄로 좁힘
  2. retry 한도를 올리면 성공률이 회복되는 대신 p95와 DB 부하가 함께 상승하는 트레이드오프 확인
  3. "충돌이 드물면 낙관적 락" 규칙이 공유 카운터가 있는 모델에서 뒤집힌다는 결론을 측정 문서에 기록

결과

  1. 서로 다른 좌석 50건 예약 성공률 비관 100%(50/50) vs 낙관 40%(20/50) — 같이 고치는 한 줄이 있으면 낙관적 락이 비싸다
  2. 40%가 낙관적 락의 고정된 성질이 아니라 구현이 선택한 retry 한도에 종속된 값임을 함께 기록
  3. p95는 비관 95ms · Redis 126ms · 낙관 215ms로 재시도 비용이 응답 시간에 반영됨을 확인

구현 기능

위 문제 해결 외에, 서비스가 돌아가기 위해 구현한 것들입니다.

← Projects