Gromit`s dev

[MySQL] 동시에 예약요청을 유니크 제약조건을 사용해서 한 건의 예약만 DB에 저장하기 본문

카테고리 없음

[MySQL] 동시에 예약요청을 유니크 제약조건을 사용해서 한 건의 예약만 DB에 저장하기

안종혁 2024. 7. 1. 12:58

⭐요구사항

코드스쿼드의 Airbnb 프로젝트 중

같은 숙소에 사람 A 와 사람 B 가 동시에 예약을 요청할 경우, 한 건의 예약만 성공해야 합니다.

(같은 숙소를 사람 A와 사람 B가 동시에 사용하면 안되니까!)

⭐고민

그래서, 동시에 예약 요청을 막기 위해

  • 비관적 락
  • 낙관적 락
  • DB의 유니크 제약조건

을 고민하게 되었습니다.

해결 과정

예약이 저장되는 코드는 다음과 같습니다.

@Transactional
public BookingResponse create(BookingSaveRequest request) {

    // 요청한 예약이 기존 예약의 일정과 중복되는지 확인하는 로직
    Long bookedStayCount = bookingRepository.countBookedStay(request.getStayId(), request.getCheckIn(), request.getCheckOut());
    if (bookedStayCount > 0) {
        throw new IllegalArgumentException("예약 불가입니다.");
    }

    // 예약을 요청한 사람이 회원인지 확인
    Member member = memberRepository.findById(request.getMemberId())
                .orElseThrow();
    // 숙소가 예약 인원을 수용할 수 있고, 사용 가능한지 확인
    Stay findStay = stayRepository.findById(request.getStayId())
                .orElseThrow(() -> new IllegalArgumentException("숙소 못찾아요"));
    findStay.validateExceedGuest(request.getGuestCount());
    findStay.validateOpenStatus();

    Booking entity = Booking.builder()
            ...(중략)
            .build();

    // 예약을 DB에 저장
    try {
        Booking saved = bookingRepository.save(entity);
        return BookingResponse.of(saved);
    } catch (PersistenceException error) {
        예외처리
    }
}

 

위 코드에서 DB에 접근하여 데이터를 가져오는 로직은 

1. Long bookedStayCount = bookingRepository.countBookedStay
2. Member member = memberRepository.findById
3. Stay findStay = stayRepository.findById

 

이렇게 총 3가지가 있습니다.

그러나, 이 3가지 경우 다 DB에서 데이터를 조회만 할 뿐, update 를 하거나 delete 를 하진 않습니다.
낙관적 락과 비관적 락을 언제 사용할지를 공부했기 때문에,
한 트랜잭션에서 DB에 접근해서 조회만 하는 제 경우에는 락이 적합하지 않다고 판단했습니다.

또한, bookingRepository.save(entity) 도 DB에 INSERT INTO 쿼리가 날라갈 뿐,

SELECT 쿼리는 실행되지 않기 때문에 비관적 락과 낙관적 락을 사용할 수 없습니다.

 

물론, 격리수준 SERIALIZABLE 로 설정해서 트랜잭션을 순차적으로 실행시킬 순 있지만

에어비앤비 서비스 특성 상 주로 숙소 조회에 많은 DB 커넥션이 사용될 것이기 때문에

트랜잭션을 순차적으로 실행하여 동시 처리량을 낮추는 것은 적합하지 않다고 판단했습니다. 


그렇기 때문에 DB의 유니크 제약 조건을 사용하여, 동시 예약 요청을 막고자 하였습니다.


유니크 제약 조건을 예약 엔티티의 숙소ID, 체크인, 체크아웃로 설정하여

같은 날짜에 같은 숙소에 2개 이상의 예약이 생기지 않도록 하였습니다.

/* 예약 엔티티 */
@Table(name = "BOOKING",
uniqueConstraints = {
        @UniqueConstraint(columnNames = {"STAY_ID", "CHECK_IN", "CHECK_OUT"})}
)
@Entity
public class Booking extends BaseTimeEntity {


이제, 테스트 코드를 사용하여 동시 예약 요청이 막아지는지 확인해보겠습니다.


테스트는 예약 Request 를 만들어서 멀티 쓰레드 환경에서 동시에 request 를 보냈을 때,

DB에 몇 건이 저장되는지 확인합니다.

@Test
void 동시_예약_요청이_오더라도_1건만_예약된다() throws InterruptedException {
    // 예약 요청 생성
    LocalDateTime checkIn = LocalDateTime.of(LocalDate.of(2024, 11, 1), LocalTime.of(15, 0, 0));
    LocalDateTime checkOut = LocalDateTime.of(LocalDate.of(2024, 11, 2), LocalTime.of(12, 0, 0));
    // 멤버ID 가 1인 사람이 숙소ID가 1인 곳에 예약 요청
    BookingSaveRequest request = new BookingSaveRequest(1L, 1L, checkIn, checkOut, 2);

    AtomicInteger successCount = new AtomicInteger();
    AtomicInteger failCount = new AtomicInteger();

    long originCount = bookingRepository.count();
    int requestCount = 30;

    ExecutorService executorService = Executors.newFixedThreadPool(requestCount);
    CountDownLatch latch = new CountDownLatch(requestCount);

    for (int i = 0; i < requestCount; i++) {
        executorService.submit(() -> {
            try {
                bookingService.create(request);
                successCount.incrementAndGet();
            } catch (Exception e) {
                failCount.incrementAndGet();
            } finally {
                latch.countDown();
            }
        });
    }
    latch.await();

    System.out.println("successCount = " + successCount);
    System.out.println("failCount = " + failCount);

    long changeCount = bookingRepository.count();
    Assertions.assertThat(originCount + 1).isEqualTo(changeCount);
}

테스크 코드에 대한 설명 -> 링크

테스트의 결과로, 성공한 개수는 1개, 실패한 개수는 29개가 나오게 되었고,


유니크 제약 조건에 의해 DB에 삽입되지 못한 트랜잭션은 다음과 같이 에러가 발생하게 되었습니다.

2024-07-01T12:34:40.659+09:00 ERROR 24864 --- [clone] [pool-2-thread-2] o.h.engine.jdbc.spi.SqlExceptionHelper   : Duplicate entry '1-2024-11-01 15:00:00.000000-2024-11-02 12:00:00.000000' for key 'BOOKING.UKl92471cusp79py2jcwnxaph0e'


DB의 예약 테이블에도 단 1건만 저장됨을 확인할 수 있었습니다.


참고 링크

테코블 - 동시 예매 시스템