비관적락? 낙관적락?


사용자 포인트 충전을 생각해보자. 1000원씩 100번 충전하면 총 100,000원이 충전되기를 기대합니다.

하지만 여러 스레드가 동시에 하나의 공유자원(사용자 포인트 잔액)을 접근하면 어떻게 될까요?

각 트랜잭션은 다른 트랜잭션이 끝나길 기다리지 않으니, 우리가 기대한 결과가 보장되지 않습니다.

그래서 우리는 “락”이라는 도구를 사용합니다.
하지만 이 락이라는 도구는 다양한 방식으로 지원됩니다.

  • 비관적락 : 충돌이 발생한다고 가정합니다.
    • “나 업데이트 할 거야. 너네 충돌 일으킬 거지? 문 잠가놓을 테니까 접근하지 마!”
    • 여기서 문은 데이터베이스의 락을 의미합니다.
  • 낙관적락 : 충돌이 발생하지 않는다고 가정합니다.
    • “일단 업데이트해. 근데 충돌이 생겼네? 그럼 롤백하지.”
    • 데이터베이스의 특정 값(version)을 보고 충돌이 생긴 경우 롤백합니다.
  • 분산락 : 분산된 서버에서 공유 자원에 동시에 접근할 때 발생하는 문제를 제어합니다.

이제 비관적락과 낙관적락을 살펴보고, 어떤 상황에 어떤 락을 사용해야 할지 고민해봅시다.

무엇을 사용해야 할까?


처음에 이렇게 생각했습니다.

  • 충돌이 많을 것 같으면? → 낙관적락으로 구현하면 많은 롤백이 발생해서 성능이 안 좋지 않을까? 비관적락을 쓰자!
  • 충돌이 적을 것 같으면? → 비관적락으로 구현하면 의미 없는 데이터베이스 락이 너무 많이 걸려서 리소스를 낭비하는 거 아닐까? 낙관적락을 쓰자!

당연히 그럴듯했습니다. 하지만 이건 눈으로 “직접” 확인하기 전까지는 가정일 뿐이었습니다.

그래서 테스트해보기로 했습니다.

테스트할 서비스 준비

이 테스트를 위해 두 가지 예시를 준비했습니다.

사용자 포인트 충전

첫 번째는 사용자 포인트 충전입니다.

포인트 충전에는 “충전을 10번만 할 수 있다” 같은 제약이 없습니다. 즉, 제한 없이 계속 충전할 수 있으므로 동시 충돌이 자주 발생할 수밖에 없는 서비스입니다.

코드는 다음과 같습니다.

@Transactional
public UserPoint charge(UserPointChargeCommand command) {
    User user = userRepository.getByIdentifier(new UserIdentifier(command.getUserIdentifier()));
    UserPoint userPoint = userPointRepository.getByUserId(user.getId());

    return userPointRepository.save(userPoint.charge(command.getPoint()));
}

흐름은 다음과 같습니다.

  1. 사용자 조회
  2. 사용자의 포인트 조회
  3. 사용자의 포인트(공유자원)의 잔액에서 요청한 포인트만큼 충전

동시성 테스트를 진행하기 위해 테스트 유틸(ConcurrencyTestUtil.class)을 개발했습니다.
이 테스트 유틸은 Virtual Thread를 이용하여 병렬로 작업을 실행하고 총 걸린 시간을 반환합니다.

@Test  
@DisplayName("정확히 1,000,000 포인트가 된다")  
void 포인트_충전_결과_확인() throws InterruptedException {  
    // 요청 수  
    int requestCount = 100;  
    BigDecimal chargeAmount = new BigDecimal(10_000);  
    String userIdentifier = "kilian";
    
    // 테스트 실행
    long timeMillis = ConcurrencyTestUtil.executeInParallelWithoutResultWithTiming(  
            requestCount,  
            index -> service.charge(new UserPointChargeCommand(userIdentifier, chargeAmount))  
    );
    
    // 시간 출력
    System.out.println("=========<mark>" + timeMillis + "</mark>========");  
    UserPoint userPoint = userPointRepository.getByUserId(user.getId());  
  
    assertSoftly(softly -> {  
        softly.assertThat(userPoint.getBalance().value().intValue()).as("최종 포인트 잔액").isEqualTo(chargeAmount.multiply(new BigDecimal(requestCount)).intValue());  
    });  
}

테스트를 통해 포인트 충전을 하는데 걸린 총 시간을 측정하고, 충전된 사용자 포인트가 기대한 값과 동일한지 검증합니다.

비관적락 구현

비관적락은 JPA의 @Lock(LockModeType.PESSIMISTIC_WRITE)을 이용하여 구현했습니다.
예시 코드는 다음과 같습니다.

public interface UserJpaRepository extends JpaRepository<UserEntity, Long> {  

    @Lock(LockModeType.PESSIMISTIC_WRITE)  
    @Query("select u from UserEntity u where u.identifier = :identifier")  
    Optional<UserEntity> findByIdentifierWithLock(String identifier);  
}

낙관적락 구현

낙관적락은 JPA에서는 @Version을 이용하여 구현했습니다.
예시 코드는 다음과 같습니다.

//... 엔티티 애노테이션
 public class UserPointEntity {  
  
    @Id  
    @GeneratedValue(strategy = GenerationType.IDENTITY)  
    private Long id;  
  
    @Version  
    private Long version;  
}

낙관적락은 충돌 발생시 OptimisticLockingFailureException이 발생합니다.

public UserPoint charge(UserPointChargeCommand command) {  
    while (true) {  
        try {  
            return chargeHelper.charge(command);  // 충전
        } catch (OptimisticLockingFailureException exception) {  
            try {  
                Thread.sleep(RETRY_DELAY_MS);  
            } catch (InterruptedException e) {  
                Thread.currentThread().interrupt();  
                throw new RuntimeException("포인트 충전 중 재시도가 중단되었습니다", e);  
            }  
        }  
    }  
}

위와 같이 낙관적락 예외 발생 시 10ms의 delay를 두고 무한정 재시도합니다.
물론 실제 코드에서 while(true)같은 코드를 작성하면 안 되지만, 테스트 한정으로 재시도합니다.

성능 비교

사용자포인트락비교

동시 요청 수 비관적락 낙관적락 성능 차이
100 421ms 1176ms 2.8x
250 698ms 2378ms 3.4x
500 1236ms 4575ms 3.7x
750 1635ms 6310ms 3.9x
1000 2088ms 8425ms 4.0x

예상한 대로 비관적락이 훨씬 좋은 성능을 보여줍니다.
그리고 그 차이는 동시 요청 수가 많아질수록 벌어집니다.

쿠폰 사용

두 번째는 사용자가 주문을 요청할 때 쿠폰을 사용하는 경우입니다. 쿠폰은 단 한 번만 사용할 수 있습니다.
즉 동시 요청하는 경우 딱 한번만 적용이 되고 이후에는 충돌이 일어나지 않으므로 충돌이 많이 일어나지 않는 서비스라고 생각하고 진행합니다.

같은 방식으로 테스트를 진행했으며, 코드는 다음과 같습니다.

@Override  
@Transactional  
public PayAmount discount(PayAmount payAmount, CouponId couponId) {  
    Coupon coupon = couponRepository.getById(couponId);  
    PayAmount discountedAmount = coupon.discount(payAmount);  
    coupon.use();  
    couponRepository.save(coupon);  
  
    return discountedAmount;  
}

흐름은 다음과 같습니다.

  1. 쿠폰 조회
  2. 주문 금액을 쿠폰을 적용하여 할인
  3. 쿠폰의 상태를 변경(coupon.use())
  4. 쿠폰을 저장

이제 쿠폰 사용을 동시 요청하여 성능을 측정합니다. 테스트 코드는 다음과 같습니다.

@Test  
@DisplayName("한번만 사용된다.")  
void usedOnce() throws InterruptedException {  
    //요청 수
    int requestCount = 100;
    
    // 테스트 실행 및 유틸안에서 시간 측정
    List<PayAmount> payAmounts = ConcurrencyTestUtil.executeInParallel(  
            requestCount,  
            index -> {  
                PayAmount payAmount = new PayAmount(new BigDecimal(10000));  
                return discountStrategy.discount(payAmount, couponId);  
            }  
    );
    
    //결과 검증
    List<PayAmount> discountedPayAmounts = payAmounts.stream()  
            .filter(payAmount -> payAmount.value().compareTo(new BigDecimal(9000)) == 0)  
            .toList();  
    assertThat(discountedPayAmounts).hasSize(1);  
}

성능비교

쿠폰락비교

동시 요청 수 비관적락 낙관적락 성능 차이
100 479ms 398ms 1.2x
250 877ms 695ms 1.3x
500 1360ms 1123ms 1.2x
750 1769ms 1208ms 1.5x
1000 2111ms 1269ms 1.7x

이 역시 예상한 결과입니다.
비관적락의 경우 이미 쿠폰이 사용되었지만, 동시 요청 시 계속 데이터베이스 락을 걸게 되어 낙관적락과 성능 차이가 나타납니다.

결론은?


처음에 나는 “충돌이 많으면 비관적락, 적으면 낙관적락”이라는 단순한 기준으로 생각했습니다.

그리고 실제 테스트 결과 이 기준은 타당했습니다.

충돌이 많은 서비스(포인트 충전)에서는 비관적락이 4배 빠르고, 충돌이 적은 서비스(쿠폰 사용)에서는 낙관적락이 1.7배 빠릅니다.

하지만 이것이 모든 상황에 적용되는 정답은 아닙니다.

락은 동시성 문제를 해결하기 위한 “도구”일 뿐입니다. 망치가 좋은 도구라고 해서 모든 못을 망치로 박는 것이 아니듯이, 락도 상황에 맞게 선택해야 합니다.

결국 중요한 것은:

  • 우리 서비스의 특성을 파악하는 것
  • 실제 동작 시나리오에서 충돌이 어떻게 발생하는지 이해하는 것
  • 그에 맞는 적절한 도구를 선택하는 것

정답이 무엇인지를 찾기보다는 “내 서비스에 맞는 도구는 무엇인가?”를 고민하고 선택하는 것이 올바른 접근입니다.