낙관적 락 동시성 제어 이슈와 해결 과정

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

문제 상황

낙관적 락(Optimistic Lock)을 적용하여 동시성 제어를 시도했지만, 버전 충돌이 발생했을 때 재시도 로직이 제대로 작동하지 않음. 재시도가 실행되어야 하는 상황에서 기타 예외가 발생하며 스레드가 종료되는 현상을 겪음.


이슈 발견 및 원인 분석

  1. 기타 예외 로그 분석
    로그에 출력된 메시지:

    기타 예외 발생: Row was updated or deleted by another transaction

    이 로그를 보고 처음에는 낙관적 락이 제대로 작동하지 않는 것으로 판단. 그러나 실제로는 버전 충돌(Exception)이 잘못 감지되고 있었음.

  2. Exception 감지 문제
    버전 충돌(Exception)은 OptimisticEntityLockException으로 감지될 것으로 예상했으나, 실제로는 ObjectOptimisticLockingFailureException로 발생하는 것을 확인. 따라서 잘못된 예외를 처리하려다 보니 재시도 로직이 실행되지 않고 종료된 것이 문제의 원인이었음.


해결 과정

  1. 올바른 예외 처리 적용

    • 낙관적 락에서 발생하는 ObjectOptimisticLockingFailureException을 감지하도록 예외 처리 로직을 수정.
  2. 재시도 로직 구현

    • 재시도를 수동으로 처리하는 while 루프 대신, Spring의 @Retryable 어노테이션을 활용.
    • @Retryable은 지정된 예외가 발생했을 때, 자동으로 재시도하도록 설정할 수 있음.
    • 재시도 횟수를 넉넉하게 설정(예: 100회)하여 높은 동시성 환경에서도 충분히 처리 가능하도록 조정.
  3. 최종 코드

    @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());
    }
  4. 테스트 코드 개선
    재시도 횟수 확인과 동시성 제어 테스트를 위해 로그와 테스트 환경을 조정:

    @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());
    }

결과

  • 동시성 제어 성공
    재시도 로직이 제대로 동작하면서 낙관적 락을 통한 데이터 정합성이 보장됨.
  • 재시도 횟수 확인
    테스트 결과 재시도 횟수는 많게는 19번까지 발생했으며, 설정된 최대 재시도 횟수 내에서 처리 성공.

느낀 점

  • Spring의 @Retryable을 활용하면 수동적인 재시도 로직보다 간결하고 효과적으로 동시성 문제를 해결할 수 있음.
  • Exception 타입의 정확한 확인이 문제 해결의 열쇠였음. 추후 Exception 발생 시 로그를 꼼꼼히 살펴보고 문제를 정확히 정의하는 습관을 가져야겠다고 느꼈음.
profile
서두르지 않으나 쉬지 않고

0개의 댓글