결제 취소하기

rejs·2025년 12월 31일

결제 취소

@Slf4j
@Service
@RequiredArgsConstructor
public class PaymentCancelFacade {
    private final PortOneAdaptor portoneAdaptor;
    private final PaymentCancelCrudService paymentCancelCrudService;

    public CompletableFuture<Void> cancelPayment(String paymentId){
        log.info("[payment.abort] 결제 취소 처리 시작 paymentId={}", paymentId);

        // try : 결제 취소내역을 의미하는 PaymentCancel을 생성한다.
        Optional<PaymentCancelDto> opt = paymentCancelCrudService.tryLockForCancel(paymentId, PaymentCancelReason.VALIDATION_FAILED);
        if (opt.isEmpty()){
            // 이미 성공하거나 실패함
            // 실패시 로직은 "정산"에서 처리할 예정
            return CompletableFuture.completedFuture(null);
        }

        PaymentCancelDto paymentCancel = opt.get();

        return portoneAdaptor.cancelPayment(paymentId, PaymentCancelReason.VALIDATION_FAILED)
                .thenAccept(isSuccess-> {
                    if(isSuccess){
                        log.info("[payment.abort.success] 결제 환불 성공 paymentUid={}", paymentId);
                        paymentCancelCrudService.canceled(paymentCancel.getId());
                    }
                })
                .exceptionally(
                        e->{
                            Throwable cause = (e instanceof CompletionException) ? e.getCause() : e;
                            if (cause instanceof PaymentCancelAlreadySuccessException exception){
                                // 이미 취소 되엇다
                                log.info("[payment.abort.success] 외부 서버에서 이미 취소된 결제. paymentId={}", paymentId);
                                paymentCancelCrudService.canceled(paymentCancel.getId());
                            }else if (cause instanceof PaymentCancelFailedException exception) {
                                // 논리상 취소가 불가능하다. 상태를 failed로 변경
                                log.warn("[payment.abort.failed] 논리적 오류로 재시도 불가. paymentId={}", paymentId, exception);
                                paymentCancelCrudService.failed(paymentCancel.getId());
                            } else if(cause instanceof PaymentCancelRetryableException exception){
                                log.warn("[payment.abort.retry] 일시적 오류로 탈출. paymentId={}", paymentId, exception);
                            } else {
                                // cancel : 결제 취소가 실패했다. 결제 취소 실패를 해도 상태를 REQUIRED로 두어 다시 취소하도록 지시한다. 중요한 것은 외부 API 서버에서 결제가 취소되지 않았는 데 REQUIRED를 CANCELED로 바꾸는 것이다.
                                log.warn("[payment.abort.fail] 예상치 못한 실패로 인한 환불에 실패했습니다. paymentUid={}", paymentId, e);
                            }
                            return null;
                        }
                );
    }
}

1. paymentCancelCrudService.tryLockForCancel

중복 호출로 인한 문제를 방지하기 위함이다.
update의 원자적 호출을 이용해서 update에 성공했을 때만 진행하도록 하기

왜 비동기적으로 처리하는 가?

그 이유는 크게 2개로 나눌 수 있다.

  1. 포트원의 sdk가 CompletableFuture를 반환하기 때문이다.
  2. 결제 취소의 호출위치에서 성능을 확보하려하기 때문이다.

결제 취소의 호출위치는 크게 3군데가 있다.
1. 결제 검증 후 결제 취소
2. 예매 취소 후 결제 취소
3. 결제 취소 실패 케이스를 재시도하는 스케줄러에 의한 결제 취소 재시도

나는 위의 3가지 중에 1번, 2번 케이스는 결제 취소 완료까지 기다릴 이유가 필요없다고 생각한다.

결제 취소는 결제 검증과, 예매와는 다른 도메인의 영역이기 때문이다.
사용자에게 유의미한 정보는 "예매가 취소되었습니다", "결제정보가 잘못되었습니다"만 빨리 보여주고 결제 취소하는 행위는 백그라운드에서 돌려도 된다는 게 나의 판단이다.

예를 들어 예매 도메인의 임무는 예매를 취소하는 것에 있다. 예매로 인한 환불처리까지 예매 도메인에서 수행할 이유는 없다고 생각하기 때문이다.

스케줄러에 대한 방어 조치

@Slf4j
@RequiredArgsConstructor
@Component
public class PaymentCancelScheduler {
    private final PaymentCancelQueryRepository paymentCancelQueryRepository;
    private final PaymentCancelFacade paymentCancelFacade;

    @Scheduled(fixedDelay = 60*1000) // 1분마다 실행
    public void autoCancelReservation(){
        List<String> paymentIds = paymentCancelQueryRepository.findAbandonedPaymentCancels(LocalDateTime.now().minusMinutes(3), 10);
        CompletableFuture.allOf(
                paymentIds.stream().map(
                        paymentCancelFacade::cancelPayment
                ).toArray(CompletableFuture[]::new)
        ).join();
    }
}

만에 하나라도 결제 취소가 실패하는 경우 DB 폴링방식으로 처리해준다.

그런데 비동기라면 그 성공여부를 확인할 수 없고, 1분마다 비동기 업무들이 계속 쌓이는 등의 문제가 생길 수 있다.

그래서 CompletableFuture.allOf의 join을 사용해서 대기하도록 하자

10개 병렬로 결제 취소 업무 실행 -> 결제 취소 업무 완료까지 대기 -> 1분 대기 -> 반복

0개의 댓글