블라인드 스터디 : 호텔 예약 시스템 설계
서버개발

블라인드 스터디 : 호텔 예약 시스템 설계

[7장] 호텔 예약 시스템

들어가면서

대규모 사용자가 접근하는 시스템에서 한정 요소에 대해 판매를 하는 시스템은 서버 시스템 설계 질문에서 자주 나오는 문제이다

동시성 문제는 당연히 있을 것이고, 특정 시간 내에 판매가 이루어진다면 급증하는 트래픽에 대한 방어책도 있어야한다

면접에서 실제로 질문 받았던 경험이 있고, 그때의 대답을 비교하면서 공부를 하였다

 

예약 시스템은 시스템을 사업에 어떻게 이용할 지에 따라 달라지므로, 질문을 던져 범위를 명확히 할 필요가 있다

 

 

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

기능 요구사항

시스템 규모는?

- 5000개 호텔에 100만개 객실을 갖춘 호텔 체인을 위한 웹사이트 구축

대금은 예약 시 지불? 체크인 시 지불?

- 예약할 때 지불

웹 사이트로만 예약이 가능한지? 아니면 오프라인으로도 예약할 수 있는지?

- 웹사이트와 앱에서만 가능

예약은 취소 가능한지?

- 가능

추가 고려 사항

- 10% 초과 예약 가능(취소를 대비하여 실제 객실 수보다 더 많은 객실이 판매 허용)

- 객실 가격은 유동적

 

 

비기능 요구사항

- 높은 수준의 동시성 지원 : 성수기, 대규모 이벤트 기간 일부 인기 호텔의 특정 객실 예약이 몰릴 수 있다

- 적절한 지연 시간 : 사용자가 예약을 할 때 응답 시간이 빠르면 이상적이나, 몇 초 걸리는 것은 괜찮다

 

 

개략적 규모 측정

- 5000개의 호텔, 100만개의 객실, 평균적으로 70%에 3일 투숙으로 가정한다면 일일 예상 약 24만 건

- 초당 예약 건수 10건 이하(낮은 TPS)

 

 

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

API 설계

호텔/객실 추가 및 삭제 API는 크게 어려운 기능이 아니므로 예약 관련 API에 대해서 설계하자

- 로그인 사용자의 예약 이력 반환

- 특정 예약의 상세 정보 반환

- 신규 예약

- 예약 취소

 

예약 ID는 이중 예약(같은 날, 같은 방에 대한 중복 예약)을 방지하기 위해 멱등한 키로 만들어져야 하며, 동시성 문제 또한 같이 해결될 수 있다

 

 

데이터 모델

어떤 데이터베이스를 사용할 지 결정하기 전 데이터 접근 패턴부터 살펴보아야 한다

예약 API에 사용되는 질의 종류는 크게 트래픽이 크지 않으나 이벤트로 순간 급증하는 트래픽에 대해서 대응할 필요가 있다

특정 이벤트가 있지 않는한 낮은 TPS를 쓰는 서비스이고, 예약을 제외하고는 읽기 연산이 대부분이므로 RDBMS를 선택한다

NoSQL은 대체로 쓰기 연산에 최적화가 되어있지만, 예약 시스템은 읽기가 대부분이므로 RDBMS도 나쁘지 않은 성능을 보인다

또한, RDBMS는 ACID를 잘 보장하여 분산 환경에서도 예약 시스템을 구축하기 편리하고, 데이터 모델링도 간편하다

 

예약 테이블에는 status가 필요한데 여기에는 pending(결제 대기), paid(결제 완료), refunded(환불 완료), canceled(취소), rejected(승인 실패) 상태가 존재한다

시스템에 따라 취소/환불 대기, 이용 완료 등이 있을 수 있다(재시도, 승인 대기 등으로)

 

 

개략적 설계안

호텔 예약 시스템은 간단한 서비스들이 나누어져 있고, 장애를 최소화하기 위해 MSA 아키텍처를 사용한다

읽기 연산이 많고, 방 정보가 거의 변하지 않기 때문에 CDN을 이용해 각 로컬에서 빠르게 정적 사이트를 접근할 수 있도록 돕는다

공개 API 게이트웨이를 사용해 처리율을 제한하고, 인증 등의 기능을 지원한다

엔드포인트를 기반으로 특정 서비스에 요청을 전달할 수 있도록 한다

 

 

3단계 : 상세 설계

개선된 데이터 모델

호텔은 특정 방이 아니라 특정 방 타입으로 예약을 하기 때문에 roomId가 아니라 roomType으로 예약한다(체크인 시 방이 결정)

그렇기 때문에 초과 예약이 가능한 시스템으로 보인다

특정 호텔, 룸 타입, 날짜(키 구성)에 대해서 인벤토리를 구축하고 남은 예약 개수(초과 예약을 포함하여)를 고려하여 조회할 수 있도록 한다

 

저장될 데이터 수는 많지 않으므로 적은 데이터베이스로도 관리 가능하지만, 하나만 두면 SPOF 문제가 발생할 수 있으므로 여러 AZ에 데이터베이스를 복제하여야 한다

 

 

동시성 문제

이중 예약을 어떻게 방지할 것인지에 대해 해결하여야 한다

 

1. 같은 사용자가 여러 예약 요청을 보낼 수 있다

일반적으로 해결하는 방법은 클라이언트 측에서 이중으로 보내지 않도록 막는 것이나, 이는 완벽한 해결법이 아니다

클라이언트 조작을 통해 충분히 다시 전송할 수 있기 때문에(글쓴이는 방탈출을 좋아해서 가끔 예약 사이트를 조작해보면 예약 불가능한 페이지를 가능하도록 사이트를 변경 해본적이 있다.. 예약은 하지 않았고 재미삼아 변경해봤다)

멱등 API를 사용해 예약을 하여야 한다

몇 번을 호출하더라도 같은 결과를 내는 API를 멱등 API라고 하며 이중 예약 문제를 해결할 수 있다

멱등 API를 하기 위해 우선 클라이언트에서 키를 만드는 것이 아닌, 예약 주문을 생성하면 예약 서비스에서 특정 상황에 대해 키를 부여하고 사용자가 이 키를 저장하여 사용한다

서버에서 특정 로직으로 생성된 키를 지급하기 때문에 같은 상황의 키가 중복되어 지급될 수 없고, 근본적으로 1번 상황을 막을 수 있다

 

2. 여러 사용자가 같은 객실을 동시에 예약하려 할 수 있다

데이터베이스 트랜잭션 격리 수준을 Serializable이라면 큰 문제가 없지만, 아니라고 가정해보자

그런 상황에서는 해당 타입의 잔여 객실이 1개일 때 2개의 요청이 오면 -1개가 될 수 있다

이 문제를 해결하기 위해서는 특정 락 방법이 사용되어야 한다

 

비관적 락

- 비관적 락을 사용하면 사용자가 레코드를 갱신하려는 순간 즉시 데이터베이스에 락을 걸어 동시 업데이트를 방지하는 기술이다

다른 사용자의 요청은 해당 요청이 종료될 때까지 진행이 되지 않는다

해당 방법은 확실하게 객실의 수를 보장할 수 있지만, 특정 이벤트로 인해 쓰기 요청이 몰리는 경우 1명의 요청을 계산하는 동안 나머지 모든 요청이 멈추게 된다

트랜잭션이 긴 경우 데이터베이스 성능에 심각한 영향을 주게 된다

또한 여러 테이블에 락을 걸게 되면 데드락이 발생할 수 있기 때문에 데드락이 발생하지 않는지 유심히 확인하여야 한다

그렇기 때문에 예약 시스템에 비관적 락은 추천되지 않는다

 

낙관적 락

- 낙관적 락은 여러 사용자가 동시에 같은 자원에 접근하는 것이 허용되며 어플리케이션 단에서도 버저닝을 통해 제어할 수 있다

버저닝와과 타임 스탬프 두가지 방법으로 구현되며, 서버 시간이 부정확할 수 있기 때문에 일반적으로 버저닝을 사용한다

일반적으로 데이터베이스에 직접적으로 락을 걸지 않기 때문에 비관적 락보다 빠르다

하지만 동시성 수준이 심해지면 수 많은 사용자가 예약 요청까지 도달하게 되지만, 실질적으로 한 명만 변경이 가능해지므로 이는 CX(사용자 경험)을 해칠 수 있고, 재시도를 하기 때문에 성능도 나빠지게 된다

일반적으로는 예약 시스템에 동시에 접근하는 요청이 많지 않기 때문에 나쁘지 않은 선택지이다

 

데이터베이스 제약 조건

- 데이터베이스 제약 조건은 낙관적 락과 유사하며, 데이터 베이스에서 total - reserved가 음수가 되는 순간 트랜잭션 롤백에 대한 규칙을 넣는 것이다. 구현이 쉽고, 데이터에 대한 경쟁이 심하지 않을 때 잘 동작하지만 낙관적 락과 마찬가지로 데이터에 대한 경쟁이 심한 경우 실패하는 연산 수가 늘어날 수 있다

객실이 있다고 사용자에게 요청하지만 늦게 예약을 한 사용자에게는 당연히 실패라고 뜨기 때문에 CX를 해치지만 total - reserved가 음수로 갈 때만 롤백이 이루어지기 때문에 낙관적 락보다는 더 적은 경험이 될 것이다

그렇기 때문에 이 예약 시스템에는 좋은 선택지이다(데이터 베이스가 제약조건을 지원하여야 하고, 변경이 어려워진다)

 

 

시스템 규모 확장

일반적으로 호텔 예약 시스템은 부하가 높지 않으나, 다른 웹사이트와 연동이 될 수 있다

유명한 여행 웹 사이트와 연동되어 API를 제공한다면 쿼리의 양이 엄청 늘어날 수 있고, 시스템 부하가 늘어나면 어떤 문제가 발생할 지 이해해야 한다

이 시스템의 모든 기능은 무상태 서비스이므로, 서버를 추가하는 것으로 성능 문제는 해결할 수 있다

하지만 상태 정보를 보관하는 데이터베이스는 단순히 서버를 늘리는 것만으로 해결할 수 없고(결국에는 데이터베이스의 한계를 경험하게 된다), 다른 방법이 필요하다

 

관련 테크 세미나 자료로 재밌게 본 우아한 테크 세미나 자료를 첨부한다(1부)

 

데이터베이스 샤딩

데이터베이스 샤딩이란 데이터베이스를 여러 대 두고, 각각에 데이터의 일부만 보관하도록 하는 방법이다 

어떤 방식으로 데이터베이스를 분산할지 정해야 하며 위 동영상에서도 여러 방법에 대해 고민한다

이 설계안에서는 대부분 질의가 hotel의 아이디로 구분되기 때문에 해당 키를 샤딩 조건으로 쓰면 적당하다

호텔 아이디로 해싱을 하여 여러 데이터베이스로 부하를 분산시킬 수 있다

 

또한, 샤딩을 하게 되면 분산된 데이터로 인해 특정 사용자가 자신의 예약 리스트를 조회하고 싶을 수 있다(호텔에 관계 없이)

이럴 때 모든 데이터베이스에서 해당 유저의 아이디로 조회할 수도 있겠지만 성능적으로는 좋지 않다

그렇기 때문에 예약을 저장할 때 해당 정보를 레디스에 날짜순으로 저장하고 조회한다면 성능적으로도 우수하며 값은 데이터베이스에 있기 때문에 데이터가 손실될 위험도 적다

 

캐시

호텔 잔여 객실 데이터는 현재 그리고 미래의 데이터만이 중요하다

과거에 어떤 객실을 예약하지는 않기 때문에 데이터를 보관할 때 낡은 데이터는 자동적으로 소멸하도록 하여도 된다

즉, 예약이 되지 않은 어제의 객실 데이터는 스케줄링을 통해 삭제하여도 무방하다

 

삭제에 유리한 것은 레디스이기 때문에 데이터베이스 앞에 캐시 계층을 두어 잔여 객실 확인 및 객실 예약 로직이 해당 계층에서 실행되도록 한다. 레디스 캐시 데이터에는 잔여 객실이 충분해 보여도 데이터베이스를 다시 한 번 확인할 필요는 있다

캐시를 사용하게 되면 캐시와 데이터베이스 사이에 일관성 유지라는 미션이 생기게 된다

1. 잔여 객실 수를 확인한다. 이는 캐시에서 이루어지게 된다

2. 잔여 객실 데이터를 갱신한다

데이터 베이스에서 먼저 갱신이 이루어지고, 이벤트 시스템을 통해 레디스를 비동기로 갱신한다

비동기적으로 반영되기 때문에 데이터베이스와 정확히 일관성을 잃을 수 있기 때문에, CX에 맞게 설계를 하여야 한다

 

서비스간 데이터 일관성

모노리스 아키텍처는 데이터의 일관성을 보장하기 위해 RDBMS를 공유하는 것이 보통이다

하지만 MSA에서는 예약 서비스가 예약 및 잔여 객실 API를 담당하고 있고, 에약 테이블과 잔여 객실 테이블을 같은 데이터베이스에 저장할 수 있으나, 면접관에 따라 각 마이크로 서비스가 독자적인 데이터베이스를 갖도록 요구할 수 있다

그렇다면 다시 데이터베이스간 일관성 문제를 야기할 수 있는데, 이를 해결하는 방법에 대해 알아보자

 

1. 2단계 커밋(2PC)

여러 노드에 걸친 원자적 트랜잭션 실행을 보증하는 데이터베이스 프로토콜로, 모든 노드가 성공하지 않으면 실패하게 된다

어느 한 노드에 장애가 발생하면 해당 장애가 복구될 때까지 진행이 중단된다

여러 노드에 걸친 하나의 트랜잭션을 통해 ACID 속성을 만족시킨다

 

2. SAGA

각 노드에 국지적으로 발생하는 트랜잭션을 하나로 엮은 것으로, 각각의 트랜잭션이 완료되면 다음 트랜잭션을 시작하는 트리거를 메시지로 보낸다. 한 트랜잭션이라도 실패하면 그 이전 트랜잭션의 결과를 전부 되돌리는 트랜잭션들을 실행한다

각 단계가 하나의 트랜잭션이라 결과적 일관성에 의존한다