PR 링크

https://github.com/standard-Chan/aws-shop/pull/7

1. 배경

결제를 진행하다보면, 멱등성을 보장해야하는 상황이 있다.

문제가 발생하는 상황

결제가 2번 발생할 수 있다.

물론 Toss Payment API 자체에서 중복 결제를 막아주긴한다. 하지만 문제는 다음과 같다.

문제의 원인

아래는 현재의 결제 코드이다.

    public TossPaymentConfirmResponse confirmPayment(ConfirmPaymentRequest confirmRequest) {
        log.info("[Payment] 결제 승인 요청, 결제 정보 : 결제 id={}, 주문 id={}, 결제 금액={}",
            confirmRequest.paymentKey(),
            confirmRequest.orderId(), confirmRequest.amount());

        Payment payment = paymentRepository.findById(confirmRequest.paymentId())
            .orElseThrow(() -> new PaymentNotFoundException(confirmRequest.paymentId()));

        OrderSummary order = getConfirmableOrder(payment.getOrderId());

        // payment 만료 여부 검증
        if (payment.isExpired(LocalDateTime.now())) {
            payment.expire();
            paymentRepository.save(payment);
            orderClient.updatePendingOrder(payment.getOrderId());
            throw new PaymentExpiredException(payment.getOrderId(), payment.getId());
        }

        // 종료 상태 결제는 승인 시도 실패로 닫지 않고, 승인 흐름 진입 전에 거부한다.
        payment.validateConfirmableStatus(); // 결제 가능한지 상태 검증
        List<OrderLineSummary> reservedLines = List.of();

        try {
            // 결제 로직 검증
            payment.validateOrderId(confirmRequest.orderId()); // ★ 위치 try문 밖으로 변경. 주문 검증
            payment.validateConfirmAmount(confirmRequest.amount()); // ★ 위치 try 문 밖으로 변경. 수량 검증

            // 결제 승인 처리 시작 및 상태 등록
            payment.start(confirmRequest.paymentKey()); // ★ 핵심 코드 라인 ★

            // 주문 상품 전체 재고 예약 처리
            reservedLines = reserveOrderStocks(order);

            paymentRepository.save(payment);

            TossPaymentConfirmResponse response = tossPaymentClient.confirm(
                new TossPaymentConfirmRequest(confirmRequest.paymentId(),
                    confirmRequest.paymentKey(), confirmRequest.amount()));

            // 결제 승인 완료
            payment.complete();

            log.info("[Payment] 결제 승인 완료. paymentKey={}, paymentId={}, amount={}",
                response.paymentKey(), response.orderId(), response.totalAmount());

            // Order 완료 처리
            orderClient.updateCompleteOrder(payment.getOrderId());

            paymentRepository.save(payment);
            return response;
        } catch (PaymentException | StockException exception) {
            // 해당 결제 실패 처리
            payment.fail();
            paymentRepository.save(payment);

            // Order 상태 pending 변경
            orderClient.updatePendingOrder(payment.getOrderId());

            restoreReservedStocks(reservedLines);

            log.warn("[Payment] 결제 실패. {} \n paymentKey={}, orderId={}, amount={}", exception,
                confirmRequest.paymentKey(), confirmRequest.orderId(), confirmRequest.amount());
            throw new PaymentConfirmExternalException(confirmRequest.paymentId(),
                confirmRequest.paymentKey(), exception);
        } catch (Exception e) {
            log.error(e.getMessage());
            throw new PaymentException("[Payment] 알 수 없는 에러가 발생하였습니다.", e);
        }
    }

위 핵심 코드라인인, payment.start() 에서 상태 변경이 발생한다.

    public void start(String paymentKey) {
        if (this.status != PaymentStatus.NOT_STARTED) {
            throw new PaymentInvalidStatusException(PaymentStatus.NOT_STARTED, this.status);
        }
        registerPaymentKey(paymentKey);
        this.status = PaymentStatus.EXECUTING;
    }

해당 결제가 NOT_STARTED 상태라면, 정상 결제처리가 진행되지만, 해당 상태가 아닌 경우, 에러가 발생하는 코드이다.