
낙관적 락(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을 활용하면 수동적인 재시도 로직보다 간결하고 효과적으로 동시성 문제를 해결할 수 있음.