영화 티켓 판매 시스템 설계: 임시 좌석 잠금 및 예약 만료

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. 결제와의 일관성

결제 게이트웨이의 콜백은 가장 오류가 발생하기 쉬운 부분입니다. 처리해야 할 세 가지 상황이 있습니다:

  1. 콜백이 두 번 옵니다. payment_id를 UNIQUE 제약 조건과 함께 저장합니다. 두 번째 콜백에서 트랜잭션이 이미 처리됨을 확인하면 200을 반환하고 아무것도 하지 않습니다.
  2. 콜백이 좌석 만료 후에 옵니다. confirm은 false를 반환합니다. 이 경우 시스템이 결제는 처리했지만 좌석을 제공할 수 없습니다. 예약을 FAILED로 표시하고 자동 환불을 발행합니다. 다른 사람이 이미 구매했을 수 있으므로 좌석을 ‘되돌리려’ 하지 마세요.
  3. 콜백이 전혀 오지 않습니다. 정기적인 조정 작업이 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와 가상 대기실을 추가하세요.

system-designspring-bootredisconcurrencycase-study