결제 실패 처리 전략 - Workaway

chaean·2025년 5월 10일

Workaway

목록 보기
4/11
post-thumbnail

결제 시스템을 개발하다 보면 ‘성공’만큼이나 중요한 것이 바로 ‘실패 처리’입니다.
특히 결제 실패 시 예약 상태를 어떻게 처리할 것인지, 실패 데이터를 어떻게 보존할 것인지에 따라 사용자 경험과 데이터 무결성에 큰 영향을 줍니다.

이번 글에서는 PortOne V2 결제 시스템을 Spring Boot 기반으로 구현하며 겪었던 결제 실패 처리 이슈와 그 해결 과정을 정리했습니다.

PortOne V2 기반 결제 시스템 설계 및 검증 로직 구현기 - TRIO

🧾 문제 상황 - 결제 실패인데 예약 상태가 그대로⁇

제가 구현한 시스템은 다음과 같은 결제 흐름을 가지고 있습니다.

1. 임시 예약 생성 → 예약 상태 : PENDING
2. PortOne 결제 → 검증 API 호출
3. 유효성 검사 이것저것...
4. 검증 실패 시 예약 상태 FAILED로 변경

하지만.. 금액이 일치하지 않아 예외를 던졌는데도 예약 상태가 여전히 PENDING으로 남아있었습니다.


🫵 원인 : Transaction Rollback

이유는 단순했습니다.
결제 검증 로직 전체가 하나의 @Transcational 안에서 실행되고 있었기 때문입니다.

@Transactional
public void 검증클래스(...) {
    // 예약 상태를 FAILED로 변경
    reservation.updateStatus(FAILED);
    reservationRepository.save(reservation);

    // 이후 금액 불일치 발생 → 예외 발생
    if (!amountMatches()) {
        throw new BusinessException(...);
    }

    // 트랜잭션 전체 롤백 → 상태 업데이트도 무효 처리됨
}

위와 같은 코드에서 updateStatus(FAILED)는 수행되었고, save()까지 호출되었지만,
결과적으로 DB에 반영되지 않았습니다.

이유는 단순합니다.
예외가 발생하면 Spring의 기본 @Transactional은 전체 트랜잭션을 롤백시키기 때문입니다.
즉, 상태 업데이트 → 예외 발생 → 전체 롤백 → 아무것도 저장되지않음...


? 이게 왜

트랜잭션이란 본래 원자성을 보장하기 위한 수단입니니다.
하지만 모든 데이터를 무조건 함께 커밋하거나 롤백해야하나??

아닙니다.
도메인 관점에서 반드시 보존해야 할 상태 변화가 존재한다면, 그에 알맞게 트랜잭션을 나눠야 합니다.

  • 예약 실패는 사용자에게 반드시 보여줘야 할 상태이다.
  • 이 상태가 롤백된다면, 사용자 입장에서는 여전히 예약이 살아 있는 것처럼 보인다.
  • 하지만 실제로 결제는 실패.

비즈니스 상 모순이 발생합니다.


⭐️ 해결 방법 : 트랜잭션 분리

전략 1 : @Transactional(propagation = REQUIRES_NEW)

예약 상태 변경만 별도의 트랜잭션으로 분리하여 커밋합니다.

@Service
public class 상태_업데이트_서비스 {

    @Transactional(propagation = Propagation.REQUIRES_NEW)
    public void 상태업데이트해줘(Reservation reservation, Status status) {
        reservation.changeStatus(status);
        reservationRepository.save(reservation)
    }
}
  • 논리적 트랜잭션 경계를 분리
  • 기존 트랜잭션이 롤백되더라도, 해당 트랜잭션은 독립적으로 유지되며 커밋된다.
  • 결제 실패 -> 예약 실패 기록은 반드시 남겨야 하는 정보이기 때문에, 이런 방식이 필수적이다.
  • 동기적으로 동작하기 때문에 해당 방식을 채택하였습니다.

주의

  • @Transactional(propagation = REQUIRES_NEW)는 AOP기반으로 동작하기 때문에, 같은 클래스에서 호출할 시 Spring AOP 프록시가 적용되지 않아 동작하지 않습니다.

전략 2 : Spring Event

Spring Event 기반의 실패 처리도 대안이 될 수 있습니다.

@Component
public class PaymentEventListener {

    @Transactional
    @EventListener
    public void handlePaymentFailed(PaymentFailedEvent event) {
        상태_업데이트_서비스.상태업데이트해줘(예약, 상태);
    }
}

하지만 저는 이 전략을 선택하지 않았습니다. 이유는 다음과 같습니다.

  • 결제 실패는 사용자에게 반영되어야 하는 정보지만 Spring Event는 비동기로 동작하여 커밋 시점이 예측되지 않습니다.
  • 실패 이벤트가 정상적으로 소비되지 않으면, 상태가 반영되지 않은 채 유실될 수 있습니다.
  • 재시도, 장애복구 등 복잡도가 높아집니다. MVP를 개발하는 단계이기 때문에 불필요하다고 판단했습니다.

결제 실패는 사용자가 직접 재시도해야 하는 흐름이라고 생각이 되었고, 단순하고 명확한 동기적 분리 트랜잭션 구조가 더 안전하고 설계에 부합한다고 판단했습니다.

이건 ChatGPT 응답 ㅎㅎ..

👍 결론

실무에서는 상태가 정확하게 반영되지 않으면, 비즈니스에 다음과 같은 리스크가 발생합니다.

  • 예약이 살아 있는 줄 알고 중복 결제 시도 → 이중 결제
  • 상태가 PENDING인데 실제로는 실패 → CS 이슈, 신뢰도 하락
  • 결제 실패 로그가 유실되어 사후 분석 불가

이런 문제는 곧 운영 비용 증가, 고객 만족도 하락, 시스템 신뢰도 저하로 이어집니다.

결제 실패 처리를 제대로 설계한다는 것은 단순히 “예외를 던지고 롤백”하는 것이 아니라,
도메인의 어떤 상태는 반드시 보존해야 하며, 그 상태를 어떻게 안전하게 분리할지를 고민하는 것이라고 생각합니다.

또한 추후에 결제 실패 알림, 결제 로그 저장, Webhook 이벤트 처리, 정산 등에 Spring Event를 적용해볼 예정입니다!!!

profile
백엔드 개발자

0개의 댓글