이번 게시글에서는 저번 [공부정리] 챗봇 구매에서 동시성 문제 해결에 이어서 배터리 구매 동시성 문제를 해결하기 위한 과정에 대해서 기술하겠다.
동시성 문제를 해결하는 여러 가지 방법들에 대해서는 챗봇 구매에서 자세히 다루고 있으므로 해당 게시글에서는 배터리 구매 동시성 문제 해결 과정을 집중적으로 살펴보겠다.
@Transactional
public UUID confirmPayment(final PaymentConfirmRequest paymentConfirmRequest) {
final PaymentEntity paymentEntity = paymentRepository.findById(paymentConfirmRequest.getOrderId())
.orElseThrow(PaymentNotFoundException::new);
validatePaymentAmount(paymentConfirmRequest, paymentEntity);
processPayment(paymentConfirmRequest, paymentEntity);
return paymentEntity.getId();
}
private void validatePaymentAmount(final PaymentConfirmRequest request, final PaymentEntity paymentEntity) {
final BigDecimal requestedAmount = request.getAmount();
final BigDecimal itemPrice = paymentEntity.getBatteryItem().getPrice();
if (requestedAmount.compareTo(itemPrice) != 0) {
throw new PaymentFailureException();
}
}
private void processPayment(final PaymentConfirmRequest request, final PaymentEntity paymentEntity) {
final TossPaymentRequest tossPaymentRequest = new TossPaymentRequest(
request.getPaymentKey(), request.getOrderId(), request.getAmount());
final PaymentResponse paymentResponse = paymentGatewayClient.payment(tossPaymentRequest)
.getData()
.orElseThrow(PaymentFailureException::new);
paymentEntity.updatePaymentDetails(paymentResponse.getKey(), paymentResponse.getProvider(),
paymentResponse.getAmount());
paymentEntity.updatePaymentStatus(PaymentStatusType.COMPLETED);
paymentEntity.getMember().addBatteries(paymentEntity.getBatteryItem().getCount());
}
위 코드에서 confirmPayment 메서드는 결제 확인 요청을 처리하며, 결제 금액 검증(validatePaymentAmount)과 결제 처리(processPayment)를 수행한다. 그러나 이 과정에서 사용자의 결제 완료 요청이 여러번 동시에 발생할 경우, 동시성 문제가 발생하여 일부 결제가 반영되지 않을 수 있다.
동시성 문제를 확인하기 위해 두 가지 테스트를 작성하였다. 순차적으로 결제를 처리하는 테스트와 병렬로 결제를 처리하는 테스트이다.
@Test
void testSequentialPaymentRequests() {
final PaymentSetupRequest setupRequest1 = new PaymentSetupRequest(BatteryItem.SMALL_BATTERY.getName());
final PaymentSetupResponse setupResponse1 = paymentService.setupPayment(setupRequest1, customOAuth2User);
final PaymentSetupRequest setupRequest2 = new PaymentSetupRequest(BatteryItem.MEDIUM_BATTERY.getName());
final PaymentSetupResponse setupResponse2 = paymentService.setupPayment(setupRequest2, customOAuth2User);
final PaymentSetupRequest setupRequest3 = new PaymentSetupRequest(BatteryItem.LARGE_BATTERY.getName());
final PaymentSetupResponse setupResponse3 = paymentService.setupPayment(setupRequest3, customOAuth2User);
final PaymentConfirmRequest confirmRequest1 = new PaymentConfirmRequest(
"testPaymentKey1", setupResponse1.getOrderId(), setupResponse1.getAmount());
final PaymentConfirmRequest confirmRequest2 = new PaymentConfirmRequest(
"testPaymentKey2", setupResponse2.getOrderId(), setupResponse2.getAmount());
final PaymentConfirmRequest confirmRequest3 = new PaymentConfirmRequest(
"testPaymentKey3", setupResponse3.getOrderId(), setupResponse3.getAmount());
simulatePaymentSuccess(confirmRequest1.getPaymentKey(), confirmRequest1.getAmount(), confirmRequest1.getOrderId());
simulatePaymentSuccess(confirmRequest2.getPaymentKey(), confirmRequest2.getAmount(), confirmRequest2.getOrderId());
simulatePaymentSuccess(confirmRequest3.getPaymentKey(), confirmRequest3.getAmount(), confirmRequest3.getOrderId());
try {
paymentService.confirmPayment(confirmRequest1, customOAuth2User);
paymentService.confirmPayment(confirmRequest2, customOAuth2User);
paymentService.confirmPayment(confirmRequest3, customOAuth2User);
} catch (final Exception e) {
System.out.println(e.getMessage());
}
final MemberEntity updatedMemberEntity = memberRepository.findById(customOAuth2User.getId())
.orElseThrow(MemberNotFoundException::new);
assertEquals(18, updatedMemberEntity.getBatteryCount(), "Remaining batteries should be 18");
}
@Test
void testConcurrentPaymentRequests() throws Exception {
final int numberOfThreads = 3;
final ExecutorService executorService = Executors.newFixedThreadPool(numberOfThreads);
final CountDownLatch countDownLatch = new CountDownLatch(numberOfThreads);
final PaymentSetupRequest setupRequest1 = new PaymentSetupRequest(BatteryItem.SMALL_BATTERY.getName());
final PaymentSetupResponse setupResponse1 = paymentService.setupPayment(setupRequest1, customOAuth2User);
final PaymentSetupRequest setupRequest2 = new PaymentSetupRequest(BatteryItem.MEDIUM_BATTERY.getName());
final PaymentSetupResponse setupResponse2 = paymentService.setupPayment(setupRequest2, customOAuth2User);
final PaymentSetupRequest setupRequest3 = new PaymentSetupRequest(BatteryItem.LARGE_BATTERY.getName());
final PaymentSetupResponse setupResponse3 = paymentService.setupPayment(setupRequest3, customOAuth2User);
final PaymentConfirmRequest confirmRequest1 = new PaymentConfirmRequest(
"testPaymentKey1", setupResponse1.getOrderId(), setupResponse1.getAmount());
final PaymentConfirmRequest confirmRequest2 = new PaymentConfirmRequest(
"testPaymentKey2", setupResponse2.getOrderId(), setupResponse2.getAmount());
final PaymentConfirmRequest confirmRequest3 = new PaymentConfirmRequest(
"testPaymentKey3", setupResponse3.getOrderId(), setupResponse3.getAmount());
simulatePaymentSuccess(confirmRequest1.getPaymentKey(), con
testSequentialPaymentRequests 메서드는 순차적으로 챗봇 구매를 테스트하고, testConcurrentPaymentRequests 메서드는 병렬적으로 챗봇 구매를 테스트한다.
순차적으로 요청하는 testSequentialPaymentRequests 테스트는 배터리 구매가 잘 반영되어 사용자의 배터리 개수가 18개로 유지된다. 반면, testConcurrentPaymentRequests 테스트는 병렬로 요청을 처리하며, 마지막 요청만 반영되는 문제가 있다.
저번에 챗봇 구매에서 결론으로 다루었듯이, Optimistic Lock을 이용해서 해결해보자.
Optimistic Lock은 트랜잭션 충돌이 발생할 가능성이 있는 경우에만 잠금을 걸어 재시도하는 방식이다. 이를 적용하여 결제 확인 요청을 처리하면, 동시에 여러 사용자가 결제 요청을 하더라도 데이터의 일관성을 유지할 수 있다.
@Retryable(retryFor = {OptimisticLockingFailureException.class})
@Transactional
public UUID confirmPayment(final PaymentConfirmRequest paymentConfirmRequest) {
final PaymentEntity paymentEntity = paymentRepository.findById(paymentConfirmRequest.getOrderId())
.orElseThrow(PaymentNotFoundException::new);
validatePaymentAmount(paymentConfirmRequest, paymentEntity);
processPayment(paymentConfirmRequest, paymentEntity);
return paymentEntity.getId();
}
@Retryable 어노테이션은 OptimisticLockingFailureException 예외가 발생할 경우 자동으로 재시도하게 한다. 이렇게 하면 여러 사용자가 동시에 결제 요청을 하더라도 요청이 성공할 때까지 반복 시도할 수 있다.
순차요청과 병렬요청 모두 통과하였다.
끝일 것이라고 생각하지만 오류가 있다.
토스 결제 승인 API는 이미 처리된 결제인 경우에는 예외 메세지를 반환한다. 즉, Optimistic Lock을 이용하여 재시도를 수행할 경우, 토스 결제 승인 API에서 예외 메세지를 반환하기 때문에 사용자의 결제 요청이 반영되지 않을 것이다.
이러한 특징을 반영해서 테스트 코드를 수정하자.
when(paymentGatewayClient.payment(eq(paymentRequest)))
.thenReturn(ClientResponse.success(successResponse))
.thenReturn(ClientResponse.failure());
이 설정을 통해 결제 요청이 첫 번째로 성공하고, 그 다음 호출에서는 실패하는 시나리오를 테스트할 수 있다.
당연하게도 Retryable을 이용하여 재시도를 수행할 경우 토스 결제 승인 API에서 오류를 반환하고, 테스트를 성공하지 못하는 모습이다.
앞에서 보았던 대로 Toss API에 재요청을 할 경우 이미 처리된 요청으로 인식하고 에러를 반환하기 때문에 Optimistic Lock을 사용하지 못한다. 따라서 Pessimistic Lock을 이용해야한다.
Pessimistic Lock은 트랜잭션이 데이터에 접근할 때 해당 데이터를 잠그는 방식으로, 동시에 여러 트랜잭션이 접근하지 못하게 한다. 이를 통해 데이터의 일관성을 보장할 수 있다.
@Override
public Optional<MemberEntity> findByIdWithPessimisticLock(final Long id) {
return Optional.ofNullable(
queryFactory.selectFrom(memberEntity)
.where(idEquals(id))
.setLockMode(LockModeType.PESSIMISTIC_WRITE)
.fetchOne()
);
}
private BooleanExpression idEquals(final Long id) {
return memberEntity.id.eq(id);
}
QueryDSL을 이용해 memberEntity를 Pessimistic Write Lock 모드로 조회한다.
Pessimistic Lock을 이용하면 토스 API의 특징에도 테스트를 잘 통과하는 모습이다.
사실 이러한 결제와 관련된 로직은 특징 문제 이외에도 동시성 문제가 발생할 경우 치명적이기 때문에 애초에 Pessimistic Lock을 적용하는 것이 더 안전하다. 그래도 재미있는 경험이었다.