✍️ 개발 기록

동시성 문제 발생 확인

ming412 2024. 5. 17. 11:34

상황

쿠폰이 10개 있는 상황이고, 30명의 사용자가 쿠폰 발급을 원하는 상황이다.

여기서 중요한건 선착순으로 요청 순서에 따라 티켓을 발급하되, 준비된 수량만큼만 발생하는 것이다.

원하는 상황만 살펴볼 수 있도록 상황을 최대한 간소화하여, 데이터베이스 테이블은 Coupon, Reservation 만 존재한다.

Coupon 엔티티를 살펴보자.

Coupon 엔티티는 생성자에서 쿠폰의 수량을 받는다. 예약 수량은 0으로 초기화한다.

회원이 쿠폰을 예약할 때마다 예약 수량을 1씩 증가시키고, 예약 수량이 쿠폰 수량과 같거나 커지면 예약 불가 예외를 발생시킨다.

public class Coupon {
    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long couponId;
    private int totalAmount; // 쿠폰 수량
    private int reservedAmount; // 예약 수량

    /***/

    public void increaseReservedAmount() {
        if (reservedAmount >= totalAmount) {
            throw new IllegalArgumentException("Sold out.");
        }
        this.reservedAmount++;
    }
}

Reservation 엔티티를 살펴보자.

Reservation은 회원이 쿠폰을 예매했을 때 쿠폰 예매 내역을 기록하는 테이블이다.

/**
 * 쿠폰 예매 내역을 기록하는 Reservation 테이블
 */
@Entity
public class Reservation {
    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long reservationId;
    @ManyToOne
    @JoinColumn(name = "coupon_id")
    private Coupon coupon;
    private int couponNumber;

    /***/
}

순차 예매

우선 모든 사용자가 순차적으로 예매를 요청한다고 생각한다.

아래 10장의 쿠폰을 30명의 사용자가 예매하는 상황을 테스트하는 코드이다.

@Test
    @DisplayName("10장의 쿠폰을 30명의 사용자가 순차적으로 예매하는 상황")
    void 쿠폰_순차적_예매_테스트() {
        // given
        int memberCount = 30;
        int ticketAmount = 10;
        Coupon coupon = couponRepository.save(new Coupon(ticketAmount));

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

        // when
        for (int i = 0; i < memberCount; i++) {
            try {
                couponService.publishCoupon(coupon.getCouponId());
                successCount.incrementAndGet();
            } catch (Exception e) {
                failCount.incrementAndGet();
            }
        }

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

        // then
        coupon = couponRepository.findById(coupon.getCouponId()).orElseThrow();
        SoftAssertions s = new SoftAssertions();
        s.assertThat(coupon.getReservedAmount()).isEqualTo(ticketAmount); // 성공한 예매 수는 ticketAmount와 같아야 함
        s.assertThat(successCount.get()).isEqualTo(ticketAmount); // 성공한 예매 수는 ticketAmount와 같아야 함
        s.assertThat(failCount.get()).isEqualTo(memberCount - ticketAmount); // 실패한 예매 수는 memberCount - ticketAmount와 같아야 함
        s.assertAll();
    }

실행 결과

당연히 앞선 10명은 예매를 성공하고, 뒤 20명은 예매에 실패한다.

하지만 실생활에서 순차적으로 예매하는 것은 거의 불가능하다.

이제 여러 사용자가 동시에 예매하는 상황을 살펴보자.

동시 예매

@Test
    @DisplayName("10장의 쿠폰을 30명의 사용자가 동시에 예매하는 상황")
    void 쿠폰_동시_예매_테스트() throws InterruptedException {
        // given
        int memberCount = 30;
        int ticketAmount = 10;
        Coupon coupon = couponRepository.save(new Coupon(ticketAmount));

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

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

        // when
        for (int i = 0; i < memberCount; i++) {
            executorService.submit(() -> {
                try {
                    couponService.publishCoupon(coupon.getCouponId());
                    successCount.incrementAndGet();
                } catch (Exception e) {
                    System.out.println(e.getMessage());
                    failCount.incrementAndGet();
                } finally {
                    latch.countDown();
                }
            });
        }

        latch.await(); // 모든 스레드의 작업이 끝날 때까지 대기

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

        // then
        long reservationCount = reservationRepository.count();
        assertThat(reservationCount)
                .isEqualTo(Math.min(memberCount, ticketAmount));
    }

실행 결과

10개보다 적은 7개만 성공한 모습을 볼 수 있다.

쿠폰의 수량보다 더 많은 요청이 있었는데, 왜 더 적게 예매되었을까?

동시성 문제 발생 원인

출력문을 보면 Deadlock found when trying to get lock; try restarting transaction 이라는 문구를 확인할 수 있다.

직역하자면 lock을 얻어오는 과정에서 Deadlock(교착 상태)가 발생했다는건데.. lock을 직접 얻어오는 과정이 없었는데 왜 교착 상태가 발생한걸까?

교착 상태 발생 원인

티켓 예매 내역을 의미하는 Reservation 엔티티를 다시 보자.

@Entity
public class Reservation {
    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long reservationId;
    @ManyToOne
    @JoinColumn(name = "coupon_id")
    private Coupon coupon;
    private int couponNumber;

    /***/
}

Coupon과 Reservation은 1:N 관계에 있다.

 

 

Reservation 엔티티에 Coupon 엔티티와의 연관관계를 설정해줌으로써, 데이터베이스의 Reservation 테이블에 외래키 컬럼 coupon_id 가 추가되었다.

MySQL 공식문서에 아래와 같은 내용이 있다.

If a FOREIGN KEY constraint is defined on a table, any insert, update, or delete that requires the constraint condition to be checked sets shared record-level locks on the records that it looks at to check the constraint. InnoDB also sets these locks in the case where the constraint fails.

외래 키 제약 조건이 있는 테이블에서 레코드를 삽입, 갱신, 삭제할 때 해당 제약 조건을 위반하는지 확인하기 위해 관련된 레코드들에 공유 잠금(s-lock)을 설정한다는 뜻이다.

공유락(s-lock)은 여러 트랜잭션이 동시에 데이터에 읽기 접근을 허용하지만, 쓰기 접근은 제한하는 잠금 유형이다. 즉, 여러 트랜잭션이 동일한 자원에 대해 공유락을 걸 수 있지만, 공유락이 걸린 상태에서는 그 자원에 베타락을 걸 수 없다.

베타락(x-lock)은 특정 자원에 대해 하나의 트랜잭션만 접근할 수 있도록 하는 잠금 유형이다. 베타락이 걸린 자원은 다른 트랜잭션이 읽거나 쓰기 접근을 할 수 없다. 즉, 특정 트랜잭션이 자원을 독점적으로 사용하는 것이다.

 

 

  1. Transaction 1이 id=1인 공유 데이터에 s-lock을 얻었다.
  2. Transaction 2가 id=1인 공유 데이터에 s-lock을 얻었다.
  3. Transaction 1이 id=1인 공유 데이터에 x-lock을 얻고 싶지만, Transaction 2에서 해당 데이터에 s-lock을 걸어두었기 때문에 대기한다.
  4. Transaction 2가 id=1인 공유 데이터에 x-lock을 얻고 싶지만, Transaction 1에서 해당 데이터에 s-lock을 걸어두었기 때문에 대기한다.
  5. 무한 대기 상태에 빠진다. 즉, 교착 상태가 발생한다.

이제 이러한 문제를 여러가지 방법으로 해결해보자.

Ref.

선착순 티켓 예매의 동시성 문제: 잠금으로 안전하게 처리하기