항공권 예약 시스템 설계: 자리 확보, 결제, 오버부킹 방지

1. 개요

항공권 판매는 간단해 보입니다: 고객이 좌석을 선택하고, 돈을 내고, 항공권을 받습니다. 어려움은 수천 명이 제한된 수의 좌석을 놓고 경쟁하는 것입니다. 결제는 제3자를 거쳐야 하고, 느릴 수도 있고 결과가 여러 번 올 수도 있습니다.

이 글에서 요구사항부터 코드까지 항공권 예약 시스템을 설계합니다. 시스템은 시간 제한이 있는 자리 확보, idempotent 결제, 중복 판매 방지, 갑작스러운 트래픽 처리를 포함합니다. 이 글을 읽은 후, 콘서트 티켓 판매, 호텔 예약, 플래시 세일 같은 유사한 문제에 적용할 수 있는 설계 틀을 갖게 됩니다.

2. 요구사항

2.1. 기능 요구사항

  • 항공편을 찾고 빈 좌석 배치도를 봅니다.
  • 한 개 이상의 좌석을 10분간 **예약(hold)**합니다.
  • 결제합니다. 성공하면 항공권을 발급하고, 실패하거나 기한이 지나면 좌석을 해제합니다.
  • 항공권을 취소하고 정책에 따라 환불합니다.

2.2. 규모와 제약사항

  • 약 5,000개 항공편/일, 항공편당 180개 좌석.
  • 읽기 (검색, 좌석 조회)가 쓰기의 약 100배.
  • 저가 티켓 판매 개시 시, 수십 개 항공편에 초당 50,000개 요청이 들어올 수 있습니다.
  • 하드 제약: 한 좌석이 절대 두 명에게 팔려서는 안 됩니다. 좌석 등급별 판매 티켓 수는 항공사가 정한 한도를 초과할 수 없습니다.

주의: 항공사는 의도적으로 오버부킹을 합니다. 예를 들어 180개 좌석이 있는데 183개를 팝니다. 이유는 항상 일부 고객이 탑승을 하지 않기 때문입니다. 우리가 막아야 할 것은 race condition으로 인한 의도하지 않은 오버부킹입니다. 의도적인 오버부킹 수준은 항공사가 제어하는 매개변수여야 합니다.

위의 두 제약사항이 아키텍처 선택을 결정합니다. 좌석 재고 데이터는 강한 일관성이 필요합니다. 검색 부분은 최종 일관성(eventual consistency)을 허용하고 적극적으로 캐시합니다.

3. 데이터 모델

PostgreSQL을 재고의 정보 원천(source of truth)으로 사용합니다:

CREATE TABLE flight (
    id              BIGINT PRIMARY KEY,
    code            VARCHAR(10) NOT NULL,
    departure_at    TIMESTAMPTZ NOT NULL
);

CREATE TABLE fare_inventory (
    flight_id       BIGINT NOT NULL REFERENCES flight(id),
    fare_class      VARCHAR(10) NOT NULL,        -- ECONOMY, BUSINESS
    capacity        INT NOT NULL,
    overbook_limit  INT NOT NULL DEFAULT 0,
    sold            INT NOT NULL DEFAULT 0,
    held            INT NOT NULL DEFAULT 0,
    PRIMARY KEY (flight_id, fare_class),
    CHECK (sold + held <= capacity + overbook_limit)
);

CREATE TABLE seat (
    flight_id   BIGINT NOT NULL REFERENCES flight(id),
    seat_no     VARCHAR(4) NOT NULL,
    status      VARCHAR(10) NOT NULL DEFAULT 'AVAILABLE', -- AVAILABLE, HELD, SOLD
    booking_id  BIGINT,
    held_until  TIMESTAMPTZ,
    PRIMARY KEY (flight_id, seat_no)
);

CREATE TABLE booking (
    id           BIGINT PRIMARY KEY,
    flight_id    BIGINT NOT NULL,
    customer_id  BIGINT NOT NULL,
    status       VARCHAR(12) NOT NULL,   -- HELD, CONFIRMED, EXPIRED, CANCELLED
    amount       NUMERIC(12,2) NOT NULL,
    expires_at   TIMESTAMPTZ NOT NULL
);

CREATE TABLE payment (
    id               BIGINT PRIMARY KEY,
    booking_id       BIGINT NOT NULL,
    idempotency_key  VARCHAR(64) NOT NULL UNIQUE,
    status           VARCHAR(10) NOT NULL
);

설계는 두 개의 재고 계층을 가지고 있습니다:

  • fare_inventory는 요금 등급별로 계산합니다. CHECK 제약은 최후의 안전망입니다: 코드에 버그가 있더라도 데이터베이스는 한도를 초과하는 오버부킹을 거부합니다.
  • seat은 각 좌석을 개별 관리합니다. 기본 키 (flight_id, seat_no)는 각 좌석이 하나의 상태 행만 가지도록 보장합니다.

4. API

GET  /flights?from=SGN&to=HAN&date=2026-12-20     -> 항공편 목록 (캐시)
GET  /flights/{id}/seats                           -> 좌석 배치도 (2-5초 캐시)
POST /bookings                                     -> 자리 예약, bookingId + expiresAt 반환
POST /bookings/{id}/payments                       -> Idempotency-Key 헤더 필수
POST /payments/webhook                             -> 결제 게이트웨이 콜백
DELETE /bookings/{id}                              -> 취소

자리 예약 요청:

{ "flightId": 1001, "customerId": 42, "fareClass": "ECONOMY", "seats": ["12A", "12B"] }

좌석 배치도는 몇 초 정도 오래된 데이터가 허용됩니다. 사용자가 ‘비어있는’ 좌석을 보았는데 예약할 때 거부당할 수 있습니다. 이는 감수할 수 있는 트레이드오프입니다. 예약 단계에서는 항상 데이터베이스에서 다시 확인합니다.

5. 자리 확보: 중복 판매 방지

가장 흔한 실수는 ’읽고 나서 쓰기’입니다: SELECT status를 하고 AVAILABLE을 보면 UPDATE합니다. 두 transaction이 동시에 읽어서 비어있음을 확인하고 쓸 수 있습니다. 결과는 한 좌석이 두 명에게 팔립니다.

해결책은 conditional update입니다: 확인 조건을 UPDATE 문에 포함시키고 영향받은 행의 수를 계산합니다.

public interface SeatRepository extends JpaRepository<Seat, SeatId> {

    @Modifying
    @Query(value = """
        UPDATE seat
           SET status = 'HELD', booking_id = :bookingId, held_until = :until
         WHERE flight_id = :flightId
           AND seat_no IN (:seatNos)
           AND (status = 'AVAILABLE' OR (status = 'HELD' AND held_until < now()))
        """, nativeQuery = true)
    int holdSeats(Long flightId, List<String> seatNos, Long bookingId, Instant until);

    @Modifying
    @Query(value = """
        UPDATE seat SET status = 'SOLD', held_until = NULL
         WHERE booking_id = :bookingId AND status = 'HELD'
        """, nativeQuery = true)
    int markSold(Long bookingId);
}

public interface FareInventoryRepository extends JpaRepository<FareInventory, FareInventoryId> {

    @Modifying
    @Query(value = """
        UPDATE fare_inventory SET held = held + :n
         WHERE flight_id = :flightId AND fare_class = :fareClass
           AND sold + held + :n <= capacity + overbook_limit
        """, nativeQuery = true)
    int reserve(Long flightId, String fareClass, int n);
}

PostgreSQL은 UPDATE를 실행할 때 행을 잠급니다. 두 번째 transaction은 대기한 후 새로운 데이터에 대해 WHERE 조건을 다시 평가합니다. 그러면 조건이 거짓이 되므로 명령이 0개 행을 반환합니다. held_until < now() 조건은 정리 job을 기다리지 않고 기한이 지난 좌석을 ‘빼앗을’ 수 있게 합니다.

Service는 두 단계를 하나의 transaction으로 결합합니다:

@Service
@RequiredArgsConstructor
public class BookingService {

    private static final Duration HOLD_TTL = Duration.ofMinutes(10);

    private final SeatRepository seatRepository;
    private final FareInventoryRepository inventoryRepository;
    private final BookingRepository bookingRepository;
    private final IdGenerator idGenerator;

    @Transactional
    public Booking hold(HoldRequest req) {
        int n = req.seats().size();
        if (inventoryRepository.reserve(req.flightId(), req.fareClass(), n) != 1) {
            throw new SoldOutException(req.flightId(), req.fareClass());
        }

        Instant until = Instant.now().plus(HOLD_TTL);
        long bookingId = idGenerator.next();
        int held = seatRepository.holdSeats(req.flightId(), req.seats(), bookingId, until);
        if (held != n) {
            // reserve와 이미 확보된 좌석을 모두 롤백합니다
            throw new SeatUnavailableException(req.seats());
        }

        return bookingRepository.save(new Booking(bookingId, req.flightId(),
                req.customerId(), BookingStatus.HELD, req.amount(), until));
    }
}

SeatUnavailableException은 RuntimeException이므로 Spring이 전체 transaction을 롤백합니다. 고객이 선택한 모든 좌석을 예약하거나 아무 좌석도 예약하지 않습니다. 12A만 예약되고 12B는 안 되는 상황은 없습니다.

주의: 두 고객이 {12A, 12B}와 {12B, 12A}를 동시에 선택할 때 교착을 피하려면 보내기 전에 seatNos를 정렬하세요. Postgres는 IN 목록에 따라 잠금 순서를 보장하지 않습니다. 좌석이 많을 때는 update 전에 SELECT … ORDER BY seat_no FOR UPDATE를 사용하세요.

좌석의 기한이 지나서 다른 고객이 빼앗았다면, 이전 booking의 held 카운터는 여전히 남아 있습니다. 섹션 7의 정리 job이 이를 감소시킵니다.

6. Idempotent 결제

결제 게이트웨이는 webhook을 여러 번 호출할 수 있습니다. 사용자가 ‘결제’ 버튼을 두 번 클릭할 수 있습니다. 모든 금전 거래는 idempotent해야 합니다.

@Service
@RequiredArgsConstructor
public class PaymentService {

    private final PaymentRepository paymentRepository;
    private final BookingRepository bookingRepository;
    private final SeatRepository seatRepository;
    private final FareInventoryRepository inventoryRepository;
    private final OutboxRepository outboxRepository;

    @Transactional
    public PaymentResult confirm(long bookingId, String idempotencyKey) {
        Optional<Payment> existing = paymentRepository.findByIdempotencyKey(idempotencyKey);
        if (existing.isPresent()) {
            return PaymentResult.of(existing.get());   // 중복 호출: 이전 결과 반환
        }

        // HELD -> CONFIRMED, booking이 만료되지 않았을 때만 성공
        int updated = bookingRepository.transition(bookingId, "HELD", "CONFIRMED");
        if (updated == 0) {
            paymentRepository.save(Payment.refundRequired(bookingId, idempotencyKey));
            return PaymentResult.REFUND;
        }

        Booking booking = bookingRepository.findById(bookingId).orElseThrow();
        int seats = seatRepository.markSold(bookingId);
        inventoryRepository.moveHeldToSold(booking.getFlightId(), booking.getFareClass(), seats);

        paymentRepository.save(Payment.success(bookingId, idempotencyKey));
        outboxRepository.save(OutboxEvent.bookingConfirmed(bookingId));
        return PaymentResult.SUCCESS;
    }
}

transition 문은 단순히 다른 conditional update입니다:

UPDATE booking SET status = :to WHERE id = :id AND status = :from

핵심은 ’입금’과 ‘예약 시간 만료’ 사이의 경쟁입니다. 결제 흐름과 expire job 모두 HELD에서 상태를 전환합니다. 한쪽만 성공합니다. 입금이 늦으면 고객은 다른 사람에게 팔린 좌석을 받는 대신 자동으로 환불받습니다.

idempotency_key UNIQUE 컬럼은 같은 key의 두 요청이 병렬로 실행되는 경우를 차단합니다. 둘 다 기존 기록을 볼 수 없지만, 두 번째 요청은 insert할 때 DataIntegrityViolationException 오류가 발생하고 해당 transaction이 롤백됩니다.

6.1. Outbox와 Kafka

항공권 발급, 이메일 전송, 체크인 시스템과의 동기화는 모두 비동기로 실행됩니다. transaction 내에서 직접 Kafka로 보내지 마세요. DB가 commit 되었지만 전송이 실패했다면(또는 그 반대), 데이터가 일치하지 않습니다. outbox 테이블에 transaction과 함께 기록한 후, relay가 Kafka로 푸시하도록 합니다:

@Component
@RequiredArgsConstructor
public class OutboxRelay {

    private final OutboxRepository outboxRepository;
    private final KafkaTemplate<String, String> kafka;

    @Scheduled(fixedDelay = 500)
    @Transactional
    public void publish() {
        for (OutboxEvent e : outboxRepository.findTop100ByPublishedFalseOrderById()) {
            kafka.send("booking-events", String.valueOf(e.getBookingId()), e.getPayload()).join();
            e.markPublished();
        }
    }
}

Relay는 at-least-once를 보장하므로, 항공권 발급 쪽 consumer도 idempotent해야 합니다. 예를 들어 처리한 bookingId를 저장하여 구현할 수 있습니다. bookingId를 key로 사용하면 동일한 booking의 이벤트가 동일 partition으로 들어가고 순서가 유지됩니다.

7. 기한 만료 예약 정리

@Scheduled(fixedDelay = 30_000)
@Transactional
public void expireHolds() {
    for (Booking b : bookingRepository.findTop500ByStatusAndExpiresAtBefore(
            BookingStatus.HELD, Instant.now())) {
        if (bookingRepository.transition(b.getId(), "HELD", "EXPIRED") == 1) {
            int released = seatRepository.releaseByBooking(b.getId()); // HELD -> AVAILABLE
            inventoryRepository.release(b.getFlightId(), b.getFareClass(), b.getSeatCount());
        }
    }
}

job은 해제된 좌석 수가 아니라 booking의 seatCount로 held를 감소시킵니다. 이유는 어떤 좌석이 이미 다른 고객에게 빼앗겼지만, 카운터는 여전히 이전 booking을 포함하기 때문입니다. 여러 인스턴스를 실행할 때는 SELECT … FOR UPDATE SKIP LOCKED 또는 ShedLock을 사용하여 노드가 중복 처리하지 않도록 합니다.

8. 갑작스러운 트래픽 처리

저가 티켓 판매 개시 시 50,000개 요청/초가 몇 개의 fare_inventory 행에 동시에 도착합니다. 이것이 hot row입니다. 모든 transaction이 동일한 행 잠금을 기다리면서 줄을 섭니다. 데이터베이스는 CPU 때문이 아니라 connection pool이 고갈되어 다운됩니다.

전략은 DB에 도달하기 전에 필터링하는 것입니다:

  1. 가상 대기실(waiting room): 초당 N명만 예약 페이지에 입장하도록 허용하고, 나머지는 대기 토큰을 받습니다.
  2. Redis의 빠른 필터: SET NX를 사용하여 좌석을 잠그고, 확실히 실패할 요청을 미리 거부합니다.
  3. DB는 최종 중재자입니다.
<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-data-redis</artifactId>
</dependency>
@Component
@RequiredArgsConstructor
public class SeatGate {

    private final StringRedisTemplate redis;

    public boolean tryEnter(long flightId, String seatNo, long customerId) {
        String key = "seat:%d:%s".formatted(flightId, seatNo);
        Boolean ok = redis.opsForValue()
                .setIfAbsent(key, String.valueOf(customerId), Duration.ofSeconds(15));
        return Boolean.TRUE.equals(ok);
    }
}

tryEnter가 false를 반환하면 API는 즉시 409 Conflict을 반환하며 어떤 transaction도 열지 않습니다. TTL 15초는 DB의 예약 단계에 충분하며, 인스턴스가 crash하면 key가 자동으로 삭제됩니다.

주의: Redis를 재고의 정보 원천으로 만들지 마세요. Redis failover 시 key를 잃고 두 명이 동시에 통과할 수 있습니다. 하지만 괜찮습니다. DB의 conditional update가 여전히 차단하기 때문입니다. 반대로 Redis만 신뢰하면 좌석이 중복 판매됩니다.

요금 등급별 hot row의 경우, fare_inventory를 여러 ’bucket’으로 분할 (예: 10개 행, 행당 18개 좌석)하고 무작위로 bucket을 선택하여 잠금 경합을 줄일 수 있습니다.

9. 중복 판매 방지 테스트

가장 중요한 테스트: 50개 thread가 동시에 같은 좌석을 예약하려 하고, 정확히 하나의 thread만 성공합니다. H2의 잠금 동작이 Postgres와 다르므로 Testcontainers를 통해 실제 PostgreSQL로 실행하세요.

@SpringBootTest
@Testcontainers
class BookingServiceConcurrencyTest {

    @Container
    @ServiceConnection
    static PostgreSQLContainer<?> postgres = new PostgreSQLContainer<>("postgres:16");

    @Autowired BookingService bookingService;

    @Test
    void onlyOneCustomerCanHoldTheSameSeat() throws Exception {
        int threads = 50;
        AtomicInteger success = new AtomicInteger();
        CountDownLatch start = new CountDownLatch(1);

        try (ExecutorService pool = Executors.newVirtualThreadPerTaskExecutor()) {
            for (int i = 0; i < threads; i++) {
                long customerId = i;
                pool.submit(() -> {
                    start.await();
                    try {
                        bookingService.hold(new HoldRequest(1001L, customerId, "ECONOMY",
                                List.of("12A"), new BigDecimal("1500000")));
                        success.incrementAndGet();
                    } catch (SeatUnavailableException | SoldOutException ignored) { }
                    return null;
                });
            }
            start.countDown();
        }

        assertThat(success.get()).isEqualTo(1);
    }
}

테스트는 샘플 데이터(항공편 1001, 좌석 12A 비어있음)가 Flyway 또는 data.sql을 통해 로드된다고 가정합니다. CountDownLatch는 모든 thread를 동시에 해제하여 경합을 최대화합니다. ExecutorService의 try-with-resources 블록 (Java 21)은 모든 task가 완료될 때까지 기다린 후 닫습니다. 예상 결과: 정확히 1번 성공, 49번 SeatUnavailableException.

10. 흔한 실수

  • JVM 내 synchronized 또는 lock 사용: 단일 인스턴스에서만 동작합니다. 두 개의 pod로 확장하면 깨집니다.
  • Hot row에 @Version을 사용한 낙관적 잠금(optimistic locking): 논리적으로는 맞지만, 갑작스런 트래픽에서는 재시도 비율이 매우 높습니다. Conditional update는 미리 읽을 필요가 없으므로 더 효율적입니다.
  • 결제 게이트웨이를 호출하는 동안 transaction을 열어두기: 행 잠금이 수 초 동안 유지됩니다. 예약을 진행하고 commit한 후, 결제를 호출하고, 새로운 transaction에서 확인하세요.
  • 예약 기한 만료 후 입금 처리 잊기: 이는 가장 큰 불만의 원인입니다. 항상 자동 환불 로직이 있어야 합니다.
  • 좌석 배치도를 너무 오래 캐시하기: 사용자가 계속 이미 팔린 좌석을 선택합니다. 2–5초면 충분합니다.

11. 결론

항공권 예약 시스템은 한 가지 원칙을 중심으로 합니다: 강한 일관성이 필요한 부분(좌석 재고, 예약 상태)을 지연을 허용하는 부분(검색, 알림, 항공권 발급)과 분리합니다. 데이터베이스의 conditional update는 분산 잠금 없이 경합을 해결합니다. HELD → CONFIRMED/EXPIRED 상태 기계와 조건부 전환이 결제와 만료 사이의 경쟁을 처리합니다. Idempotency key와 outbox는 금전과 이벤트의 중복을 방지합니다. Redis와 가상 대기실은 트래픽이 급증할 때 데이터베이스를 보호합니다.

이 설계 틀을 ‘제한된 자원, 많은 경쟁자’ 같은 모든 문제에 적용할 수 있습니다: 콘서트 티켓, 호텔 예약, 진료 예약 슬롯, 플래시 세일 등.

system-designspring-bootconcurrencyrediskafka