문제 정의

쿠폰 발급에서 쿠폰 재고 수량은 공유 자원이기 때문에 여러 사용자가 동시에 쿠폰 발급을 요청하면 동시성 문제가 발생합니다.

여러 트랜잭션이 동일한 재고 값을 읽은 후 업데이트를 수행하면 쿠폰이 과발급 될 수 있습니다.

상황

현재 시나리오 테스트에서는 쿠폰 수량을 100개로 생성하고, 200명이 동시에 쿠폰 발급을 요청하는 상황을 가정합니다.

    @Test
    @DisplayName("쿠폰 동시성 테스트")
    void testConcurrentCouponIssuance() throws Exception {
        // 동시에 쿠폰 발급 요청
        int threadCount = 200;
        ExecutorService executorService = Executors.newFixedThreadPool(10);
        CountDownLatch latch = new CountDownLatch(threadCount);

        // 총 수행 시간 측정
        long start = System.currentTimeMillis();
        for (int i = 0; i < threadCount; i++) {
            final long userId = i; // 각 쓰레드마다 고유한 사용자 ID 사용
            executorService.execute(() -> {
                try {
                    CouponIssueRequestDto couponIssueRequestDto = CouponIssueRequestDto.builder()
                            .promotionId(promotionId)
                            .couponId(couponId)
                            .userId(userId)
                            .build();

                    // 쿠폰 발급
                    couponApplicationService.issueCoupon(couponIssueRequestDto);

                } catch (Exception e) {
                    // 예상되는 예외 처리 (쿠폰 소진 예외 등)
                    System.out.println("Exception: " + e.getMessage());
                } finally {
                    latch.countDown();
                }
            });
        }
        latch.await();
        executorService.shutdown();

        long end = System.currentTimeMillis();

        // 총 수행 시간 출력
        System.out.println("총 수행 시간: " + (end - start) + "ms");

        int issuedCouponCount = couponUserRepository.countByCouponId(couponId);
        System.out.println("발급된 쿠폰 수량: " + issuedCouponCount);

        // 발급된 쿠폰 수량이 100개인지 확인
        Assertions.assertThat(issuedCouponCount).isEqualTo(100);
    }
}

100개의 사용자 쿠폰을 발급하고 쿠폰 수량이 0이되면 예외가 발생해야하지만, 예상과 다르게 요청에 따라 사용자 쿠폰이 200개가 모두 발급 되고, 쿠폰 수량은 72개로 제대로 차감이 되지 않은 결과입니다.

public void issueCoupon() {
    if (this.totalQuantity <= 0) {
        throw new IllegalArgumentException("쿠폰 발급 가능 수량이 없습니다.");
    }
    this.totalQuantity--;
}

원인 - 동시성 문제

this.totalQuantity--는 현재 스레드 간 안전하지 않은 연산입니다. coupon.issueCoupon() 메서드를 호출할 때, 각 스레드가 쿠폰의 totalQuantity 필드를 동시에 읽고 쓰는 과정에서 **경쟁 조건(Race Condition)**이 발생하여, 쿠폰 재고가 예상한 대로 감소하지 않게 됩니다.

이로 인해 200개의 요청이 모두 성공적으로 처리되고, 쿠폰 수량이 비정상적으로 남게 됩니다.

해결 방법

이러한 동시성 문제를 해결하기 위해서는 동시에 여러 스레드가 해당 자원을 접근할 수 없도록 해야합니다.

비관적 락(X-Lock)을 이용하여 UPDATE (PostgreSQL 기준)

PostgreSQL에서는 값을 업데이트 할 때 X-Lock(Exclusive Lock)을 이용하여 데이터베이스 차원에서 해당 행(row)에 대해 배타적 락이 걸리며, 다른 트랜잭션이 해당 행을 수정하거나 삭제하는 것을 방지합니다.

따라서 데이터베이스 상에서 동시성 처리를 위해 @Modifying로 UPDATE 쿼리를 이용하여 쿠폰 수량을 차감 처리합니다.

@Modifying
@Query("UPDATE Coupon c SET c.totalQuantity = c.totalQuantity - 1 WHERE c.couponId = :couponId AND c.totalQuantity > 0")
int decreaseCouponQuantity(@Param("couponId") UUID couponId);