호텔 예약 시스템 설계: 일일 인벤토리와 중복 예약 방지

1. 개요

호텔 예약 문제는 간단해 보인다. 방이 남으면 예약을 받으면 된다. 하지만 두 명의 고객이 12월 31일 밤의 마지막 방에 대해 동시에 ‘예약’ 버튼을 누르면, 시스템은 한 방을 두 사람에게 팔기 쉽다.

이 글에서는 실제 설계를 다룬다: 요구사항, 야간 단위 인벤토리 모델, API, 예약 보류(hold) 후 확인하는 흐름, PostgreSQL을 이용한 중복 예약 방지, 그리고 트래픽 급증 대처 방법. 이 글을 읽은 후 Spring Boot 3.x로 의도하지 않은 중복 예약 없이 예약 시스템의 핵심 부분을 직접 구축할 수 있을 것이다.

2. 요구사항

2.1. 기능 요구사항

  • 호텔, 객실 타입, 체크인 날짜, 체크아웃 날짜로 빈 객실을 검색한다.
  • 결제를 위해 객실을 10분간 보류한다.
  • 결제 성공 후 예약을 확정한다.
  • 예약을 취소하고 인벤토리를 반환한다.

2.2. 규모 및 제약사항

  • 약 50,000개의 호텔. 각 호텔은 평균 5가지 객실 타입. 시스템은 365일 전 예약을 오픈한다.
  • 읽기가 쓰기보다 훨씬 크다. 검색과 예약의 비율이 약 100:1이다.
  • 피크 시간에 초당 약 50건의 예약. 플래시 세일 때는 이 수치가 20배까지 증가할 수 있다.
  • 까다로운 제약: 구성된 객실 수를 초과하여 판매하지 않는다. 검색 결과는 몇 초 차이가 나도 된다.

인벤토리 행의 수는 약 50,000 × 5 × 365 ≈ 91백만 행이다. 인덱스가 잘 만들어진 PostgreSQL 테이블이면 이 규모는 충분히 처리할 수 있다.

3. 데이터 모델

이 글에서 가장 중요한 결정은: 고객이 특정 객실(302호)을 예약하는 것이 아니라, 특정 기간의 객실 타입을 예약한다는 것이다. 시스템은 체크인 시에만 객실 번호를 할당한다. 따라서 인벤토리는 (호텔, 객실_타입, 야간)이라는 키로 저장된다.

CREATE TABLE room_inventory (
    hotel_id      BIGINT  NOT NULL,
    room_type_id  BIGINT  NOT NULL,
    stay_date     DATE    NOT NULL,
    total_rooms   INT     NOT NULL,
    reserved      INT     NOT NULL DEFAULT 0,
    PRIMARY KEY (hotel_id, room_type_id, stay_date),
    CONSTRAINT chk_not_oversold CHECK (reserved >= 0 AND reserved <= total_rooms)
);

CREATE TABLE booking (
    id               BIGSERIAL PRIMARY KEY,
    idempotency_key  VARCHAR(64) NOT NULL UNIQUE,
    customer_id      BIGINT      NOT NULL,
    hotel_id         BIGINT      NOT NULL,
    room_type_id     BIGINT      NOT NULL,
    check_in         DATE        NOT NULL,
    check_out        DATE        NOT NULL,
    quantity         INT         NOT NULL,
    status           VARCHAR(16) NOT NULL, -- HOLD, CONFIRMED, CANCELLED, EXPIRED
    expires_at       TIMESTAMPTZ NOT NULL
);

CREATE INDEX idx_booking_expiry ON booking (expires_at) WHERE status = 'HOLD';

주요 포인트 설명:

  • 12월 10일부터 13일까지의 숙박은 3 야간을 차지한다: 10, 11, 12일. 체크아웃 날짜는 포함하지 않는다. 모든 쿼리는 반개구간 [check_in, check_out)을 사용한다.
  • CHECK (reserved <= total_rooms)은 마지막 방어선이다. 코드에 버그가 있어도 데이터베이스가 거절하지, 조용히 초과 판매하지 않는다.
  • Partial index idx_booking_expiry는 상태가 HOLD인 예약만 포함한다. 정리 job이 빠르게 스캔하고 index가 방대해지지 않는다.

참고: 많은 호텔은 고객이 취소할 것으로 예상하고 3–5% 정도 의도적으로 초과 예약한다. 이 정책은 total_rooms를 물리적 객실 수보다 크게 설정하여 표현할 수 있다. 중복 예약 방지 로직을 완화하지 마라.

4. API

GET  /hotels/{hotelId}/availability?checkIn=2026-12-10&checkOut=2026-12-13
POST /bookings                      (Header: Idempotency-Key)  -> 201, status=HOLD
POST /bookings/{id}/confirm         (결제 성공 후 호출)
POST /bookings/{id}/cancel

예약 생성 요청:

public record HoldRequest(long customerId, long hotelId, long roomTypeId,
                          LocalDate checkIn, LocalDate checkOut, int quantity) {
    public HoldRequest {
        if (!checkOut.isAfter(checkIn)) throw new IllegalArgumentException("checkOut은 checkIn보다 이후여야 함");
        if (quantity < 1) throw new IllegalArgumentException("quantity >= 1");
    }

    public int nights() {
        return (int) ChronoUnit.DAYS.between(checkIn, checkOut);
    }
}

Header Idempotency-Key는 클라이언트가 생성한다(UUID). 네트워크가 불안정할 때 클라이언트는 안심하고 재시도할 수 있고, 예약이 중복 생성되지 않는다.

5. 처리 흐름

고객 ──► POST /bookings ──► 모든 야간의 인벤토리 차감 (1 transaction) ──► 예약 HOLD, 10분 후 만료
     ──► 결제 ──► POST /confirm ──► HOLD → CONFIRMED
                     └► 10분 내 결제 안 함 ──► 정리 job: HOLD → EXPIRED, 인벤토리 회복

인벤토리는 결제를 기다리지 말고 hold를 만드는 즉시 차감한다. 이렇게 하면 고객이 결제 페이지에 도착한 이후로는 확실히 방이 남아있다. 대신 시스템은 hold 만료 시 객실을 반환하는 메커니즘을 가져야 한다.

6. 어려움 1: 중복 예약 방지

6.1. “읽고 쓰기”가 안전하지 않은 이유

소박한 방식은 SELECT로 남은 객실 수를 가져가고, Java에서 확인한 후 UPDATE하는 것이다. 두 transaction이 동시에 reserved = 9, total = 10을 읽고 모두 reserved = 10을 쓸 수 있다. 결과는 두 고객이 같은 방을 보류한다. 이것은 lost update라는 고전적인 오류다.

6.2. 순서대로 락한 후 조건부 업데이트

Dependency 추가:

<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-data-jpa</artifactId>
</dependency>
<dependency>
    <groupId>org.postgresql</groupId>
    <artifactId>postgresql</artifactId>
    <scope>runtime</scope>
</dependency>

Repository는 native query를 사용:

public interface InventoryRepository extends Repository<RoomInventory, InventoryId> {

    @Query(value = """
        SELECT stay_date FROM room_inventory
        WHERE hotel_id = :hotelId AND room_type_id = :roomTypeId
          AND stay_date >= :checkIn AND stay_date < :checkOut
        ORDER BY stay_date
        FOR UPDATE
        """, nativeQuery = true)
    List<LocalDate> lockNights(long hotelId, long roomTypeId, LocalDate checkIn, LocalDate checkOut);

    @Modifying
    @Query(value = """
        UPDATE room_inventory SET reserved = reserved + :qty
        WHERE hotel_id = :hotelId AND room_type_id = :roomTypeId
          AND stay_date >= :checkIn AND stay_date < :checkOut
          AND total_rooms - reserved >= :qty
        """, nativeQuery = true)
    int reserve(long hotelId, long roomTypeId, LocalDate checkIn, LocalDate checkOut, int qty);

    @Modifying
    @Query(value = """
        UPDATE room_inventory SET reserved = reserved - :qty
        WHERE hotel_id = :hotelId AND room_type_id = :roomTypeId
          AND stay_date >= :checkIn AND stay_date < :checkOut
        """, nativeQuery = true)
    int release(long hotelId, long roomTypeId, LocalDate checkIn, LocalDate checkOut, int qty);
}

Hold를 생성하는 service:

@Service
public class BookingService {

    private static final Duration HOLD_TTL = Duration.ofMinutes(10);
    private final InventoryRepository inventory;
    private final BookingRepository bookings;
    private final Clock clock;

    public BookingService(InventoryRepository inventory, BookingRepository bookings, Clock clock) {
        this.inventory = inventory;
        this.bookings = bookings;
        this.clock = clock;
    }

    @Transactional
    public Booking hold(HoldRequest req, String idempotencyKey) {
        Optional<Booking> existing = bookings.findByIdempotencyKey(idempotencyKey);
        if (existing.isPresent()) return existing.get();

        inventory.lockNights(req.hotelId(), req.roomTypeId(), req.checkIn(), req.checkOut());
        int updated = inventory.reserve(req.hotelId(), req.roomTypeId(),
                req.checkIn(), req.checkOut(), req.quantity());

        if (updated != req.nights()) {
            throw new RoomUnavailableException(req.hotelId(), req.checkIn(), req.checkOut());
        }
        return bookings.save(Booking.hold(req, idempotencyKey, Instant.now(clock).plus(HOLD_TTL)));
    }
}

각 단계의 동작:

  1. lockNights는 인벤토리 행을 stay_date 순서대로 락한다. 고객 A가 10–13일을 예약하고 고객 B가 11–14일을 예약한다고 하자. 순서 없이 락하면 A가 10일을 잠그고 11일을 기다리고, B가 12일을 잠그고 11일을 기다려서 데드락이 생길 수 있다. 모든 transaction이 같은 순서로 락하면 이런 상황이 발생하지 않는다.
  2. reserve는 아직도 공간이 남은 야간에만 reserved를 더한다. 업데이트된 행의 수를 반환한다.
  3. 행 수가 야간 수보다 작으면, 어떤 야간이 이미 만석이거나 아직 오픈하지 않았을 것이다(행이 없음). Service는 RoomUnavailableException을 던진다. 이것은 RuntimeException이므로 @Transactional이 전체를 롤백하고, 이미 더해진 야간도 함께 롤백된다.

마지막 방을 두 request가 동시에 노릴 때의 결과:

T1: lockNights (락 획득) → reserve = 3 행 → INSERT booking → COMMIT
T2: lockNights (T1 대기)  → reserve = 0 행 → RoomUnavailableException → ROLLBACK → HTTP 409

여기서 isolation 레벨을 SERIALIZABLE로 올릴 필요는 없다. READ COMMITTED와 행 락이면 충분하다.

6.3. 동시 재시도 시 Idempotency

같은 Idempotency-Key를 가진 두 request가 동시에 findByIdempotencyKey 단계를 통과할 수 있다. UNIQUE 제약이 두 번째 request를 DataIntegrityViolationException으로 막는다. Controller 단계에서 이 exception을 잡고, key로 예약을 다시 읽어서 이전 결과를 반환한다. 패배한 transaction은 이미 롤백되었으므로 방금 차감한 인벤토리도 함께 복구된다.

7. 어려움 2: hold 만료와 확인의 경합

정리 job은 30초마다 실행:

@Component
public class HoldExpiryJob {

    private final BookingRepository bookings;
    private final InventoryRepository inventory;

    public HoldExpiryJob(BookingRepository bookings, InventoryRepository inventory) {
        this.bookings = bookings;
        this.inventory = inventory;
    }

    @Scheduled(fixedDelay = 30_000)
    @Transactional
    public void expireHolds() {
        // SELECT ... WHERE status='HOLD' AND expires_at < now() LIMIT 500 FOR UPDATE SKIP LOCKED
        for (Booking b : bookings.lockExpiredHolds(500)) {
            inventory.release(b.getHotelId(), b.getRoomTypeId(), b.getCheckIn(), b.getCheckOut(), b.getQuantity());
            b.markExpired();
        }
    }
}

FOR UPDATE SKIP LOCKED를 사용하면 여러 인스턴스가 동시에 실행되어도 같은 예약을 처리하지 않는다. 각 배치는 500행만 가져가서 transaction을 짧게 유지한다.

@EnableScheduling을 한 @Configuration 클래스에서 활성화해야 한다. 인벤토리를 반환할 때 hold할 때처럼 야간을 같은 순서로 락해서 deadlock을 피한다.

가장 위험한 경합은: 고객이 결제를 끝낸 정확한 그 순간 job이 hold를 만료시키는 것이다. 조건부 상태 업데이트로 해결:

UPDATE booking SET status = 'CONFIRMED'
WHERE id = :id AND status = 'HOLD' AND expires_at > now();

쿼리가 0행을 반환하면 hold가 이미 만료되어 방이 다른 사람에게 넘어갔을 수 있다. Service는 이 경우 환불을 하거나, 다시 hold한 후 확인을 시도해야 한다. EXPIRED에서 CONFIRMED로 변경할 때 인벤토리를 다시 차감하지 않으면 안 된다.

8. 어려움 3: 트래픽 급증

읽기가 쓰기보다 100배 많으므로, 검색이 락을 기다리는 행을 건드리지 않아야 한다. Redis에 짧은 TTL로 객실 상태를 캐시할 수 있다:

@Service
public class AvailabilityCache {

    private final StringRedisTemplate redis;

    public AvailabilityCache(StringRedisTemplate redis) {
        this.redis = redis;
    }

    public Optional<Integer> get(long hotelId, long roomTypeId, LocalDate night) {
        String v = redis.opsForValue().get(key(hotelId, roomTypeId, night));
        return Optional.ofNullable(v).map(Integer::valueOf);
    }

    public void put(long hotelId, long roomTypeId, LocalDate night, int available) {
        redis.opsForValue().set(key(hotelId, roomTypeId, night), String.valueOf(available), Duration.ofSeconds(30));
    }

    private String key(long h, long rt, LocalDate d) {
        return "avail:%d:%d:%s".formatted(h, rt, d);
    }
}

지켜야 할 원칙:

  • Redis는 읽기만 제공한다. PostgreSQL이 진실의 원천이다. 검색 결과가 “방 1개 남음”이라 해도 hold 단계가 실패하면 고객은 HTTP 409 “방이 방금 떨어졌습니다”를 받을 뿐이다. 잘못된 예약이 생성되지 않는다.
  • hold, release, cancel 후마다 outbox 테이블에 event를 같은 transaction에 기록한다. Relay가 이 event를 Kafka topic inventory-changed에 푸시한다. Consumer가 Redis 키를 지우거나 검색 인덱스를 갱신한다. Outbox는 app이 commit 직후 죽어도 event를 잃지 않게 보장한다.
  • 플래시 세일 시 일부 “핫한” 호텔에 행 락이 집중된다. API gateway에서 hotelId별로 속도 제한을 하고, 예약 transaction의 lock_timeout을 2초 정도로 설정하는 게 좋다. 그러면 실패한 request가 빨리 떨어져서 connection pool을 독점하지 않는다.

9. 중복 예약 테스트

가장 중요한 테스트는 많은 thread가 마지막 방을 놓고 싸우는 상황이다. H2는 락 동작을 올바로 시뮬레이션하지 않으므로 실제 PostgreSQL이 필요하다. Dependency 추가:

<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-testcontainers</artifactId>
    <scope>test</scope>
</dependency>
<dependency>
    <groupId>org.testcontainers</groupId>
    <artifactId>postgresql</artifactId>
    <scope>test</scope>
</dependency>
@SpringBootTest
@Testcontainers
class BookingServiceConcurrencyTest {

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

    @Autowired BookingService bookingService;
    @Autowired JdbcTemplate jdbc;

    @Test
    void onlyOneGuestGetsTheLastRoom() throws Exception {
        for (int d = 10; d <= 12; d++) {
            jdbc.update("INSERT INTO room_inventory VALUES (1, 1, ?, 1, 0)", LocalDate.of(2026, 12, d));
        }
        var req = new HoldRequest(42L, 1L, 1L, LocalDate.of(2026, 12, 10), LocalDate.of(2026, 12, 13), 1);

        AtomicInteger success = new AtomicInteger();
        try (var pool = Executors.newVirtualThreadPerTaskExecutor()) {
            List<Future<?>> futures = IntStream.range(0, 20)
                .mapToObj(i -> pool.submit(() -> {
                    try {
                        bookingService.hold(req, "key-" + i);
                        success.incrementAndGet();
                    } catch (RoomUnavailableException ignored) { }
                }))
                .toList();
            for (Future<?> f : futures) f.get();
        }

        assertThat(success.get()).isEqualTo(1);
        assertThat(jdbc.queryForList("SELECT reserved FROM room_inventory", Integer.class))
            .containsOnly(1);
    }
}

20개의 virtual thread가 다른 key로 hold를 호출한다. 테스트는 정확히 한 thread만 성공하고, 3개 야간 모두 reserved = 1임을 단언한다. 어떤 야간도 롤백 누락으로 인한 과다 증분이 없다. 테이블은 3절의 schema.sql 스크립트나 Flyway migration으로 생성된다.

10. 흔한 실수

  • 체크아웃 날짜를 야간으로 포함시킨다. 이 버그는 고객이 인벤토리를 하나 더 차감하게 한다. 모든 곳에서 [check_in, check_out) 반개구간을 사용하라.
  • SELECT 후 FOR UPDATE 없이 Java에서 인벤토리를 확인한다. 이것은 lost update를 일으킨다. 조건이 UPDATE 안에 있거나 FOR UPDATE 뒤에 있어야 한다.
  • 업데이트된 행의 수를 야간 수와 비교하지 않는다. 야간 중 하나가 아직 인벤토리 행이 없으면 UPDATE는 여전히 “성공”하지만 나머지 야간만 차감된다.
  • 결제 API 호출을 행 락을 잡은 상태에서 transaction 안에 넣는다. 락이 수 초 동안 유지되고 전체 호텔이 막힐 수 있다. 순서는: hold (commit), 결제, confirm이어야 한다.
  • Redis DECR을 진실의 원천으로 사용한다. Redis가 failover 후 데이터를 잃으면 인벤토리도 잃는다. 이 접근은 부하가 database 용량을 크게 초과할 때만 고려할 만하고, 그 경우도 DB와 대조할 필요가 있다.

참고: 객실 타입별 개수 모델은 고객이 특정 객실 번호 302를 선택하는 홈스테이나 독채 같은 경우에는 맞지 않다. 그럴 땐 room_night(room_id, stay_date)를 유니크 제약으로 만들거나 PostgreSQL의 exclusion constraint를 daterange에 사용해야 한다.

11. 결론

호텔 예약 시스템의 핵심은 야간 단위 인벤토리 테이블이다. 그 위에 짧은 transaction이 있다: 야간을 순서대로 락하고, 조건부로 업데이트하고, 영향받은 행의 수를 확인한다. CHECK 제약이 마지막 방어망이다. Hold는 시간 제한이 있고, SKIP LOCKED를 쓰는 정리 job이 결제를 포기한 고객 문제를 푼다. 상태에 조건을 붙인 UPDATE가 confirm과 expire 사이의 경합을 푼다.

읽기 부분은 Redis로 분리하고 outbox를 통해 Kafka로 동기화되므로 검색이 큰 부하를 견디면서도 쓰기 쪽 일관성을 해치지 않는다. 이 설계에서 시작한 후, 한 PostgreSQL 노드가 정말로 수용 불가능할 때만 hotel_id로 sharding을 추가하면 된다.

system-designspring-bootpostgresqlconcurrencyredis