영화 티켓 판매 시스템 설계: 임시 좌석 잠금 및 예약 만료
1. 개요
블록버스터 영화의 첫 상영 티켓 판매를 시작하면 수천 명이 동시에 수백 개 좌석만 있는 좌석도를 클릭합니다. 시스템은 각 좌석이 한 사람에게만 속하도록 보장해야 합니다. 좌석은 고객이 결제하는 동안 임시로 예약되며, 고객이 떠나면 자동으로 해제되어야 합니다.
이 글은 전체 설계를 다룹니다: 요구사항, 데이터 모델, API, 처리 흐름, 그리고 어려운 부분들입니다. 예제 코드는 Java 21, Spring Boot 3.x, SQL, Redis를 사용합니다. 이 글을 읽고 나면 동시성이 높은 상황에서 안전한 좌석 예약 메커니즘을 직접 구현할 수 있습니다. 정리 작업에 의존하지 않고 만료된 예약을 처리하는 방법도 배우게 됩니다.
2. 요구사항
2.1. 기능 요구사항
- 고객은 상영회의 좌석도를 확인할 수 있으며, 각 좌석은 빈 상태, 예약 중, 또는 판매됨 상태를 가집니다.
- 고객은 최대 8개 좌석을 선택하고 10분 동안 유지할 수 있습니다.
- 좌석 예약은 All-or-Nothing 방식입니다: 하나의 좌석도 예약할 수 없으면 어떤 좌석도 예약되지 않습니다.
- 고객이 예약 기간 내에 결제하면 좌석은 판매됨 상태로 전환됩니다.
- 10분 후에도 결제하지 않으면 좌석은 자동으로 빈 상태로 돌아갑니다.
2.2. 규모 및 제약조건
- 약 500개의 영화관, 각 영화관당 10개의 상영관, 각 상영관당 100–300개의 좌석입니다.
- 평상시에는 초당 수백 개의 요청이 있습니다. 인기 영화 판매 시작 시 초당 50,000개의 요청으로 급증하며, 이는 소수의 상영회에 집중됩니다.
- 같은 좌석을 두 번 팔 수 없습니다. 이는 엄격한 제약조건입니다.
- 읽기와 쓰기 비율이 매우 불균형합니다: 한 번의 좌석 예약에 대해 수십 번의 좌석도 조회가 발생합니다.
- 결제는 외부 게이트웨이를 통해 처리됩니다. 콜백은 지연될 수 있고, 두 번 올 수 있으며, 전혀 오지 않을 수도 있습니다.
3. 데이터 모델
핵심 테이블은 show_seat입니다. 각 행은 특정 상영회의 한 좌석에 대응합니다. 좌석 상태와 예약 정보를 같은 원자적 문장으로 업데이트해야 하므로 별도의 ‘예약’ 테이블을 두지 않습니다.
CREATE TABLE show_seat (
showtime_id BIGINT NOT NULL,
seat_code VARCHAR(8) NOT NULL,
status VARCHAR(16) NOT NULL DEFAULT 'AVAILABLE', -- AVAILABLE | HELD | BOOKED
hold_id UUID,
customer_id BIGINT,
hold_expires_at TIMESTAMP,
booking_id BIGINT,
PRIMARY KEY (showtime_id, seat_code)
);
CREATE INDEX idx_show_seat_hold ON show_seat (hold_id);
CREATE INDEX idx_show_seat_expired ON show_seat (status, hold_expires_at);
주의할 점 몇 가지:
- 기본 키
(showtime_id, seat_code)는 같은 상영회의 좌석 그룹을 매우 빠르게 업데이트할 수 있게 합니다. hold_id는 한 번의 예약에서 여러 좌석을 그룹화합니다. 결제 단계는 이hold_id로 전체 그룹을 확인합니다.- 인덱스
(status, hold_expires_at)는 섹션 7의 만료된 좌석 정리 작업에 사용됩니다.
showtime, booking, payment 테이블은 일반적인 CRUD 테이블이므로 자세히 다루지 않습니다.
4. API
GET /showtimes/{showtimeId}/seats -> 좌석도 + 상태
POST /showtimes/{showtimeId}/holds -> 좌석 예약, 본문: {"seats":["F5","F6"]}
201 {"holdId":"...","expiresAt":"..."} | 409 예약 불가
POST /holds/{holdId}/checkout -> 결제 세션 생성 (Idempotency-Key 헤더)
DELETE /holds/{holdId} -> 고객 취소, 즉시 좌석 해제
POST /payments/callback -> 결제 게이트웨이 콜백
다른 고객이 예약한 좌석일 때 409 Conflict를 반환합니다. 클라이언트는 이 상태 코드를 기반으로 좌석도를 새로 고쳐 고객에게 다른 좌석을 선택하도록 안내합니다.
5. 처리 흐름
고객 API DB / Redis 결제 게이트웨이
| 좌석 선택 | | |
|------------>| 조건부 UPDATE | |
| |---------------------->| HELD, 10분 후 만료 |
|<------------| 201 holdId | |
| 결제 | | |
|------------>| 결제 생성 (8분) |------------------------->|
| | | 콜백 성공 |
| |<-------------------------------------------------|
| | 아직 만료 안 됐으면 | |
| | HELD -> BOOKED | |
결제 세션은 8분 후 만료되도록 설정되며, 이는 좌석 예약 시간보다 짧습니다. 2분의 버퍼 시간은 대부분의 콜백이 좌석이 해제되기 전에 도착하도록 합니다.
6. 임시 좌석 잠금
필요한 의존성:
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-jdbc</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-data-redis</artifactId>
</dependency>
<dependency>
<groupId>com.h2database</groupId>
<artifactId>h2</artifactId>
<scope>test</scope>
</dependency>
6.1. 조건부 UPDATE가 진실의 원천
흔한 실수는 먼저 읽은 후 쓰는 것입니다: SELECT로 빈 좌석을 확인한 후 UPDATE합니다. 두 문장 사이에 다른 요청이 끼어들어 좌석을 예약할 수 있습니다. 결과적으로 두 사람 모두 자신이 좌석을 예약했다고 생각하게 됩니다.
올바른 방법은 확인 로직을 WHERE 조건에 포함시킨 후 영향받은 행의 수를 확인하는 것입니다:
@Service
public class SeatHoldService {
private static final Duration HOLD_TTL = Duration.ofMinutes(10);
private final NamedParameterJdbcTemplate jdbc;
private final Clock clock;
public SeatHoldService(NamedParameterJdbcTemplate jdbc, Clock clock) {
this.jdbc = jdbc;
this.clock = clock;
}
@Transactional
public SeatHold hold(long showtimeId, List<String> seatCodes, long customerId) {
UUID holdId = UUID.randomUUID();
Instant now = clock.instant();
Instant expiresAt = now.plus(HOLD_TTL);
var params = new MapSqlParameterSource()
.addValue("holdId", holdId)
.addValue("customerId", customerId)
.addValue("showtimeId", showtimeId)
.addValue("seatCodes", seatCodes.stream().sorted().toList())
.addValue("now", Timestamp.from(now))
.addValue("expiresAt", Timestamp.from(expiresAt));
int updated = jdbc.update("""
UPDATE show_seat
SET status = 'HELD', hold_id = :holdId,
customer_id = :customerId, hold_expires_at = :expiresAt
WHERE showtime_id = :showtimeId
AND seat_code IN (:seatCodes)
AND (status = 'AVAILABLE'
OR (status = 'HELD' AND hold_expires_at < :now))
""", params);
if (updated != seatCodes.size()) {
throw new SeatUnavailableException(showtimeId, seatCodes);
}
return new SeatHold(holdId, expiresAt);
}
}
public record SeatHold(UUID holdId, Instant expiresAt) {}
public class SeatUnavailableException extends RuntimeException {
public SeatUnavailableException(long showtimeId, List<String> seats) {
super("상영회 " + showtimeId + "의 좌석 " + seats + "은(는) 사용 불가능합니다");
}
}
코드는 다음과 같이 작동합니다:
UPDATE문은 각 좌석 행에 행 잠금을 획득합니다. 같은 좌석F5를 대상으로 하는 두 개의 트랜잭션은 순서대로 처리됩니다.- 나중에 도착한 트랜잭션은
WHERE조건을 다시 평가하고status = 'HELD'를 확인한 후 해당 행을 업데이트하지 않습니다. - 조건
status = 'HELD' AND hold_expires_at < :now는 **지연 만료(lazy expiration)**입니다. 만료된 좌석은 정리 작업이 아직 실행되지 않았더라도 빈 상태로 간주됩니다. - 업데이트된 행의 수가 요청한 좌석 수보다 적으면 예외를 던집니다.
@Transactional이 전체를 롤백하므로 All-or-Nothing 특성을 얻습니다.Clock은 서비스에 주입되므로 테스트에서 시간을 제어할 수 있습니다.
고객 A가 F5, F6을 예약한 후 고객 B가 F6, F7을 예약한다고 가정하면 B는 다음을 받습니다:
HTTP 409 Conflict
{"error":"상영회 42의 좌석 [F6, F7]은(는) 사용 불가능합니다"}
B의 좌석 F7은 여전히 비어있습니다. 왜냐하면 B의 트랜잭션이 롤백되었기 때문입니다.
참고: 업데이트 전에
seatCodes를 정렬합니다. A가F5 → F6순서로 잠그고 B가F6 → F5순서로 잠글 경우 두 트랜잭션이 데드락될 수 있습니다. PostgreSQL의 경우 잠금 순서는 행 스캔 순서에 따라 달라집니다. 확실하게 하려면 업데이트 전에SELECT ... ORDER BY seat_code FOR UPDATE를 실행할 수 있습니다.
6.2. Redis를 빠른 필터 레이어로 사용
트래픽이 급증하면 한 상영회의 수백 행에 50,000개의 좌석 예약 요청이 동시에 들어옵니다. 데이터베이스는 매우 긴 잠금 대기열을 관리해야 합니다. Redis를 앞단에 배치하여 확실히 실패할 요청을 조기에 필터링합니다. 아래 Lua 스크립트는 여러 키에 대한 작업을 원자적으로 만듭니다:
@Component
public class RedisSeatGate {
private static final RedisScript<Long> TRY_HOLD = new DefaultRedisScript<>("""
for _, key in ipairs(KEYS) do
if redis.call('EXISTS', key) == 1 then return 0 end
end
for _, key in ipairs(KEYS) do
redis.call('SET', key, ARGV[1], 'PX', ARGV[2])
end
return 1
""", Long.class);
private final StringRedisTemplate redis;
public RedisSeatGate(StringRedisTemplate redis) {
this.redis = redis;
}
public boolean tryHold(long showtimeId, List<String> seats, UUID holdId, Duration ttl) {
List<String> keys = seats.stream()
.map(s -> "seat:{" + showtimeId + "}:" + s)
.toList();
Long ok = redis.execute(TRY_HOLD, keys, holdId.toString(),
String.valueOf(ttl.toMillis()));
return ok != null && ok == 1L;
}
}
사용 방법은 다음과 같습니다:
- Redis는 단일 스레드에서 Lua 스크립트를 실행합니다.
EXISTS확인과SET사이에 어떤 요청도 끼어들 수 없습니다. PX는 키에 TTL을 설정하므로 Redis는 만료 시 키를 자동으로 삭제합니다. 이것이 캐시 레이어의 자연스러운 만료 메커니즘입니다.{showtimeId}부분은 해시 태그입니다. Redis Cluster에서는 한 상영회의 모든 키가 동일한 슬롯에 위치합니다. 해시 태그가 없으면 다중 키 스크립트가CROSSSLOT오류를 보고합니다.
완전한 흐름은 다음과 같습니다: 먼저 Redis에서 tryHold를 호출합니다. 실패하면 바로 409를 반환합니다. 성공하면 섹션 6.1의 UPDATE를 실행합니다. UPDATE가 실패하면 방금 설정한 Redis 키를 삭제합니다.
참고: Redis는 진실의 원천이 아닙니다. Redis는 failover 중에 데이터를 잃을 수 있으며 키는 DB와 비교하여 밀리초 단위로 만료될 수 있습니다. 최종 결정은 항상 조건부
UPDATE입니다. 시스템에 트래픽 급증이 없다면 운영할 컴포넌트를 줄이기 위해 Redis 레이어를 완전히 제거할 수 있습니다.
7. 예약 만료
만료의 정확성은 섹션 6.1의 조건 hold_expires_at < :now에 의해 보장됩니다. 그러나 좌석도는 누군가 덮어쓸 때까지 계속 HELD를 표시합니다. 따라서 UI가 올바르게 표시되도록 정리 작업을 추가해야 합니다:
@Component
public class ExpiredHoldSweeper {
private final JdbcTemplate jdbc;
private final Clock clock;
public ExpiredHoldSweeper(JdbcTemplate jdbc, Clock clock) {
this.jdbc = jdbc;
this.clock = clock;
}
@Scheduled(fixedDelay = 15_000)
public void releaseExpired() {
int released = jdbc.update("""
UPDATE show_seat
SET status = 'AVAILABLE', hold_id = NULL,
customer_id = NULL, hold_expires_at = NULL
WHERE status = 'HELD' AND hold_expires_at < ?
""", Timestamp.from(clock.instant()));
if (released > 0) {
// 좌석 해제 이벤트를 발행하여 좌석도 캐시 업데이트
}
}
}
이 작업은 15초마다 실행됩니다. 늦게 실행되거나 여러 인스턴스에서 동시에 실행되어도 상관없습니다. 쿼리는 멱등성이 있기 때문입니다. 지연 만료 덕분에 작업이 몇 분 동안 중단되어도 좌석 중복 판매가 발생하지 않습니다. 유일한 결과는 그 시간 동안 UI가 오래된 데이터를 표시한다는 것입니다. 설정에서 @EnableScheduling을 활성화하는 것을 잊지 마세요.
결제 확인 단계는 동일한 문장 내에서 시간 제한을 확인해야 합니다:
@Transactional
public boolean confirm(UUID holdId, long bookingId) {
int updated = jdbc.update("""
UPDATE show_seat
SET status = 'BOOKED', booking_id = :bookingId, hold_expires_at = NULL
WHERE hold_id = :holdId AND status = 'HELD'
AND hold_expires_at >= :now
""", new MapSqlParameterSource()
.addValue("holdId", holdId)
.addValue("bookingId", bookingId)
.addValue("now", Timestamp.from(clock.instant())));
return updated > 0;
}
이 함수는 두 가지 경우에 false를 반환합니다: 예약이 만료되었거나 좌석이 다른 사람에게 예약되었을 때. 두 경우 모두 결제 단계에서 처리되어야 합니다.
8. 결제와의 일관성
결제 게이트웨이의 콜백은 가장 오류가 발생하기 쉬운 부분입니다. 처리해야 할 세 가지 상황이 있습니다:
- 콜백이 두 번 옵니다.
payment_id를UNIQUE제약 조건과 함께 저장합니다. 두 번째 콜백에서 트랜잭션이 이미 처리됨을 확인하면200을 반환하고 아무것도 하지 않습니다. - 콜백이 좌석 만료 후에 옵니다.
confirm은false를 반환합니다. 이 경우 시스템이 결제는 처리했지만 좌석을 제공할 수 없습니다. 예약을FAILED로 표시하고 자동 환불을 발행합니다. 다른 사람이 이미 구매했을 수 있으므로 좌석을 ‘되돌리려’ 하지 마세요. - 콜백이 전혀 오지 않습니다. 정기적인 조정 작업이 15분 이상 된
PENDING결제의 상태를 게이트웨이에 쿼리한 후 위의 두 경우에 해당하는 로직을 적용합니다.
confirm이 성공한 후 이메일 전송, QR 티켓 발급, 멤버십 포인트 추가와 같은 단계는 동일한 트랜잭션 내에서 실행되면 안 됩니다. 그 트랜잭션 내에서 BookingConfirmed 행을 outbox 테이블에 기록합니다. 별도의 프로세스가 이벤트를 Kafka로 전송하며, 토픽은 booking-events이고 키는 bookingId입니다. 이 방식은 DB가 커밋된 후에만 이벤트가 발행되고, 커밋되면 이벤트가 확실히 발행되도록 보장합니다.
9. 트래픽 급증 대응
섹션 6.2의 Redis 레이어는 쓰기 부분만 해결합니다. 인기 영화 판매를 시작할 때 다음 조치를 추가해야 합니다:
- 가상 대기실(Virtual Waiting Room). 각 인기 상영회는 동시에 최대 N명만 좌석 선택 페이지에 들어갈 수 있습니다. 나머지 사람들은 대기 토큰을 받으며, 이는 도착 시간을 점수로 하는 Redis 정렬 세트에 저장됩니다. 수백 개의 좌석을 위해 50,000명이 동시에 선택할 필요는 없습니다.
- 좌석도 캐싱. 좌석도는 Redis 또는 CDN에서 읽고
SeatsHeld/SeatsReleased이벤트를 통해 새로 고칩니다. 좌석도가 1–2초 지연되어도 괜찮습니다. 좌석 예약 단계에서 정확성을 다시 확인할 것이기 때문입니다. - 고객별 속도 제한. 예를 들어 분당 최대 5회의 좌석 예약으로 좌석 스캔 봇을 차단합니다.
showtime_id에 따른 파티셔닝. 모든 경합은 한 상영회 내에서 발생합니다. 분산 트랜잭션 없이showtime_id로 DB를 샤드할 수 있습니다.
10. 동시성 테스트
검증해야 할 가장 중요한 동작은: 여러 고객이 같은 좌석을 예약하려고 할 때 오직 한 명만 성공한다는 것입니다.
@SpringBootTest
class SeatHoldServiceTest {
@Autowired SeatHoldService service;
@Autowired JdbcTemplate jdbc;
@BeforeEach
void setUp() {
jdbc.update("DELETE FROM show_seat");
jdbc.update("INSERT INTO show_seat (showtime_id, seat_code) VALUES (42, 'F5'), (42, 'F6')");
}
@Test
void onlyOneCustomerCanHoldTheSameSeat() throws Exception {
int customers = 20;
var start = new CountDownLatch(1);
var successes = new AtomicInteger();
try (var pool = Executors.newFixedThreadPool(customers)) {
for (int i = 0; i < customers; i++) {
long customerId = i;
pool.submit(() -> {
start.await();
try {
service.hold(42, List.of("F5", "F6"), customerId);
successes.incrementAndGet();
} catch (SeatUnavailableException ignored) { }
return null;
});
}
start.countDown();
}
assertThat(successes.get()).isEqualTo(1);
assertThat(jdbc.queryForObject(
"SELECT COUNT(DISTINCT hold_id) FROM show_seat WHERE status = 'HELD'",
Integer.class)).isEqualTo(1);
}
}
테스트 설명:
CountDownLatch는 20개의 스레드를 시작점에서 잠시 멈추었다 동시에 해제하여 동시성 충돌 가능성을 최대화합니다.- Java 21의
ExecutorService에 대한try-with-resources는 assertion을 실행하기 전에 모든 작업이 완료될 때까지 기다립니다. - 두 번째 assertion은 두 좌석이 같은
hold_id에 속하는지 확인하여 ‘각 사람이 한 좌석씩 가지는’ 상황이 없음을 검증합니다.
테스트는 섹션 3의 schema.sql 파일과 함께 H2에서 실행됩니다 (H2가 이미 지원하는 UUID 타입으로 변경). Clock.systemUTC() bean을 선언해야 합니다. 만료를 테스트하려면 시간을 조정할 수 있는 mock Clock으로 빈을 교체하세요.
참고: H2는 PostgreSQL이나 MySQL의 잠금 동작을 정확히 모방하지 않습니다. 동시성 관련 코드의 경우 Testcontainers 같은 것을 통해 실제 프로덕션 데이터베이스에서 추가 테스트를 실행하세요.
11. 흔한 실수
- 먼저 확인 후 쓰기, 즉 lock 없이
SELECT후UPDATE. 이것이 좌석 중복 판매의 가장 큰 원인입니다. - 정리 작업에만 의존하여 좌석을 해제합니다. 작업이 중단되면 좌석이 영구적으로 잠금됩니다.
WHERE의 지연 만료가 이 위험을 제거합니다. - Redis 잠금을 진실의 원천으로 사용합니다. failover 중에 잠금이 손실되어 두 사람이 같은 좌석을 예약할 수 있습니다.
SELECT ... FOR UPDATE를 사용하여 결제를 기다리는 동안 10분 동안 잠금 유지합니다. 연결 풀이 빠르게 고갈됩니다. DB 잠금은 몇 밀리초만 유지되어야 합니다. 긴 좌석 예약 기간은 데이터로 표현되어야 하며, 구체적으로는hold_expires_at열입니다.- 여러 머신의 시계에서 시간을 가져옵니다. 시간 원천을 통일해야 합니다: DB의
now()또는 엄격하게 NTP 동기화된 앱의Clock입니다.
12. 결론
티켓 판매 시스템의 핵심은 조건부 UPDATE 문입니다. 이 문은 원자적으로 좌석을 잠그면서 동시에 만료된 예약을 빈 좌석으로 취급하므로 좌석 중복 판매가 발생하지 않으며 정리 작업이 필수가 아닙니다. 나머지 컴포넌트들은 이 핵심 주위에 구축됩니다:
- Redis Lua 스크립트는 트래픽을 조기에 차단합니다.
- 정리 작업은 UI가 올바른 상태를 표시하도록 합니다.
- 멱등성과 조정은 비정상적인 결제 콜백을 처리합니다.
- Outbox와 Kafka는 신뢰할 수 있는 이벤트 발행을 제공합니다.
- 가상 대기실은 트래픽 급증을 흡수합니다.
구현할 때는 SQL 핵심 부분과 동시성 테스트부터 시작하세요. 실제 트래픽 메트릭이 필요함을 보여줄 때만 Redis와 가상 대기실을 추가하세요.