결제 실패 이벤트를 언제 발행해야 하는지에 대한 정책을 팀과 논의했다. 선점 시간 내 재시도 가능 정책으로 인해 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를 분리함으로써 재시도 가능 여부를 상태만 보고 판단할 수 있게 됐다. 도메인 객체가 스스로 상태 전이 조건을 검증하는 것이 핵심이다.
'Spring 개발일지' 카테고리의 다른 글
| [팀프로젝트] MSA 기반 티켓팅 프로그램 - 결제 서비스 코드 리팩토링 (0) | 2026.05.25 |
|---|---|
| [팀프로젝트] MSA 기반 티켓팅 프로그램 - booking.payment.refund 이벤트 수신 후 환불 처리 (1) | 2026.05.22 |
| [팀프로젝트] MSA 기반 티켓팅 프로그램 - 결제 실패 이벤트 발행 (0) | 2026.05.20 |
| [팀프로젝트] MSA 기반 티켓팅 프로그램 - 전체 레이어 테스트 코드 작성 (0) | 2026.05.19 |
| [팀프로젝트] MSA 기반 티켓팅 프로그램 - MSA 서비스 간 통신 정리 (0) | 2026.05.18 |