가상 면접 사례로 배우는 대규모 시스템 설계 기초 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, room | hotel_id, name, address / room_id, room_type_id, floor, number |
| 요금 | room_type_rate | hotel_id, date, rate |
| 예약 | reservation | reservation_id, hotel_id, room_id, start_date, end_date, status, guest_id |
| 투숙객 | guest | guest_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_id | room_type_id | date | total_inventory | total_reserved |
|---|---|---|---|---|
| 211 | 1001 | 2026-12-24 | 100 | 80 |
| 211 | 1001 | 2026-12-25 | 100 | 82 |
| 211 | 1002 | 2026-12-24 | 200 | 164 |
한 줄이 "호텔 211의 객실 유형 1001, 12월 24일 하루치 재고"다. 2박 예약이면 두 줄을 모두 확인하고 갱신한다.
행이 몇 개나 될까? 5,000개 호텔 × 호텔당 객실 유형 20개 × 2년치 날짜 730일 ≈ 7,300만 행. DB 한 대에 충분히 들어간다. 단, 매일 앞으로의 날짜를 미리 채워 두는 작업이 필요하다.
예약 가능 여부는 날짜 범위의 재고를 읽어서 확인한다.
SELECT date, total_inventory, total_reservedFROM room_type_inventoryWHERE 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_inventorySET total_reserved = total_reserved + 1, version = version + 1WHERE 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단계: 마무리
이 장에서 가져갈 다섯 가지.
- 객실 유형 + 날짜 단위 재고: 방 번호가 아니라
(hotel_id, room_type_id, date)한 줄이 재고 단위다. - 멱등 키로 이중 예약 방지: 주문서를 열 때
reservation_id를 발급하고 유일성 조건으로 막는다. - 경쟁이 적으면 낙관적 락이나 DB 제약 조건: 비관적 락은 확장성이 나빠 권장하지 않는다.
- 샤딩은 hotel_id, 캐시는 재고만: 최종 판단은 DB가 하고, 캐시는 CDC로 따라간다.
- 서비스 경계보다 트랜잭션: 2PC·사가의 복잡도를 피하려고 예약과 재고를 한 DB에 둔다.
스터디에서 나눈 질문
- 초과 예약 10%로 실제로 방이 모자라면 어떻게 처리할까? 시스템이 할 일과 사람이 할 일은?
- 인기 호텔의 크리스마스 이브 재고처럼 특정 행에 경쟁이 몰리면 낙관적 락만으로 충분할까?
- 대기열을 두고 순서대로 처리하거나, 그 행만 비관적 락을 쓰는 방법도 생각해 볼 수 있다
- 결제는 성공했는데 예약 확정이 실패하면? 이 경우 사가의 보상 트랜잭션은 어떤 모양일까?