Architecture(2) - 호텔 예약 시스템 아키텍처



여기서 설계할 대규모 호텔 예약 시스템은 수백만 개의 객실 데이터를 관리하고, 성수기나 대규모 이벤트 시 발생하는 폭발적인 트래픽 속에서도 이중 예약(Double Booking) 없이 정확하고 안정적으로 예약을 처리하는 분산 아키텍처 시스템이다.
본질적으로 항공권 예약이나 영화 티켓 예매 시스템과 동일한 기술적 과제(동시성 제어, 초과 예약 처리 등)를 해결해야 한다.

여기서는 5,000개의 호텔과 100만개의 객실을 갖춘 대규모 호텔 체인 시스템을 설계해본다.


1. 요구사항 정의

  • 시스템 규모
    • Q: 시스템의 전반적인 규모는 어느 정도인가?
    • A: 전 세계 5,000개 호텔과 총 100만 개의 객실을 보유한 대형 호텔 체인을 위한 웹사이트 및 앱 시스템이다.
  • 결제 및 예약 채널
    • Q: 대금 지불은 어느 시점에 이루어지며, 어떤 채널을 지원해야 하는가?
    • A: 예약 시점에 온라인으로 전액 결제한다. 채널은 오직 웹사이트와 모바일 앱을 통해서만 예약할 수 있으며, 전화 예약을 고려하지 않는다.
  • 예약 취소 및 정책
    • Q: 예약 취소 기능도 지원해야 하는가?
    • A: 필수로 지원해야 한다.
  • 객실 가격 변동성
    • Q: 객실 가격은 고정인가, 실시간으로 변동하는가?
    • A: 유동적이다. 해당 날짜에 객실 여유가 얼마나 있는지, 성수기인지 비성수기인지에 따라 매일 가격이 달라질 수 있다.
  • 초과 예약(Overbooking) 정책
    • Q: 호텔 비즈니스 특성상 발생하는 초과 예약을 고려해야 하는가?
    • A: 실제 객실 수의 10% 수준까지 초과 예약을 허용해야 한다. 실제 투숙객들이 예약을 취소하는 비율을 고려하여 방을 더 많이 판매할 수 있도록 지원해야 한다.
  • 설계 및 구현 범위
    • Q: 타이트한 일정 내에 가장 집중해야 할 핵심 기능은 무엇인가?
    • A: 일정 제약이 있으므로 복잡한 ‘객실 검색/필터링’ 기능을 제외한다. 아래 기능에 집중한다.
      • 호텔/객실 상세 페이지 조회
      • 객실 예약 처리
      • 관리자용 백오피스(호텔/객실 정보 CRUD 및 초과 관리)

1.1. 비기능 요구사항

대규모 호텔 예약 시스템의 성패는 비즈니스 로직의 구현보다 분산 환경에서의 안정성에 달려 있다.
여기서 만족해야 할 핵심 비기능 요구사항은 다음과 같다.

  • 높은 수준의 동시성 지원
    • 성수기나 대규모 이벤트(예: 유명 콘서트, 축제 등) 기간에는 특정 인기 호텔의 한정된 객실을 예약하려는 트래픽이 한 번에 몰린다.
    • 이 때 시스템은 이중 예약 없이 정확하게 단 하나의 요청만 성공시켜야 한다.
  • 적절한 응답 지연 시간
    • 사용자가 잔여 객실을 조회할 때는 ms 단위의 빠른 응답이 필요하지만, 최종 ‘예약하기’ 버튼을 눌렀을 때는 안정적인 데이터 처리를 위해 수 초 정도의 지연 시간이 발생하는 것은 비즈니스적으로 허용된다.

2. 개략적 규모 추정

2.1. 일일 예상 예약 건수와 TPS

시스템의 평균적인 부하를 파악하기 위해 아래와 같은 비즈니스 지표를 가정한다.

  • 총 객실 수: 1,000,000개
  • 평균 객실 가동률(Occupancy Rate): 70%
  • 고객별 평균 투숙 기간: 3일

이를 바탕으로 하루에 새롭게 발생하는 예약 건수를 계산하면 아래와 같다. \[\text{일일 예상 예약 건수} = \frac{1,000,000객실 \times 0.7}{3일} = 233,333.33\]

계산 편의를 위해 하루 평균 240,000건의 예약이 발생한다고 가정하자.
이를 초당 예약 건수, 즉 예약 트랜잭션 수(TPS)로 환산하면 아래와 같다. \[\text{평균 예약 TPS} = \frac{240,000 \text{ 건}}{86,400 \text{ 초}} \approx 2.77\]

즉, 일상적인 상황에서 최종 예약 버튼을 누르는 트래픽은 약 3 TPS로 그리 높지 않은 편이다.
하지만, 이것만 보고 안심해서는 안 된다.
사용자의 실제 서비스 이용 흐름에 따른 페이지별 QPS(Queries Per Second)를 봐야 진짜 병목 구간이 보인다.


2.2. 단계별 유저 이탈률을 고려한 페이지별 QPS 예측

호텔 웹사이트를 이용하는 사용자는 최종 결제 단계로 갈수록 깔때기(Funnel) 형태의 이탈 흐름을 보인다.
업계 표준을 반영하여 다음 단계로 진입하는 사용자의 비율을 10%로 가정하자.
즉, 90%의 유저는 최종 예약을 완료하기 전에 이탈한다.

  • 1단계: 호텔/객실 상세 페이지 조회(정보 탐색) → 90% 이탈
  • 2단계: 예약 상세 정보 확인 페이지(날짜, 결제 수단 입력) → 90% 이탈
  • 3단계: 최종 객실 예약 요청(트랜잭션 발생, 3 QPS)

최종 예약 단계가 3 QPS이므로 역산하여 각 단계별 필요한 최대 QPS를 도출하면 아래와 같은 규모 추정 표가 완성된다.

서비스 단계예상 처리량 (QPS)주요 작업 특성
3단계: 객실 예약 페이지3쓰기(Write) 연산 위주, 고도의 일관성 필요
2단계: 예약 상세 확인 페이지30읽기(Read) 연산 위주, 실시간 가용성 확인
1단계: 호텔/객실 상세 페이지300압도적인 읽기(Read) 연산, 캐싱 적극 활용 구간

규모 추정 결과, 최종 예약 처리(3 QPS) 자체는 단일 데이터베이스 서버로도 충분히 처리할 수 있는 수준이다.
하지만 1단계와 2단계의 조회 트래픽(총 330 QPS)이 성수기나 대규모 이벤트 시점에 수십에서 수백 배 급증할 수 있다는 점이 이 아키텍처 설계의 핵심 과제이다.

따라서 뒤에 다룰 상세 설계에서는 조회 성능 극대화를 위한 캐싱 전략(Redis)안전한 예약 처리를 위한 동시성 메커니즘을 결합한 하이브리드 아키텍처를 도출한다.


3. MSA 기반의 개략적 아키텍처 및 API 설계

대규모 호텔 예약 시스템은 시스템의 확장성과 서비스 간 독립적인 배포를 위해 MSA를 채택한다.
각 도메인의 핵심 비즈니스 로직을 격리하고, 폭발적인 읽기 트래픽과 안정적인 쓰기 트래픽을 동시에 처리할 수 있는 개략적 구조를 먼저 살펴본다.


3.1. 컴포넌트별 역할 분담

시스템의 전체적인 구조는 클라이언트 요청이 들어오는 외부 영역과, 실제 비즈니스 로직이 처리되는 내부 사설망 영역으로 명확히 분리된다.

개략적 설계안


3.1.1. 인프라 및 게이트웨이 계층

  • CDN(콘텐츠 전송 네트워크)
    • 자바스크립트 번들, 호텔 전경 이미지, 룸 타입별 동영상, 정적 HTML 등 모든 정적 콘텐츠를 캐시하여 클라이언트와 가장 가까운 엣지 서버에서 반환한다.
    • 백엔드 서버의 부하를 줄이고 초기 페이지 로딩 속도를 극대화한다.
  • 공개 API 게이트웨이
    • 인증, 처리율 제한(Rate Limiting), SSL 종단 등의 공통 인프라 기능을 수행하는 Fully Managed 서비스이다.
    • 엔드포인트에 따라 적절한 마이크로서비스로 요청을 라우팅한다.
  • 내부 API/VPN
    • 외부 인터넷망에 노출되지 않는 사설 가상망(VPN)이다.
    • 오직 승인된 사내 시스템이나 백오피스 관리자 페이지를 통해서만 접근이 가능하도록 보호한다.

3.1.2. 핵심 도메인 마이크로서비스 계층

  • 호텔 서비스
    • 호텔 위치, 전화번호, 객실 유형 등 정적인 마스터 데이터를 제공한다.
    • 데이터 변경 빈도가 매우 낮기 때문에 대다수 요청은 내장 캐시나 데이터베이스 복제본(Replica)에서 소화한다.
  • 요금 서비스
    • 특정 날짜, 시즌, 가용 객실 추이에 따라 실시간으로 변동되는 유동적 객실 요금 데이터를 제공한다.
  • 예약 서비스
    • 사용자의 예약 요청을 접수하고, 예약 상태를 관리하며, 잔여 객실 재고를 차감하거나 복구하는 시스템의 핵심 코어이다.
  • 결제 서비스
    • PG(Payment Gateway)사와의 연동을 통해 실제 결제를 승인/취소하며, 결제 성공 여부에 따라 예약 서비스의 상태를 갱신하도록 트리거한다.
  • 호텔 관리 서비스(백오피스)
    • 승인된 호텔 임직원이 객실 상태를 변경하거나, 긴급 유지보수를 위해 가용 객실을 차감하고, 수동으로 예약 및 취소를 처리하는 관리용 서비스이다.

서비스 간 연결

실무 환경에서는 이러한 마이크로서비스 간 통신 성능을 극대화하기 위해 REST API 대신 gRPC 프레임워크를 적극적으로 활용한다.


3.2. API 설계

호텔 웹사이트를 완성하려면 특정 기준에 맞는 객실을 검색하는 등의 기능도 필요하지만 기술적으로 도전적이지 않으므로 여기서는 핵심 비즈니스 기능인 호텔, 객실, 예약 도메인 API만 정의한다.


3.2.1. 호텔 및 객실 관리 API(주로 관리자 및 조회용)

호텔 관련 엔드 포인트

HTTP 메서드엔드포인트설명접근 권한
GET/v1/hotels/:id호텔의 상세 정보 및 소개 반환전체 공개
POST/v1/hotels신규 호텔 체인 추가호텔 직원 전용
PUT/v1/hotels/:id호텔 기본 정보 수정호텔 직원 전용
DELETE/v1/hotels/:id호텔 정보 삭제 (Soft Delete 권장)호텔 직원 전용

객실 관련 엔드포인트

HTTP 메서드엔드포인트설명접근 권한
GET/v1/hotels/:id/rooms/:room_id특정 객실의 상세 정보 조회전체 공개
POST/v1/hotels/:id/rooms특정 호텔에 신규 객실 물리 데이터 추가호텔 직원 전용
PUT/v1/hotels/:id/rooms/:room_id객실 상태 정보 (예: 정비 중 등) 업데이트호텔 직원 전용
DELETE/v1/hotels/:id/rooms/:room_id특정 객실 데이터 삭제호텔 직원 전용

3.2.2. 예약 API 및 멱등성 설계

고객이 실제 머무를 방을 선택하고 결제 프로세스로 진입하기 위한 API 세트이다.

HTTP 메서드엔드포인트설명
GET/v1/reservations현재 로그인한 사용자의 과거 및 현재 예약 이력 전체 반환
GET/v1/reservations/:id특정 예약 건에 대한 결제 상태 및 투숙 상세 정보 반환
POST/v1/reservations[핵심] 신규 객실 예약 요청 접수 및 가용 재고 선점
DELETE/v1/reservations/:id특정 예약 취소 및 잔여 객실 재고 복구 요청

신규 예약 생성 시 Request Body와 멱등 키

{
  "startDate": "2026-07-01",
  "endDate": "2026-07-03",
  "hotelID": "111",
  "roomID": "U12354673389",
  "reservationID": "13422445"
}

요청 본문의 reservationID는 단순한 데이터 식별자가 아니라, 네트워크 지연 등으로 인한 동일한 요청이 중복 접수되었을 때 시스템이 단 한 번만 처리하도록 보장하는 멱등 키(Idempotent Key) 역할을 수행한다.
이 유일성 제약 조건을 통해 분산 환경에서의 ‘따닥 클릭’ 중복 예약을 원천 차단한다.


3.3. 초기 데이터 모델링과 RDBMS 선택 이유

시스템의 트래픽 흐름을 보면, 전체 사용자 중 90% 이상이 ‘조회’ 연산을 수행하고, 단 1% 미만의 사용자만이 최종 ‘예약(쓰기)’ 연산에 도달한다.

  • 호텔 상세 정보 확인
  • 지정된 날짜 범위에 사용 가능한 객실 유형 확인
  • 예약 정보 기록
  • 예약 내역 또는 과거 예약 이력 정보 조회

이러한 접근 패턴을 바탕으로 초기 데이터베이스 스키마를 다음과 같이 설계할 수 있다.

DB 스키마

또한 예약 테이블(reservation)의 status 필드는 결제 진행 상황에 따라 유연하게 변하는 상태 머신(State Machine) 구조를 가진다.

예약 상태

대략적인 추정 과정을 통해 시스템 규모가 크지 않은 것은 알았지만, 대규모 이벤트가 있는 경우 트래픽이 급증할 수 있으니 대비해야 한다. 여기서는 RDBMS를 사용한다.


💡왜 대규모 트래픽 아키텍처에 RDBMS를 사용했을까?

아래와 같은 의문이 들 수 있다.

‘트래픽이 높으면 당연히 무한 확장 가능한 NoSQL을 써야 하는 것 아닐까?’
‘읽기 트래픽이 많을 때 앞단에 Redis 캐시를 두는 구조라면, 백엔드 DB는 NoSQL이 더 유리하지 않을까?’

결론부터 말하자면, 예약 및 결제 도메인에서는 RDBMS가 절대적으로 유리하다.
이유는 다음과 같다.

  • 강력한 ACID 트랜잭션 보장
    • 예약 시스템은 ‘재고 차감’과 ‘예약서 생성’이 하나의 원자적(Atomicity) 단위로 묶여야 한다.
    • NoSQL은 단일 도큐먼트 수준의 트랜잭션은 잘 지원하지만, 여러 테이블 혹은 분산 노드 간의 엄격한 격리성(Isolation)을 보장하는데 취약하다.
    • 반면, RDBMS는 Lock 기능을 이용하여 이전 작업이 완전히 끝날 때까지 다른 스레드가 대기하도록 할 수 있다.
  • 읽기 최적화는 캐시 계층의 몫
    • RDBMS도 적절한 인덱스 설계가 동반되면 수천 QPS 수준의 읽기는 매우 안정적으로 처리한다.
    • 한계를 넘어선 초대형 읽기 트래픽은 앞단에 Redis 같은 인메모리 캐시 계층을 배치하여 흡수하면 되기 때문에, 굳이 영속성 저장소인 메인 DB를 ACID가 약한 NoSQL로 변경하여 데이터 정합성 지옥에 빠질 이유가 없다.
  • 명확한 구조적 관계
    • 호텔, 객실, 고객, 예약은 비즈니스적 관계가 매우 끈끈한 정형 데이터이다.
    • FK를 통한 무결성 검증과 Join 연산의 이점을 RDBMS에서 온전히 누릴 수 있다.

🚨 초기 데이터 모델의 치명적인 한계점과 수정 예고

위 스키마를 보면 실무 비즈니스 환경에서는 사용할 수 없는 설계 결함이 존재한다.
바로 reservation 테이블이 room_id를 직접 참조하고 있다는 점이다.

고객은 호텔을 예약할 때 ‘302호 방’이라는 특정 물리 객실을 예약하지 않는다.
대다수의 호텔 예약 플랫폼은 ‘디럭스 킹 룸(Room Type)’을 예약하고, 실제 몇 호에 묵을지는 투숙객이 당일 체크인하는 시점에 결정된다.

이 요구사항을 반영하여 room_id 기반의 스키마를 room_type_id 중심의 확장 가능한 모델로 변경해야 한다.
이 구체적인 개선 과정은 4. 상세 설계 1단계: 데이터 모델 개선에서 알아본다.


3.4. 마이크로서비스 간 고성능 통신 전략: gRPC

MSA로 시스템을 잘게 쪼개면 서비스 간 네트워크 통신 횟수가 급증한다.
예를 들어 예약 서비스는 최종 결제 전에 총 요금을 정산하기 위해 요금 서비스에 질의해야 하고, 관리 서비스는 변경 사항을 타 서비스로 전파해야 한다.
이 때 2026년 대규모 아키텍처의 필수 프로토콜로 자리잡은 gRPC가 활약한다.

전통적인 REST API(JSON over HTTP/1.1)는 텍스트 기반 포맷을 사용하여 직렬화/역직렬화 비용이 크고, 한 연결당 하나의 요청만 처리할 수 있어 MSA 환경에서 심각한 네트워크 병목(Head-of-Line Blocking)을 유발한다.

Google이 개발한 gRPC는 이를 혁신적으로 해결한다.

  • HTTP/2 기반 멀티플렉싱
    • 하나의 TCP 커넥션 위에서 수많은 요청과 응답을 동시에 비동기적으로 주고받는다.
    • 네트워크 핸드셰이크 비용이 극적으로 절감되어 MSA 내부 통신의 지연 시간이 최소화된다.
  • 프로토콜 버퍼 활용
    • 사람이 읽는 텍스트(JSON) 대신, 컴파일된 바이너리 형식으로 데이터를 압축하여 전송한다.
    • 페이로드 크기가 JSON 대비 최대 10배 이상 작아져 대역폭을 극도로 절감한다.
  • 강력한 타입 명시와 Stub 생성
    • IDL(Interface Definition Language) 파일인 .proto 파일에 데이터 구조를 정의하면 Java, Kotlin, Go 등 다양한 언어로 통신 코드가 자동 생성(Stub)된다.
    • 서비스 간 호출이 마치 같은 프로세스 내의 로컬 함수를 호출하는 것처럼 명확하고 안전해진다.

4. 상세 설계 1단계: 데이터 모델 개선

위에서 언급했듯이, 투숙객은 구체적인 호수(예: 302호)가 아니라 객실 유형(Room Type)을 보고 예약한다.
따라서 데이터 모델을 한 단계 진화시켜야 한다.


4.1. Room 중심에서 RoomType 중심으로의 전환

기존의 예약 API(POST /v1/reservations) 요청 구조에서 구체적인 방을 가리키던 roomID를 객실 유형을 나타내는 roomTypeID로 변경한다.

{
  "startDate": "2026-07-01",
  "endDate": "2026-07-03",
  "hotelID": "111",
  "roomTypeID": "RT001",
  "reservationID": "13422445"
}

이 비즈니스 요구사항을 지원하기 위해 데이터베이스 스키마를 아래와 같이 구조화한다.

갱신된 스키마

핵심은 예약 서비스 관리를 위해 추가된 room_type_inventory 테이블이다.
이 테이블은 특정 호텔의 객실 유형별로 일자마다 재고를 관리한다.

  • total_inventory: 총 객실 수에서 일시적으로 제외한 객실 수(유지보수, 청소 등으로 인한 가용 제외)를 뺀 실제 판매 가능 객실 수
  • total_reserved: 지정된 hotel_id, room_type_id, date에 이미 예약이 완료된 객실의 수

room_type_inventory 테이블의 PK는 (hotel_id, room_type_id, date)복합키로 구성한다.
이 테이블은 대략 미래 2년 치의 데이터를 매일 주기적인 일괄 작업(Batch)으로 돌려 미리 채워두는 전략을 취한다.


4.2. 가용 객실 파악을 위한 쿼리 및 초과 예약 로직

hotel_idroom_type_iddatetotal_inventorytotal_reserved
21110012026-06-0110080
21110012026-06-0210082
21110012026-06-0310086
2111001
21110012028-05-311000
21110022026-06-01200164
22101012026-06-013023
22101012026-06-023025
  • 입력: startDate(2026-06-01), endDate(2026-06-03), roomTypeId, hotelId, numberOfRoomsToReserve
  • 출력: 해당 유형의 객실에 여유가 있고, 사용자가 예약 가능한 상태이면 True, 아니면 False

사용자가 특정 기간(2026-06-01 ~ 2026-06-03) 동안 방이 남아있는지 조회할 때, 시스템은 아래와 같은 2단계 절차를 거친다.

-- 1단계: 주어진 기간 내의 해당 객실 유형 레코드를 확보
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};

위 쿼리의 결과는 아래와 같다.

datetotal_inventorytotal_reserved
2026-06-0110097
2026-06-0210096
2026-06-0310095

애플리케이션 레이어에서는 반환된 모든 일자별 레코드를 순회하며 아래 조건문을 검증한다.
요구 사항이었던 10% 초과 예약 수식도 이 비즈니스 로직에 함께 녹여낼 수 있다.

// 일반적인 재고 검증 로직
if ((total_reserved + ${numberOfRoomsToReserve}) <= total_inventory) { ... }

// 10% 초과 예약을 허용하는 비즈니스 적용 시
if ((total_reserved + ${numberOfRoomsToReserve}) <= (total_inventory * 1.1)) { ... }

4.3. 데이터 볼륨 관리와 수평 확장(Sharding)

5,000개 호텔에 각각 20개의 객실 유형이 있고, 2년(730일) 치 데이터를 미리 적재한다고 하면 이 테이블의 레코드 수는 약 7,300만 건(5,000개 호텔 * 20개 객실 유형 * 730일)이 된다.
단일 데이터베이스 서버로도 저장 자체는 가능하지만, 단일 장애점(SPOF, Single-Point-Of-Failure)을 방지하고 트래픽을 분산하기 위해 수평 확장(Sharding)을 도입해야 한다.

2.2. 단계별 유저 이탈률을 고려한 페이지별 QPS 예측에서 계산한 시스템 전체의 평상 시 조회 및 쓰기 트래픽은 아래와 같았다.

  • 객실 상세 조회: 300 QPS
  • 예약 상세 확인: 30 QPS
  • 최종 예약 완료: 3 QPS
  • 전체 시스템 요청: 약 333 QPS

만일 호텔 시스템이 자체 웹사이트 뿐 아니라, 전 세계의 수많은 여행 대행사(OTA, Online Travel Agency, 온라인 여행 대행사) API와 연동된다면 트래픽이 평소보다 100배는 늘어날 수 있다. (333 * 100 = 약 30,000 QPS)
단일 MySQL 서버는 아무리 스펙을 높여도 초당 30,000건의 QPS를 혼자 감당할 수 없다.

데이터베이스 샤딩

이 시스템의 거의 모든 쿼리가 hotel_id를 조건절로 달고 다니기 때문에, hotel_id를 샤딩 키로 채택하는 것이 가장 자연스럽다.
hash(hotel_id) % 16과 같은 방식으로 데이터베이스 부하를 16개의 샤드로 균등하게 분산하면 각 샤드가 받는 부하는 아래와 같다. \[\text{각 샤드(DB 서버 1대)가 받는 부하} = \frac{\text{총 트래픽 (30,000 QPS)}}{\text{샤드 개수 (16개)}} = 1,875 \text{ QPS}\]

1,875 QPS는 MySQL 서버 1대로도 충분히 안정적인 범위 내로 부하가 통제된다.


5. 상세 설계 2단계: 분산 환경에서의 동시성(Concurrency)

호텔 예약 시스템의 가장 거대한 도전 과제는 바로 이중 예약(Double Booking)의 원천 차단이다.
동시성 문제는 크게 두 가지 시나리오로 나누어 쪼개 보아야 한다.


5.1. 시나리오 A: 동일 사용자가 예약 버튼을 여러 번 누르는 경우

네트워크가 지연될 때 사용자는 초조해하며 ‘예약하기’ 버튼을 연타하게 되고, 동일한 주문 요청이 중복해서 들어온다.

같은 고객의 이중 예약

클라이언트 측에서 버튼을 비활성화하는 조치는 자바스크립트 조작 등으로 쉽게 우회당하므로, 반드시 서버 측에서 멱등성 API로 방어해야 한다.

sequenceDiagram
    actor 사용자 as 사용자
    participant 예약 서비스 as 예약 서비스

    activate 사용자
    사용자->>예약 서비스: ① 예약 주문 생성
    activate 예약 서비스
    예약 서비스-->>사용자: ② 예약 주문서 표시 (reservation_id)
    deactivate 예약 서비스

    사용자->>예약 서비스: ③-a 예약 제출 (reservation_id)
    activate 예약 서비스
    사용자-x예약 서비스: ③-b 예약 제출 (reservation_id)
    deactivate 예약 서비스
    
    %% Note to indicate the error condition and its cause %%
    Note right of 예약 서비스: 유일성 조건 위반 (reservation_id)
    deactivate 사용자
  • ① 예약 주문 생성
    • 고객이 투숙 날짜, 인원 등의 세부 정보를 입력하고 ‘다음 단계’ 버튼을 누르면, 예약 서비스는 최종 결제 전에 예약 주문(Draft)을 생성한다.
    • 이 시점(=최종 예약 완료 버튼을 누르기 전)에 reservation 테이블에 데이터가 Insert 된다.
    • 다만, 아직 결제를 하기 전이므로 status 필드의 값은 PENDING_PAY(결제 대기) 혹은 DRAFT(초안) 상태로 저장된다.
    • 이 과정이 선행되어야만 안전한 식별자를 발행할 수 있다.
  • ② 전역 유일 식별자 발행 및 주문서 반환
    • 고객이 검토할 수 있도록 예약 주문서 정보를 반환하는데, 이 때 응답 데이터에 reservation_id를 함께 실어서 보낸다.
    • 이 식별자는 분산 환경 전체에서 중복이 없는 전역적 유일성(Globally Unique)을 보장하는 ID여야 한다.
  • ③-a 검토 완료 후 예약 요청 전송(멱등 키 활용)
    • 고객이 주문서를 최종 확인하고 ‘예약 완료(결제)’ 버튼을 누르면, 2단계에서 받아 두었던 reservation_id가 요청에 함께 포함되어 서버로 전송된다.
    • reservation_id가 바로 예약 테이블의 PK이자, 중복을 걸러내는 멱등 키 역할을 하게 된다.
  • ③-b 중복 클릭 시 DB 유일성 제약 조건 위반 방어
    • 네트워크 지연으로 인해 사용자가 예약 완료 버튼을 한 번 더 누르는 바람에 똑같은 요청이 서버로 다시 전송되었다고 하자.
    • 하지만 이미 ③-a에 의해 DB에는 해당 reservation_id를 가진 레코드가 정상적으로 처리되고 있거나 상태가 변경된 상태이다.
    • reservation_id는 예약 테이블의 PK이므로, 두 번째 요청이 들어와 Insert를 시도하는 순간 기본키 유일성 조건(Unique Constraint Violation)을 위반하게 된다.

유일성 조건 위반

결과적으로 DB 엔진 레벨에서 새로운 중복 레코드가 생성되는 것을 원천 차단해주기 때문에, 시스템은 아무리 버튼을 연타해도 단 한 번의 예약만 안전하게 성공시키는 멱등성을 완벽히 보장할 수 있다.

기술 표준 관점에서 멱등 키는 ‘해당 요청이 고유한 의도를 가졌음’을 증명하는 독립적인 토큰(예: UUID)이면 무엇이든 가능하기 때문에 꼭 reservation_id를 멱등키로 사용하지 않아도 된다.
예를 들어 HTTP 헤더에 X-Idempotency-Key: f81d4fae-... 같은 임의의 UUID를 실어 보내고, 서버가 이 키를 Redis 같은 분산 캐시에 먼저 조회하여 중복 요청을 차단하는 방식으로 설계해도 된다.
즉, 멱등성 구현 방법은 다양하지만 본 아키텍처에서는 어차피 생성해야 하는 엔티티의 PK(reservation_id)가 존재하므로, 이를 멱등키로 재활용하는 것이 비용 효율적이고 직관적이기 때문에 선택한 것뿐이다.


5.2. 시나리오 B: 여러 사용자가 같은 객실을 동시에 예약하려 하는 경우

동시성 제어에서 가장 해결하기 까다로운 영역은 바로 여러 사용자가 ‘동시에’ 같은 자원을 선점하려고 경쟁할 때 발생하는 경쟁 조건(Race Condition)이다.

Race Condition

이해를 돕기 위해 데이터베이스 트랜잭션 격리 수준이 가장 높은 수준인 직렬화 가능 수준(Serializable)으로 설정되어 있지 않은 일반적인 환경을 가정해보자.
현재 상황은 total_inventory = 100, total_reserved = 99 라고 가정한다.

  • ① 사용자 1과 2가 동시에 예약 프로세스에 진입한다.
  • ② 트랜잭션 2가 재고를 검증하기 위해 (total_reserved + room_to_book) <= total_inventory인지 검사한다.
    • 99+1 <= 100 이므로 True가 반환된다.
  • ③ 거의 동시에 트랜잭션 1도 동일한 재고 검증 쿼리를 실행한다.
    • 트랜잭션 2가 아직 커밋을 안했기 때문에 데이터베이스 기준으로는 여전히 방이 하나 남은 상태이다.
    • 따라서 트랜잭션 1에게도 True가 반환된다.
  • ④ 트랜잭션 1이 객실 예약을 처리하고 객실 예약 현황(total_reserved)을 1 증가시켜 값을 100으로 갱신한다.
  • ⑤ 그 직후 트랜잭션 2도 해당 객실 예약을 진행한다.
    • 데이터베이스 ACID 속성 중 I(Isolation, 격리성)에 의하면, 모든 트랜잭션은 다른 트랜잭션과 무관하게 독립적으로 작업을 완료해야 한다.
    • 즉, 트랜잭션 1이 데이터를 변경했더라도 최종 완료(Commit)되기 전까지는 트랜잭션 2의 눈에 이 변경사항이 보이지 않는다.
    • 트랜잭션 1의 변경 내용을 모르는 트랜잭션 2는 자신이 읽었던 데이터(99개 예약됨)를 기반으로 예약을 완료하고 total_reserved의 값을 다시 100으로 갱신한다.
    • 결과적으로 2명의 예약이 겹치는 이중 예약이 발생한다.
  • ⑥ 트랜잭션 1이 변경 사항을 성공적으로 Commit 한다.
  • ⑦ 트랜잭션 2도 자신의 변경 사항을 Commit 한다.

이 문제를 해결하려면 어떤 형태로든 Lock을 활용해야 한다.
해결책을 알아보기 전에 객실 예약에 사용되는 SQL 질의문의 pseudo code부터 살펴 보자.

# 1단계: 예약 가능 객실 현황 확인
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}

# 1단계에서 반환되는 모든 객실에 다음 사항 확인
  if((total_reserved + ${numberOfRoomsToReserve}) > 110% * total_inventory) {
    Rollback
  }

# 2단계: 객실 예약
UPDATE room_type_inventory
SET total_reserved = total_reserved + ${numberOfRoomsToReserve}
WHERE room_type_id = ${roomTypeId}
  AND date between ${startDate} and ${endDate}

Commit

이 쿼리 흐름이 동시 다발적으로 호출될 때 데이터가 뒤틀리는 것을 막기 위해 데이터베이스 격리 수준에만 의존하지 말고, 비관적 락, 낙관적 락, 혹은 데이터베이스 제약 조건을 아키텍처에 명시적으로 도입하여 이 타임라인의 뒤틀림을 강제로 바로 잡아주어야 한다.


💡트랜잭션 격리 수준의 종류와 ‘직렬화 가능 수준’이란?

트랜잭션 격리 수준은 동시에 여러 트랜잭션이 처리될 때, 특정 트랜잭션이 변경한 데이터를 다른 트랜잭션이 볼 수 있도록 허용할지 말지 결정하는 규칙이다.

ANSI/ISO SQL 표준 기준으로 트랜잭션 격리 수준(Isolation Level)은 크게 4가지로 나뉜다.
내려갈수록 격리성은 엄격해지지만 동시 처리 성능은 떨어진다.

  • Read Uncommitted(미커밋 읽기)
    • 다른 트랜잭션이 커밋하지 않은 데이터도 읽을 수 있다.
    • 존재하지 않는 데이터를 읽는 Dirty Read 문제가 발생한다.
  • Read Committed(커밋 읽기)
    • 커밋이 완료된 데이터만 읽을 수 있다.
    • 일반적인 RDBMS의 기본값이다.
  • Repeatable Read(반복 가능 읽기)
    • 하나의 트랜잭션 내에서 동일한 데이터를 여러 번 조회해도 항상 같은 결과를 보장한다.
    • MySQL InnoDB의 기본 격리 수준이다.
  • Serializable(직렬화 가능)
    • 가장 엄격하고 안전한 격리 수준이다.

💡Repeatable Read(반복 가능 읽기)

하나의 트랜잭션을 열고 똑같은 조회를 두 번 했는데, 그 사이에 값이 바뀌는 것이 이상할 수도 있다.
아래 Read Committed 일 때의 상황을 보자.

Read Committed

사용자 A는 분명 하나의 트랜잭션 안에서 똑같은 SELECT 문을 두 번 날렸는데 T2 시점에는 1개였던 재고가, T4 시점에는 0개로 바뀌어버렸다.

이처럼 다른 트랜잭션이 중간에 데이터를 변경하고 커밋을 해버리면, 내 트랜잭션 안으로 그 변경사항이 실시간으로 스며드는 현상을 반복 불가능한 읽기(Non-Repeatable Read) 현상이라고 한다.

Repeatable Read는 이 문제를 MVCC(Multi-Version Concurrency Control, 다중 버전 동시성 제어)로 해결한다.

  • 사용자 A가 트랜잭션을 시작한 순간(T1), 데이터베이스는 그 시점의 스냅샷을 찍어둔다.
  • 중간에 사용자 B가 해당 데이터를 변경하여 커밋해도, 사용자 A가 두 번째 조회(T4)를 할 때 DB는 롤백 세그먼트(Undo 로그)에 저장된 이전 버전의 데이터를 뒤져서 처음과 똑같은 결과인 1개를 강제로 보여준다.

그럼 B가 데이터를 변경했는데도 A가 계속 1개 남았다고 잘못된 데이터를 보는 건 아닌가? 하는 생각이 들 수 있다.
맞다. 하지만 하나의 트랜잭션 안에서 데이터의 일관성을 유지하기 위해 데이터베이스가 하는 격리 장치이다.


💡Serializable(직렬화 가능 수준)

여러 트랜잭션이 동시에 실행되더라도, 마치 동시성이 전혀 없이 한 줄로 서서 한 번에 하나씩 순차적으로 실행(Serial)되는 것과 같은 결과를 보장하는 수준이다.
데이터베이스가 트랜잭션이 접근하는 모든 영역에 읽기/쓰기 Lock을 걸어버리기 때문에 데이터 정합성은 완벽하지만, 초당 처리량은 매우 낮아져서 대규모 트래픽을 처리해야 하는 상용 아키텍처에서는 거의 사용되지 않는다.


5.2.1. 방안 1: 비관적 락(Pessimistic Lock)

비관적 락은 데이터 충돌이 무조건 발생할 것이라고 가정하고, 레코드를 읽는 순간부터 다른 트랜잭션이 접근하지 못하도록 먼저 Lock을 거는 방식이다.
데이터베이스 레벨에서 SELECT ... FOR UPDATE 구문을 사용하여 구현한다.

비관적 락

위 그림처럼 트랜잭션 1이 먼저 실행되었다면 다른 트랜잭션은 트랜잭션 1이 종료되기를 기다려야 한다.
트랜잭션 1이 끝나고 나면 예약된 객실 수는 100이 되므로 사용자 2는 객실 예약이 불가하다.

-- 트랜잭션 시작과 동시에 해당 일자 레코드에 배타적 락(X-Lock) 획득
START TRANSACTION;

SELECT total_inventory, total_reserved 
FROM room_type_inventory 
WHERE hotel_id = 111 AND room_type_id = 'RT01' AND date = '2026-07-01' 
FOR UPDATE;

-- 애플리케이션에서 재고 확인 후 업데이트 진행 (위 pseudo code의 2단계)
UPDATE room_type_inventory 
SET total_reserved = total_reserved + 1 
WHERE hotel_id = 111 AND room_type_id = 'RT01' AND date = '2026-07-01';

COMMIT;
  • 장점
    • 확실한 데이터 보호
      • 내 트랜잭션(애플리케이션)이 수정하려고 변경 중이거나 이미 수정을 마친 데이터를, 다른 트랜잭션이 중간에 끼어들어 무단으로 조작하거나 갱신하는 일을 데이터베이스 레벨에서 원천 차단할 수 있음
    • 쉬운 구현 난이도
      • 모든 갱신 연산을 데이터베이스 엔진 레벨에서 직렬화(Serialization)하여 충돌을 막아주므로, 복잡한 실패 재시도 로직을 구현할 필요가 없다.
    • 높은 경합 상황에 유리
      • 데이터에 대한 경합(Contention)이 매우 심할 때, 낙관적 락처럼 롤백 후 재시도하는 비용을 쓰지 않고 차라리 한 줄로 세우는 것이 성능상 유용하다.
  • 단점
    • Deadlock 위험
      • 여러 레코드에 동시에 Lock을 걸면 트랜잭션 간에 자원을 물고 늘어지는 DeadLock이 발생할 수 있으며, 이를 피하는 애플리케이션 코드를 작성하는 것은 생각보다 까다롭다.
    • 낮은 확장성
      • 하나의 트랜잭션이 너무 오랫동안 락을 해제하지 않고 있으면, 동일한 자원에 접근하려는 모든 다른 트랜잭션은 대기 상태에 빠져 무한정 멈추게 된다.
    • 성능 저하
      • 특히 트랜잭션의 수명이 길거나(외부 API 연동 등) 많은 엔티티(여러 날짜의 객실 재고)에 연관된 경우, 커넥션 풀이 고갈되고, 데이터베이스 전체 성능에 심각한 영향을 준다.

이러한 치명적인 성능 확장성 제약과 DeadLock 위험성 때문에, 읽기 부하가 압도적인 호텔 예약 시스템 아키텍처에서 비관적 락 메커니즘은 권장하지 않는다.

비관적 락은 자원 경쟁이 치열하고, 결제 정합성이 무엇보다 중요하며, 실패 시 재시도 비용이 너무 막대한 시스템(예: 은행 계좌 이체, 증권 거래 시스템)에 적합하다.


💡DeadLock이 생기지 않는 애플리케이션 코드를 어떻게 짜야 할까?

비관적 락을 사용할 때 단점으로 지적된 교착 상태를 완벽히 차단하려면, 애플리케이션 레이어에서 아래 3가지 핵심 규칙을 준수해야 한다.

  • Lock 획득 순서의 전역적 일관성 강제(Lock Ordering)
    • 교착 상태의 근본 원인은 서로 다른 트랜잭션이 자원을 서로 반대 순서로 점유하려고 하기 때문이다.
    • 예) 트랜잭션 A는 락 1 → 락 2 시도, 트랜잭션 B는 락 2 → 락 1 시도
    • 이를 막으려면 연박 예약(예: 1박 2일) 등으로 여러 레코드에 락을 걸 때, 애플리케이션 코드가 리스트를 항상 동일한 기준(예: 날짜 오름차순, 혹은 ID 정렬 순)으로 Sort 한 뒤 순차적으로 쿼리를 실행하도록 강제해야 한다.
    • 사용자 A는 7/1 → 7/2 → 7/3 순서로 락을 시도하고, 사용자 B는 7/3 → 7/2 → 7/1 순서로 역방향 락을 시도하면 서로가 서로의 자원을 물고 늘어지는 교착 상태에 빠진다.
  • Lock Timeout 지정 및 비차단 옵션 활용
    • 락을 얻지 못했을 때 무한정 대기하도록 두면 DeadLock 시 시스템이 완전히 마비된다.
    • 데이터베이스 쿼리에 타임아웃을 명시하거나, MySQL의 경우 FOR UPDATE NOWAIT(락이 걸려있으면 즉시 에러 반환) 또는 FOR UPDATE SKIP LOCKED(이미 락이 걸린 행은 건너뜀) 같은 모던 SQL 옵션을 주어 애플리케이션이 제어권을 잃지 않도록 해야 한다.
  • 트랜잭션 범위(Scope)와 점유 시간 최소화
    • START TRANSACTION을 켜고 비관적 락을 획득한 뒤, 그 안에서 결제 PG사 API를 호출하거나 무거운 비즈니스 연산을 처리하는 것은 DeadLock을 유발하는 행위이다.
    • 데이터 계산, 파싱, 외부 통신 등은 트랜잭션 외부에서 미리 다 끝내놓고, 오직 DB 레코드를 검증하고 업데이트하는 ms 단위의 찰나의 순간에만 트랜잭션을 열고 락을 건 뒤 즉시 COMMIT으로 자원을 반환해야 한다.

5.2.2. 방안 2: 낙관적 락(Optimistic Lock)

낙관적 락은 여러 사용자가 동시에 같은 자원을 갱신하려 시도하는 것을 허용한다.

데이터베이스에 물리적인 Lock을 걸지 않는 대신, 애플리케이션 레벨에서 데이터의 버전 번호(version)나 타임스탬프를 비교하여 정합성을 유지하는 방식이다.
분산 시스템에서 서버 간 시계가 미세하게 어긋날 수 있으므로 일반적으로 버전 번호를 사용하는 것이 훨씬 안정적이다.

낙관적 락

  • 버전 열 추가
    • 테이블에 version이라는 새 열(Column)을 추가한다.
  • 버전 읽기
    • 사용자가 레코드를 수정하기 전에, 애플리케이션은 해당 레코드의 잔여 객실 수와 함께 버전 번호를 먼저 읽어온다.
  • 버전 가산
    • 사용자가 레코드를 갱신할 때, 애플리케이션은 읽어왔던 버전 번호에 1을 더한 후 테이블에 기록할 준비를 한다.
  • 유효성 검사 및 커밋
    • 업데이트 시점에 유효성 검사를 수행한다.
    • 즉, 내가 기록하려는 다음 버전 번호가 데이터베이스에 저장된 현재 버전보다 정확히 1만큼 큰 값이어야 한다.
    • 만일 이 유효성 검사가 실패하면 트랜잭션은 rollback되고, 사용자는 2단계(데이터 다시 읽기)부터 모든 절차를 처음부터 재시도해야 한다.
-- 1. 먼저 현재 버전 번호를 조회 (예: 당시 조회된 version이 1이라고 가정)
SELECT total_inventory, total_reserved, version
FROM room_type_inventory
WHERE hotel_id = 111 AND room_type_id = 'RT01' AND date = '2026-07-01';

-- 2. 조건절에 내가 조회했던 당시의 버전 번호를 명시하며 갱신 시도
UPDATE room_type_inventory
SET total_reserved = total_reserved + 1, version = version + 1
WHERE hotel_id = 111 AND room_type_id = 'RT01' AND date = '2026-07-01'
  AND version = 1; -- 원자적 검증 구간
  • 장점
    • 애플리케이션 레벨의 정합성 보장
      • 애플리케이션이 이미 유효하지 않게 된(다른 사람이 선점한) 구버전 데이터를 기반으로 편집하고 갱신하는 일은 막아준다.
    • 자원 점유 최소화
      • 데이터베이스에 무겁게 Lock을 걸 필요가 없다.
      • 버전 번호를 통해 데이터 일관성을 유지할 책임이 온전히 애플리케이션에 있기 때문에 DB 엔진의 가용성이 높아진다.
    • 낮은 경쟁 상황에 최적
      • 데이터에 대한 경쟁이 치열하지 않은 상황에 매우 적합하다. 락을 관리하는 오버헤드가 전혀 없기 때문이다.
        • 반면, 대형 콘서트 티켓팅처럼 경쟁이 치열한 상황에서는 수많은 트랜잭션이 무한 재시도를 하므로 애플리케이션 자원을 고갈시키기 때문에 절대 권장하지 않는다.
  • 단점
    • 높은 경쟁 상황에서의 성능 저하
      • 데이터에 대한 경쟁이 치열한 성수기나 핫딜 타임에는 연쇄적인 롤백과 재시도 폭풍 때문에 시스템 성능이 기하급수적으로 떨어진다.

호텔 예약 시스템은 초당 최종 예약 처리(3 QPS) 자체의 경쟁률이 주식 거래나 콘서트 티켓팅처럼 초 단위로 치열하게 엉키는 도메인이 아니기 때문에, 락 오버헤드가 없는 낙관적 락이 아주 훌륭한 선택지가 된다.


🚨동시성 수준이 아주 높을 때 낙관적 락이 성능이 급격히 나빠지는 이유

낙관적 락은 일반적으로 비관적 락보다 빠르다. 데이터베이스 자체에 무거운 Lock을 걸지 않기 때문이다.
하지만 동시성 수준이 아주 높으면(High Contention) 성능이 급격하게 나빠지는 치명적인 부작용이 있다. 왜 그럴까?

많은 클라이언트가 동일한 객실을 동시에 예약하려고 몰려드는 상황을 생각해보자.
낙관적 락은 읽기 연산을 제한하지 않으므로, 모든 클라이언트는 아무런 제약 없이 똑같은 잔여 객실 수와 똑같은 버전 번호(예: version=1)를 동시에 취득하게 된다.

그러나 실제로 데이터베이스에 먼저 도달하여 버전 번호 갱신(version=2)에 성공하는 클라이언트는 오직 하나뿐이다.
그 직후 도달한 다른 모든 클라이언트들은 데이터베이스의 버전이 이미 2로 올라가 버렸기 때문에 ‘버전 번호 검사 실패’ 메시지를 받으며 트랜잭션이 rollback 된다.

실패한 클라이언트들은 예약을 완료하기 위해 처음부터 다시 데이터를 읽고 재시도해야 한다.
하지만 다음번 역시 단 한 명의 클라이언트만 성공하고, 나머지 클라이언트들은 또 다시 실패하여 재시도 궤도에 진입한다.

💥결과
최종 결과 자체는 데이터 뒤틀림 없이 정확하겠지만, 백엔드에서는 수많은 쓰기 실패와 무한 재시도 루프(Retry Storm)가 발생하여 서버 자원을 극심하게 고갈시킨다.
유저 역시 화면이 뱅글뱅글 돌며 반복되는 재시도를 겪어야 하므로 매우 불쾌하고 짜증나는 경험을 하게 된다.


5.2.3. 데이터베이스 제약 조건(DB Constraint)

이 접근법은 데이터베이스 엔진 자체의 물리적인 무결성 제약 조건을 활용하여 동시성을 제어하는 방식으로, 낙관적 락과 아주 유사한 메커니즘으로 동작한다.
room_type_inventory 테이블에 아래와 같이 잔여 객실 수가 음수가 되지 않도록 CHECK 제약 조건을 추가하여 구현한다.

-- 테이블 생성 시 제약 조건 명시
ALTER TABLE room_type_inventory 
ADD CONSTRAINT check_room_count CHECK (total_inventory - total_reserved >= 0);

데이터베이스 제약 조건

위 그림처럼 사용자 1과 사용자 2가 동시에 남은 1개의 객실을 예약하려고 경합하는 상황을 예로 들어보자.
사용자 1의 트랜잭션이 먼저 성공하여 total_reserved가 100이 된 상태에서, 곧바로 사용자 2의 트랜잭션이 똑같은 수정을 시도하면 total_reserved가 101이 되려고 할 것이다.

이 때 데이터베이스 엔진은 total_inventory(100) - total_reserved(101) >= 0 이라는 제약 조건을 위배했음을 감지하고, 사용자 2의 트랜잭션을 중단시키고 롤백처리 한다.

  • 장점
    • 매우 쉬운 구현
      • 애플리케이션 레이어에서 복잡한 버전 관리나 Lock을 관리할 필요 없이, DB에 제약 조건 한 줄만 선언하면 되므로 구현이 압도적으로 쉽다.
    • 낮은 경쟁 상황에 유용
      • 낙관적 락과 마찬가지로 데이터에 대한 경쟁이 심하지 않을 때 깔끔하고 가볍게 잘 동작한다.
  • 단점
    • 치열한 경쟁 시 사용자 경험 저하
      • 낙관적 락이 가진 문제점과 동일하게, 데이터 경합이 심한 상황에서는 실패하여 튕겨나가는 연산 수가 엄청나게 늘어난다.
      • 사용자는 화면 상에서 분명 객실이 남아있는 것을 보고 시도했음에도, 정작 최종 단계에서 ‘객실이 없다’는 롤백 응답을 마주하게 되어 서비스 경험이 나빠질 수 있다.
    • 버전 통제(형상 관리)의 어려움
      • 데이터베이스 제약 조건은 애플리케이션 소스 코드와 달라서 버전을 통제하기가 매우 까다롭다.
        • 여기서 말하는 버전은 낙관적 락의 version 컬럼이 아니라, 애플리케이션 소스 코드의 ‘Git Version Control’과 ‘형상 마이그레이션’을 뜻한다.
        • 코드로 비즈니스 로직을 짜면 Git에 커밋 이력이 남아 문제가 생겼을 때 이전 코드로 롤백하기가 쉽다.
        • 하지만 비즈니스 검증 규칙(재고가 0 이상이어야 한다는 룰)을 데이터베이스 엔진 내부의 CHECK 제약 조건으로 고정해 버리면, 이 규칙을 바꾸고 싶을 때마다 DB에 직접 ALTER TABLE DDL 문을 날려 마이그레이션을 해야 한다.
        • 만약 서버는 구버전인데 DB 제약 조건만 신버전으로 먼저 반영되거나, 혹은 반대의 상황이 발생하면 인프라 배포 동기화가 깨지면서 대형 장애로 이어질 수 있다.
        • “비즈니스 로직의 통제권이 Git 중심의 애플리케이션 레이어를 벗어나 DB 서버로 넘어가 버리기 때문에 버전 관리 및 인프라 통제가 어려워진다.”
    • 데이터베이스 이식성 저하
      • 모든 DBMS 제품군이 CHECK 제약 조건을 완벽하게 허용하거나 동일하게 지원하는 것은 아니다.
      • 따라서 향후 비즈니스 확장으로 인해 데이터베이스 제품군을 다른 제품으로 교체하려고 할 때 이 마이그레이션 제약이 걸림돌이 될 수 있다.

이 접근법은 구현이 매우 쉽고, 호텔 예약 시스템의 특성상 평상시에는 데이터에 대한 경쟁이 그리 심하지 않기 때문에 호텔 대규모 분산 시스템 환경에서 도입을 고려하기에 좋은 선택지 중 하나이다.


6. 상세 설계 3단계: 시스템 규모 확장

6.1. 데이터베이스 샤딩

4.3. 데이터 볼륨 관리와 수평 확장(Sharding) 참고.


6.2. Redis 캐시 계층 도입과 데이터 동기화 아키텍처

호텔 잔여 객실 데이터는 오직 ‘현재와 미래의 데이터’만 의미가 있다.
고객이 과거의 날짜를 예약하려 하지는 않기 때문이다.
따라서 영구 저장할 필요가 없고, 일정 시간이 지나면 자동으로 사라져도 좋은 가용 객실 데이터의 특성상 인메모리 캐시 시스템인 Redis를 도입하기에 좋다.


💡Redis의 LRU란?

LRU(Least Recently Used)는 ‘가장 오랫동안 사용되지 않은 데이터를 메모리에서 우선적으로 퇴출하는 알고리즘’이다.

캐시 메모리가 가득 찼을 때, Redis는 최근에 사용(조회/수정)된 적이 있는 데이터들은 그대로 보존하고, 수일간 아무도 이용하지 않은 낡은 호텔 재고 데이터부터 자동으로 삭제하여 빈 공간을 확보한다.
여기에 TTL 설정을 결합하여 과거의 날짜 데이터를 자동으로 소멸시키면, 안정된 메모리 자원을 실시간으로 가장 인기 있는 핵심 데이터 위주로 최적화하여 유지할 수 있다.

캐시


컴포넌트별 핵심 역할 분담

  • 예약 서비스
    • 시스템의 메인 인프라로서 아래와 같은 핵심 잔여 객실 관리 API를 백엔드에 제공한다.
      • 지정된 호텔, 객실 유형, 주어진 날짜 범위에 대해 현재 이용 가능한 객실의 수를 질의하는 엔드포인트 제공
      • 고객이 예약을 완료하면 가용 재고를 확인한 후 room_type_inventory 테이블의 total_reserved 값을 1 증가시키는 트랜잭션 처리
      • 고객이 예약을 취소하면 즉시 취소 이벤트를 수신하여 잔여 객실 수를 다시 원복(갱신)하는 로직 수행
  • 잔여 객실 서비스
    • 잔여 객실 관리에 필요한 모든 읽기(조회) 질의를 데이터베이스가 아닌 Redis 인메모리 캐시 계층으로 완전히 이관하여 처리
    • 빠른 조회를 위해 2년 이내의 모든 미래 날짜에 대한 가용 객실 데이터를 사전에 레디스 캐시에 저장해두어야 한다.
      • Key: hotelId_roomtypeId_{date}, 예) 111_RT001_20260701
      • Value: 주어진 hotelId, 객실 유형 id, 날짜에 맞는 잔여 객실 수로 실시간 차감 및 복구 대상
  • 잔여 객실 데이터베이스
    • 최종 재고 데이터의 영속성을 보장하는 마스터 저장소(SSOT, Single Source of Truth)이다.
    • 캐시가 감당하지 못하는 최종 쓰기 트랜잭션과 유효성 검증을 한다.

<잔여 객실 캐시 서비스 및 데이터 접근 패턴>

  • 캐시 데이터 구조
    • Key: hotelId_roomtypeId_{date}, 예) 111_RT001_20260701
    • Value: 주어진 hotelId, 객실 유형 id, 날짜에 맞는 잔여 객실 수로 실시간 차감 및 복구 대상
  • 조회 흐름
    • 호텔 예약 시스템의 읽기 연산 빈도는 쓰기보다 훨씬 압도적이다.
    • 잔여 객실을 조회하는 99%의 요청은 메인 DB가 아닌 메모리(Redis)단에서 즉시 반환되어 높은 성능을 보장한다.
  • 갱신 흐름(동기화 이슈 해결)
    • 사용자가 객실을 최종 예약하면 데이터베이스의 재고 데이터가 먼저 갱신되고, 캐시 시스템에는 비동기적으로 변경 내역이 반영된다.

💡CDC와 Debezium이란?

캐시 계층을 추가하면 시스템의 확장성과 처리량은 대폭 증가하지만, 데이터베이스와 캐시 사이의 데이터 일관성 유지에 관한 문제를 직면하게 된다.
데이터베이스와 캐시 간의 일관성을 맞추기 위해 애플리케이션 코드에 ‘DB 저장 후 Redis 수정’ 로직을 집어넣으면 소스 코드가 복잡해지고 트랜잭션이 무거워진다.
CDC(Change Data Capture) 메커니즘은 이를 해결해준다.

[예약 서비스] ──> [잔여객실 DB]
                         │ (Binlog 기록)[Debezium 커넥터] ──> [Kafka] ──> [Redis 캐시 갱신]

보편적으로 많이 사용되는 오픈소스 솔루션인 드베지움(Debezium)을 활용하여 데이터베이스의 바이너리 로그(Binlog) 변화를 실시간으로 감지(Capture)한다.
가용 재고가 차감되는 순간 이 변경 이벤트를 메시지 큐(Kafka)를 통해 Redis 캐시 시스템에 전파하여 자동으로 싱크를 맞춘다.


💡캐시 지연으로 인한 데이터 불일치의 해답

비동기 갱신 특성상 DB에는 방이 없는데 캐시에는 아직 방이 1개 남아있다고 나오는 짧은 찰나의 ‘불일치 구간’이 발생할 수 있다.
결론부터 말하자면 비즈니스적으로 전혀 문제가 되지 않는다. 최종 방어선인 메인 데이터베이스가 존재하기 때문이다.

캐시 불일치를 보고 사용자가 ‘예약하기’ 버튼을 누르면 요청이 메인 데이터베이스(샤드)에 도달한다.
이 때 DB 레벨에서 앞서 구현한 5.2. 시나리오 B: 여러 사용자가 같은 객실을 동시에 예약하려 하는 경우 절의 유효성 검사(낙관적 락 또는 CHECK 제약 조건)가 다시 한번 수행된다.
실제 남은 객실이 없음이 확인되면 트랜잭션은 즉시 롤백되고, 클라이언트에게 ‘남은 객실 없음’ 오류 메시지를 반환해주어 데이터의 정합성이 유지된다.

<캐시 계층 도입>

  • 장점
    • 읽기 질의를 메모리에서 초고속으로 실행하므로 폭발적인 대규모 트래픽 속에서도 높은 성능과 높은 확장성을 보장
  • 단점
    • DB와 캐시 사이의 일관성이 아주 잠깐 깨질 수 있으므로, 이러한 불일치 상황이 사용자 경험에 미칠 영향을 신중하게 고려하여 최종 DB 레벨의 밸런싱 검증 코드를 반드시 매핑해두어야 함

6.3. 마이크로서비스 간 데이터 일관성 제어

MSA를 설계할 때 가장 흔히 범하는 오류는 ‘교조주의적(Dogmatic) 접근’에 빠지는 것이다.
교조주의적 MSA란 ‘마이크로서비스는 무조건 자신만의 독자적인 독립 데이터베이스를 가져야만 해’ 라고 기계적으로 아키텍처를 분리해 버리는 것을 말한다.

모노리스 vs MSA

만일 이 원칙을 고집하여 ‘예약 서비스’와 ‘잔여 객실 서비스’의 데이터베이스를 물리적으로 쪼개버리면, 논리적으로는 하나의 원자적 연산이어야 할 예약 처리가 여러 데이터베이스에 걸쳐 분산 실행되는 분산 트랜잭션이 생겨버린다.

전통적인 모노리스 아키텍처의 경우, 아래 그림처럼 데이터베이스를 공유하므로 단일 트랜잭션 하나로 완벽한 ACID 속성을 만족시킬 수 있다.

모노리스 아키텍처

하지만 교조주의적 MSA 환경에서는 아래 그림처럼 하나의 예약 요청을 처리하기 위해 여러 서비스의 독립 DB가 엮이게 된다.
하나의 트랜잭션으로 데이터 일관성을 보증하는 기법을 사용할 수 없다는 뜻이다.

MSA 아키텍처

만약 예약 서비스 DB 갱신을 성공했는데 잔여 객실 서비스 DB 갱신 연산이 네트워크 장애로 실패한다면 어떻게 될까?
잔여 객실 DB에 기록된 예약 객실 수는 원래 값으로 롤백되어야 하지만, 단일 트랜잭션이 아니기 때문에 자동으로 되돌릴 방법이 없다.
실패 시 데이터 불일치 문제가 발생할 수 있는 실행 경로가 너무 많아지는 것이다.

이를 해결하기 위해 분산 환경에서 업계가 주로 사용하는 두 가지 트랜잭션 관리 기법을 알아보자.


6.3.1. 방안 1: 2단계 커밋(2-Phase Commit, 2PC)

2PC는 여러 물리적인 서버 노드에 걸쳐진 분산 데이터베이스 환경에서 전체 트랜잭션의 원자성을 보장하기 위해 고안된 전통적인 프로토콜이다.
전체 과정을 조율하는 조정자(Coordinator)의 지휘 아래 1단계(준비-Prepare)와 2단계(커밋-Commit)로 나누어 모든 노드가 다 함께 성공하든가, 다 함께 실패하도록 강제한다.

여기서 말하는 노드는 분산 네트워크 아키텍처상에서 독립적으로 실행되는 ‘개별 서버’ 혹은 ‘개별 데이터베이스 인스턴스’를 뜻한다.

2PC는 비중단 실행(Non-Blocking)이 가능한 프로토콜이 아니다.(Blocking Protocol)
‘비중단 실행’이란 어떤 자식 노드 하나가 갑자기 장애가 나도, 남은 시스템들은 멈추지 않고 자기 할 일을 계속 이어나갈 수 있는 구조를 말한다.

반면, 2PC는 치명적인 블로킹 프로토콜이다.
1단계 준비를 마치고 2단계 최종 승인을 기다리던 도중, 조정자 서버가 갑자기 다운되어 버리면, 모든 자식 노드(데이터베이스 서버들)는 커밋을 할지 롤백을 할지 결정을 내리지 못한 채 Lock을 쥔 상태로 영원히 대기 상태에 멈춰버린다.(Blocking)
이 장애가 복구될 때까지 다른 모든 사용자들의 예약 요청도 중단되므로 성능상 커다란 단점이 된다.


6.3.2. 방안 2: 사가 패턴(Saga Pattern)

2PC의 성능 한계를 극복하기 위해 모던 MSA에서 가장 애용되는 기법이 바로 사가(Saga) 패턴이다.

2.2. 사가(saga): 여러 트랜잭션에 걸친 비즈니스 로직

사가는 분산 트랜잭션을 하나의 커다란 덩어리로 묶지 않고, 각 노드에 국지적으로 발생하는 로컬 트랜잭션을 체인처럼 순차적으로 연결한 비즈니스 흐름이다.

[단계 1: 예약 생성] ── (성공 이벤트 발행) ──> [단계 2: 재고 차감]
        │                                      │
        └─ (만약 실패 시!) <── [보상 트랜잭션 실행] ──┘

각각의 로컬 트랜잭션은 자신의 데이터베이스에 값을 반영한 뒤 ‘완료 이벤트’를 발행하여 다음 단계의 서비스를 트리거한다.

만약 마지막 단계인 재고 차감 도중 에러가 발생하면, 사가는 시스템을 거꾸로 거슬러 올라가며 이전에 성공했던 단계들의 결과를 전부 물리적으로 되돌리는 보상 트랜잭션들을 순차적으로 실행하여 데이터를 원복한다.


분산 트랜잭션을 완벽하게 제어하기 위해 2PC나 사가 패턴을 도입하면 아키텍처의 설계 복잡성과 인프라 관리 비용이 기하급수적으로 늘어난다.

“과연 마이크로서비스의 독립 DB 원칙을 지키기 위해 이 거대한 복잡성을 감당할 가치가 있는가?”

본 설계안에서는 그만한 가치가 없다고 판단한다.
대신 교조주의적 MSA의 고정관념을 깨고 예약 서비스가 예약 처리와 잔여 객실 재고 관리를 모두 전담하도록 설계하였다.
그리고 예약 테이블(reservation)과 재고 테이블(room_type_inventory)을 동일한 RDBMS(샤드 데이터베이스) 공간에 함께 저장하는 ‘실용적인 하이브리드 접근법’을 선택하였다.

이렇게 구조를 모아두는 것만으로 복잡한 분산 트랜잭션 기술 없이도 단일 RDBMS 고유의 강력한 ACID 속성과 로컬 트랜잭션을 온전히 활용할 수 있게 되며, 분산 환경에서 발생하는 수많은 데이터 일관성 난제들을 깔끔하게 해결할 수 있다.


7. 마무리 및 요약

7.1. 호텔 예약 시스템 핵심 아키텍처 요약

이번 설계에서 도출한 핵심 아키텍처 결정 사항(Design Decisions)은 아래와 같다.

마인드 맵

  • 도메인 중심의 데이터 모델 개선
    • room_id 기반의 물리적 예약 구조에서 벗어나, 비즈니스 현실에 맞춘 room_type_id 중심의 재고 관리 테이블(room_type_inventory)을 도출하고 10% 초과 예약 공식 처리
  • 철저한 중복 요청 방어(Idempotency)
    • 사용자 단의 ‘따닥 클릭’으로 인한 이중 결제를 막기 위해, 주문서 생성 시점부터 전역 유일 식별자인 reservation_id를 발행하여 데이터베이스 유일성 제약 조건(PK)과 매핑하는 멱등성 API 구축
  • 동시성 선점 경쟁 제어
    • 마지막 남은 객실 하나를 두고 수많은 트랜잭션이 충돌할 때, 확장성을 저해하는 비관적 락 대신 락 오버헤드가 없는 낙관적 락(Version 검증)과 DB 제약 조건(CHECK 무결성)을 조합하여 시스템 가용성 극대화
  • 하이브리드 MSA와 데이터 일관성
    • 서비스 간 데이터 정합성을 맞추기 위해 무리하게 분산 트랜잭션(2PC, Saga 패턴)을 도입하는 교조주의적 접근을 지양
    • 대신 예약과 재고 데이터를 동일한 RDBMS 샤드 내에 배치하여 단일 트랜잭션(ACID)의 이점을 온전히 누리는 실용주의적 아키텍처 선택
  • 초고속 캐싱과 CDC 동기화
    • OTA 연동으로 인한 수만 QPS의 읽기 폭탄을 방어하기 위해 Redis 인메모리 캐시 계층을 앞단에 세우고, CDC(Change Data Capture) 솔루션인 드베지움(Debezium)을 통해 DB와 캐시 간의 싱크를 비동기로 연결

7.2. 상용 솔루션 시장 동향

이런 설계로 만들어진 시스템이 이미 상용화되어 기성 제품으로 팔고 있는 것이 있을까? 당연히 있다.

여기서 설계한 핵심 엔진들을 PMS(Property Management System, 자산 관리 시스템)CRS(Central Reservation System, 중앙 예약 시스템) 라는 도메인 용어로 부르며, 이미 전 세계 표준으로 자리 잡은 거대 상용 플랫폼들이 시장을 지배하고 있다.

직접 구축하지 않고 엔터프라이즈급 기성 제품(COTS, Commercial Off-The-Shelf)이나 SaaS를 도입할 때 검토할 수 있는 대표적인 글로벌 상용 제품은 다음과 같다.

  • Oracle Hospitality OPERA Cloud (오라클 오페라)
    • 시장 위치: 글로벌 5성급 체인 호텔(메리어트, 하얏트, 힐튼 등)의 약 80% 이상이 사용하는 전 세계 PMS 시장의 절대 강자이자 표준
    • 특징
      • 여기서 고민했던 대규모 분산 객실 재고 관리, 체크인 시점의 실시간 방 배정(Room Assignment) 알고리즘, 그리고 강력한 데이터베이스 정합성 기능이 오라클 DB의 강력한 트랜잭션 엔진 위에서 완벽하게 구현되어 있음
      • 다만 시스템이 다소 무겁고 구축 비용이 상상을 초월한다는 단점이 있음
  • Sabre SynXis / Amadeus CRS (세이버 신지스 / 아마데우스)
    • 시장 위치: 전 세계 항공권 및 호텔 가용 재고를 실시간으로 중계하는 글로벌 유통 시스템(GDS) 연동의 핵심 코어이자 대표적인 중앙 예약 시스템(CRS) 솔루션
    • 특징
      • “부킹닷컴, 아고다, 익스피디아 등 수많은 글로벌 OTA 채널과 실시간으로 연동되어 발생하는 초고부하 트래픽”을 실제로 완벽하게 받아내고 분산 처리해 주는 엔진들
      • 채널 매니저(CMS) 기능을 내장하여 실시간 오버부킹 제어와 유동적 요금제(Dynamic Pricing) 동기화를 완벽하게 지원
  • Mews (뮤즈) / Cloudbeds (클라우드베드)
    • 시장 위치: 스타트업이나 부티크 호텔, 모던 크루즈 등에서 최근 폭발적으로 도입하고 있는 SaaS 기반의 클라우드 네이티브 PMS 선두 주자들
    • 특징
      • 오라클 오페라 같은 전통적인 대형 제품들과 달리, 현대적인 REST/gRPC 기반의 Open API 아키텍처가 예술적으로 잘 구축되어 있음
      • 만약 대규모 예약을 처리하는 자체 플랫폼 아키텍처를 바닥부터 엔지니어링하기 부담스러운 기업들은, 이 Mews나 Cloudbeds의 SaaS 인프라를 백엔드로 두고 제공되는 가용 재고 API만 가져와 앞단의 웹/앱 서비스만 가볍게 구현하는 방식을 적극적으로 채택하고 있음

참고 사이트 & 함께 보면 좋은 사이트

본 포스트는 알렉스 쉬, 산 람 저자의 가상 면접 사례로 배우는 대규모 시스템 설계 기초 2를 기반으로 스터디하며 정리한 내용들입니다.






© 2020.08. by assu10

Powered by assu10