개발ETC/면접 후기

선점 잠금 방식을 활용한 호텔 예약 기능 구현 - 동시성 문제

벨포트조던 2023. 2. 13.
반응형

https://velog.io/@qotndus43/%EB%B9%84%EC%84%A0%EC%A0%90-%EC%9E%A0%EA%B8%88-%EB%B0%A9%EC%8B%9D%EC%9D%84-%ED%99%9C%EC%9A%A9%ED%95%9C-%ED%98%B8%ED%85%94-%EC%98%88%EC%95%BD-%EA%B8%B0%EB%8A%A5-%EA%B5%AC%ED%98%84

 

선점 잠금 방식을 활용한 호텔 예약 기능 구현 - 동시성 문제

룸 인벤토리에서 재고를 확인한다. 재고가 있는 경우 물량을 1 감소시킨다.예약을 진행한다.위 절차는 한 트랜잭션 내에서 수행되며 각 단계 수행 중 문제가 생기는 경우 전체가 롤백됩니다. 이

velog.io

 

호텔 예약 기능 Workflow

  1. 룸 인벤토리에서 재고를 확인한다.
  2. 재고가 있는 경우 물량을 1 감소시킨다.
  3. 예약을 진행한다.

숙박업소 예약 서비스의 호텔 예약 기능 Workflow는 위와 같습니다.
룸 인벤토리에 재고가 있다면 예약이 가능한 상태로 판별하고 예약을 진행합니다.

Booking 엔티티

@Entity
@NoArgsConstructor(access = AccessLevel.PROTECTED)
@Getter
public class Booking extends BaseTimeEntity {

    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    @Column(name = "booking_id")
    private Long id;

    private long userId;

    @Column(name = "roomtype_id")
    private long roomTypeId;
    private LocalDate checkInDate;
    private LocalDate checkOutDate;

    @Enumerated(EnumType.STRING)
    private BookingStatus bookingStatus;

    public static Booking createInstance(long userId, long roomTypeId, LocalDate checkInDate, LocalDate checkOutDate) {
        return new Booking(userId, roomTypeId, checkInDate, checkOutDate, BookingStatus.BOOKED);
    }

    private Booking(long userId, long roomTypeId, LocalDate checkInDate, LocalDate checkOutDate,
                   BookingStatus bookingStatus) {
        this.userId = userId;
        this.roomTypeId = roomTypeId;
        this.checkInDate = checkInDate;
        this.checkOutDate = checkOutDate;
        this.bookingStatus = bookingStatus;
    }
}
public enum BookingStatus {

    BOOKED("예약"), CANCEL("취소");

    private final String title;

    BookingStatus(String title) {
        this.title = title;
    }

    public String getTitle() {
        return title;
    }
}

Booking 엔티티는 위와 같습니다.
Booking 엔티티는 roomTypeId를 갖는데 이는 특정 숙소의 프리미엄방, 온돌방 등을 의미합니다.

문제점

위 절차는 한 트랜잭션 내에서 수행되며 각 단계 수행 중 문제가 생기는 경우 전체가 롤백됩니다.

이 때 두명의 고객이 재고가 하나 남은 방을 동시에 예약하려고 하는 상황을 가정해보도록 하겠습니다.

두 고객이 동시에 재고를 확인할 것이고 재고가 존재하기 때문에 예약을 진행할 것 입니다.

이때 하나의 방이 두명의 고객에게 동시에 예약 되는 문제가 발생할 수 있습니다.

선점 잠금(Pessimistic Lock)

첫번째로 떠올린 방법은 선점 잠금 방식으로 트랜잭션 충돌 문제를 해결하는 것 이었습니다.

선점 잠금은 한 스레드가 수정하려고하는 데이터에 접근하는 순간 락을 걸어 다른 스레드에서 해당 데이터를 수정하는 행위를 막는 방식입니다.

이 락은 첫 번째 스레드에서 커밋할 때 잠금을 해제하게되고 그 동안 두 번째 스레드는 대기하게 됩니다.

http://localhost:8080/api/roomtypes/1/bookings

 {
    "checkInDate" : "2021-10-01",
    "checkOutDate": "2021-10-03"
}

한 고객이 1번 roomType의 방을 예약하는 상황을 가정해보겠습니다.

CREATE UNIQUE INDEX idx_roomtype_date ON room_inventory(roomtype_id, inventory_date);
  • room_inventory 테이블에는 roomtype_id와 inventory_date 컬럼에 대해 인덱스가 걸려있습니다.
SELECT * FROM ROOM_INVENTORY WHERE roomtype_id = ? AND inventory_date >= ? AND inventory_date <= ? FOR UPDATE;
  • FOR UPDATE 문법을 사용하여 잠금을 획득할 수 있습니다.


이 경우 인벤토리 테이블의 2021-10-01 ~ 2021-10-03 날짜로 SELECT 된 모든 행에 lock이 걸려 다른 트랜잭션의 잠금을 제한합니다.

lock이 걸려있는 동안 다른 스레드에서 데이터에 Lock을 걸 수 없으므로 동시에 데이터를 수정할 때 발생하는 데이터 충돌 문제를 해결할 수 있습니다.

그러나 선점 잠금 방식은 트랜잭션이 종료될 때 까지 Lock이 걸려 트랜잭션이 길어질수록 동시성이 낮아지며 Lock 잠금 순서에 따른 데드락이 발생할 수 있습니다.
(여러 레코드(인덱스)에 대한 잠금은 한 번에 이루어지지 않고 레코드별로 이루어집니다.)

비선점 잠금(Optimistic Lock)

앞서 살펴본 선점 잠금의 단점과 한계를 해결하기 위해 비선점 잠금 방식을 사용할 수 있습니다.

비선점 방식은 lock을 사용하여 데이터의 접근을 막는대신 변경한 데이터를 데이터베이스에 반영하는 시점에 변경가능 여부를 확인하는 방식입니다.

UPDATE room_inventory SET available_count = available_count - 1
WHERE roominventory_id = 1 and available_count = 3;

첫 번째 고객이 먼저 데이터를 수정하고 커밋하여 version(available_count 값)을 변경하였기 때문에 때문에 두번째 트랜잭션에서는 위 쿼리 실행시 수정된 행의 개수가 0이 됩니다.

    public void batchReduceRoomInventories(List<RoomInventory> roomInventories) {
        String sql = "UPDATE room_inventory SET available_count = available_count - 1"
            + " WHERE roominventory_id = ? and available_count = ?";

        int[] rowsAffectedArray = jdbcTemplate.batchUpdate(sql, new BatchPreparedStatementSetter() {

            @Override
            public void setValues(PreparedStatement ps, int index) throws SQLException {
                ps.setLong(1, roomInventories.get(index).getId());
                ps.setInt(2, roomInventories.get(index).getAvailableCount());
            }

            @Override
            public int getBatchSize() {
                return roomInventories.size();
            }
        });

        if (rowsAffectedArray.length != roomInventories.size()) {
            throw new NonBookableException();
        }
    }

UPDATE를 수행할 때 version 데이터인 available_count를 조건절에 사용해 비선점 잠금 쿼리를 날리도록 하였습니다.

쿼리 수행 결과 변경된 행의 개수가 변경해야하는 행의 개수와 같지않다면 다른 스레드에서 먼저 데이터를 수정하여 커밋하였다는 뜻이기 때문에 Exception을 발생시켰습니다.

어떤 방식이 더 좋을까?

선점 잠금과 비선점 잠금 방식의 장단점을 고려하여 적합한 방식을 적용해보도록 하겠습니다.

비선점 잠금 방식은 영화관이나 기차 좌석처럼 예약 대상이 하나의 수량을 갖는 경우에는 적절할 수 있습니다.

그러나 현재 구현중인 호텔 예약 시스템에서는 예약 기능의 기준이 룸타입(슈페리어 더블, 슈페리어 트윈, Standard, Premium, 파로나마 뷰, etc.)이 되고 재고가 남아있는 룸타입의 호텔을 예약할 수 있습니다.

예를 들어 베이직 룸타입의 방이 20개가 남아있는 상황에서 동시에 여러 유저가 베이직 룸타입에 예약을 요청하면 먼저 UPDATE & COMMIT한 딱 한명의 유저만 예약에 성공하게 됩니다.

나머지 실패한 고객에 대해서는 아래 두가지 방식으로 처리할 수 있습니다.

1) Exception을 발생시켜 Application단에서 Rollback 후, 고객에게는 다른 사용자가 예약 중이니 나중에 다시 시도해보라는 안내를 보낸다.
2) 특정 횟수만큼 retry한다.

💥 그러나
2)번 방식의 경우 다시 한번 시도한다고 하더라도 역시나 먼저 UPDATE & COMMIT 한 한명의 유저만 예약에 성공하기 때문에 요청이 성공할 것이라는 보장이 없습니다.
1)번 방식의 경우 방이 20개나 있음에도 불구하고 예약에 실패하는 상황이 발생해 서비스 만족도가 급격하게 떨어질 것입니다.

따라서 저는 선점 잠금(Pessimistic Lock) 방식을 적용하기로 하였습니다.

선점 잠금의 경우 lock 잠금 순서에 따른 데드락이 발생할 수 있다는 단점을 앞서 살펴보았습니다. (4박 5일 이상 호텔 예약을 하는 경우는 드물다고 생각하여 트랜잭션이 길어질수록 동시성이 낮아지는 문제는 고려하지 않았습니다.)

  1. 스레드1: 데이터 A에 대한 선점 잠금 구함
  2. 스레드2: 데이터 B에 대한 선점 잠금 구함
  3. 스레드1: 데이터 B에 대한 선점 잠금 시도
  4. 스레드2: 데이터 A에 대한 선점 잠금 시도

위와 같이 두 스레드가 잠금을 구한 상황에서 서로가 가진 잠금을 시도하는 경우 두 스레드 모두 영영 잠금을 얻지 못하는 데드락이 발생하게 됩니다.

특히 이틀 이상 연박 예약을 요청하는 케이스가 많아질수록 여러 개의 잠금을 설정하려하고 교착상태가 발생할 확률이 높아질 것입니다.

SELECT * FROM ROOM_INVENTORY WHERE roomtype_id = ? AND inventory_date >= ? AND inventory_date <= ? FOR UPDATE;

FOR UPDATE 문을 사용하면 트랜잭션 시작 후 조회 된 행에 대해서 트랜잭션이 종료될 때까지 X락을 걸어 다른 잠금을 막습니다. (InnoDB의 경우 내부적으로는 인덱스에 잠금 연산을 수행합니다)

이 때, 범위를 지정한 FOR UPDATE 쿼리를 실행하게되면 record lock(인덱스 레코드에 대한)과 gap lock(인덱스 레코드 사이)이 복합적으로 사용된 next-key lock이 적용됩니다.

이 때 여러 레코드(인덱스)에 대한 잠금은 한 번에 이루어지지 않고 레코드별로 이루어지기 때문에 여러 쿼리가 병렬로 실행될 때 교착 상태가 발생할 수 있습니다.

데드락 회피하기

앞서 여러 레코드(인덱스)에 대한 잠금이 병렬로 실행될 때 교착상태가 발생할 수 있음을 확인하였습니다.

어떻게 해야 한 번의 잠금으로 동시성 문제를 해결할 수 있을까요?

호텔 예약은 한번에 한 roomType에 대해서만 예약이 가능하기 때문에 roomType에 잠금을 걸어 예약 요청받은 roomtype이 동일하면 대기 큐에 쌓아 순차적으로 요청을 처리함으로써 교착상태를 회피할 수 있습니다.

결론

동시성 문제를 해결하기 위해 예약 기능 로직 실행 이전 roomType에 선점 잠금을 걸도록 하였습니다. (FOR UPDATE 쿼리)

해당 roomType에 잠금을 획득한 트랜잭션에 대해서만 인벤토리 데이터를 변경할 수 있게되며 호텔 예약시 발생할 수 있는 동시성 문제를 해결할 수 있습니다.

다음 포스팅에서는 오늘 내용을 바탕으로 코드 구현 방법에 대해 알아보겠습니다.

References

https://www.cubrid.org/manual/ko/9.1.0/sql/transaction.html

https://sabarada.tistory.com/122

https://idea-sketch.tistory.com/45

https://backdoosaan.tistory.com/58

https://jaeseongdev.github.io/development/2021/06/16/Lock%EC%9D%98-%EC%A2%85%EB%A5%98-(Shared-Lock,-Exclusive-Lock,-Record-Lock,-Gap-Lock,-Next-key-Lock)/

https://www.baeldung.com/jpa-pessimistic-locking

 

- 예전에 과제 테스트하면서 작업했던 내용중 기억할만한 내용이라 정리

 

반응형

댓글