동시성 제어 테스트에서 비관적 락 실패 문제 해결

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

1️⃣ 문제 상황

  • 목표: 쿠폰 발급 기능에서 동시성 제어를 테스트하기 위해 비관적 락 (Pessimistic Lock)을 적용.
  • 증상:
    • 테스트 코드 실행 결과:
      • 쿠폰 수량: 100
      • 발급된 쿠폰 수량: 0
    • 동시성 문제가 해결되지 않았으며, 비관적 락이 제대로 동작하지 않음.

2️⃣ 문제 원인 분석

🔍 비관적 락과 트랜잭션의 관계

  • 비관적 락은 트랜잭션 내에서만 유효합니다.
    • 락은 트랜잭션 시작 시 설정되고, 트랜잭션 종료 시 해제됩니다.
  • 테스트 실행 시, @Transactional이 없는 상태에서 트랜잭션이 제대로 관리되지 않아 락이 즉시 해제되었음.
  • 결과적으로, 여러 스레드가 동시에 쿠폰 수량을 감소시키려 했으나, 락이 동작하지 않아 동시성 문제가 발생.

🔍 테스트 코드의 설계 문제

  • 서비스 레이어에서 트랜잭션 경계를 명확히 설정하지 않았음.
  • 테스트 메서드 자체에서 트랜잭션을 사용하려고 했으나, 비관적 락은 트랜잭션 범위를 벗어나면 동작하지 않음.

3️⃣ 문제 해결

🛠️ 서비스 레이어에 @Transactional 추가

  • CouponServiceissueCoupon 메서드에 트랜잭션을 적용.
    • 비관적 락의 생명 주기를 트랜잭션 경계 내로 설정하여 락이 올바르게 동작하도록 수정.
@Transactional
public void issueCoupon(Long couponId, RequestDto request, Member member) {
    Coupon coupon = couponRepository.findByIdWithPessimisticLock(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);
}

🛠️ 비관적 락 적용

  • CouponRepository에 비관적 락을 적용한 JPQL 작성:
@Query("SELECT c FROM Coupon c WHERE c.id = :id FOR UPDATE")
Optional<Coupon> findByIdWithPessimisticLock(@Param("id") Long id);

🛠️ 테스트 코드 수정

  • 기존 테스트 코드에서 트랜잭션 경계를 테스트 메서드가 아닌 서비스 레이어로 이동.
  • 각 스레드가 쿠폰을 발급받을 때 새로운 Member를 생성하여 동시성 문제를 재현.
  • 스레드 실행 결과를 검증하도록 추가 로그 및 검증 코드 작성.

4️⃣ 결과

수정 후 테스트 결과

  • 남은 쿠폰 수량: 0
  • 발급된 쿠폰 수량: 100
  • 비관적 락이 트랜잭션 경계 내에서 제대로 동작하여 동시성 문제가 해결됨.

로그

Thread 시작: 0
Thread 시작: 1
Thread 시작: 2
...
Thread 완료: 98
Thread 완료: 99
남은 쿠폰 수량: 0
발급된 쿠폰 수량: 100

5️⃣ 트랜잭션의 중요성

🔑 왜 트랜잭션이 중요한가?

  • 비관적 락은 트랜잭션 내부에서만 유효합니다.
  • 트랜잭션이 시작되지 않으면 락이 바로 해제되어 다른 스레드가 데이터에 접근.
  • 스프링의 @Transactional은 트랜잭션의 경계를 명확히 설정하여 락의 생명 주기를 보장.

6️⃣ 트랜잭션 미적용 시 문제점

  • 트랜잭션이 없는 경우:
    • 비관적 락이 제대로 적용되지 않아 동시성 문제가 발생.
    • 테스트 코드 실행 결과, 모든 스레드가 동시에 쿠폰을 발급하려고 시도하여 발급된 쿠폰 수량이 0으로 유지됨.
  • 트랜잭션 적용 후:
    • 비관적 락이 트랜잭션 시작 시 설정되고 종료 시 해제.
    • 하나의 스레드가 쿠폰 발급 작업을 완료하기 전까지 다른 스레드는 대기.

7️⃣ 향후 개선점

  1. 비관적 락과 성능 분석:

    • 비관적 락은 성능 저하(TPS 감소)를 초래할 수 있음.
    • Redis 분산 락과의 성능 비교를 통해 최적의 동시성 제어 방식 선택.
  2. 테스트 시나리오 확장:

    • 대규모 사용자 환경(TPS 증가)을 가정한 테스트.
    • 실제 배포 환경과 유사한 성능 테스트 도구(nGrinder) 활용.
  3. 에러 로그 및 예외 처리 개선:

    • GlobalException 외에 동시성 제어 실패 로그를 추가하여 성능 병목점 분석.

8️⃣ 최종 정리

  • 비관적 락은 트랜잭션 경계 내에서만 동작하며, 트랜잭션 관리가 필수.
  • 트랜잭션을 활용한 락 관리로 동시성 제어 문제를 해결할 수 있음.
  • 쿠폰 발급과 같은 민감한 동시성 로직에는 락 방식과 성능의 균형을 고려한 설계가 중요.

참고 코드: 테스트 결과 확인

@Test
@DisplayName("비관적 락 동시성 제어 테스트")
void pessimisticLockConcurrencyTest() throws InterruptedException {
    for (int i = 0; i < threadCount; i++) {
        int threadId = i;
        executorService.submit(() -> {
            try {
                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 (GlobalException e) {
                System.out.println("Thread " + threadId + " 예외 발생: " + e.getMessage());
            } finally {
                latch.countDown();
            }
        });
    }
    latch.await();

    Coupon updatedCoupon = couponRepository.findById(coupon.getId()).orElseThrow();
    System.out.println("남은 쿠폰 수량: " + updatedCoupon.getQuantity());
    System.out.println("발급된 쿠폰 수량: " + issuanceRepository.count());
    assertEquals(0, updatedCoupon.getQuantity());
    assertEquals(100, issuanceRepository.count());
}
profile
서두르지 않으나 쉬지 않고

0개의 댓글