
쿠폰 발급 기능에서 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의 활용: