항공권 예약 시스템 설계: 자리 확보, 결제, 오버부킹 방지
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에 도달하기 전에 필터링하는 것입니다:
- 가상 대기실(waiting room): 초당 N명만 예약 페이지에 입장하도록 허용하고, 나머지는 대기 토큰을 받습니다.
- Redis의 빠른 필터: SET NX를 사용하여 좌석을 잠그고, 확실히 실패할 요청을 미리 거부합니다.
- 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와 가상 대기실은 트래픽이 급증할 때 데이터베이스를 보호합니다.
이 설계 틀을 ‘제한된 자원, 많은 경쟁자’ 같은 모든 문제에 적용할 수 있습니다: 콘서트 티켓, 호텔 예약, 진료 예약 슬롯, 플래시 세일 등.