Skip to content
sweepty

가상 면접 사례로 배우는 대규모 시스템 설계 기초 2편 정리 (7장 호텔 예약 시스템)

— 16 min read

『가상 면접 사례로 배우는 대규모 시스템 설계 기초 2』 7장을 스터디에서 같이 보며 정리한 내용이다. 책의 흐름을 따라가되, 이해를 돕기 위해 예시는 직접 만들어 붙였다.

한 줄 요약

메리어트 같은 호텔 체인의 객실 검색 → 예약 → 결제 흐름을 설계하는 장이다. 트래픽 자체는 크지 않지만, 마지막 남은 방 하나를 두 사람이 동시에 예약할 때 어떻게 할지가 핵심이다.

영화관 좌석 예매에 빗대면 이해하기 쉽다. 다만 호텔은 "몇 번 방"이 아니라 "스탠다드 더블 1개"처럼 객실 유형으로 예약한다는 점이 다르다. 방 번호는 체크인할 때 정해진다.

1단계: 요구사항

기능 요구사항

  • 호텔 정보 페이지, 객실 정보 페이지 표시
  • 객실 예약 · 예약 취소
  • 관리자 페이지: 호텔·객실 정보 추가, 수정, 삭제
  • 초과 예약(overbooking) 지원: 취소를 감안해 실제 객실보다 10% 더 받는다
  • 객실 가격은 날짜마다 바뀐다

비기능 요구사항

  • 높은 수준의 동시성: 성수기에 인기 호텔의 같은 객실을 여러 사람이 동시에 예약하려 한다
  • 적절한 수준의 응답 지연: 예약 처리에 몇 초 걸리는 정도는 괜찮다

개략적 추정

항목값
호텔 수5,000개
전체 객실 수100만 개
평균 투숙률 · 투숙 기간70% · 3일
일간 예약100만 × 0.7 ÷ 3 ≈ 24만 건
초당 예약24만 ÷ 10⁵초 ≈ 3 TPS

페이지별 QPS는 단계마다 사용자 10%만 다음 단계로 넘어간다고 보고 거꾸로 계산한다.

단계QPS
호텔·객실 상세 페이지 조회300
예약 상세 페이지(주문서) 조회30
예약 완료3

초당 3건이면 DB 한 대로도 충분하다. 이 장의 어려움은 양이 아니라 동시성에 있다.

2단계: 개략적 설계

API

구분API
호텔GET/POST/PUT/DELETE /v1/hotels/:id
객실GET/POST/PUT/DELETE /v1/hotels/:id/rooms/:id
예약GET /v1/reservations (내 예약 목록)
GET /v1/reservations/:id (예약 상세)
POST /v1/reservations (신규 예약)
DELETE /v1/reservations/:id (예약 취소)

신규 예약 요청 본문에는 reservationID가 들어간다. 이게 같은 예약이 두 번 생기지 않게 막는 멱등 키 역할을 한다(3단계에서 자세히).

{
"startDate": "2026-12-24",
"endDate": "2026-12-26",
"hotelID": "245",
"roomID": "U12354673389",
"reservationID": "13422445"
}

데이터 모델: 관계형 DB

관계형 DB를 고른 이유:

  • 읽기가 쓰기보다 훨씬 많다: 조회 300 QPS vs 예약 3 TPS
  • ACID가 필요하다: 잔액이 음수가 되거나 이중 청구되는 일을 막는 데 트랜잭션이 가장 쉽다
  • 모델링이 쉽다: 호텔, 객실, 예약처럼 관계가 뚜렷한 데이터

서비스별 테이블은 대략 이렇다.

서비스테이블주요 칼럼
호텔hotel, roomhotel_id, name, address / room_id, room_type_id, floor, number
요금room_type_ratehotel_id, date, rate
예약reservationreservation_id, hotel_id, room_id, start_date, end_date, status, guest_id
투숙객guestguest_id, first_name, last_name, email

예약 상태(status)는 pending → paid → refunded / canceled / rejected 순서로 바뀐다.

이 모델에는 문제가 하나 있다. 사용자는 "1203호"가 아니라 "스탠다드 더블"을 예약하는데, 테이블은 room_id로 예약한다. 3단계에서 고친다.

전체 구조

사용자 → CDN → 공개 API 게이트웨이 ─┬→ 호텔 서비스 ─┐
├→ 요금 서비스 ├→ (서비스별) DB
├→ 예약 서비스 │
└→ 결제 서비스 ─┘
관리자 → 내부 API → 호텔 관리 서비스
  • CDN: 정적 콘텐츠(이미지, JS) 캐싱
  • 공개 API 게이트웨이: 인증, 처리율 제한, 라우팅
  • 호텔 관리 서비스: 직원만 쓰는 내부 서비스 (예약 조회·취소 등)
  • 서비스끼리는 gRPC처럼 빠른 RPC로 통신한다

3단계: 상세 설계

개선된 데이터 모델: 객실 유형별 재고

예약을 room_id 대신 room_type_id로 받고, 날짜별 재고를 따로 관리한다.

hotel_idroom_type_iddatetotal_inventorytotal_reserved
21110012026-12-2410080
21110012026-12-2510082
21110022026-12-24200164

한 줄이 "호텔 211의 객실 유형 1001, 12월 24일 하루치 재고"다. 2박 예약이면 두 줄을 모두 확인하고 갱신한다.

행이 몇 개나 될까? 5,000개 호텔 × 호텔당 객실 유형 20개 × 2년치 날짜 730일 ≈ 7,300만 행. DB 한 대에 충분히 들어간다. 단, 매일 앞으로의 날짜를 미리 채워 두는 작업이 필요하다.

예약 가능 여부는 날짜 범위의 재고를 읽어서 확인한다.

SELECT date, total_inventory, total_reserved
FROM room_type_inventory
WHERE room_type_id = ${roomTypeId} AND hotel_id = ${hotelId}
AND date BETWEEN ${startDate} AND ${endDate};

모든 날짜에서 다음 조건이 맞으면 예약할 수 있다. 초과 예약 10%가 여기서 반영된다.

total_reserved + 예약할 객실 수 <= 110% × total_inventory

예: 객실 100개 중 108개가 예약됐으면 2개까지 더 받을 수 있다.

데이터가 더 커지면 오래된 예약은 아카이브하거나 냉동 저장소로 옮기고, 필요하면 DB를 샤딩한다.

동시성 문제 ① 같은 사용자가 예약 버튼을 두 번 누른다

  • 클라이언트: 요청을 보내면 버튼을 비활성화한다. 하지만 클라이언트 측 처리는 우회할 수 있어서 이것만으로는 부족하다.
  • 멱등 API: 예약 화면(주문서)을 열 때 전역 고유 ID 생성기로 reservation_id를 미리 만들어 두고, 예약 요청에 그 ID를 실어 보낸다. reservation_id는 기본 키라서 같은 ID로 두 번째 요청이 오면 유일성 조건 위반으로 실패한다.
① 주문서 열기 → 서버가 reservation_id=13422445 발급
② 예약 버튼 → INSERT reservation(13422445, ...) 성공
③ 또 누름 → INSERT reservation(13422445, ...) → 유일성 위반, 무시

동시성 문제 ② 여러 사용자가 마지막 방을 동시에 예약한다

남은 스탠다드 더블이 1개일 때, 사용자 A와 B가 동시에 예약한다.

A 트랜잭션 B 트랜잭션
1 재고 읽기: 99/100 → 예약 가능
2 재고 읽기: 99/100 → 예약 가능
3 total_reserved = 100 으로 갱신
4 total_reserved = 100 으로 갱신
5 커밋 커밋 → 방 1개에 예약 2건

각 트랜잭션은 격리되어 있어서 서로의 변경을 모른다. 해결책은 세 가지다.

비관적 락 (pessimistic lock)

SELECT ... FOR UPDATE로 읽는 순간 행을 잠근다. B는 A가 커밋할 때까지 기다리고, A가 끝난 뒤 다시 읽으면 재고가 0이라 실패한다.

  • 장점: 갱신 중인 데이터를 확실히 막는다. 구현이 쉽다.
  • 단점: 여러 행을 잠그다 교착 상태가 생길 수 있다. 트랜잭션이 길면 다른 요청이 모두 기다려야 해서 확장성이 나쁘다.
  • 책의 결론: 권장하지 않는다.

낙관적 락 (optimistic lock)

테이블에 version 칼럼을 둔다. 읽을 때 버전을 기억해 두고, 쓸 때 버전이 그대로인지 확인한다.

UPDATE room_type_inventory
SET total_reserved = total_reserved + 1, version = version + 1
WHERE hotel_id = 211 AND room_type_id = 1001 AND date = '2026-12-24'
AND version = 7; -- 읽을 때 본 버전

A가 먼저 커밋해서 버전이 8이 되면, B의 version = 7 조건은 맞지 않아 0행이 갱신되고 B는 재시도한다.

  • 장점: 잠그지 않아 빠르다. 경쟁이 적을 때 잘 맞는다.
  • 단점: 경쟁이 심하면 실패와 재시도가 반복되어 사용자 경험이 나빠진다.

데이터베이스 제약 조건

CONSTRAINT check_room_count CHECK ((total_inventory - total_reserved >= 0))

B의 갱신이 제약을 어기면 DB가 거부하고 롤백한다.

  • 장점: 구현이 쉽고, 경쟁이 적을 때 잘 동작한다.
  • 단점: 경쟁이 심하면 실패가 많다. 제약 조건은 코드처럼 버전 관리하기 어렵고, 모든 DB가 지원하지도 않는다.
방식막는 시점경쟁 적을 때경쟁 심할 때
비관적 락읽을 때 잠금불필요하게 느림대기·교착
낙관적 락쓸 때 버전 비교좋음재시도 폭주
DB 제약 조건쓸 때 DB가 검사좋음실패 다수

호텔 예약은 초당 3건 정도라 경쟁이 심하지 않다. 그래서 낙관적 락이나 DB 제약 조건이 적절하다.

규모 확장성

부킹닷컴이나 익스피디아처럼 트래픽이 1,000배 늘어난다면?

데이터베이스 샤딩

대부분의 질의가 hotel_id로 걸러지므로 hotel_id % 샤드 수로 샤딩한다. 예약이 30,000 QPS라도 샤드 16개로 나누면 샤드당 약 1,875 QPS다.

캐시

재고는 현재와 미래 날짜만 의미가 있으니 레디스에 올려 둔다.

키: hotelID_roomTypeID_{날짜} 예) 211_1001_20261224
값: 남은 객실 수 예) 20
  • 예약 서비스는 먼저 캐시에서 재고를 확인한다. 대부분의 "예약 가능?" 질의가 DB까지 가지 않는다.
  • DB가 최종 기준이다. 재고를 갱신하면 DB에 먼저 쓰고, CDC(변경 데이터 감지, 예: 디비지움)로 캐시에 비동기로 반영한다.
  • 그래서 캐시와 DB가 잠깐 어긋날 수 있다. 캐시는 남았다고 했는데 DB에서 막히는 건 괜찮다. 마지막 검증은 DB가 하기 때문이다.
장점단점
DB 부하 감소캐시와 DB의 일관성을 유지하기 복잡함
조회 성능 향상사용자가 "있다"고 본 방이 실제로는 없을 수 있음

서비스 간 데이터 일관성

마이크로서비스 원칙대로라면 서비스마다 DB가 따로 있어야 한다. 그러면 예약과 재고가 다른 DB에 있게 되고, 둘을 한 트랜잭션으로 묶을 수 없다.

예: 예약 DB에는 성공했는데 재고 DB 갱신이 실패하면?

방법방식특징
2단계 커밋(2PC)모든 노드가 준비되면 한꺼번에 커밋원자성 보장. 하지만 한 노드가 느리면 전체가 막히는 블로킹 프로토콜이라 느림
사가(Saga)서비스별 로컬 트랜잭션을 순서대로 실행, 실패하면 보상 트랜잭션으로 되돌림결과적 일관성. 흐름 관리가 복잡함

책의 결론은 실용적이다. 이런 메커니즘은 설계 전체의 복잡도를 크게 높이므로, 이 설계안은 예약과 재고를 같은 관계형 DB에 두고 ACID 트랜잭션으로 처리한다. 원칙보다 문제에 맞는 선택을 한 것이다.

4단계: 마무리

이 장에서 가져갈 다섯 가지.

  1. 객실 유형 + 날짜 단위 재고: 방 번호가 아니라 (hotel_id, room_type_id, date) 한 줄이 재고 단위다.
  2. 멱등 키로 이중 예약 방지: 주문서를 열 때 reservation_id를 발급하고 유일성 조건으로 막는다.
  3. 경쟁이 적으면 낙관적 락이나 DB 제약 조건: 비관적 락은 확장성이 나빠 권장하지 않는다.
  4. 샤딩은 hotel_id, 캐시는 재고만: 최종 판단은 DB가 하고, 캐시는 CDC로 따라간다.
  5. 서비스 경계보다 트랜잭션: 2PC·사가의 복잡도를 피하려고 예약과 재고를 한 DB에 둔다.

스터디에서 나눈 질문

  • 초과 예약 10%로 실제로 방이 모자라면 어떻게 처리할까? 시스템이 할 일과 사람이 할 일은?
  • 인기 호텔의 크리스마스 이브 재고처럼 특정 행에 경쟁이 몰리면 낙관적 락만으로 충분할까?
    • 대기열을 두고 순서대로 처리하거나, 그 행만 비관적 락을 쓰는 방법도 생각해 볼 수 있다
  • 결제는 성공했는데 예약 확정이 실패하면? 이 경우 사가의 보상 트랜잭션은 어떤 모양일까?
© 2026 by sweepty. All rights reserved.
Theme by LekoArts