7장 호텔 예약 시스템
디비 선택
관계형 데이터베이스는 읽기 빈도가 쓰기 연산에 비해 높은 작업 흐름을 잘 지원한다
호텔 웹사이트는 호텔을 예약하는 사용자보다 방문하는 사용자의 수가 더 많다.
따라서 쓰기 연산에 최적화된 NoSQL 보다 관계형 데이터베이스가 더 적절하다
관계형 데이터베이스는 ACID 속성을 보장한다.
따라서 잔액이 마이너스 되는 문제, 이중 청구문제, 이중 예약 문제등을 방지하기 수월하다
개략적 설계안
MSA 아키텍처를 사용한다.
각각의 서비스 간 통신에는 gRPC와 같은 방법을 사용하곤 한다.
설계
예약 프로세스의 입출력은 다음과 같다.
입력: startDate, endDate, roomTypeId, hotelId, numberOfRoomsToReserve
출력: 해당 유형 객실에 여유가 있고 사용자가 예약 가능한 상태면 true, 그렇지 않으면 false 를 반환
- 주어진 기간에 해당하는 레코드를 구한다
SELECT datee, total_inventory, total_reserved
FROM room_type_inventory
WHERE room_type_id = #{roomTypeId} AND hotel_id = #{hotelId}
AND datee BETWEEN #{startDate} AND #{endDate}
date | total_inventory | total_reserved |
---|---|---|
2022-07-01 | 100 | 97 |
2022-07-02 | 100 | 96 |
2022-07-03 | 100 | 95 |
- 반환된 각 레코드 마다 다음 조건을 확인한다.
if ((total_reserved + ${numberOfRoomsToReserve} <= 110 * total_inventory)) {
// 호텔 예약 가능 상태
}
동시성 문제
위 방법으로 예약은 가능하지만 이중 예약 문제는 해결하지 못한다.
해결하기 위핸 두 가지 문제를 해결해야한다.
- 같은 사용자가 예약 버튼을 여러번 누를 수 있다.
- 여러 사용자가 같은 객실을 동시에 예약하려 할 수 있다.
시나리오 1
reservation_id | hotel_id | room_type_id | start_date | end_date | status | guest_id |
---|---|---|---|---|---|---|
121 | 2 | 3 | 2021-07-01 | 2021-07-04 | pending_pay | guest1 |
121 | 2 | 3 | 2021-07-01 | 2021-07-04 | pending_pay | guest1 |
클라이언트 측 구현: 요청을 전송하고 난 다음에 예약 버튼을 비활성화 하는 방법. 하지만 변조를 통해 우회할 수 있다.
멱등 API: 예약 API 요청에 멱등 키를 추가하는 방법이다. 몇 번을 호출해도 같은 결과를 내는 API 를 멱등 API 라고 한다. reservation_id 를 사용해 멱등성을 보장할 수 있다.
- 예약 주문서를 만든다. 고객이 정보를 입력하고 ‘계속’ 버튼을 누르면 예약 주문을 생성한다.
-
고객이 검토할 수 있도록 예약 주문서를 반환한다. 이때 API 는 반환 결과에
reservation_id
를 넣는다 - 검토가 끝난 예약을 전송한다 이때 요청에도
reservation_id
가 붙는다. - 사용자가 예약 완료 버튼을 한 번 더 누르게 되면
reservation_id
는 PK 이기 때문에 새로운 레코드는 생성되지 않는다.
시나리오 2
여러 사용자가 잔여 객실이 하나밖에 없는 유형의 객실을 동시에 예약하려는 경우
- 디비 트랜잭션이 없는 경우를 가정, 사용자1과 사용자2가같은 유형의 객실을 예약하려고 할때
- 트랜잭션 1은
(total_reserved + rooms_to_book) <= total_inventory
인지 검사 - 트랜잭션 2도
(total_reserved + rooms_to_book) <= total_inventory
인지 검사 - 트랜잭션 1이 먼저 객실을 예약하고 현황을 갱신하여 reserved_room 은 100이 된다
- 그 후 트랜잭션 2가 예약하게 된다면 트랜잭션2의 관점에서 total_reserved 의 값은 여전히 99이다
위 문제를 해결하려면 어떤 형태로든 락을 활용해야 한다.
- 비관적 락 (선점 잠금, Pessimistic Lock)
- 낙관적 락 (비선점 잠금, Optimistic Lock)
- 데이터베이스 제약 조건
비관적 락
사용자가 레코드를 갱신하려고 하는 순간 즉시 락을 걸어 동시 업데이트를 방지하는 방법이다.
해당 레코드를 갱신하려는 다른 사용자는 먼저 락을 건 사용자가 변경을 마치고 락을 해제할때까지 기다려야 한다.
MySQL 의 경우 SELECT ... FOR UPDATE
를 실행하면 레코드에 락이 걸린다.
장점 :
- 애플리케이션이 변경 중이거나 변경이 끝난 데이터를 갱신하는 일을 막을 수 있다.
- 구현이 쉽다
단점 :
- 여러 레코드에 락을 걸면 교착 상태가 발생할 수 있다.
- 확장성이 낮다. 트랜잭션이 너무 오랫동안 락을 해제 하지 않는다면 성능에 영향을 줄 수 있다.
낙관적 락
낙관적 락은 일반적으로 버전 번호, 타임 스탬프 두 가지 방법으로 구현한다.
- 테이블에 version 이라는 컬럼을 추가한다.
- 사용자가 수정하기 전에 앱에서 해당 레코드의 버전을 읽는다.
- 레코드를 갱신할때 버전에 1을 더한 다음 디비에 다시 기록한다.
- 이때 유효성 검사를 한다. 즉 다음 버전 번호는 현재 버전 번호보다 1만큼 큰 값이어야 한다. 이 검사가 실패한다면 즉시 중지한다.
장점:
- 낙관적 락은 일반적으로 비관적 락보다 빠르다. 디비에 락을 걸지 않기 때문이다.
- 경쟁이 치열하지 않은 상황에 적합
단점:
- 경쟁이 치열한 상황에서는 성능이 좋지 않다