인프런 커뮤니티 질문&답변

JIHYE CHU님의 프로필 이미지
JIHYE CHU

작성한 질문수

재고시스템으로 알아보는 동시성이슈 해결방법

낙관적 락 무한루프 질문

작성

·

233

·

수정됨

0

강사님의 강의를 보며 현재 제 프로젝트에 낙관적 락을 적용시켜 보았습니다.

구현하고자 하는 서비스는 쿠폰 발급 서비스이며, DB 구조는
user 1 : N user_coupon N : 1 coupon 입니다.

 

@Transactional
public void issueCoupon(CouponIssueParam param, User user) {
    // 쿠폰 조회
    Coupon coupon = getCoupon(param.getCouponId());

    // 쿠폰 발급
    UserCoupon userCoupon = UserCoupon.CreateUserCoupon(coupon, user);

    userCouponQueryService.saveUserCoupon(userCoupon);
}
public static UserCoupon CreateUserCoupon(Coupon coupon, User user) {
    // 쿠폰 검증
    coupon.validateCoupon();

    // 재고 감소
    coupon.decreaseQuantity();

    return UserCoupon.builder()
            .coupon(coupon)
            .user(user)
            .build();
}

위 코드와 같이 유저가 특정 쿠폰 Id 를 통해 쿠폰 발급 요청을 하고, 중간 테이블에 관계가 매핑됨으로써 쿠폰 발급이 이루어집니다.

 

public void validateCoupon() {
    if(this.stockStatus.equals(StockStatus.OUT_OF_STOCK)) throw new IllegalArgumentException("쿠폰이 매진되었습니다.");
    if(this.expiredAt.isBefore(LocalDateTime.now())) throw new IllegalArgumentException("쿠폰이 만료되었습니다.");
}

이때 user_coupon 생성 전 쿠폰의 매진 및 만료 상태에 따라 예외를 던지는 검증 메서드가 존재합니다.

 

public void decreaseQuantity() {
    this.remainQuantity = this.remainQuantity - 1;
    if(this.remainQuantity <= 0){
        this.stockStatus = StockStatus.OUT_OF_STOCK;
    }
}

추가로 쿠폰 내부에 재고가 감소하는 메서드가 존재하며 0에 다다를 경우 상태값을 변경해줍니다.

 

이때 동시성 이슈가 발생하는 이유는 user_coupon 을 insert 하면서, 부모 테이블인 coupon 의 재고를 update 하는 과정에서 발생하는 것으로 파악했습니다.

 

이에 쿠폰 발급 초기에 coupon 을 조회할 때

 

@Lock(LockModeType.OPTIMISTIC)
@Query("select c from Coupon c where c.id = :couponId and c.isDeleted = false")
Optional<Coupon> findOneCouponByCouponId(@Param("couponId") Long couponId);

이처럼 낙관적 락 어노테이션을 달아주었으며

 

@Component
@RequiredArgsConstructor
public class OptimisticLockFacade {

    private final CouponService couponService;

    public void issueCoupon(CouponIssueParam param, User user) throws InterruptedException {
        while (true) {
            try {
                couponService.issueCoupon(param, user);
                break;
            } catch (Exception e) {
                Thread.sleep(50); // 재시도 전 잠시 대기
            }
        }
    }
}

위와 같이 퍼사드 클래스를 생성하였습니다.

 

@Test
@DisplayName("쿠폰 여러 명 발급")
void 쿠폰_여러_명_발급() throws InterruptedException {
    int threadCount = 1000;
    ExecutorService executorService = Executors.newFixedThreadPool(32);
    CountDownLatch latch = new CountDownLatch(threadCount);

    for (int i = 0; i < threadCount; i++) {
        final int threadNumber = i + 1;
        int key = i;
        executorService.submit(() -> {
            try {
                optimisticLockFacade.issueCoupon(param, users.get(key));
                System.out.println("Thread " + threadNumber + " - 성공");

            } catch (PessimisticLockingFailureException e) {
                System.out.println("Thread " + threadNumber + " - 락 충돌 감지");

            } catch (Exception e) {
                System.out.println("Thread " + threadNumber + " - " + e.getMessage());

            } finally {
                latch.countDown();
            }
        });
    }

    latch.await();
    executorService.shutdown();

    Long count = userCouponRepository.countByCouponId(param.getCouponId());

    assertThat(count).isEqualTo(100);
}

 쿠폰의 재고가 100개며, 1,000 명의 유저가 32개의 스레드 환경에서 쿠폰 발급을 요청할 때 예상되는 발급 쿠폰 수는 100개가 되어야 합니다.

 

이렇게 낙관적 락을 적용하여 테스트를 수행하니 무한 루프에 빠지게 되었습니다.

무한루프로 인해 테스트가 종료되지 않자, 강제적으로 정지 시킨 후 DB 를 확인했는데 쿠폰의 수는 예상한 대로 100개가 생성된 것을 확인할 수 있었습니다.

 

그런데 왜 메서드가 종료되지 않고 무한루프가 돌아간 것인지 이유를 모르겠습니다.

 

public void issueCoupon(CouponIssueParam param, User user) throws InterruptedException {
    while (true) {
        try {
            couponService.issueCoupon(param, user);
            break;
        } catch (IllegalArgumentException e) {
            System.out.println("쿠폰 발급 실패: " + e.getMessage());
            break;
        } catch (Exception e) {
            Thread.sleep(50); // 재시도 전 잠시 대기
        }
    }
}

 

혹시나 하여, 쿠폰 발급 로직에 쿠폰이 매진이 될 경우 예외를 던지는 검증 메서드가 존재했고 이에 퍼사드 클래스에서 해당 예외를 잡아내면 루프를 빠져나오게 설정했습니다.

 

이렇게 구현하니 테스트는 성공적으로 통과하였습니다.

 

현재 제가 구현한 서비스 구성에서는 이런식으로 접근하는 게 맞는 걸까요 ?

답변 3

0

쿠폰 소진 되었을 때 예외를 던져서 그렇네요. 따라서 예외 캐치하면 정상 끝나는 것이구요.

0

최상용님의 프로필 이미지
최상용
지식공유자

JIHYE CHU 님 안녕하세요.

제가 JIHYE CHU 님의 프로젝트에 대한 내용을 전혀 모르기때문에 어떤게 올바른 방법이다 라고 말씀을 드릴수는 없을것 같습니다.

적어주신 내용으로만 보자면 메소드가 종료되지 않는이유는 validateCoupon 로직에서 Exception 이 발생되기 때문에 발생되는 것으로 보이네요.
IllegalArgumentException 을 모두 잡아서 재시도를 하지 않으면 예상치 못한케이스에도 재시도를 하지 않을 수 있을 것 같습니다.
그렇기때문에 별도의 Excpetion class 를 생성후 validation 에 실패하였다면 별도로 만든 Exception 을 발생시키는 것은 어떨까요 ?

0

안녕하세요, 인프런 AI 인턴입니다.

JIHYE CHU님의 질문과 유사한 상황에 대한 내용을 찾았습니다. 이전에 다루어진 질문들 중에서도 낙관적 락을 사용하면서 무한 루프가 발생한 경우에 대해 공유된 사례가 있어 해당 내용이 도움될 수 있을 것 같습니다.

  1. 강의보고 토이프로젝트로 재고감소 낙관적락 기법 적용 질문
    이 게시글에서는 낙관적 락을 적용하였을 때 무한 루프에 빠지는 문제와 관련해 토론이 있었습니다. 동시성 문제를 해결하기 위해 낙관적 락을 적용했지만, 무한 루프가 발생했고, 이 문제는 커넥션 풀 크기 조정으로 일시적으로 해결할 수 있었습니다. 하지만, 근본적인 해결을 위해서는 시스템 전반에 대한 검토가 필요하다는 것을 알 수 있습니다.

  2. 낙관적 락 무한루프 도는 이유 공유합니다 !
    본 게시글에서는 낙관적 락을 사용할 때 발생할 수 있는 무한 루프의 원인에 대해 상세히 설명하고 있습니다. MySQL의 Isolation Level 설정 때문에 발생할 수 있는 무한 루프 문제를 포함하여, 문제의 근본적인 원인과, @Transactional 어노테이션을 특정 방식으로 사용할 때 주의해야 할 점을 설명합니다.

JIHYE CHU님의 경우, 쿠폰 발급 시스템의 낙관적 락 적용 과정에서 유사한 문제를 겪고 계신 것으로 보입니다. 특정 예외를 적절히 처리함으로써 루프에서 벗어나는 구현 방식은 적절한 접근 방법 중 하나로 보입니다. 하지만, 전체 시스템과 특정 예외 유형에 대한 세밀한 관리가 필요할 수 있습니다.

이러한 정보들을 바탕으로 현재 구현하신 서비스 구성이 특정 상황에 대응하기 위한 좋은 시작점이 되었다고 생각합니다. 그러나 낙관적 락을 활용하는 과정에서 발생할 수 있는 다양한 케이스를 고려하고, 필요한 경우 시스템의 다른 부분도 함께 검토하여 최적의 해결 방안을 찾아나가는 것이 중요합니다.

저는 질문자님의 이해를 돕고자 지식공유자님의 기존 답변을 참고하여 유사한 답변 링크를 추천해드리고 있습니다.
현재 베타 기능으로 답변이 만족스럽지 않을 수 있는 점 양해 부탁드립니다. 🙏
추가적으로 궁금한 점이 있으시면, 이어서 질문해 주세요. 곧 지식공유자께서 답변해 주실 것입니다.

JIHYE CHU님의 프로필 이미지
JIHYE CHU

작성한 질문수

질문하기