낙관적 락(Optimistic Lock)을 적용하여 동시성 제어를 시도했지만, 버전 충돌이 발생했을 때 재시도 로직이 제대로 작동하지 않음. 재시도가 실행되어야 하는 상황에서 기타 예외가 발생하며 스레드가 종료되는 현상을 겪음.
기타 예외 로그 분석
로그에 출력된 메시지:
기타 예외 발생: Row was updated or deleted by another transaction
이 로그를 보고 처음에는 낙관적 락이 제대로 작동하지 않는 것으로 판단. 그러나 실제로는 버전 충돌(Exception)이 잘못 감지되고 있었음.
Exception 감지 문제
버전 충돌(Exception)은 OptimisticEntityLockException
으로 감지될 것으로 예상했으나, 실제로는 ObjectOptimisticLockingFailureException
로 발생하는 것을 확인. 따라서 잘못된 예외를 처리하려다 보니 재시도 로직이 실행되지 않고 종료된 것이 문제의 원인이었음.
올바른 예외 처리 적용
ObjectOptimisticLockingFailureException
을 감지하도록 예외 처리 로직을 수정.재시도 로직 구현
while
루프 대신, Spring의 @Retryable
어노테이션을 활용.@Retryable
은 지정된 예외가 발생했을 때, 자동으로 재시도하도록 설정할 수 있음.최종 코드
@Transactional
@Retryable(
value = {ObjectOptimisticLockingFailureException.class},
maxAttempts = 100,
backoff = @Backoff(delay = 50) // 50ms 간격으로 재시도
)
public void issueCoupon(Long couponId, RequestDto request, Member member) {
System.out.println("재시도 횟수: " + RetrySynchronizationManager.getContext().getRetryCount());
// 디비 단에서 낙관적 락 적용
Coupon coupon = couponRepository.findByIdWithOptimisticLock(couponId)
.orElseThrow(() -> new GlobalException(COUPON_NOT_FOUND));
if (issuanceRepository.findByMemberIdAndCouponId(member.getId(), couponId).isPresent()) {
throw new GlobalException(COUPON_ALREADY_ISSUED);
}
coupon.validateQuantity();
coupon.decreaseQuantity();
// 버전 업데이트
couponRepository.saveAndFlush(coupon);
Issuance issuance = Issuance.builder()
.member(member)
.coupon(coupon)
.build();
issuanceRepository.save(issuance);
System.out.println("쿠폰 발급 성공. 쿠폰 버전: " + coupon.getVersion());
}
테스트 코드 개선
재시도 횟수 확인과 동시성 제어 테스트를 위해 로그와 테스트 환경을 조정:
@Test
@DisplayName("낙관적 락 동시성 제어 테스트")
void optimisticLockConcurrencyTest() throws InterruptedException {
for (int i = 0; i < threadCount; i++) {
int threadId = i;
executorService.submit(() -> {
try {
System.out.println("Thread 시작: " + threadId);
Member threadMember = new Member("Test User" + threadId, "test" + threadId + "@example.com", "password", MemberRole.USER);
memberRepository.save(threadMember);
couponService.issueCoupon(coupon.getId(), new RequestDto(threadMember.getId()), threadMember);
} catch (Exception e) {
System.out.println("Thread 예외: " + threadId + ", " + e.getMessage());
} finally {
latch.countDown();
System.out.println("Thread 완료: " + threadId);
}
});
}
latch.await();
// 결과 검증
Coupon updatedCoupon = couponRepository.findById(coupon.getId()).orElseThrow();
System.out.println("남은 쿠폰 수량: " + updatedCoupon.getQuantity());
System.out.println("발급된 쿠폰 수량: " + issuanceRepository.count());
}
@Retryable
을 활용하면 수동적인 재시도 로직보다 간결하고 효과적으로 동시성 문제를 해결할 수 있음.