제목에 대한 대답을 하자면, "비관적 락을 잡았기 때문"이다.
잠깐 자랑을 하자면 생애 첫 해외여행을 다녀왔다!! 에어비앤비로 숙소를 예약하던 때를 떠올리며 호텔 예약 시스템을 어떻게 설계할 수 있는지 살펴보자. 이번 장에서는 데이터 동시성과 일관성에 대한 이야기를 중점적으로 살펴볼 것이다. 전체적인 구조로는 MSA를 채택했다. 여기저기서 워낙 많이 쓰인 아키텍처이기 때문에 이에 대해 자세히 설명하기보다는 호텔 예약 시스템이라는 비즈니스 특성상 어떤걸 고려해야하는지, MSA에서 데이터를 다룰 때 무엇을 신경써야 하는지에 집중했다.
전체 구조에 특별한 점은 없어서 이번에는 그림을 생략하겠다.
이제 서비스가 다룰 데이터 특성을 살펴보자. 호텔 서비스의 데이터는 조금 특이한 점이 있다.
위 특성을 고려해서 어떤식으로 데이터를 저장하면 좋을지 생각해보자. 추가로 우리가 MSA 를 사용한다는 점도 잊지 말자.
먼저, 데이터 특성을 생각해서 보관 기간에 따라 저장소 위치를 바꿀 수 있다. hot/warm/cold 데이터로 나누는 것이다. 과거 날짜의 객실을 read 할 필요성은 거의 없기 때문에 성능은 비교적 느리지만 더 많은 양을 저장할 수 있는 warm, cold 저장소에 보관하는 방법이다. 이 때 warm, cold 저장소는 무엇을 쓰느냐에 따라 다르다. 예를 들어 엘라스틱서치 클러스터의 hot-warm-cold 를 사용하고, 각 페이즈마다 사용하는 노드의 성능에 차이를 둘 수 있다.
용량을 보장할 수 있는 또 다른 방법은 오래된 데이터를 아예 삭제해버리는 것이다. 몇 달 전, 혹은 몇 년 전 데이터까지 보관할 필요가 없다고 판단되면 그 기간이 지났을 때 자동으로 데이터를 삭제하도록 설정하는 방법이다. 예를 들어 몽고DB의 TTL index 를 사용하면 그 컬렉션에 있는 데이터가 일정 시간이 지난 뒤 자동으로 삭제된다. 몽고DB의 경우 1분마다 배치 job 을 실행하기 때문에 오차는 조금 날 수 있다. 또한 한꺼번에 대량의 데이터를 지우느라 성능에 영향을 주지 않도록 만료 시간에 약간의 차이를 두고 저장하는 것을 권장한다.
순수 MSA 에서는 이렇게 각 서비스마다 데이터베이스를 가진다.
하지만 이건……. 딱 봐도 너무 비효율적이다. 사용자가 호텔을 조회하고 예약 페이지에 들어가서 결제를 완료하기까지 여정이 너무 길다. 하나의 요청을 처리하기 위해 DB 4개를 거쳐야하기 때문이다. 그리고 그 여정동안 각 DB에 있는 데이터들은 모두 일관성을 가져야 한다. 예를 들어 결제 페이지로 들어가서 정상 결제가 되었다고 떴지만 사실은 그 전에 다른 사람이 먼저 예약을 선점해버려서 결제 서비스의 DB와 예약 서비스의 DB가 어긋나는 경우가 생길 수 있다. DB 1개만 쓰고 컬렉션(혹은 테이블)만 분리한다면 트랜잭션으로 얼마든지 막을 수 있는 경우다.
실제로 서로 다른 DB 간의 데이터 일관성을 챙기는 일은 쉽지 않으며 많은 안전장치가 필요하다. 그 안전장치를 위한 안전장치가 필요해질 수도 있다. 책에서도 “순수 MSA론자” 라면 이렇게 했겠지만 설계가 너무 복잡해지기 때문에 굳이 권장하지 않는다고 한다.
하지만 MSA 이야기가 나온 김에 이런 케이스를 어떻게 방지할 수 있는지 그 방법은 간단하게 알아보자. 다른 어딘가에서 써먹을 수 있을지도 모른다.
친구랑 같이 에어비앤비 숙소를 잡고 있을 때였다. 친구가 먼저 결제를 하던 도중에 계획이 바뀌어서 내가 결제를 하기로 했다. 같은 날짜에 같은 숙소, 같은 객실을 잡으려고 보니 예약 불가능한 페이지라고 나왔다. 친구가 결제 페이지에 들어가서 그 객실이 선점된 것이다. 그리고 몇 분쯤 기다리고 나니 그제서야 결제 페이지로 들어갈 수 있었다.
아마 에어비앤비에서는 동시성 문제를 해결하기 위해 이렇게 만들었을 것이다. 일종의 락을 건 것이다. 하지만 페이지가 풀리기를 기다리는 사용자 입장에선 좀 별로였다. 특히 풀리기까지 기다리는 시간이 너무 길었다. 적어도 5분은 넘게 기다렸던 것 같다. 그 전에 봤던 다른 객실도 이런식으로 누군가 결제하다가 취소했거나, 아직 결제를 완료하지 않았지만 선점 처리 되어버려서 놓쳤을 가능성이 있다.
위에서 내가 겪었던 락의 형태가 바로 비관적 락이다. 사용자가 데이터를 갱신하려고 하는 순간 다른 사용자는 그 데이터에 접근할 수 없도록 막는 것이다. 장점은 확실하다. 업데이트가 진행중일 때, 다른 누구도 그 데이터를 건드릴 수 없다. 구현도 비교적 쉬운 편이다.
단점도 명확하다. 앞서 내가 겪었던 것처럼 트랜잭션이 너무 오랫동안 락을 잡고 있으면 다른 트랜잭션은 그 데이터에 접근할 방법이 없다. 데드락이 생길 수도 있다.
반면, 여러명의 사용자가 동시에 데이터 갱신을 시도하는 것을 막지 않는 락도 있다. 낙관적 락에서는 갱신 “시도”를 막지 않는다. 하지만 최종 버전에 반영되는건 단 1개뿐이다. 채택되지 못한 트랜잭션의 갱신 시도는 그대로 무시되며, 사용자 입장에서는 계속해서 재시도를 해야 한다. 최종 결제 버튼까지 눌렀더니 “오류가 발생했습니다. 다시 시도해주세요.” 같은게 뜨는 것이다.
낙관적 락은 데이터베이스가 아닌 애플리케이션 레벨에서 구현한다. 보통 버전 번호나 타임스탬프를 사용해서 어떤 요청을 최종적으로 받아들일 지 결정하는데, 타임스탬프를 쓰면 서버에 따라 부정확해질 수 있기 때문에 버전 번호를 따로 관리하는게 일반적이라고 한다. 테이블에 version
같은 필드를 하나 만들고, 규칙을 정하는 것이다. 예를 들어 “레코드를 수정하기 전에 서버는 기존 레코드의 버전 번호를 읽고, 그 버전 번호보다 정확히 1만큼 큰 값일 때만 받아들인다.” 라는 규칙을 정하면 이렇게 동작한다.
낙관적 락은 DB 관점에서 거는 락이 없다는 장점이 있지만, 경쟁이 심한 상황에서는 성능이 떨어진다. 그럴 때는 비관적 락을 쓰는게 낫다. 또한 애플리케이션 로직이 비교적 복잡해지고 롤백이 필요하다면 직접 구현해야 한다는 단점이 있다.
락을 설정하지 않고 해결할 수 있는 다른 방법이 있다. 바로 DB 제약 조건을 거는 것이다. room_inventory
라는 테이블에 총 객실 수(total_inventory
)와 예약된 객실 수(reserved_inventory
)를 저장한다고 하면 이런 조건을 걸 수 있다.
CONSTRAINT `check_room_count` CHECK((`total_inventory` - `reserved_inventory` >= 0))
그러면 DB 락을 잡거나 애플리케이션 레벨에서 직접 락을 구현하지 않고도 간단하게 동시성 문제를 해결할 수 있다. 이 방법도 낙관적 락과 비슷하게 시도 자체는 막지 않고 DB에 저장하려고 할 때 에러가 나기 때문에, 데이터에 대한 경쟁이 심하면 실패하는 연산 수가 많아지면서 성능이 낮아질 수 있다. 또한 DB의 종류에 따라 제약 조건 기능을 지원하지 않을 수 있으며, 코드에 이 제약조건이 적히는게 아니기 때문에 관리하기 어렵다는 단점도 있다.
…에 대한 답을 하려면 항상 그랬듯이 데이터 접근 패턴을 확인해야 한다. 호텔 예약 사이트에서 정확히 같은 시간대에 같은 날짜, 같은 숙소, 같은 객실을 잡으려는 사람이 생기는게 흔한 일은 아닐 것이다. 맨 처음 분석했던 것처럼 100만개 객실이 있을 때, 일일 예약 건수를 약 24만건으로 가정하더라도 초당 예약 건수는 3 QPS 밖에 안 된다.
따라서 책에서는 이 서비스는 데이터에 대한 경합이 심한 편이 아니라고 보고, 낙관적 락이나 DB 제약 조건을 쓰는걸 선택했다.
호텔 잔여 객실 데이터에는 재미있는 특성이 있다. 오직 현재 그리고 미래의 데이터만이 중요하다는 것이다. (…) 따라서 데이터를 보관할 때 낡은 데이터는 자동적으로 소멸되도록 TTL 을 설정할 수 있다면 바람직하다. (…) 레디스는 이런 상황에 적합한데 TTL과 LRU 캐시 교체 정책을 사용하여 메모리를 최적으로 활용할 수 있기 때문이다. - 7장 3단계: 상세 설계
→ 데이터 TTL 설정이 필요할 때 레디스는 유용하게 쓰일 수 있다.
데이터베이스가 먼저 갱신되고, 캐시에는 비동기적으로 변경 내역이 반영된다. 이 비동기적 갱신 작업은 애플리케이션 측에서 수행할 수도 있는데, 그 경우 애플리케이션은 데이터베이스에 데이터를 저장한 다음에 캐시 데이터를 수정한다. 변경 데이터 감지(Change Data Capture, CDC)라는 메커니즘을 사용하는 방법도 있다. CDC는 데이터베이스에서 발생한 변화를 감지하여 해당 변경 내역을 다른 시스템에 적용할 수 있도록 하는 메커니즘이다.
→ DB write 연산에 대한 캐시를 업데이트 해야 할 때, 애플리케이션에서 하거나 CDC를 활용하는 방법이 있다.