본문 바로가기

Project

결제 시나리오 3. 악의적 선점 - 데이터 정합성 문제(1)

문제 상황

Spring Scheduler를 활용하여 10분이 지난 IN_PROGRESS 상태의 결제를 자동으로 취소하는 방식으로 구현하면서 트러블 슈팅이 발생했습니다.

(악의적 사용자가 아닌)일반 사용자가 어떠한 사유로 9분 5x초에 결제를 진행하면 결제 요청과 스케줄러의 실행이 동시에 이루어질 수 있습니다. 이로 인해 실제 결제는 성공했지만 시스템에서는 결제가 취소되는 데이터 정합성 문제가 발생했습니다.

[문제 상황 설명]

  1. 사용자가 결제 페이지에 진입
    • Payment 상태 : IN_PROGRESS
    • 사용자는 결제 정보 입력 후, 9분 5x초에 결제 진행
  2. TossPayments에 결제 요청
    • TossPayments에서 /success 엔드포인트로 PaymentKey, orderId, amount(가격) 정보와 함께 POST 요청을 보냄
    • DB에 저장된 결제 금액과 TossPayments의 요청 금액을 검증
    • 검증을 진행하면서 Payment 상태를 CONFIRMING으로 변경
  3. 동시에 10분이 지나면서 스케줄러 실행
    • 스케줄러가 IN_PROGRESS 상태이고 10분이 지난 Payments들을 조회
    • 스케줄러가 조회한 시점은 IN_PROGRESS 이므로 CONFIRMING으로 변경 되어도 스케줄러는 주문 취소를 진행
  4. TossPayment에 최종 승인 요청
    • TossPayments로 최종 승인 요청을 보냄
    • 실제 결제는 성공했지만 금액이 차감됨.

→문제 발생!

  • 결제는 성공했는데, 시스템에서는 결제가 취소된 것으로 기록
  • 결국 사용자는 돈을 냈지만 결제 취소가 되는 치명적인 오류가 발생!

[문제 발생 원인 분석]

  • 트랜잭션 타이밍 이슈
    • 스케줄러가 실행될 때 Payment 상태가 IN_PROGRESS로 조회됨
    • 하지만 거의 동시에 결제 승인 요청이 들어와 Payment 상태가 CONFIRMING으로 변경됨
    • 스케줄러는 이미 조회된 Payment 정보를 기반으로 CANCEL 처리를 진행하여 실제 결제가 완료되었음에도 불구하고 시스템에서는 결제가 취소된 상태가 되어버림

해결과정

  • 스케줄러가 실행될 때 Payment 데이터를 조회하면서 상태를 IN_PROGRESS로 확인한다.
  • 이 상태가 CONFIRMING으로 변경되었더라도 스케줄러가 이미 조회한 데이터 기준으로 취소를 진행할 가능성이 있다.
  • 이미 조회된 데이터(IN_PROGRESS)를 기반으로 취소를 실행하면 결국 결제가 완료되었는데도 취소될 수 있다!

"그럼 스케줄러가 조회하고 다시 한번 최신상태로 조회하는 로직을 짜면 어떨까?" 생각을 했습니다.

    @Scheduled(cron = "0 */10 * * * *")
    public void paymentTimeoutScheduler() {
        List<Payment> payments = paymentRepository.findAllExpired(); // 10분이 지난 IN_PROGRESS 상태들을 불러옴.
        for (Payment payment : payments) {
            paymentStateService.cancelTimeOutPayments(payment);
        }
    }
    @Transactional
    public void cancelTimeOutPayments(Payment payment) {
        // 여기서 다시 한번 조회하여 최신 상태 조회
        Payment paymentInDB = paymentRepository.findById(payment.getId())
                .orElseThrow();

        if (paymentInDB.getState() != PaymentState.IN_PROGRESS) {
            return;
        }

        paymentInDB.updatePaymentStatus(PaymentState.CANCEL);
        paymentInDB.getOrder().updateState(OrderState.CANCELED);
        paymentInDB.getOrder().getTickets().forEach(orderTicket ->
                orderTicket.getTicket().updateState(SeatState.IDLE));
        paymentRepository.save(paymentInDB);
    }
  • 스케줄러가 실행되고 1차적으로 결제 진행이 10분 이상된 결제 정보들을 불러오고 그 사이에 결제 상태가 바뀐 것을 해결하기 위해 최종적으로 조회하여 최신 상태를 반영 합니다.
    • IN_PROGRESS 상태가 CONFIRMING 또는 COMPLETED로 변경되었다면 취소하지 않고 바로 return하여 스케줄 메서드를 종료합니다.

결론

이번 트러블 슈팅을 통해 스케줄러가 조회한 오래된 데이터(IN_PROGRESS 상태)로 인해 결제가 완료되었음에도 취소되는 문제를 해결할 수 있었습니다. 기존에는 스케줄러가 조회한 데이터를 바로 취소 처리하는 방식이었지만, 최신 상태를 다시 한 번 조회하는 로직을 추가함으로써, CONFIRMING 또는 COMPLETED 상태로 변경된 결제는 취소되지 않도록 방어 로직을 적용하였습니다.

이를 통해 데이터 정합성을 유지하면서도 불필요한 취소를 방지할 수 있었으며, 결제 과정에서 발생하는 타이밍 이슈를 보다 안전하게 처리할 수 있게 되었습니다.