READY 취소와 PAID 환불을 같은 취소로 다루지 않기
취소와 환불은 다른 작업이다
사용자 입장에서는 모두 취소다.
결제하기 전에 예약을 취소해도 취소다. 결제한 뒤 환불을 받아도 취소다. 화면에서는 둘 다 “취소 완료”로 보일 수 있다.
하지만 서버 상태 전이에서는 둘을 같은 의미로 다루면 안 된다.
결제 전 취소는 외부 결제 제공자에 취소할 거래가 없을 수 있다. 결제 후 환불은 이미 승인된 거래를 되돌리는 작업이다. 두 작업은 실패 가능성도 다르고, 호출해야 할 외부 API도 다르며, 남겨야 할 감사 정보도 다르다.
READY 상태에서는 외부 취소가 필요 없다
처음에는 주문 상태만 보면 충분해 보인다.
PENDING -> CANCELLED
CONFIRMED -> CANCELLED하지만 결제 상태가 들어오면 이야기가 달라진다.
payment.READY -> ?
payment.PAID -> ?READY는 아직 외부 결제가 완료되지 않은 상태다. 이때 사용자가 취소하면 로컬 결제 intent를 CANCELLED로 바꾸면 된다.
PAID는 이미 돈이 이동한 상태다. 이때는 외부 결제 제공자에 취소나 환불을 요청해야 한다. 요청이 실패할 수도 있다. 실패했는데 주문만 취소 완료로 바꾸면, 사용자에게는 취소된 주문처럼 보이지만 실제 결제는 남아 있는 상태가 된다.
PAID 상태에서는 환불 실패를 남겨야 한다
문제는 “취소 버튼을 눌렀다”가 아니다.
문제는 현재 결제 상태에 따라 어떤 상태 전이가 가능한지다.
Order: PENDING, Payment: READY
-> 외부 호출 없이 Order CANCELLED, Payment CANCELLED
Order: CONFIRMED, Payment: PAID
-> 외부 환불 성공 후 Order CANCELLED, Payment REFUNDED
Order: CANCEL_REQUESTED, Payment: PAID
-> 늦게 도착한 결제 완료 이벤트를 환불로 수렴여기서 중요한 상태가 CANCEL_REQUESTED다. 취소 요청은 받았지만 외부 환불이 아직 끝나지 않았다는 뜻이다. 이 상태가 없으면 실패한 환불을 추적하기 어렵다.
CANCEL_REQUESTED로 중간 상태를 표현하기
결제 상태 전이를 명시적으로 나눈다.
READY -> CANCELLED
PAID -> REFUNDED주문 상태도 즉시 완료로 바꾸지 않고, 필요한 경우 취소 요청 상태를 거친다.
PENDING -> CANCELLED
CONFIRMED -> CANCEL_REQUESTED -> CANCELLED늦게 도착하는 이벤트도 고려한다. 사용자가 취소를 눌렀는데 결제 완료 이벤트가 뒤늦게 들어올 수 있다. 이때 주문을 다시 확정하면 안 된다. 이미 취소 요청 상태라면 결제 완료 이벤트는 환불 대상으로 처리해야 한다.
상태별 처리 흐름
package cancel
import "context"
type Payment struct {
ID string
Status string
}
type Repository interface {
GetPayment(ctx context.Context, orderID string) (*Payment, error)
MarkOrderCancelled(ctx context.Context, orderID string) error
MarkOrderCancelRequested(ctx context.Context, orderID string) error
MarkPaymentCancelled(ctx context.Context, paymentID string) error
MarkPaymentRefunded(ctx context.Context, paymentID string) error
MarkRefundFailed(ctx context.Context, paymentID string, reason string) error
}
type PaymentGateway interface {
Refund(ctx context.Context, paymentID string, reason string) error
}
type Service struct {
repo Repository
gateway PaymentGateway
}
func (s *Service) CancelOrder(ctx context.Context, orderID string) error {
payment, err := s.repo.GetPayment(ctx, orderID)
if err != nil {
return err
}
switch payment.Status {
case "READY":
if err := s.repo.MarkPaymentCancelled(ctx, payment.ID); err != nil {
return err
}
return s.repo.MarkOrderCancelled(ctx, orderID)
case "PAID":
if err := s.repo.MarkOrderCancelRequested(ctx, orderID); err != nil {
return err
}
if err := s.gateway.Refund(ctx, payment.ID, "user requested"); err != nil {
_ = s.repo.MarkRefundFailed(ctx, payment.ID, err.Error())
return err
}
if err := s.repo.MarkPaymentRefunded(ctx, payment.ID); err != nil {
return err
}
return s.repo.MarkOrderCancelled(ctx, orderID)
case "REFUNDED", "CANCELLED":
return s.repo.MarkOrderCancelled(ctx, orderID)
default:
return nil
}
}이 예제는 단순화를 위해 트랜잭션과 재시도 처리를 생략했다. 실제 구현에서는 외부 환불 호출을 DB 트랜잭션 안에 오래 묶지 않는 편이 안전하다. 외부 호출은 실패할 수 있고, 실패한 사실이 재시도 가능한 상태로 남아야 한다.
용어를 나누면 예외 처리가 단순해진다
취소와 환불은 화면에서는 비슷하지만 서버에서는 다르다.
결제 전 취소는 로컬 상태 전이다. 결제 후 환불은 외부 시스템과 함께 처리해야 하는 보상 작업이다. 이 차이를 상태 모델에 드러내지 않으면 실패했을 때 어디까지 처리됐는지 알기 어렵다.
처음부터 복잡한 상태 머신을 만들 필요는 없다. 하지만 최소한 다음 구분은 필요하다.
- 결제 전 취소:
READY -> CANCELLED - 결제 후 환불:
PAID -> REFUNDED - 환불 진행 중:
CANCEL_REQUESTED
이 세 가지가 있으면 늦은 결제 이벤트와 환불 실패를 훨씬 차분하게 다룰 수 있다.