[설계] 가상 면접 사례로 배우는 대규모 시스템 설계 기초 2 - 7장. 호텔 예약 시스템 (동시성 문제, 경쟁 조건 해결책 - 락, 시스템 규모 확장-샤딩, 캐시) ✅

hailey·2025년 2월 2일

시스템설계

목록 보기
1/8

7장. 호텔 예약 시스템

7장 요약

이 장에서 다루는 설계와 기법은 다른 인기 면접 문제에 활용될 수도
있다.

  • 에어비앤비 시스템 설계
  • 항공권 예약 시스템 설계
  • 영화 티켓 예매 시스템 설계

1단계: 문제 이해 및 설계 범위 확정

  • 호텔 예약 시스템은 복잡하고 그 컴포넌트는 시스템을 사업에 어떻게 이용할지에 따라 달라진다.
  • 설계 시작 전 면접관에게 질문을 던져 범위를 명확히 해자.

👩🏻‍💼(지원자) : 시스템 규모는 어느정도입니까?

👨🏻‍💻(면접관) : 5000개 호텔에 100만개 객실을 갖춘 호텔 체인을 위한 웹사이트를 구축한다고 가정합시다.

👩🏻‍💼(지원자) : 대금은 예약 시 지불하나요, 아니면 호텔에 도착했을 때 지불하나요?

👨🏻‍💻(면접관) : 시간 제한이 있으니 예약할 때 전부 지불한다고 합시다.

👩🏻‍💼(지원자) : 고객은 객실을 호텔의 웹사이트에서만 예약할 수 있나요, 아니면 전화 같은 다른 시스템으로도 할 수 있나요?

👨🏻‍💻(면접관) : 호텔 웹사이트나 앱에서만 가능하다고 합시다.

👩🏻‍💼(지원자) : 예약을 취소할 수도 있어야 하나요?

👨🏻‍💻(면접관) : 물론입니다.

👩🏻‍💼(지원자) : 고려할 다른 사항이 더 있을까요?

👨🏻‍💻(면접관) : 네. 10% 초과 예약이 가능해야 합니다. 즉, 실제 객실 수보다 더 많은 객실을 판매할 수 있어야 한다는 것입니다. 호텔은 일부 고객이 예약을 취소할 것을 예상해 초과 예약을 허용하곤 합니다.

👩🏻‍💼(지원자) : 시간이 제한되어 있으므로, 객실 검색은 범위에 넣지 않겠습니다. 다음과 같은 사항에만 집중해 보려고 합니다.

  • 호텔 정보 페이지 표시
  • 객질 정보 페이지 표시
  • 객실 예약 지원
  • 호텔이나 객실 정보를 추가/삭제/갱신하는 관리자 페이지 지원
  • 초과 예약 지원

👨🏻‍💻(면접관) : 좋습니다.
한가지 잊은게 있네요. 객실 가격은 유동적입니다. 그날 객실에 여유가 얼마나 있는지에 따라 달라진다고 하겠습니다. 또한 매일 달라질 수 있다고 가정하겠습니다.

👩🏻‍💼(지원자) : 유념하겠습니다.

그 다음으로는 중요 비기능 요구사항에 대해 이야기해 볼 수 있을 것이다.

비기능 요구사항

  • 높은 수준의 동시성 지원 : 성수기, 대규모 이벤트 기간에는 일부 인기 호텔의 특정 객실을 예약하려는 고객이 많이 몰릴 수 있다.
  • 적절한 지연 시간 : 사용자가 예약을 할 때 응답 시간이 빠르면 이상적이겠으나 예약 요청 처리에 몇 초 정도 걸리는 것은 괜찮다.

개략적 규모 추정

  • 총 5000개 호텔, 100만개 객실
  • 평균적으로 객실의 70%가 사용중이고, 평균 투숙 기간은 3일이라고 가정.
  • 일일 예상 예약 건수 : 1백만x0.7 / 3 = 233,333 (올림 해서 약 240,000)
  • 초당 예약 건수 : 240,000/10^5(하루에 초 수) = 3.xx
    따라서 초당 예약 트랜잭션 수(TPS) 는 그다지 높지 않다.

다음으로 시스템 내 모든 페이지의 QPS(Queries-per-second)를 계산해보자. 일반적으로 고객이 이 웹사이트를 사용하는 흐름에는 세가지 단계가 있다!

  1. 호텔/객실 상세 페이지 : 사용자가 호텔/객실 정보를 확인(조회 발생)
  2. 예약 상세 정보 페이지 : 사용자가 날짜,투숙 인원, 결제 방법 등 상세 정보를 예약 전에 확인한다. (조회 발생)
  3. 객실 예약 페이지: 사용자가 '예약' 버튼을 눌러 객실을 예약한다 (트랜잭션 발생)

대략 10% 사용자가 다음 단계로 진행하고 90%의 사용자는 최종 단계에 도달하기 전에 흐름을 이탈한다고 하자.

최종 예약 TPS는 3이라는 것을 알고 있으므로, 그 수치에서 역산한 결과이다. 예약페이지의 QPS는 30, 객실 정보 확인 페이지의 QPS는 300이다.

2단계: 개략적 설계안 제시 및 동의 구하기

이번 절에서는 다음 사항을 살펴본다.

  • API 설계
  • 데이터 모델
  • 개략적 설계안

API 설계

  • 호텔 웹사이트를 전부 완성하려면 특정 기준에 맞는 객실을 검색하는 등의 직관적 기능도 필요하다. 하지만 그런 기능 구현에 필요한 API는 중요하긴 해도 기술적으로 도전적이지는 않다. 따라서 이번장에서 다루지 않겠다.

호텔 관련 API

  • GET /v1/hotels/id : 호텔 상세 정보 반환
  • POST /v1/hotels : 신규 호텔 추가 (직원)
  • PUT /v1/hotels/id : 호텔 정보 갱신 (직원)
  • DELETE /v1/hotels/id : 호텔 정보 삭제 (직원)

객실 관련 API

  • GET /v1/hotels/:id/rooms/id : 객실 상세 정보
  • POST /v1/hotels/:id/rooms : 신규 객실 추가 (직원)
  • PUT /v1/hotels/:id/rooms/id : 객실 정보 갱신 (직원)
  • DELETE /v1/hotels/:id/rooms/id : 객실 정보 삭제 (직원)

예약 관련 API

  • GET /v1/reservations : 로그인 사용자의 예약 이력 반환
  • GET /v1/reservations/id : 특정 예약 상세 정보 반환
  • POST /v1/reservations : 신규 예약
  • DELETE /v1/reservations/id : 예약 취소

  • 신규 예약 접수는 아주 중요한 기능이다.
  • reservationID이중 예약을 방지하고, 동일한 예약은 단 한번만 이루어지도록 보증하는 **멱등 키다.

데이터 모델

  • 어떤 데이터베이스를 사용할 지 결정하기 전에 데이터 접근 패턴부터 자세히 살펴보자. 호텔 예약 시스템은 다음 쿼리를 지원해야 한다.
  1. 호텔 상세 정보 확인
  2. 지정된 날짜 범위에 사용 가능한 객실 유형 확인
  3. 예약 정보 기록
  4. 예약 내역 또는 과거 예약 이력 정보 조회
  • 대략적인 추정 과정을 통해 시스템 규모가 크지 않은 것은 알았으나, 대규모 이벤트가 있는 경우엔 트래픽이 급증할 수 있으니 대비해야 한다.
  • 이러한 요구사항을 종합적으로 고려했을 때 본 설계안에서는 관계형 데이터베이스를 선택할 것이다.

관계형 데이터 베이스를 선택하는 이유

  1. 관계형 디비는 읽기 빈도가 쓰기 빈도에 비해 높은 작업 흐름을 잘 지원한다. 호텔 웹사이트/앱을 방문하는 사용자 수는 실제로 객실을 예약하는 사용자에 비해 압도적으로 높다. NoSQL 디비는 대체로 쓰기 연산에 최적화되어 있다. 관계형 디비는 읽기가 압도적인 작업 흐름은 충분히 잘 지원한다.

  2. 관계형 디비는 ACID 속성을 보장한다. 이 속성은 예약 시스템을 만드는 경우 중요하다. 이 속성이 만족되지 않으면 잔액이 마이너스 되는 문제, 이중 청구 문제, 이중 예약 문제 등을 방지하기 어렵다. ACID 속성이 충족되는 디비를 사용하면 애플리케이션 코드는 훨씬 단순해지며 이해하기 쉬워진다. 관계형 디비는 일반적으로 ACID 속성을 보장한다.

  3. 관계형 데이터베이스를 사용하면 데이터를 쉽게 모델링 할 수 있다. 비즈니스 데이터의 구조를 명확하게 표현할 수 있을 뿐 아니라 엔티티(호텔, 객실, 객실 유형 등)간 관계를 안정적으로 지원할 수 있다.

스키마 설계

  • hotel : 호텔
  • room : 객실
  • room_type_rate : 요금
  • guest : 투숙객
  • reservation : 예약
    • status : 이 필드는 결제 대기(pending), 결제 완료(paid), 환불 완료(refunded), 취소(canceled), 승인 실패(rejected) 다섯 상태 중 하나를 값으로 가질 수 있다.

예약 상태 천이도(state machine) 다이어그램

스키마 디자인 문제점

  • 위 스키마 디자인에는 문제가 있는데, room_id 가 있어서 에어비앤비 같은 회사에는 적합하나 호텔의 경우에는 그렇지 않다. 사용자는 특정 객실을 예약하는 것이 아니라 특정 호텔의 특정 객실 유형을 예약하기 때문이다.
  • 여기서 객실 유형은 스탠다드 룸, 킹 사이즈 룸, 퀸 사이즈 룸 등이 될 수 있다.
  • 객실 번호는 예약할 때가 아닌, 투숙객이 체크인 하는 시점에 부여된다.
  • 이 요구사항을 반영하려면 데이터 모델을 손볼 필요가 있다.

개략적 설계안

  • 호텔 예약 시스템은 MSA를 사용한다.
  • 사용자 : 휴대폰이나 PC로 객실을 예약하는 대상
  • 관리자(호텔 직원): 고객 환불, 예약 취소, 객실 정보 갱신 등의 관리 작업을 수행할 권한이 있는 호텔 지원
  • CDN(콘텐츠 전송 네트워크) : 자바스크립트 코드 번들, 이미지, 동영상, HTML 등 모든 정적 컨텐츠를 캐싱해서 웹사이트 로드 성능을 개선하는 데 이용된다.
  • 공개 API 게이트웨이 : 처리율 제한, 인증 등의 기능을 지원하는 완전 관리형 서비스다. 엔드포인트 기반으로 특정 서비스에 요청을 전달할 수 있도록 구성된다. 예를 들어 호텔 홈페이지 요청은 호텔 서비스로, 호텔 객실 예약 요청은 예약 서비스로 전달하는 역할을 담당한다.
  • 내부 API : 승인된 호텔 직원만 사용 가능한 API로, 내부 소프트웨어나 웹사이트를 통해서만 사용 가능하다. VPN(가상 사설 네트워크) 기술을 사용해 외부 공격으로부터 보호한다.
  • 호텔 서비스 : 호텔, 객실 상세정보를 제공. 호텔과 객실 정보는 보통 적적이라서 캐시해 둘 수 있다.
  • 요금 서비스 : 미래의 어떤 날 어떤 요금을 받아야 할 지 데이터를 제공하는 서비스. 객실의 요금은 해당 날짜에 호텔에 얼마나 많은 손님이 몰리느냐에 따라 달라질 수 있다.
  • 예약 서비스 : 예약 요청을 받고, 객실을 예약하는 과정을 처리. 객실이 예약되거나 취소될 때 잔여 객실 정보를 갱신하는 역할도 담당한다.
  • 결제 서비스 : 고객의 결제를 맡아 처리하고, 절차가 성공적으로 마무리되면 예약 상태를 결제 완료로 갱신하며 실패한 경우엔 승인 실패로 업데이트 한다.
  • 호텔 관리 서비스 : 승인된 호텔 직원만 사용 가능한 서비스. 임박한 예약 기록 확인, 고객 객실 예약, 예약 취소 등의 기능을 제공.

  • 예약 서비스와 요금 서비스 사이에는 화살표가 있어야 한다.
  • 예약 서비스는 총 객실 요금 계산을 위해 요금 서비스에 쿼리할 필요가 있기 때문이다.
  • 아울러 호텔 관리 서비스는 다른 대부분의 서비스 사이에 화살표가 있어야한다. 관리자가 호텔 관리 서비스를 통해 데이터를 변경하면 해당 데이터를 담당하는 실제 서비스로 요청이 전달되어 갱신이 처리된다.
  • 실제 상업적으로 이용되는 시스템의 서비스간 통신에는 gRPC 같은 고성능 원격 프로시저 호출 (Remote Procedure Call, RPC) 프레임워크를 사용하곤 한다.

3단계: 상세 설계

  • 개선된 데이터 모델
  • 동시성 문제
  • 시스템의 규모 확장
  • 마이크로서비스 아키텍처에서 데이터 일관성 문제에 대한 해결 방안

개선된 데이터 모델

  • 개략적 설계안에서 본대로, 호텔 객실 예약 시 특정 객실이 아닌 특정 객실 유형을 예약하게 된다.
  • 이 요구사항을 수용하려면 API / 스키마의 어떤 부분을 변경해야 할까?
    • 예약 API : roomId를 roomTypeId로 변경한다.
    • {
       "startDate" : "2021-04-28",
       "endDate" : "2021-04-30",
       "hotelID" : "245",
       "roomTypeId" : "U1234563389",
       "reservationID" : "123445"
      }

변경된 스키마

  • room_type_inventory : 호텔의 모든 객실 유형을 담는 테이블이다.
    • hotel_id : 호텔 식별자
    • room_type_id: 객실 유형 식별자
    • date: 일자
    • total_inventory : 총 객실수에서 일시적으로 제외한 객실수를 뺀 값이다. 일부 객실은 유지보수를 위해 예약 가능 목록에서 빼둘 수 있어야 한다.
    • total_reserved : 지정된 hotel_id, room_type_id, date에 예약된 모든 객실의 수.

  • 다르게 설계하는 방법도 있을 수 있다.
    하지만 날짜당 하나의 레코드를 사용하면 날짜 범위 내 예약을 쉽게 관리하고 쿼리할 수 있다.
  • 이 테이블의 기본키는 (hotel_id, room_type_id, date) 복합키다.
  • 이 테이블은 2년 이내 모른 미래 날짜에 대한 가용 객실 데이터 질의 결과를 토대로 미리 채워 놓고, 실시간 흐름에 따라 새로 추가해야 하는 객실 정보는 매일 한 번씩 일괄 작업을 돌려 반영한다.

저장 용량 추정

  • 5000개 호텔, 각 호텔에는 20개의 객실 유형 존재.
  • 위 테이블에 저장해야 하는 총 레코드의 수는 5000개 x 20 x 2년 x 365일 = 7300만개.
  • 많은 데이터가 아니므로 데이터베이스 하나면 저장하기 충분하다.
  • 하지만 그렇다고 데이터베이스 서버를 하나만 두면 SPOF (Single-Point-Of-Failure) 문제를 피할 수 없다.
  • 고가용성을 달성하려면 여러 지역, 또는 가용성 구역에 데이터베이스를 복제해두어야 한다.

room_type_invenotry 테이블 데이터 예시

  • room_type_inventory 테이블은 고객이 특정 유형의 객실을 예약할 수 있는지 여부를 확인할 때 사용한다.
  1. 주어진 기간에 해당하는 레코드 조회 쿼리
SELECT date, total_inventory, total_reserved
FROM room_type_invenotry
WHERE room_type_id = ${roomTypeId} AND hotel_id = ${hotelId} AND date between ${startDate} and ${endDate}
  1. 반환된 레코드마다 다음 조건을 확인한다.
if ( (total_reserved + ${numberOfRoomsToReserve} <= total_inventory )
  • 레코드의 모든 행을 검사한 결과가 True 를 반환되면 주어진 기간 내 모든 날짜에 충분한 객실이 있다는 뜻이다.
  • 요구사항 중 10% 초과 예약이 가능하도록 해 달라는 것이 있었는데,
    아래와 같이 구현할 수 있다.
if ( (total_reserved + ${numberOfRoomsToReserve} <= 110%*total_inventory )

"예약 데이터가 단일 데이터베이스에 담기에 너무 크다면 어떻게 하시겠어요?" → 샤딩

  • 현재 및 향후 예약 데이터만 저장한다. 예약 이력은 자주 접근하지 않으므로 아카이빙 하거나 냉동 저장소(cold storage)로 옮길 수도 있다.
  • 데이터베이스를 샤딩한다.
    가장 자주 사용되는 쿼리는 예약을 하거나, 투숙객 이름으로 예약을 확인하는 쿼리일 것이다. 두 쿼리 모두 우선 호텔을 먼저 알아야 하므로, hotel_id가 샤딩 키로 적합하다. 데이터는 hash(hotel_id) % number_of_servers로 샤딩해 두면 된다.

동시성 문제 → reservation_id를 멱등키로 사용 ✅

  • 또 하나 중요한 문제는 이중 예약을 어떻게 방지할 것이냐 하는 것이다.
  1. 같은 사용자가 예약 버튼을 여러번 누를 수 있다.
  2. 여러 사용자가 같은 객실을 동시에 예약하려 할 수 있다.

이 문제를 푸는 일반적 접근법은 2가지가 있다.
1. 클라이언트 측 구현 : 클라이언트가 요청을 전송하고 난 다음 '예약'버튼을 회색으로 표시하거나, 숨기거나, 비활성화하는 것이다. 하지만 안정적인 방법은 아니다. 사용자가 자바스크립트 비활성하면 클라이언트 측 확인 절차를 우회할수도 있다.
2. 멱등(idempotent) API : 예약 API 요청에 멱등 키를 추가하는 방안이다. 몇번을 호출해도 같은 결과를 내는 API 를 멱등 API라고 부른다. reservation_id를 멱등키로 사용해서 이중 예약 문제를 해결하는 방안이다.

  1. 예약 주문서를 만든다.
    고객이 예약 세부 정보를 입력한 후 '계속' 버튼 클릭 시 예약 서비스는 예약 주문을 생성한다.

  2. 고객이 검토할 수 있도록 예약 주문서를 반환한다.
    이때 API는 반환 결과에 reservation_id를 넣는다. 이 식별자는 전역적 유일성을 보증하는 ID 생성기가 만들어 낸 것이어야 한다.

  3. 검토가 끝난 예약을 전송한다.

  • 이때 요청에도 reservation_id가 붙는다. 이 값은 예약 테이블의 기본키다. 유의할 것은 reservation_id를 꼭 멱등키로 써야할 필요는 없다.
  • 사용자가 예약 완료 버튼을 한번더 누르는 바람에 같은 예약이 서버에 다시 전송된다. reservation_id가 예약 테이블 기본키이므로, 기본키 유일성 조건에 위반되어 새로운 레코드는 생성되지 않는다. 따라서 이중 예약 문제를 피할 수 있다.

시나리오2. 여러 사용자가 잔여 객실이 하나인 유형의 객실을 동시에 예약하려 하면 무슨일이 생길까? → 락 활용

  1. 데이터베이스 트랜잭션 격리수준이 가장 높은, serializable 수준으로 설정되어 있지 않다고 한다. 이런 상황에서 사용자1, 2가 동시에 같은 유형의 객실을 예약하려고 하지만 남은 객실은 하나뿐이다. 호텔에는 100개 객실이 있고, 그중 99개가 예약 중이다.
  2. 트랜잭션2는 (total_reserved + room_to_book) <= total_inventory 검사한다. 객실이 하나 남은 상황이어서 true가 반환된다.
  3. 트랜잭션1도 마찬가지로 true 반환.
  4. 트랜잭션1이 먼저 예약을 완료하여 reserved_room 값이 100이 된다.
  5. 그 직후 트랜잭션 2가 해당 객실을 예약하고, 데이터베이스 ACID 속성에서 I, 즉 한 트랜잭션은 다른 트랜잭션과 무관하게 작업을 완료해야 한다는 속성에 의해 트랜잭션1이 커밋을 하기전까진 트랜잭션2에는 보이지 않는다. 그래서 여전히 total_reserved 값이 99다.
  6. 트랜잭션2도 예약을 완료하고 예약 현황을 갱신한다.

→ 결과적으로 한객실에 이중 예약이 발생하였다.

이 문제를 해결하기 위해서는 어떤 형태로든 락(lock)을 활용해야 한다.

  • 비관적 락
  • 낙관적 락
  • 데이터베이스 제약 조건

방안 1. 비관적 락

  • 비관적 락은 비관적 동시성 제어 방안이라고도 불리며, 사용자가 레코드를 갱신하려는 순간 즉시 락을 걸어 동시 업데이트를 방지하는 기술이다.
  • 해당 레코드를 갱신하려는 다른 사용자는 먼저 락을 건 사용자가 변경을 마치고 락을 해제할 때까지 기다려야 한다.
  • MySQL 의 경우 "select ... for update" 문을 실행하면 select 가 반환한 레코드에 락이 걸린다.
  • 트랜잭션1이 먼저 실행된 경우, 레코드에 락을 걸기 때문에, 트랜잭션2의 "select ... for update" 문은 트랜잭션1이 종료되길 기다려야 한다.
  • 트랜잭션1이 끝나고 나면 예약된 객실수는 100이므로 사용자2는 예약할 수 없다.

장점

  • 애플리케이션이 변경 중이거나 변경이 끝난 데이터를 갱신하는 일을 막을 수 있다.
  • 구현이 쉽고 모든 갱신 연산을 직렬화해서 충돌을 막는다. 비관적 락은 데이터에 대한 경합이 심할 때 유용하다.

단점

  • 여러 레코드에 락을 걸면 데드락이 발생할 수 있다. 데드락이 생기지 않는 애플리케이션 코드 작성이 까다로울 수 있다.
  • 확장성이 낮다. 트랜잭션이 너무 오랫동안 락을 해제하지 않으면 다른 트랜잭션은 락이 걸린 자원에 접근할 수 없다. 이는 특히 트랜잭션 수명이 길거나 많은 엔티티에 관련된 경우, 데이터베이스 성능에 심각한 영향을 끼친다.

이러한 이유로 예약 시스템에 비관적 락 메커니즘을 사용하는 것은 권장하지 않는다.

방안 2. 낙관적 락 ✅

  • 낙관적 락은 낙관적 동시성 제어라고도 불리는 방안으로 여러 사용자가 동시에 같은 자원을 갱신하려 시도하는 것을 허용한다.
  • 낙관적 락은 일반적으로 버전 번호, 타임 스탬프 2가지 방법으로 구현한다.
  • 서버 시계는 시간이 지남에 따라 부정확해질 수 있어 일반적으로는 버전 번호를 더 나은 선택지로 본다.

버전 번호로 낙관적 락 구현하기


1. 데이터베이스 테이블에 version 이라는 새 컬럼을 추가한다.
2. 사용자가 디비 레코드를 수정하기 전에 애플리케이션은 해당 레코드의 버전 번호를 읽는다.
3. 사용자가 레코드를 갱신할 때 애플리케이션은 버전 번호에 1을 더한다음, 데이터베이스에 다시 기록한다.

  1. 이때 유효성 검사를 한다. 즉, 다음 버전 번호는 현재 버전 번호보다 1만큼 큰 값이어야 하고, 이 유효성 검사가 실패하면 트랜잭션은 중단되고 사용자는 다시 2단계부터 절차를 반복한다.

  • 낙관적 락은 일반적으로 비관적 락보다 빠르다.
  • 데이터베이스에 락을 걸지 않기 때문이다.
  • 하지만 동시성 수준이 아주 높으면 성능이 급격히 나빠진다.
  • 많은 클라이언트가 같은 호텔 객실을 동시에 예약하는 경우를 생각해 보자.
    잔여 객실 수를 읽을 수 있는 클라이언트 수에 제한이 없으므로, 모든 클라이언트는 같은 잔여 객실 수와 같은 버전 정보를 취득할 것이다.
  • 하지만 실제로 버전 번호를 갱신하는 클라이언트는 오직 하나이며,
    다른 모든 클라이언트는 버전 번호 검사에 실패했다는 메시지를 받을 것이다.
  • 실패한 클라이언트는 다시 예약을 시도해야 한다.
  • 그러나 다음번 시도에서도 성공한 클라이언트는 오직 하나일 것이고, 반복되는 재시도로 사용자는 아주 불쾌한 경험을 할 것이다.

장점

  • 애플리케이션이 유효하지 않은 데이터를 편집하는 일을 막는다.
  • 데이터베이스 자원에 락을 걸 필요가 없다. 데이터베이스 관점에선 락은 없고, 버전 번호를 통해 데이터 일관성을 유지할 책임이 애플리케이션에 있다.
  • 낙관적 락은 데이터에 대한 경쟁이 치열하지 않은 상황에 적합하다. 그런 상황에선 락을 관리하는 비용 없이 트랜잭션을 실행할 수 있다.

단점

  • 데이터에 대한 경쟁이 치열한 상황에서 성능이 좋지 못하다.

→ 낙관적 락은 호텔 예약 시스템에 적합한 선택지다. 예약 QPS가 일반적으론 높지 않기 때문이다.

방안 3. 데이터베이스 제약 조건

  • 이 접근법은 낙관적 락과 유사한데, room_type_inventory 테이블에 다음 제약 조건을 추가하는 것이다.
CONSTRAINT `check_room_count` CHECK((`total_inventory - total_reserved` >= 0))
  • 사용자가 객실을 예약하려 할 때 total_reserved 값이 101이 되어 제약조건을 위반하게 되고, 트랜잭션이 중단되어 롤백되게 된다.

장점

  • 구현이 쉽다.
  • 데이터에 대한 경쟁이 심하지 않을 때 잘 작동한다.

단점

  • 낙관적 락과 마찬가지로 데이터에 대한 경쟁이 심하면, 실패 연산수가 엄청나게 늘 수 있다. 사용자는 객실이 있다고 보고 예약을 시도하겠지만 정작 예약하려고 하면 "객실이 없습니다" 라는 응답을 볼 것이다. 사용자 입장에선 괴로운 경험이다.
  • 데이터베이스 제약 조건은 애플리케이션 코드와 달라서 버전을 통제하기 어렵다.
  • 제약조건을 허용하지 않는 데이터베이스도 있어서, 디비를 다른걸로 교체하려할 때 문제가 있을 수 있다.

시스템 규모 확장

  • 일반적으로 호텔 예약 시스템에 대한 부하는 높지 않다.
  • 하지만 면접관이 이런 질문을 한다면?
    "만약 호텔 예약 시스템이 해당 호텔 웹사이트에만 연동되는 것이 아닌, booking.com이나 expedia.com 같은 유명 여행 예약 웹사이트와 연동되어야 한다면?" 그 경우 QPS는 천배 늘어날 수 있다.
  • 시스템 부하가 높을때는, 무엇이 병목이 될 수 있는가를 이해해야 한다.
  • 본 시스템의 모든 서비스는 무상태 서비스이므로, 서버를 추가하는 것으로 성능 문제는 해결할 수 있다. 하지만 모든 상태 정보가 보관되는 데이터베이스는 단순히 데이터베이스 서버를 늘리는 것만으로 성능 문제를 해결할 수 없다.

시스템 규모 확장1. 데이터베이스 샤딩

  • 데이터베이스의 규모를 늘리는 한가지 방법은 샤딩을 적용하는 것이다.
  • 데이터베이스를 여러대 두고, 각각에 데이터의 일부만 보관하도록 하는것이 기본적인 아이디어다.

시스템 규모 확장2. 캐시

  • 호텔 잔여 객실 데이터는 현재, 미래의 데이터만 중요하고 과거는 중요하지 않다. 따라서 데이터를 보관할 때 낡은 데이터는 자동 소멸 되도록 TTL을 설정할 수 있다면 바람직하다.
  • 이력 데이터는 다른 데이터베이스를 통해 질의하도록 하면 된다.
  • 레디스는 이런 상황에서 적합한데, TTL, LRU 캐시 교체 정책을 사용해서 메모리를 최적으로 활용할 수 있기 때문이다.
  • 데이터 로딩 속도와 데이터베이스 확장성이 문제가 되기 시작하면 데이터베이스 앞에 캐시 계층을 두고 잔여 객실 확인 및 객실 예약 로직이 해당 계층에서 실행되도록 할 수 있다.
  • 이렇게 하면 요청 가운데 일부만 잔여 객실 디비가 처리하고, 나머진 캐시가 담당한다.
  • 그러나 레디스 캐시 데이터에 잔여 객실이 충분해 보여도, 디비를 한번 더 확인할 필요가 있다! 잔여 객실수에 대한 최종적 진실은 결국 디비안에 있기 때문이다.

  • 잔여 객실 캐시 : 모든 잔여 객실 관리에 필요한 질의는 레디스로 구현되는 잔여 객실 캐시로 옮긴다. 따라서 사전에 잔여 객실 정보를 캐시에 미리 저장해두어야 한다!
    • 키 : hotelIDroomTypeID{날짜}
    • 값 : 주어진 호텔 ID, 객실 유형 ID, 날짜에 맞는 잔여 객실 수
  • 호텔 예약 시스템은, 잔여 객실 확인 작업 때문에 읽기 연산 빈도가 쓰기 연산보다 훨씬 많다. 대부분의 읽기 연산은 캐시가 처리한다.
  • 잔여 객실 데이터베이스 : 잔여 객실 수에 대한 가장 믿을만한 정보가 보관되는 장소.

캐시가 주는 새로운 과제 > 데이터 일관성 문제

  • 캐시 계층을 추가하면 시스템의 확장성, 처리량은 대폭 증가하지만
    데이터베이스-캐시 간 데이터 일관성 유지에 관한 새로운 도전에 직면하게 된다.
  • 사용자가 객실을 예약할 때 아무 문제가 없는 경우엔 다음 두가지 작업이 이뤄진다.
    • 1.잔여 객실수를 질의해 충분한지 확인한다. (캐시에서 실행)
    • 2.잔여 객실 데이터를 갱신한다.
      DB 먼저 갱신되고, 캐시에는 비동기적으로 변경 내역이 반영된다.
      이 비동기적 갱신 작업은 애플리케이션 측에서 수행할 수도 있는데, 그 경우 애플리케이션은 데이터베이스에 데이터를 저장한 다음 캐시 데이터를 수정한다.
      • 변경 데이터 감지(Change-Data-Capture;CDC)라는 메커니즘을 사용하는 방법도 있다. 데이터베이스에서 발생한 변화를 감지해서 해당 변경 내역을 다른 시스템에 적용할 수 있도록 하는 메커니즘이다.
  • 잔여 객실 데이터에 대한 변화를 데이터베이스에 먼저 반영하므로,
    캐시에는 최신 데이터가 없을 가능성
    이 있다.
  • 예를 들어, 데이터베이스 데이터 관점에선 잔여 객실이 없는데
    캐시 질의 결과엔 여전히 남은 객실이 있다고 나오는 등. 문제가 있을 수 있다.
  • 하지만 좀 더 깊이 생각해 보면 이런 불일치는 데이터베이스가 최종적으로 잔여 객실 확인을 하도록 하면 문제가 되지 않는다는 것을 알 수 있다.
  • 예를 들어, 캐시 질의 결과는 잔여 객실이 있는 것으로 나오지만
    데이터베이스 데이터를 기준으로 하면 잔여 객실이 없는 경우다.
    • 사용자는 질의 결과 객실이 있으므로 예약을 시도할 것이지만, 해당 요청이 데이터베이스에 도달하면 유효성 검사가 수행되고 남은 객실이 없음이 확인될 것이다.
    • 그 결과 클라이언트는 다른 사람이 마지막 객실을 예약했다는 오류 메시지를 보게되고, 웹사이트 새로고침 시 데이터베이스-캐시 동기화가 끝났을 것이므로 잔여 객실이 없다는 사실을 확인하게 된다.

장점

  • 읽기 질의를 캐시가 처리하므로 데이터베이스 부하가 크게 줄어든다.
  • 읽기 질의를 메모리에서 실행하므로 높은 성능을 보장한다.

단점

  • 데이터베이스와 캐시 사이 데이터 일관성을 유지하는 것은 어려운 문제다.
  • 데이터 불일치가 사용자 경험에 어떤 영향을 끼치게 될 지 신중하게 따져야 한다.

서비스 간 데이터 일관성

  • 전통적인 모노리스 아키텍처의 경우, 데이터 일관성 보장을 위해 관계형 디비를 공유하는 것이 보통이다.
  • 그러나 MSA 기반 예약 서비스가 예약 및 잔여 객실 API 를 모두 담당하도록 했고, 예약 테이블과 잔여 객실 테이블을 동일한 관계형 디비에 저장하는 하이브리드 접근법을 택했다.
  • "동시성 문제" 절에서 설명한대로 이렇게 하면 관계형 디비의 ACID 속성을 활용해 예약 처리 과정에서 발생하는 많은 동시성 문제를 효과적으로 처리할 수 있다.
  • 그러나 면접관이 MSA 순수주의자라면 하이브리드 접근법에 이의를 제기할 수 있는데, MSA에서 각 마이크로 서비스는 독자적인 디비를 갖추도록 해야한다고 생각할 수 있기 때문이다.
  • 하지만 이런 접근은 다양한 데이터 일관성 문제를 낳는다.
  • 논리적으로는 하나의 원자적 연산이 여러 데이터베이스에 걸쳐 실행되는 일을 피할 수 없기 때문이다.
  • 하나의 트랜잭션으로 데이터 일관성을 보증하는 기법을 사용할 수 없다는 뜻이다.
  • 예약 데이터베이스 갱신 연산이 실패했을 경우,
    잔여 객실 데이터베이스에 기록된 예약 객실수는 원래값으로 돌아가야 한다.
  • 모든 것이 문제 없이 실행되는 경로(haapy path)는 하나뿐이지만, 실패하면 데이터 불일치 문제가 발생할 수 있는 실행경로는 많다.
  • 이러한 데이터 일관성 문제 해결을 위해 업계에서 널리 사용되는 방법은 2-phase commit, saga 등이 있다.

데이터 일관성 해결 방법

  • 2단계 커밋 (2-phase commit, 2PC) : 여러 노드에 걸친 원자적 트랜잭션 실행을 보증하는 데이터베이스 프로토콜이다. 모든 노드가 성공하든, 실패하든 둘 중 하나로 트랜잭션이 마무리되도록 보증한다. 2PC는 비중단 실행이 가능한 프로토콜이 아니여서 (blocking-protocol) 어느 한노드에 장애가 발생하면 해당 장애가 복구될때까지 진행이 중단된다. 성능이 뛰어난 프로토콜이 아니다.
  • 사가(SAGA) : 각 노드에 국지적으로 발생하는 트랜잭션을 하나로 엮은것이라 보면 된다. 각각의 트랜잭션은 완료되면 다음 트랜잭션을 시작하는 트리거로 쓰일 메시지를 만들어 보낸다. 어느 한 트랜잭션이라도 실패하면 사가는 그 이전 트랜잭션의 결과를 전부 되돌리는 트랜잭션들을 순차적으로 실행한다.

2PC는 여러 노드에 걸친 하나의 트랜잭션을 통해 ACID를 만족시키는 개념이지만, SAGA는 각 단계가 하나의 트랜잭션이라서, 결과적 일관성에 의존하는 것으로 보아야 한다.

마이크로서비스 간 데이터 불일치를 해결하기 위해 사용되는 복잡한 메커니즘은 시스템 전체 설계의 복잡성을 크게 증가시킨다. 증가한 복잡성이 그만한 가치가 있는지 결정하는 것은 설계자의 몫이다. 본 설계안의 경우에 그만한 가치가 없다고 판단하였으므로 예약/잔여 객실 정보를 동일 관계형 디비에 저장하는 좀 더 실용적인 접근 방식을 선택하였다.

4단계: 마무리

  • 호텔 예약 시스템의 설계얀
    • 요구사항 수집, 규모를 파악하기 위한 추정치 계산
  • 개략적 설계안
    • API 설계
    • 데이터 모델 초안
    • 시스템 아키텍처 다이어그램
  • 상세 설계안
    • 특정 객실이 아닌 객실 유형에 대한 예약으로 스키마 변경
    • 경쟁 조건 발생 시나리오
    • 해결책
      • 비관적 락
      • 낙관적 락
      • 디비 제약 조건 등
    • 시스템 규모 확장을 위한 전략
      • 데이터베이스 샤딩
      • 레디스 캐시

2025.0202
2025.0211

profile
Fail Fast, Fail Often

0개의 댓글