주문이 실패하면 결제 취소하기

5win·2025년 1월 7일
0

1. 왜 실패하면 결제를 취소해야 할까?

현재까지 구현한 주문의 로직 플로우는 다음과 같다.

  1. 결제 요청
  2. 결제 승인
  3. 주문

결제가 승인되면 주문 접수할 수 있게 주문 API를 요청하게 된다.

여기서 만약 주문 과정에서 어떠한 문제로 인해 실패한다면 어떻게 될까?

고객은 이미 결제가 승인되어 돈을 지불한 상태인데, 주문은 접수되지 않게 되고 당연히 배달 서비스에 있어서는 매우 큰 결함이다.


또한, 주문 데이터를 생성하는 과정에서 메뉴는 잘 저장되었으나, 메뉴의 옵션 저장에 실패한다면 어떻게 될까?

햄버거를 시키면서 치킨 너겟을 추가했는데, 주문에는 치킨 너겟이 포함되지 않을 수도 있다.


따라서 주문 과정을 하나의 트랜잭션으로 묶어 원자적으로 처리해야 하고, 만약 트랜잭션이 실패한다면 승인되었던 결제를 취소해서 환불해주어야 한다.

주문 로직을 트랜잭션으로 묶는 것은 @Transactional 을 통해 해결했지만, 롤백 시 특정 로직을 처리하는 것은 처음이어서 정리하고자 한다.


2. @TransactionalEventListener 를 통한 결제 취소 로직

처음에 어떻게 로직을 구현할까 고민하면서 이벤트 방식이 떠오르긴 했는데, 찾아보니 실제로 이렇게 많이 사용하는 것 같다.

스프링에서는 ApplicationEventPublisher 를 통해 특정 이벤트를 발행할 수 있고, EventListener 로 이벤트를 수신하여 로직을 수행할 수 있다.

다만 트랜잭션과 관련된 이벤트는 TransactionEventListener 를 사용하여 처리한다.

사용 방법은 다소 간단하였고 다음과 같이 리스너를 구현했다.

@Slf4j
@RequiredArgsConstructor
@Component
public class PaymentTransactionEventListener {

    private final PaymentRepository paymentRepository;

    private final TossPaymentClient tossPaymentClient;

    @Transactional(propagation = Propagation.REQUIRES_NEW)
    @TransactionalEventListener(phase = TransactionPhase.AFTER_ROLLBACK)
    public void paymentCancelEventListener(String paymentKey) {
        log.error("주문 과정에서 문제가 발생하여, 결제 취소를 요청합니다.");

        Payment payment = paymentRepository.findById(paymentKey)
            .orElseThrow(() -> new PaymentNotExistsException(HttpStatus.BAD_REQUEST, "결제 정보가 존재하지 않습니다."));

        var response = tossPaymentClient.cancelPayment(paymentKey, "주문 오류로 인한 결제 취소.");
        if (response.getStatusCode() == HttpStatus.OK) {
            payment.setCancelled();
        }
    }
}

@TransactionalEventListener 애노테이션으로 리스너 등록을 하되, 롤백 이벤트를 받아야하므로 AFTER_ROLLBACK 으로 phase를 지정해주었다.

그리고 처음에 payment.setCancelled() 가 적용되지 않아 당황했었는데, Dirty Checking 으로 업데이트해주어야 한다는 것을 잊고 있었다..ㅎ
@Transactional 로 해결했고, 해당 롤백 로직 자체도 별도의 트랜잭션으로 간주해야하기에 REQUIRES_NEW 옵션을 주었다.

주문 로직에는 ApplicationEventPublisher 주입받아 다음 코드만 최상단에 추가했다.

eventPublisher.publishEvent(saveRequest.getPaymentKey());

그리고 퍼블리셔 코드는 오류가 나기 전에 수행되어야 하는데, 주문 로직에서 어떤 오류가 발생하더라도 결제를 취소해야 하므로 최상단에 배치했다.


3. 롤백 시, 결제 취소 테스트 코드 작성

대단한 부분은 없고, 그냥 예외를 발생시켜서 Payment의 상태값이 CANCELLED 으로 바뀌었는지 확인하는 코드이다.

@Test
@DisplayName("주문 데이터를 저장하는 과정(save)에서 문제 발생 시, 결제를 취소한다.")
void whenSaveOrderFail_thenRollbackOrder() {
    // 유저, 매장 등등 정보 생성 후 장바구니에 담음
    addToCart();

    // 승인된 결제 정보 저장
    UUID orderId = UUID.randomUUID();
    String paymentKey = "tgen_20250102210202h9Oy0";
    savePayment(orderId, paymentKey, 26_000, PaymentState.CONFIRMED);

    // OrderRepository 모킹
    doThrow(RuntimeException.class)
        .when(orderJpaRepository)
        .save(any());

    when(tossPaymentClient.cancelPayment(any(), any()))
        .thenReturn(ResponseEntity.ok().build());

    // 주문
    OrderSaveRequest orderSaveRequest = OrderSaveRequest.builder()
        .paymentKey(paymentKey)
        .deliveryFee(0)
        .payMethod(PayMethod.TOSS)
        .discount(0)
        .build();

    Assertions.assertThrows(RuntimeException.class, () -> {
        orderService.registerOrder(orderSaveRequest, "username");
    });
    Payment result = paymentJpaRepository.findById(paymentKey).get();
    assertThat(result.getState()).isEqualTo(PaymentState.CANCELLED);
}

토스페이먼츠의 결제 취소 API를 직접 호출할 수 없어서 이부분도 모킹으로 대체했다.


4. 수동 테스트 해보기

결제 취소가 실제로 동작하는지도 확인해야하므로 직접 수동 테스트를 진행했다.

주문 로직은 결제된 금액과 주문 요청의 총 금액이 다르면 예외를 던지며 롤백하게 되어있다.

따라서 결제된 50,000원과 다른 가격으로 주문 요청을 하여 롤백을 유도했으며, 사진과 같이 로그가 뜨고, 곧이어 결제 취소 토스 알림이 울렸다.

사진에서 알 수 있듯이, 총 주문 오류를 2번 유도했고 마지막엔 주문 성공을 테스트해보았다.

DB 확인 결과 결제 상태도 CANCELLED 두 개와 CONFIRMED 1개로 되어있는 것을 확인할 수 있다.


주문 롤백과 결제 취소라는 목적에 맞는 부분만 작성하느라 글의 내용을 다소 짧지만, 실제 성공하는 과정은 이런저런 일들이 많았다.

결제 기능을 처음 구현하다보니 결제와 주문 플로우를 이상하게 설계해놓았던 부분도 싹 갈아엎었으며,

결제 정보인 Payment 엔티티 또한 잘못된 역할을 할당했었기에 고치는 과정에서 비즈니스 로직과 테스트코드까지 꽤 많은 수정을 거쳐야 했다.

그래도 결과적으로 결제 승인, 결제 취소, 주문 로직들이 잘 맞물려 동작하는 것을 보니 즐거운 기억으로 남을 수 있을 것 같다.

0개의 댓글