호텔 예약 시스템
- 7.1 문제 이해 및 설계 범위 확정
- 7.2 개략적 설계안 제시
- 7.3 상세 설계
- 7.4 마무리
호텔 예약 시스템은 에어비앤비나 부킹닷컴 같은 서비스에서 객실을 예약하는 시스템이야. 핵심 난이도는 동시성 제어 — 두 사람이 동시에 같은 객실을 예약하려 할 때 이중 예약(double booking)을 어떻게 막을 것인가.
기능 요구사항을 보면:
- 호텔/객실 정보를 조회한다
- 특정 날짜에 객실을 예약한다
- 예약을 취소한다
- 호텔 관리자가 객실과 가격 정보를 관리한다
비기능 요구사항은:
- 이중 예약 방지 — 같은 객실이 같은 날짜에 두 번 예약되면 안 돼
- 높은 동시성 지원 — 인기 호텔은 수천 명이 동시에 예약을 시도할 수 있지
- 적절한 지연시간 — 예약 요청에 대한 응답이 수 초 이내여야 해
규모를 잡아보면 — 호텔 5,000개, 객실 총 100만 개, 일일 예약 건수 약 100만 건. QPS로 치면 초당 약 12건이라 높지 않지만, 인기 호텔에 동시 요청이 몰리는 게 문제야.
주요 API는 간단해.
GET /hotels/{id}— 호텔 상세 정보GET /hotels/{id}/rooms— 객실 목록과 가용성POST /reservations— 예약 생성DELETE /reservations/{id}— 예약 취소
핵심 테이블은 세 개야.
hotel — 호텔 정보 (이름, 주소 등)
room_type — 객실 유형별 정보 (디럭스, 스탠다드 등, 총 객실 수)
reservation — 예약 기록 (호텔 ID, 객실 유형, 체크인/체크아웃, 사용자 등)
가용성 확인을 위해 room_type_inventory 테이블이 있어. (hotel_id, room_type_id, date, total_inventory, total_reserved) 형태지. 특정 호텔의 특정 객실 유형이 특정 날짜에 얼마나 남았는지를 관리해.
예약 흐름은 이래:
- 사용자가 객실 유형과 날짜를 선택해서 예약 요청
room_type_inventory에서 해당 날짜 범위의 잔여 객실을 확인- 잔여가 있으면
total_reserved를 +1 하고 예약 레코드를 생성 - 잔여가 없으면 "만실" 반환
2번과 3번 사이에 문제가 생기지. 두 사용자가 동시에 잔여를 확인하면 둘 다 "1개 남음"을 보고, 둘 다 예약을 시도해. 결과적으로 2개가 예약돼서 오버부킹.
이걸 해결하는 방법이 여러 가지야.
방법 1: 비관적 잠금(Pessimistic Locking)
SELECT ... FOR UPDATE 같은 쿼리로 해당 행을 읽을 때 락을 걸어. 다른 트랜잭션은 이 행을 읽거나 수정할 수 없고, 대기해야 하지.
장점은 확실하게 이중 예약을 방지해. 단점은 락이 걸려 있는 동안 다른 요청이 대기하니까 처리량이 떨어져. 인기 호텔에 수천 명이 동시에 몰리면 대기 시간이 길어지지. 데드락 가능성도 있고.
방법 2: 낙관적 잠금(Optimistic Locking)
락을 걸지 않고 읽어. 대신 업데이트할 때 버전 번호를 확인하지. "내가 읽었을 때 version=5였으니, version=5인 경우에만 업데이트해줘" 하고 조건부 업데이트.
UPDATE room_type_inventory
SET total_reserved = total_reserved + 1, version = version + 1
WHERE hotel_id = ? AND room_type_id = ? AND date = ?
AND version = 5
version이 이미 6으로 바뀌어 있으면 업데이트가 0건이 돼서 실패를 감지할 수 있어. 실패하면 다시 읽고 재시도.
장점은 락이 없으니 동시 읽기 성능이 좋아. 단점은 충돌이 자주 발생하면 재시도가 반복돼서 오히려 비효율적이지.
방법 3: 제약 조건 기반
DB에 유니크 제약 조건을 걸어서 이중 예약 자체를 불가능하게 만들어. 예약 테이블에 (hotel_id, room_type_id, date) 조합이 특정 수를 초과하면 제약 위반으로 실패.
하지만 이 방식은 구현이 복잡하고, "남은 수량"이라는 개념을 제약 조건으로 표현하기가 쉽지 않지.
책에서 추천하는 건 낙관적 잠금과 조건부 업데이트의 조합이야. 이렇게:
UPDATE room_type_inventory
SET total_reserved = total_reserved + 1
WHERE hotel_id = ? AND room_type_id = ? AND date = ?
AND total_reserved + 1 <= total_inventory
핵심은 total_reserved + 1 <= total_inventory 조건이야. 이 조건 덕분에 동시에 여러 트랜잭션이 실행돼도, 재고가 0이 되면 더 이상 예약이 안 돼. 별도의 버전 번호도 필요 없지.
이 방법은 호텔 예약처럼 충돌이 상대적으로 적은 시나리오에 적합해. 같은 호텔, 같은 객실 유형, 같은 날짜에 동시에 예약하는 건 그렇게 흔한 일이 아니니까.
네트워크 문제로 사용자가 예약 버튼을 두 번 누르거나, 클라이언트가 재시도를 하면 같은 예약이 두 번 생길 수 있어. 이를 막기 위해 **멱등성 키(idempotency key)**를 사용하지.
클라이언트가 예약 요청 시 고유한 멱등성 키를 포함하고, 서버는 이 키가 이미 처리됐는지 확인해. 처리됐으면 기존 결과를 반환하고, 새 요청이면 정상 처리.
예약에는 상태 머신이 필요해.
pending → confirmed → checked_in → checked_out
pending → cancelled
confirmed → cancelled
pending 상태는 타임아웃이 필요하지. 결제를 안 하고 방치하는 사용자가 있으면 일정 시간 후 자동으로 cancelled로 전환해서 재고를 풀어줘야 해.
전체 QPS는 낮지만, 읽기(가용성 조회)가 쓰기보다 훨씬 많아. 읽기 복제본을 두면 읽기 부하를 분산할 수 있지. 캐시(Redis)도 활용 가능한데, 재고 데이터를 캐싱할 때 캐시와 DB의 일관성에 주의해야 해.
더 큰 규모에서는 호텔 ID 기준으로 데이터베이스 샤딩을 할 수 있지.
호텔 예약 시스템은 "동시성 제어"를 연습하기 딱 좋은 문제야. 비관적/낙관적 잠금의 트레이드오프를 이해하고, 실제로 어떤 상황에 어떤 방법이 적합한지를 판단하는 게 면접에서의 핵심 포인트지.
정리
7장 읽고 기억할 거 세 가지:
- 이중 예약 방지의 핵심은 DB 레벨의 조건부 업데이트야.
total_reserved + 1 <= total_inventory같은 WHERE 조건으로 원자적으로 재고를 확인하고 차감하지. - 비관적 잠금은 확실하지만 느리고, 낙관적 잠금은 빠르지만 충돌 시 재시도가 필요해. 충돌 빈도에 따라 적합한 방법이 달라지지.
- 멱등성 키로 중복 예약을 방지하고, 타임아웃으로 방치된 예약을 정리해. 실전에서 놓치기 쉬운 엣지 케이스들이야.