Spring 개발일지

[팀프로젝트] MSA 기반 티켓팅 프로그램 - 결제 최종 실패 정책 설계

김둘리 2026. 5. 21. 08:48

 

결제 실패 이벤트를 언제 발행해야 하는지에 대한 정책을 팀과 논의했다. 선점 시간 내 재시도 가능 정책으로 인해 FINAL_FAILED 상태가 추가됐다.


1. 문제 발견

처음 구현에서는 토스 검증 실패 시 바로 payment.failed 이벤트를 발행했다.

// 토스 응답 검증 실패 시
payment.fail(300);
Events.publish("payment.failed", ...); // 바로 발행

코드래빗 리뷰:

payment.failed를 발행한 뒤 같은 결제를 다시 SUCCESS로 전이할 수 있습니다.


2. 팀 논의 - 재시도 정책

팀에서 정한 정책은 이랬다.

좌석 선점 시간(10분) 내에는 결제 재시도 가능
선점 시간이 만료되면 재시도 불가

Booking 담당자 확인:

결제 실패 == 재시도까지 다 하고 실패한 경우
좌석 선점 시간 내에는 재시도 가능, 만료 후 최종 실패 시에만 예매 취소 처리

3. 정책 정리

케이스 1 - 결제 실패 (선점 시간 내)

토스 검증 실패
→ FAILED 상태 변경만 (이벤트 발행 X)
→ 사용자가 다른 카드로 재시도 가능

케이스 2 - 선점 시간 만료 후 결제 실패

토스 검증 실패
→ 선점 시간 확인 → 만료됨
→ FINAL_FAILED 상태로 변경
→ payment.failed 이벤트 발행
→ Booking이 예매 취소 + 좌석 선점 해제

4. FINAL_FAILED 상태 추가

public enum PaymentStatus {
    PENDING,
    SUCCESS,
    FAILED,       // 재시도 가능 (선점 시간 내)
    FINAL_FAILED, // 재시도 불가 (선점 시간 만료)
    REFUNDED
}

5. Payment 도메인 수정

isFinalFailed() 추가

public boolean isFinalFailed() {
    LocalDateTime now = LocalDateTime.now();
    return this.status == PaymentStatus.FAILED
            && this.retryExpiredAt != null
            && !now.isBefore(this.retryExpiredAt);
}

상태 조건 없이 시간만 체크하면 FAILED → SUCCESS 전이 후에도 true가 될 수 있다. 코드래빗 지적으로 status == FAILED 조건을 함께 체크하도록 수정했다.

finalFail() 추가

public void finalFail() {
    if (!isFinalFailed()) { // 만료 여부 + FAILED 상태 동시 검증
        throw new PaymentException(PaymentErrorCode.PAYMENT_INVALID_STATUS);
    }
    this.status = PaymentStatus.FINAL_FAILED;
    this.histories.add(PaymentHistory.create(this.id, PaymentStatus.FINAL_FAILED, null, null));
}

도메인 객체가 스스로 불변식을 보장한다. 외부에서 잘못 호출해도 도메인이 직접 막아준다.

confirm() 수정

FINAL_FAILED 상태에서는 재시도 불가.

public void confirm(String paymentKey, LocalDateTime approvedAt) {
    if (this.status != PaymentStatus.PENDING && this.status != PaymentStatus.FAILED) {
        throw new PaymentException(PaymentErrorCode.PAYMENT_INVALID_STATUS);
    }
    // FINAL_FAILED는 예외 발생
    ...
}

6. PaymentCommandService 수정

// 토스 응답 검증 실패 시
if (payment.getStatus() == PaymentStatus.PENDING) {
    // 첫 번째 실패 → FAILED 상태로 변경 (재시도 가능)
    payment.fail(300);
    paymentRepository.save(payment);
} else if (payment.getStatus() == PaymentStatus.FAILED && payment.isFinalFailed()) {
    // 선점 시간 만료 후 재시도 실패 → 최종 실패
    payment.finalFail();
    paymentRepository.save(payment);
    Events.publish(
        UUID.randomUUID().toString(),
        "PAYMENT",
        payment.getId(),
        "payment.failed",
        PaymentFailedPayload.from(payment, "토스 결제 승인 실패")
    );
}

그리고 FINAL_FAILED 상태에서는 바로 예외를 던지도록 상단에 추가했다.

// 최종 실패된 결제는 재시도 불가
if (payment.getStatus() == PaymentStatus.FINAL_FAILED) {
    throw new PaymentException(PaymentErrorCode.PAYMENT_CONFIRM_FAILED);
}

7. 참고 - retryTtlSeconds 하드코딩

현재 재시도 만료 시간이 300초(5분)로 하드코딩되어 있다.

payment.fail(300); // 하드코딩

좌석 선점 시간은 10분(600초)인데 맞춰야 한다. 추후 설정값으로 변경 예정이다.


마무리

상태 설계는 단순히 구현의 문제가 아니라 비즈니스 정책을 코드로 표현하는 것이다.

FAILED와 FINAL_FAILED를 분리함으로써 재시도 가능 여부를 상태만 보고 판단할 수 있게 됐다. 도메인 객체가 스스로 상태 전이 조건을 검증하는 것이 핵심이다.