쿠폰 발급 기능에서 Redisson 락을 활용하여 동시성 문제를 해결하려고 했지만, 레이스 컨디션으로 인해 남은 쿠폰 수량과 발급된 쿠폰 수량이 불일치하는 문제가 발생했습니다.
트랜잭션과 락의 비동기적 해제
issuanceRepository.save()
로 쿠폰 발급 데이터를 저장했지만, DB 트랜잭션이 커밋되지 않은 상태에서 락이 해제됨.트랜잭션 종료와 락 해제의 타이밍 차이
finally
블록에서 해제됨.락을 트랜잭션의 종료 후 해제하도록 구현. 이를 위해 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);
}
}
트랜잭션과 락의 타이밍 관리:
TransactionSynchronizationManager
의 활용: