저는 여러 개의 상품을 한 번에 결제할 수 있게 기획하였습니다. orderId
를 통해 결제 트랜잭션 정보 조회 후, 상품의 종류 만큼 userProductService.decreaseProductQuantityWithLock
메소드를 호출하여 재고량을 조정하였습니다.
// PayConfirmService
@Transactional
public TossPaymentPayTransaction modifyProductStockQuantity(Map<Long, Integer> transactionMap, final String orderId) {
// 결제 트랜잭션 정보 조회
TossPaymentPayTransaction tossPaymentPayTransaction = tossPaymentPayTransactionRepository.findTossPaymentPayTransactionAndPayTransactionAndProductByOrderId(orderId)
.orElseThrow(() -> new CustomException(ErrorCode.PAY_TRANSACTION_NOT_FOUND));
// 상품 재고량 수정
List<PayTransaction> payTransactions = tossPaymentPayTransaction.getPayTransactions();
for (PayTransaction payTransaction : payTransactions) {
final long productId = payTransaction.getProduct().getProductId();
userProductService.decreaseProductQuantityWithLock(productId, payTransaction.getQuantity());
transactionMap.put(productId, payTransaction.getQuantity());
}
return tossPaymentPayTransaction;
}
해당 메소드 또한 동시성에 대한 테스트를 진행해보았습니다. userProductService.decreaseProductQuantityWithLock
에서 베타락을 이용해 동시성을 제어하고 있으므로 당연히 테스트가 통과할 줄 알았습니다.
하지만... 데드락이 발생하였습니다.
데드락(교착상태)
둘 이상의 프로세스가 다른 프로세스가 점유하고 있는 자원을 서로 기다릴 때 무한 대기에 빠지는 상황을 의미합니다.
@Transactional
어노테이션의 기본 전파 레벨은 REQUIRED입니다.
REQUIRED
부모 트랜잭션이 존재한다면 부모 트랜잭션에 합류하고, 그렇지 않으면 새로운 트랜잭션을 만듭니다.
아래 Flow Chart는 3개의 상품을 한 번에 결제하는 예시입니다. 마지막 상품까지 재고량 수정이 완료되어야 트랜잭션이 종료됩니다. 즉, 트랜잭션이 종료될 때까지 각 상품들에 대한 베타락이 종료되지 않습니다.
두 명의 사용자가 같은 상품을 결제하는 경우에 대한 예시를 살펴보겠습니다.
- 사용자 A가 상품1에 베타락을 걸고 재고량을 수정합니다.
- 사용자 B가 상품2에 베타락을 걸고 재고량을 수정합니다.
- 사용자 A가 상품2에 대한 베타락을 획득하려고 할 때, 사용자 B의 트랜잭션이 종료되지 않아 대기 상태에 빠집니다.
- 사용자 B가 상품1에 대한 베타락을 획득하려고 할 때, 사용자 A의 트랜잭션이 종료되지 않아 대기 상태에 빠집니다.
이 과정에서 순환 대기가 발생해 데드락이 발생합니다.
저는 트랜잭션의 범위가 너무 커서 데드락이 발생한다고 생각했습니다. 트랜잭션을 어떻게 분리할지 고민한 끝에, 전파 레벨을 수정하기로 했습니다.
REQUIRED_NEW
무조건 새로운 트랜잭션을 만듭니다. Nested한 방식으로 메소드 호출이 이루어지더라도 rollback은 각각 이루어집니다.
이렇게 트랜잭션의 전파 레벨을 REQUIRED_NEW
로 변경하여 트랜잭션을 분리하였습니다.
이제 사용자 A가 상품2에 대한 베타락을 획득하려고 할 때, 사용자 B가 상품2를 수정중이더라도 트랜잭션이 금방 종료되어 베타락을 획득할 수 있으므로 순환 대기가 발생하지 않습니다.
// UserProductService
@Transactional(propagation = Propagation.REQUIRES_NEW)
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);
}
https://deious.tistory.com/297
https://devlog-wjdrbs96.tistory.com/424