Redisson 락을 활용한 동시성 문제 해결

coldrice99·2024년 12월 16일
0
post-thumbnail

문제 상황

쿠폰 발급 기능에서 Redisson 락을 활용하여 동시성 문제를 해결하려고 했지만, 레이스 컨디션으로 인해 남은 쿠폰 수량발급된 쿠폰 수량이 불일치하는 문제가 발생했습니다.

  • 증상:
    • 락이 해제된 시점에 트랜잭션이 아직 커밋되지 않아 다른 스레드가 동일한 리소스에 접근하게 됨.
    • 결과적으로 남은 쿠폰 수량은 줄지 않고, 발급된 쿠폰 수량은 중복되어 계산됨.

원인 분석

  1. 트랜잭션과 락의 비동기적 해제

    • issuanceRepository.save()로 쿠폰 발급 데이터를 저장했지만, DB 트랜잭션이 커밋되지 않은 상태에서 락이 해제됨.
    • 다른 스레드가 락을 획득하여 발급 중인 데이터가 아직 반영되지 않은 쿠폰 수량을 기반으로 작업을 수행함.
  2. 트랜잭션 종료와 락 해제의 타이밍 차이

    • 트랜잭션 커밋은 비즈니스 로직이 끝난 후에 발생.
    • 하지만 락은 트랜잭션 종료와 무관하게 finally 블록에서 해제됨.

해결 방법

1. 락을 트랜잭션 커밋 이후에 해제

락을 트랜잭션의 종료 후 해제하도록 구현. 이를 위해 TransactionSynchronizationManager.registerSynchronization을 사용하여 트랜잭션의 커밋 이후 이벤트를 감지.


수정된 코드

@Transactional
public void issueCoupon(Long couponId, RequestDto request, Member member) {
    String lockKey = "coupon:" + couponId;
    RLock lock = redissonClient.getLock(lockKey);

    try {
        // 락 획득
        if (!lock.tryLock(5, 10, TimeUnit.SECONDS)) {
            System.out.println("Thread " + Thread.currentThread().getId() + "락 획득 실패.");
            throw new IllegalStateException("동시에 너무 많은 요청이 발생했습니다. 잠시 후 다시 시도해주세요.");
        }
        System.out.println("Thread " + Thread.currentThread().getId() + "락 획득.");

        // 트랜잭션 종료 후 락 해제를 보장
        TransactionSynchronizationManager.registerSynchronization(new TransactionSynchronizationAdapter() {
            @Override
            public void afterCompletion(int status) {
                if (lock.isHeldByCurrentThread()) {
                    lock.unlock();
                    System.out.println("Thread " + Thread.currentThread().getId() + " released lock.");
                }
            }
        });

        // 비즈니스 로직
        Coupon coupon = couponRepository.findById(couponId)
            .orElseThrow(() -> new GlobalException(COUPON_NOT_FOUND));

        if (issuanceRepository.findByMemberIdAndCouponId(member.getId(), couponId).isPresent()) {
            throw new GlobalException(COUPON_ALREADY_ISSUED);
        }

        coupon.validateQuantity();
        coupon.decreaseQuantity();

        Issuance issuance = Issuance.builder()
            .member(member)
            .coupon(coupon)
            .build();
        issuanceRepository.save(issuance);

    } catch (InterruptedException e) {
        System.out.println("Thread " + Thread.currentThread().getId() + " was interrupted.");
        throw new IllegalArgumentException("Redis 락을 획득하는데 실패했습니다.", e);
    }
}

결과

  • 테스트 시 기대 결과:
    • 남은 쿠폰 수량: 0
    • 발급된 쿠폰 수량: 100
  • 동시성 문제 해결:
    • 락이 트랜잭션 종료 후에 해제되므로 DB 커밋된 데이터를 기반으로 다음 스레드가 작업을 수행함.
    • 레이스 컨디션 방지 성공.

배운 점

  1. 트랜잭션과 락의 타이밍 관리:

    • 락 해제는 트랜잭션 종료 이후에 실행해야 동시성 문제를 방지할 수 있음.
  2. TransactionSynchronizationManager의 활용:

    • 트랜잭션의 상태(커밋/롤백)를 감지하여, 필요한 로직을 실행할 수 있음.
profile
서두르지 않으나 쉬지 않고

0개의 댓글