호텔 예약 시스템에서의 동시성 문제 다루기 (낙관적 락 vs 비관적 락)

Jongma·2024년 10월 13일
1
post-thumbnail

개요

호텔 예약 시스템에서 사용자가 객실 예약을 할 때 발생할 수 있는 동시성 문제에 대해서 알아보겠다. 아래와 같이 두 가지 시나리오를 예상해 볼 수 있다.

  1. 동일한 사용자의 다중 요청으로 인한 객실 중복 예약 문제
  2. 마지막 객실이 남은 상태에서 여러 사용자의 동시 요청으로 인한 초과 예약 문제

이러한 동시성 문제는 나쁜 사용자 경험을 제공하고 비즈니스 적으로 심각한 이슈를 발생할 수 있기 때문에 이러한 문제가 발생하지 않도록 방지해야 된다. 이어서 위 두 가지 시나리오에 대해서 자세히 살펴보고 해결 방법을 알아보겠다.


동시성 문제 시나리오

객실 중복 예약 문제: 동일 사용자의 다중 요청

만약 사용자가 연속으로 예약 버튼을 누르게 되면 예약 요청이 두 번 연속으로 전송될 수 있다. 그러면 객실이 중복으로 예약되는 문제가 발생할 수 있다.

이 문제는 클라이언트 측에서 예약 버튼을 누를 때 버튼을 비활성화 처리하면 간단하게 해결할 수 있지만 모든 상황에서의 해결책은 될 수 없다. 예를 들어서 네트워크 지연 등의 문제로 클라이언트 측에서 의도하지 않은 재요청 등이 발생하면 여전히 다중 요청이 발생할 가능성이 존재한다.

따라서, 클라이언트 측에서 처리를 했다고 하더라도 서버 측에서도 이러한 문제에 대해서 예측하고 대비를 해둬야 한다. 서버 측에서는 아래와 같이 멱등 API 구현을 통해서 방지할 수 있다.

  1. 예약 테이블에서 reservation_id 컬럼에 유니크 제약 조건 추가
  2. 사용자가 예약 주문서 페이지에 진입하면 서버에서는 멱등키인 reservation_id를 생성해서 응답
  3. 클라이언트 측에서는 요청할 때 reservaction_id를 포함해서 요청
  4. 이중 요청이 발생한 상황을 가정 → 첫 번째 예약 요청은 reservaction_id을 가진 예약이 없으므로 성공 처리가 되고, 두 번째 예약 요청은 유일성 조건에 위배되어 이중 예약이 방지됨

초과 예약 문제: 여러 사용자의 동시 요청

전체 예약 가능 객실 수가 100개이고 현재 예약된 객실 수가 99개인 상황을 가정해보자. 이때 두 명의 사용자가 동시에 마지막 남은 객실에 대해서 예약 요청을 진행한다면 아래와 같은 경쟁 조건이 발생하게 된다.

race_condition

  • 1, 2번에서 트랜잭션에서 잔여 객실이 1개 남아 있음을 조회
  • 3번에서 트랜잭션1이 먼저 객실 예약을 진행 → 예약 객실 수가 100으로 변경됨
  • 4번에서 트랜잭션2에서 객실 예약을 진행 → 예약 객실 수가 100으로 변경됨
    • 트랜잭션은 다른 트랜잭션과는 독립적으로 동작하는 특징이 있음 (격리성)
    • 따라서, 트랜잭션1에서 변경된 내용은 트랜잭션2에서는 알 수 없음 (격리 수준에 따라 다름)
  • 5, 6번에서 트랜잭션들이 성공적으로 데이터베이스에 반영

결과적으로 잔여 객실이 1개인 상황에서도 2명이 예약했기 때문에 데이터의 일관성이 지켜지지 않았다. 이로 인해서 두 사용자가 호텔에 갔더니 서로 객실을 예약했다고 주장하는 상황이 펼쳐지는 등 비즈니스적인 문제도 발생할 수 있다.

따라서, 이러한 곤란한 상황을 겪지 않으려면 동시성 문제를 방지해야 한다.


동시성 문제 해결 방법

위와 같은 동시성 문제는 데이터베이스 락을 활용해서 해결할 수 있다. 락 메커니즘은 대표적으로 비관적 락과 낙관적 락 방식이 있다.

비관적 락 (Pessimistic Lock)

비관적 락은 먼저 쿼리한 사용자가 레코드를 선점하고 락을 거는 방식이다.

아래 그림과 같이 트랜잭션1에서 먼저 쿼리를 실행해서 락을 걸어두면 트랜잭션2는 트랜잭션1이 완료될 때까지 기다리고 나서 갱신을 수행할 수 있다.

pessimistic_lock

MySQL에서는 비관적 락을 “SELECT ••• FOR UPDATE” 쿼리 문으로 구현할 수 있다.

장점

  • 구현이 쉽고 모든 갱신 연산을 직렬화 해서 충돌을 막음
  • 변경 중이거나 변경이 끝난 데이터를 다른 트랜잭션에서 갱신하는 일을 막음

단점

  • 여러 레코드에 락을 걸리면 교착 상태(deadlock)가 발생할 수 있음
  • 트랜잭션이 락을 오래 유지하면 다른 트랜잭션은 락이 걸린 자원에 접근할 수 없음
  • 특히 수행이 오래 걸리거나 많은 엔티티가 연관된 트랜잭션일 경우 성능에 심각한 영향이 갈 수 있음

낙관적 락 (Optimistic Lock)

낙관적 락은 레코드의 버전을 관리하고 해당 버전으로 충돌을 감지해서 동시성을 제어하는 방식이다. 버전 값으로는 일반적으로는 정수 번호를 사용하지만 상황에 따라서 타임스탬프나 해시 값을 사용할 수 있다.

다음 그림은 두 사용자가 데이터 갱신을 성공하는 사례와 실패하는 사례다.

optimistic_lock

  1. 데이터베이스 테이블에 version_id 컬럼을 생성
  2. 어플리케이션 로직에서 데이터 조회와 함께 version_id를 같이 읽어들임
  3. 도메인 로직을 통해서 데이터를 변경하고, 버전을 1 올려서 데이터베이스에 기록
  4. 현재 버전과 다음 버전으로 충돌을 판단하고 성공 실패 여부가 갈림
    • 테이블의 현재 버전이 갱신할 다음 버전보다 작은 경우 성공 (v2 < v3)
    • 테이블의 현재 버전이 갱신할 다음 버전보다 크거나 동일한 경우 실패 (v2 ≥ v2), 이미 다른 트랜잭션에서 데이터를 갱신 했음을 의미함

SQLAlchemy에서는 간단하게 version_id_col에 컬럼 지정으로 구현 가능

class User(Base):
    __tablename__ = "user"

    id = mapped_column(Integer, primary_key=True)
    version_id = mapped_column(Integer, nullable=False)

    __mapper_args__ = {"version_id_col": version_id}

낙관적 락은 데이터베이스에 락을 걸지 않기 때문에 일반적으로 비관적 락보다 빠르다. 하지만 동시성 수준이 아주 높아지면 성능이 급격하게 나빠진다. 예를 들면 다음과 같은 상황이 있을 수 있다.

  1. 많은 클라이언트가 같은 호텔 객실을 동시에 예약하는 상황
  2. 모든 사용자가 동시에 잔여 객실 수를 읽을 수 있음 → 모두 같은 버전 획득 (v1)
  3. v2로 갱신하는 사용자는 오직 한 명이고 나머지 사용자들은 모두 충돌이 발생하며 실패
  4. 나머지 사용자들은 다시 예약시도를 해야하기 때문에 좋지 않은 UX를 제공하게 됨

장점

  • 데이터베이스 자원에 락을 걸지 않으므로 많은 사용자가 동시에 데이터를 읽고 처리할 수 있음
  • 트랜잭션 동안 락을 유지하지 않으므로 데이터베이스의 락 관리 비용이 없음

단점

  • 데이터에 대한 경쟁이 치열한 상황에서는 성능이 좋지 못함

비관적 락 vs 낙관적 락

위와 같이 낙관적 락 방식은 동시성 수준이 높은 상황에서는 오히려 좋지 않은 선택이 될 수 있다. 따라서, 각 상황에 맞는 락 매커니즘 방식을 선택하는 게 중요하다.

비관적 락을 선택해야 하는 경우

  • 데이터의 일관성이 절대적으로 중요한 경우
  • 동시에 데이터를 수정하는 트랜잭션이 많아 충돌 가능성이 높은 경우
  • 예: 은행 거래 시스템, 공연 티켓 예약 시스템 등

낙관적 락을 선택해야 하는 경우

  • 읽기 작업 위주의 시스템인 경우
  • 동시에 데이터를 수정하는 트랜잭션이 적어 충돌 가능성이 낮은 경우
  • 데이터베이스 락을 줄여 성능을 높여야 하는 경우
  • 예: 사용자 프로필 조회 시스템 등


마무리

이처럼 호텔 예약 시스템에서 발생할 수 있는 동시성 문제와 해결 방법을 살펴보았다.

동시성 문제가 실무적인 이슈에 좀 더 가까워서 먼저 다뤄봤는데, 추후에는 이번 글에서 다루지 못한 트랜잭션의 ACID 특성이나 격리 수준과 같이 이론적인 부분에 대해서도 다뤄보도록 하겠다.

참고

0개의 댓글