아래는 가장 간단한 상품 재고량 감소 메소드입니다. 이 메소드는 상품의 재고량을 조회하여 구매 수량과 비교한 후, 재고량을 수정합니다.
// UserProductService
/**
* 상품 재고량 감소
*/
@Transactional
public void decreaseProductQuantityWithLock(final long productId, final int quantity) {
Product product = productRepository.findById(productId)
.orElseThrow(() -> new CustomException(ErrorCode.PRODUCT_NOT_FOUND));
// 재고량 검증
if (quantity > product.getStockQuantity()) {
throw new CustomException(ErrorCode.PRODUCT_STOCK_NOT_ENOUGH);
}
product.decreaseStockQuantity(quantity);
productRepository.save(product);
}
테스트 결과, 정상적으로 재고량이 감소되는 것을 확인하였습니다. 하지만 20명의 사용자가 동시에 접근하는 경우에는 어떻게 될까요?
아래는 25개의 재고가 있는 상황에서 20명의 사용자가 동시에 구매하는 경우를 테스트하는 코드입니다.
@Test
@DisplayName("상품 재고량 감소 + 베타락")
void 상품_재고량_감소_베타락() throws InterruptedException {
// given
Product product = productRepository.findAll().get(0);
// when
ExecutorService executorService = Executors.newFixedThreadPool(32);
CountDownLatch countDownLatch = new CountDownLatch(20);
for (int i = 0; i < 20; i++) {
final long productId = product.getProductId();
executorService.submit(() -> {
try {
userProductService.decreaseProductQuantityWithLock(productId, 1);
} catch (CustomException e) {
if (e.getErrorCode() != ErrorCode.PRODUCT_STOCK_NOT_ENOUGH) {
log.error("CustomException :: ", e);
}
} catch (Exception e) {
log.error("Exception :: ", e);
} finally {
countDownLatch.countDown();
}
});
}
countDownLatch.await();
// then
product = productRepository.findAll().get(0);
assertThat(product.getStockQuantity()).isEqualTo(5);
}
예상되는 남은 재고량은 5이지만, 실제 결과는 22입니다. 이유가 뭘까요?
동시성 문제란 여러 쓰레드가 동시에 같은 인스턴스의 필드의 값을 변경하면서 발생하는 문제입니다. 동시성 문제는 보통 트래픽이 많을 때 발생하게 됩니다.
경쟁 조건
여러 프로세스 및 쓰레드가 동시에 동일한 데이터를 조작할 때 접근 순서 혹은 타이밍에 따라 예상했던 결과가 달라질 수 있는 상황을 의미합니다.
낙관적 락은 자원에 락을 걸지 않고, 동시성 문제가 발생하면 처리합니다. 즉, 충돌이 거의 발생하지 않을 것이라고 가정하고, 충돌이 발생한 경우에 대비하는 방식입니다.
비관적 락은 충돌이 발생할 확률이 높다고 가정하여, 실제로 데이터에 액세스하기 전에 먼저 락을 걸어 충돌을 예방하는 방식입니다.
결제 로직에서는 데이터의 무결성이 더 중요하다고 판단되어 비관적 락(베타락)을 사용하게 되었습니다.
JPA에서 베타락을 사용할 수 있는 여러 방법 중 QueryDSL을 이용한 방법을 사용하기로 했습니다.
LockedModeType.PESSIMISTIC_WRITE
를 이용해 베타락을 사용할 수 있습니다.
// ProductRepositoryCustomImpl
@Override
public Optional<Product> findByIdWithLock(final long productId) {
return Optional.ofNullable(jpaQueryFactory.from(product)
.where(product.productId.eq(productId))
.select(product)
.setLockMode(LockModeType.PESSIMISTIC_WRITE)
.fetchOne());
}
// UserProductService
@Transactional
public void decreaseProductQuantityWithLock(final long productId, final int quantity) {
Product product = productRepository.findByIdWithLock(productId)
.orElseThrow(() -> new CustomException(ErrorCode.PRODUCT_NOT_FOUND));
// 재고량 검증
if (quantity > product.getStockQuantity()) {
throw new CustomException(ErrorCode.PRODUCT_STOCK_NOT_ENOUGH);
}
product.decreaseStockQuantity(quantity);
productRepository.save(product);
}
쿼리가 실행될 때 아래와 같이 for update
가 추가된 것을 확인할 수 있습니다.
위에서 진행한 테스트를 동일하게 실행한 결과, 테스트가 정상적으로 통과합니다!