결제 시스템을 개발하다 보면 ‘성공’만큼이나 중요한 것이 바로 ‘실패 처리’입니다.
특히 결제 실패 시 예약 상태를 어떻게 처리할 것인지, 실패 데이터를 어떻게 보존할 것인지에 따라 사용자 경험과 데이터 무결성에 큰 영향을 줍니다.
이번 글에서는 PortOne V2 결제 시스템을 Spring Boot 기반으로 구현하며 겪었던 결제 실패 처리 이슈와 그 해결 과정을 정리했습니다.
PortOne V2 기반 결제 시스템 설계 및 검증 로직 구현기 - TRIO
제가 구현한 시스템은 다음과 같은 결제 흐름을 가지고 있습니다.
1. 임시 예약 생성 → 예약 상태 : PENDING
2. PortOne 결제 → 검증 API 호출
3. 유효성 검사 이것저것...
4. 검증 실패 시 예약 상태 FAILED로 변경
하지만.. 금액이 일치하지 않아 예외를 던졌는데도 예약 상태가 여전히 PENDING으로 남아있었습니다.
이유는 단순했습니다.
결제 검증 로직 전체가 하나의 @Transcational 안에서 실행되고 있었기 때문입니다.
@Transactional
public void 검증클래스(...) {
// 예약 상태를 FAILED로 변경
reservation.updateStatus(FAILED);
reservationRepository.save(reservation);
// 이후 금액 불일치 발생 → 예외 발생
if (!amountMatches()) {
throw new BusinessException(...);
}
// 트랜잭션 전체 롤백 → 상태 업데이트도 무효 처리됨
}
위와 같은 코드에서 updateStatus(FAILED)는 수행되었고, save()까지 호출되었지만,
결과적으로 DB에 반영되지 않았습니다.
이유는 단순합니다.
예외가 발생하면 Spring의 기본 @Transactional은 전체 트랜잭션을 롤백시키기 때문입니다.
즉, 상태 업데이트 → 예외 발생 → 전체 롤백 → 아무것도 저장되지않음...
트랜잭션이란 본래 원자성을 보장하기 위한 수단입니니다.
하지만 모든 데이터를 무조건 함께 커밋하거나 롤백해야하나??
아닙니다.
도메인 관점에서 반드시 보존해야 할 상태 변화가 존재한다면, 그에 알맞게 트랜잭션을 나눠야 합니다.
비즈니스 상 모순이 발생합니다.
예약 상태 변경만 별도의 트랜잭션으로 분리하여 커밋합니다.
@Service
public class 상태_업데이트_서비스 {
@Transactional(propagation = Propagation.REQUIRES_NEW)
public void 상태업데이트해줘(Reservation reservation, Status status) {
reservation.changeStatus(status);
reservationRepository.save(reservation)
}
}
Spring Event 기반의 실패 처리도 대안이 될 수 있습니다.
@Component
public class PaymentEventListener {
@Transactional
@EventListener
public void handlePaymentFailed(PaymentFailedEvent event) {
상태_업데이트_서비스.상태업데이트해줘(예약, 상태);
}
}
하지만 저는 이 전략을 선택하지 않았습니다. 이유는 다음과 같습니다.
결제 실패는 사용자가 직접 재시도해야 하는 흐름이라고 생각이 되었고, 단순하고 명확한 동기적 분리 트랜잭션 구조가 더 안전하고 설계에 부합한다고 판단했습니다.

실무에서는 상태가 정확하게 반영되지 않으면, 비즈니스에 다음과 같은 리스크가 발생합니다.
이런 문제는 곧 운영 비용 증가, 고객 만족도 하락, 시스템 신뢰도 저하로 이어집니다.
결제 실패 처리를 제대로 설계한다는 것은 단순히 “예외를 던지고 롤백”하는 것이 아니라,
도메인의 어떤 상태는 반드시 보존해야 하며, 그 상태를 어떻게 안전하게 분리할지를 고민하는 것이라고 생각합니다.
또한 추후에 결제 실패 알림, 결제 로그 저장, Webhook 이벤트 처리, 정산 등에 Spring Event를 적용해볼 예정입니다!!!