
호텔 예약 시스템을 설계하기 위해 우선 기능적 요구사항과 비기능적 요구사항을 파악한다. 요구사항을 파악할때는 고객의 워크플로우를 따라가며 각 항목들에 대해 어떤것들이 기능적, 비기능적 요구사항인지 분석한다.
현재까지의 요구사항들에 대해 트래픽을 추정해보자.
상황을 아래처럼 가정한다
100만개 객실이므로 (100만 * 0.7) / 3 = 대략 24만건 그리고 이걸 초단위로 환산하면 초당 시스템을 통한 예약은 3건이다. TPS = 3
각 페이지별 QPS(Query per second) 를 구해보자. 그리고 각 페이지별 비용 특징에 대해 알아본다.
사용자들은 각 단계에 대해 다음단계로 10%만 이동하며 90%는 이탈한다고 가정한다.
| 내용 | QPS |
|---|---|
| 객실 상세 | 300 |
| 예약 상세 | 30 |
| 예약 | 3 |
이 표는 최종 예약이 TPS = 3 이므로 이를 역산한 과정이다.
위 요구사항을 바탕으로 API 설계를 한다.
호텔 API
| HTTP method | Endpoint | 상세 |
|---|---|---|
| GET | /v1/hotels/id | 호텔 상세 정보 |
| POST | /v1/hotels | 신규 호텔 생성 |
| PUT | /v1/hotels/id | 호텔 정보 갱신 |
| DELETE | /v1/hotels/id | 호텔 정보 제거 |
객실 API
| HTTP method | Endpoint | 상세 |
|---|---|---|
| GET | /v1/hotels/:id/rooms/id | 객실 상세 정보 |
| POST | /v1/hotels/:id/rooms | 신규 객실 생성 |
| PUT | /v1/hotels/:id/rooms/id | 객실 정보 갱신 |
| DELETE | /v1/hotels/:id/rooms/id | 객실 정보 제거 |
예약 API
| HTTP method | Endpoint | 상세 |
|---|---|---|
| GET | /v1/reservations/id | 예약 상세 정보 |
| POST | /v1/reservations | 신규 예약 생성 |
| PUT | /v1/reservations/id | 예약 정보 갱신 |
| DELETE | /v1/reservations/id | 예약 정보 제거 |
예약시 반드시 필요한 인자는 무엇일까 ? startDate, endDate, reservationId, roomId 등이 있다. 이때 reservationId 는 이중예약 방지를 위한 멱등키로 활용될 수 있다.
데이터베이스 선택 전 데이터 접근 패턴을 살펴보면 데이터는 주로 호텔 상세 정보, 지정한 날짜의 사용 가능한 객실 정보, 사용자가 예약한 예약 정보 등이 있다.
이 조회 패턴에는 관계형 데이터베이스가 적절하다. 그 이유는
hotel / room / room_type_rate / reservation / guest
로 나눌 수 있겠다

그리고 reservation 은 상태를 가져야 하며 위와 같이 결재 대기, 취소, 결제완료, 승인실패, 환불완료 를 가진다.

위 설계안은 마이크로 서비스 아키텍처이다
CDN: js 코드 번들, 이미지, 동영상 등 정적 데이터를 캐시하여 웹로드 성능을 개선
API 게이트웨이: 처리율 제한, 인증 등 기능 지원, 엔드포인트 기반 특정 서비스로 요청 전달
결제 서비스: 고객 결제 처리, 절차 성공시 예약 상태 갱신
위 마이크로 서비스에서는 서비스간 통신을 위해 gRPC 를 이용하여 통신하기로 한다.
gRPC 특징으로는 JSON같이 텍스트 기반이 아닌 이진 직렬화를 사용하며 HTTP2를 통해 멀티 플렉싱, 헤더 압축 을 통해 효율적으로 전달이 가능하고 강력한 타입 체크를 지원하여 장점이 많다.
아래는 다른 대안들에 대한 비교 표이다.
| 비교 항목 | gRPC | REST | GraphQL | Message Queue |
|---|---|---|---|---|
| 직렬화 | Binary (Protobuf) | Text (JSON) | Text (JSON) | Binary/Text |
| 프로토콜 | HTTP2 | HTTP/1.1 | HTTP/1.1/2 | 자체 프로토콜 |
| 통신 방식 | 동기/비동기 (Stream) | 동기 (Request-Reply) | 동기 | 비동기(pub/sub) |
| 타입 안정성 | 매우 높음 | 낮음 | 높음 | 보통 |
| 성능 | 매우 높음 | 보통 | 보통 | 높음(Throughput) |
실제 호텔에서는 사실 각 객실별 Id를 가지기 어렵다 보통 예약을 할때 객실 유형을 선택하여 예약한다. 그러므로 테이블 스키마를 아래와 같이 변경한다.
room은 room_type_id 컬럼을 가지도록 하고
reservation 테이블은 reservation_id(멱등키), hotel_id, room_type_id 를 컬럼으로 가진다.
roome_type_inventory 테이블은 예약시 남은 예약 수 자원을 관리하는 중요한 테이블로 hotel_id, room_type_id, date 컬럼을 엮어서 복합키를 기본키로 가지도록 한다. 그리고 total_inventory, total_reserved 컬럼을 통해 전체 예약 객실 수, 예약 된 객실 수 정보를 가진다.
각 방의 타입별로 날짜당 하나의 레코드를 가지게하여 예약가능한 방 수를 관리하면 관리가 쉽다.
용량에 대한 추정은 5000개 호텔 20개 객실 유형 2년 * 365 = 약 7300만개 이다
이정도의 데이터는 1개의 데이터베이스로 가능하지만 하나만 두면 SPOF(단일 실패 포인트) 문제가 가능하므로 여러 지역에 DB를 복제해두는 것이 좋다.
예약시 조회하는 데이터는 아래의 room_type_inventory 테이블이다.
| date | total_inventory | total_reserved |
|---|---|---|
| 2026-01-01 | 100 | 97 |
| 2026-01-02 | 100 | 87 |
| 2026-01-03 | 100 | 77 |
여기서 10% 초과 예약에 대해 반영하려면 ?
if ((totalReserved + 예약할 방수) <= 1.1 * total_inventory) -> true
가 되어야 한다.
만약 저 데이터가 단일 DB에 담기 너무 큰 양이라면 ? 예약 데이터는 현재 기준으로 현재와 미래의 데이터를 조회하므로 이전 데이터를 조회하고 변경하는 경우는 드물다. 따라서 현재와 향후 데이터만 저장하거나 hotel_id 로 샤딩 전략을 선택하여 여러 데이터베이스에 나누어 데이터를 저장할 수 있다.
경쟁조건

이중 예약을 어떻게 방지할까 ?
1번 같은 경우 멱등키를 사용하여 멱등 API를 만드는 것이다. 예약시 주문지 생성을 해서 미리 reservation_id를 생성하도록 하고 이 id는 전역적으로 유일하도록 한다. 이 키를 통해 여러번 생성해도 새로운 레코드는 생성되지 않도록 한다.
2번 의 경우 트랜잭션 격리 수준이 serializable 로 설정되어있지 않으면 1번 트랜잭션 커밋 전 2번 트랜잭션은 똑같은 값을 읽게 되어 2중 예약 발생이 가능하다.
이때 락을 사용할 수 있으며 비관적 락과 낙관적 락을 고려할 수 있다.

장점: 구현이 쉽고 락 경합이 심할때 유용하다
단점: 여러 레코드에 락을 걸면 데드락이 발생할 수 있으며, 코드 작성이 까다롭다. 락을 오래 잡고있게 되면 확장성도 떨어지게된다
예약 시스템에서는 비교적 경합이 낮고 (TPS = 3) 확장성이 중요하므로 비관적 락을 권장하지 않는다.

장점: DB에 락을 걸지 않고 버전 번호를 통해 관리하므로 일관성은 애플리케이션에서 관리된다. 락 관리 비용 없이 트랜잭션을 실행할 수 있다
단점: 데이터 경쟁이 치열할때 성능이 나빠진다
만약 시스템 부하가 급격히 높아진다면 무엇이 병목일까 ? 서버는 수평 확장하고 로드밸런싱을 하면 되지만 DB는 그게 어려울 수 있다.

DB 샤딩을 적용하면 된다. DB를 여러대 두고 각각 일부 데이터만 보관한다. 그렇다면 이때 어떤 기준으로 데이터를 분배해야할까 ? DB에 해당 테이블에 대해 조회시 기준이 되는 것을 선택한다. hotel 관련 테이블에서는 예를 들어 hotel_id가 될 수 있다.
호텔 예약 도메인 특성상 현재, 미래 데이터만 중요하다. 과거 이력을 조회하거나 수정하는 경우는 드물기 때문이다. 그래서 캐시 정책을 TTL, LRU 를 사용한다. 즉 데이터는 자동 소멸되도록 하고 가장 적게 사용한 캐시 데이터를 제거하도록 한다. 다른 캐시 정책으로는 LFU, FIFO 등이 있다.
LRU가 동작하는 방식은 데이터에 접근하면 위치를 가장 앞으로 옮기고 메모리가 차면 가장 뒤에 데이터를 없애는 방식이다. 그래서 단점으로는 이런 작업에 대한 오버헤드가 커질 수 있다는 점이다.

조회 요청이 DB로 직접적으로 가게 되면 부하가 커질 수 있다. 그래서 캐시 레이어를 두고 여기서 잔여 객실 확인 요청이 처리되도록 한다. 갱신은 DB가 처리하도록 하여 최종 진실은 DB가 가지도록 한다. 갱신이 되면 이후 DB는 캐시로 비동기 갱신 처리를 진행하도록 한다.
대부분의 잔여 객실 읽기 연산은 캐시를 통해 처리되도록 한다. 이는 예약 시스템이 쓰기 연산보다 읽기 연산이 훨씬 많아 이를 효율적으로 처리하기 위함이다. 하지만 일관성 문제를 해결해야한다. 이를 위해 CDC 메커니즘을 사용하 수 있다.
이는 DB에 변경된 내용들을 다른 서비스로 전파하는 방식이다. 주로 드베지움을 사용하며 DB 로그를 추출하여 Kafka Connect 위에서 변경 이벤트를 토픽으로 발행하여 사용된다.
모놀리틱에서는 데이터 일관성을 위해 DB를 공유한다. 하지만 MSA에서는 각 서비스가 독자적으로 DB를 가져야할 수 있다. 이 경우 하나의 원자적 연산에 대해 여러 서비스에 연산들이 여러 DB에 걸쳐서 실행되어야 할 수 있다. 그러면 단순 DB의 트랜잭션으로는 이를 원자적으로 처리하기 불가능하다 (왜냐면 DB가 물리적으로 분리되어있으므로) 각기 다른 DB 연산을 원자적으로 묶을 수 없기때문이다.
그래서 2PC, Saga, Outbox 패턴 등을 사용한다.
2PC는 여러 노드에 하나의 트랜잭션을 적용해서 대기가 발생하여 성능이 비교적 낮다.
Saga 에서는 보상 트랜잭션 로직과 OutBox 패턴을 함께 사용한다. DB 저장과 이벤트 발행을 하나의 로컬 트랜잭션으로 묶을 수 있어서 저장과 이벤트 전송의 일관성을 해결한다. 그래서 이 패턴을 사용하기도 한다.
요구사항 구현을 Claude code 를 사용하여 MSA + DDD + Gradle 멀티모듈 로 구현했다. 서비스간 통신은 동기 비동기 Kafka. 데이터는 Database per Service (MySQL), 가용성 조회는 Redis Read Model로 진행하였다.
┌──────────────────┐ ┌──────────────────┐
│ hotel-service │ │ rate-service │
│ (마스터 CRUD + │ │ (요금 정책) │
│ Redis Read │ │ │
│ Model) │ │ │
└────────┬─────────┘ └────────┬─────────┘
│ hotel-events · rate-events (Kafka)
▼ ▼
┌───────────────────────────────────────────┐
│ reservation-service │
│ (예약 수명주기 · 재고 SoT) │
│ ReservationCreated / Cancelled 발행 │
└────────┬──────────────────────────────────┘
│ Kafka
▼
┌──────────────────┐
│ guest-service │
│ (투숙객 정보) │
└──────────────────┘