이전 게시글에서 데드락을 방지하기 위해
REQUIRED_NEW
를 이용해 트랜잭션을 분리하였습니다.
이번에는 이에 대한 롤백 방식에 대해 작성해 보고자 합니다.
롤백(Rollback)이란 트랜잭션 처리 중 오류가 발생했을 때, 해당 트랜잭션 범위 내의 모든 작업을 취소하고 데이터베이스를 이전 상태로 되돌리는 것을 말합니다. 기본적으로 @Transactional
어노테이션이 붙어 있는 경우, 예외가 발생하면 자동으로 해당 트랜잭션 범위 내에서 롤백이 이루어집니다.
@Transactional
public void test() {
Test test1 = new Test();
testRepository.save(test1);
Test test2 = new Test();
testRepository.save(test2);
}
test1
을 저장한 후 test2
를 저장하는 과정에서 오류가 발생하면, test1
이 롤백됩니다.
이는 test1
과 test2
가 같은 트랜잭션 범위에 있기 때문입니다.
하지만, 아래와 같은 코드에서 TestService.save()
메소드를 진행하는 과정에서 오류가 발생하면 어떻게 될까요?
@Transactional
public void test() {
Test1 test1 = new Test();
testRepository.save(test1);
testService.save();
}
// TestService
class TestService {
@Transactional(propagation = Propagation.REQUIRED_NEW)
public void save() {
Test2 test2 = new Test();
testRepository.save(test2);
}
}
test()
메소드와 TestService.save()
메소드는 서로 다른 트랜잭션 범위에 있기 때문에 test()
메소드의 내용은 롤백되지 않습니다.
저는 이러한 문제를 해결하기 위해 주로 MSA에서 사용하는 Saga 패턴과 유사하게 처리하였습니다.
보상 트랜잭션이란?
보상 트랜잭션(Compensating Transaction)은 분산 트랜잭션 시스템에서 특정 작업이 실패했을 때 성공한 작업들을 취소하여 시스템의 일관성을 유지하기 위해 사용되는 트랜잭션입니다.
3개의 상품(상품 ID : 1, 2, 3)을 결제하는 예시로 설명하겠습니다.
상품1에 대한 재고량 감소 작업이 완료되면 transactionMap
에는 (Key: 1, Value: 재고량)이 적재됩니다.
이때 상품 2에 대한 재고량 감소 과정에서 오류가 발생하면 try ~ catch
블록에 의해 rollbackProductQuantity
메소드가 실행됩니다. 이 메소드에서는 상품에 대해 재고량을 다시 증가시켜 주는 작업을 합니다. 이 과정을 통해 상품 재고량에 대한 일관성을 유지합니다.
만약 모든 상품에 대한 재고량 감소 작업이 완료되면 transactionMap
에는 3개의 상품ID - 재고량 데이터가 적재됩니다. 이때 결제 승인 API 호출 과정에서 오류가 발생하면 3개의 상품에 대해 재고량을 다시 증가시켜줍니다.
/**
* 결제 승인 API
*/
@PostMapping("/confirm")
public ResponseEntity<ApiResponseEntity<String>> confirm(@Valid @RequestBody UserPayReqDto.VerifyPayment dto) {
Map<Long, Integer> transactionMap = new HashMap<>();
TossPaymentPayTransaction tossPaymentPayTransaction;
try {
// 재고량 확인 및 수정
tossPaymentPayTransaction = payConfirmService.modifyProductStockQuantity(transactionMap, dto.orderId());
} catch (Exception e) {
log.error("재고량 확인 및 수정 실패 :: ", e);
rollbackProductQuantity(transactionMap);
throw new PayApiException();
}
try {
// 결제 승인 API 호출
payConfirmService.payConfirm(tossPaymentPayTransaction, dto);
} catch (Exception e) {
log.error("결제 승인 API 호출 실패 :: ", e);
rollbackProductQuantity(transactionMap);
throw new PayApiException();
}
// 장바구니 정보 삭제(비동기)
payConfirmService.removeShoppingCart(dto.orderId());
return ResponseEntity.ok(ApiResponseEntity.of(ResponseText.SUCCESS_PAY));
}
/**
* 재고량 복원
*/
private void rollbackProductQuantity(Map<Long, Integer> transactionMap) {
for (Entry<Long, Integer> entry : transactionMap.entrySet()) {
userProductService.increaseProductQuantityWithLock(entry.getKey(), entry.getValue());
}
}
만약, 재고량을 복원하는 과정에서 오류가 발생할 가능성이 있으므로,
Retry
를 통해 반복 처리하거나 관리자에게 알림을 전송하는 등의 추가 조치가 필요할 것 같습니다.