주문 취소 플로우 설계

·2026년 2월 3일
post-thumbnail

1. 문제 상황

주문 취소는 단순히 주문 레코드를 지우는 게 아니다.

취소 사유마다 환불 금액이 다르다
결제 수단마다 환불 방식이 다르다 (포인트 vs PG)
판매자 귀책이면 판매자가 먼저 승인해야 한다
이벤트가 유실되면 환불이 안 된다

이 복잡한 플로우를 어떻게 관리할 것인가가 핵심 설계 과제였다.


2. 도메인 모델

CancelStatus — 취소 상태

public enum CancelStatus {
    REQUESTED,   // 취소 요청 (구매자 귀책)
    PENDING,     // 판매자 승인 대기 (판매자 귀책)
    APPROVED,    // 판매자 승인
    REJECTED,    // 판매자 거부
    COMPLETED    // 환불 완료
}

CancelReason — 취소 사유와 귀책 구분

public enum CancelReason {
    GROUP_PURCHASE_FAILED,  // 시스템 (공동구매 실패)

    CHANGE_OF_MIND,         // 구매자 귀책: 단순 변심

    PRODUCT_DEFECT,         // 판매자 귀책: 상품 하자
    DELIVERY_DELAY,         // 판매자 귀책: 배송 지연
    OUT_OF_STOCK;           // 판매자 귀책: 재고 부족

    public boolean isBuyerFault()  { return BUYER_FAULT.contains(this); }
    public boolean isSellerFault() { return SELLER_FAULT.contains(this); }
}

취소 사유가 귀책을 결정하고, 귀책이 플로우를 결정한다.

상태 전이

구매자 귀책:
  취소 요청 → REQUESTED → (환불 처리) → COMPLETED

판매자 귀책:
  취소 요청 → PENDING → APPROVED → (환불 처리) → COMPLETED
                       → REJECTED (취소 거부, 종료)

3. 취소 요청 플로우

POST /api/orders/cancel/{orderId}

@Transactional
public void cancelOrder(OrderCancelCommand command) {
    // 1. 멱등성 체크 — 이미 취소된 주문이면 무시
    if (canceledOrderRepository.existsByOrderId(command.orderId())) {
        return;
    }

    // 2. 주문 유효성 검증
    Order order = orderRepository.findById(command.orderId()).orElseThrow(...);

    if (order.getStatus() != OrderStatus.PAYMENT_COMPLETED) {
        throw new CustomException(CANNOT_CANCEL_ORDER_INVALID_STATUS);
    }
    if (!command.memberId().equals(order.getMemberId())) {
        throw new CustomException(ORDER_ACCESS_DENIED);
    }

    // 3. 환불 정책 결정 및 금액 계산
    GroupPurchase groupPurchase = groupPurchaseService.findByGroupPurchase(order.getGroupPurchaseId());
    String productName = productService.findByProductName(groupPurchase.getProductId());

    // requestCanceledAt()을 resolve() 앞에 호출해야 order.getCanceledAt()이 null이 아님
    // → isInReversedPeriod(order.getCanceledAt()) NPE 방지
    order.requestCanceledAt();

    OrderCancellationPolicy policy = orderCancellationPolicyResolver.resolve(groupPurchase, order, command.reason());
    RefundAmount refundAmount = policy.calculate(order);

    // 4. 상태 분기: 귀책에 따라 다른 초기 상태
    CancelStatus status = command.reason().isBuyerFault()
            ? CancelStatus.REQUESTED
            : CancelStatus.PENDING;

    // 5. CanceledOrder 저장 (정책 스냅샷 포함)
    CanceledOrder canceledOrder = CanceledOrder.createCanceledOrder(
            order.getOrderId(), command.memberId(), order.getSellerId(),
            order.getPaidPrice(), refundAmount.cancellationFee(),
            refundAmount.shippingFee(), refundAmount.refundAmount(),
            policy.getPolicyId(), policy.buildSnapshot(refundAmount),
            status, command.reason(), command.detailReason(),
            command.idempotencyKey(), order.getPaymentMethod()
    );

    try {
        canceledOrderRepository.save(canceledOrder);
    } catch (DataIntegrityViolationException e) {
        return;  // 동시 중복 요청 → DB 유니크 제약으로 차단
    }

    // 6. 구매자 귀책: 즉시 수량 감소 + 환불 이벤트 발행
    //    판매자 귀책: 수량 감소 없음, 판매자 승인 대기
    if (command.reason().isBuyerFault()) {
        groupPurchaseQuantityService.decreaseQuantity(groupPurchase.getGroupPurchaseId(), order.getQuantity());
        publishCancellationEvent(canceledOrder, productName);
    }
}

핵심: 수량 감소 타이밍

귀책수량 감소 시점이유
구매자취소 요청 시구매자가 취소하면 바로 확정
판매자판매자 승인 시REJECTED 가능성 → 보상 트랜잭션 불필요

4. 환불 정책 — Strategy 패턴

취소 시점과 사유에 따라 환불 금액이 달라진다. 전략 패턴으로 분리했다.

정책 선택 로직

public OrderCancellationPolicy resolve(GroupPurchase gp, Order order, CancelReason reason) {
    if (reason.isBuyerFault()) {
        if (gp.isInVoidPeriod())             return voidPolicy;      // 공동구매 성공 전
        if (gp.isInReversedPeriod(...))      return reversalPolicy;  // 성공 후 48시간 이내
        if (gp.isInReturnedPeriod(...))      return refundPolicy;    // 성공 후 48h ~ 2주
    }
    if (reason.isSellerFault()) {
        return voidPolicy;  // 판매자 귀책은 항상 전액 환불
    }
    throw new CustomException(ORDER_CANCELLATION_NOT_ALLOWED);
}

정책 시간 기준

세 기간 메서드의 기준 시각은 모두 GroupPurchase.succeededAt — 공동구매 성공이 확정된 시각이다.

// 공동구매가 아직 성공하지 않음 (succeededAt == null)
public boolean isInVoidPeriod() {
    return this.succeededAt == null;
}

// 공동구매 성공 시각 기준 48시간 이내
public boolean isInReversedPeriod(OffsetDateTime canceledAt) {
    if (this.succeededAt == null) return false;
    return canceledAt.isBefore(this.succeededAt.plusDays(2));
}

// 공동구매 성공 시각 기준 48시간 초과 ~ 2주 이내
public boolean isInReturnedPeriod(OffsetDateTime canceledAt) {
    if (this.succeededAt == null) return false;
    return canceledAt.isAfter(this.succeededAt.plusDays(2))
            && canceledAt.isBefore(this.succeededAt.plusWeeks(2));
}

기준 시각: GroupPurchase.succeededAt (공동구매 목표 수량 달성 시각)

예) 공동구매가 2월 1일 12:00에 성공했다면:

  • Void 기간: 2월 1일 12:00 이전에 취소
  • Reversal 기간: 2월 1일 12:00 ~ 2월 3일 12:00 사이 취소
  • Refund 기간: 2월 3일 12:00 ~ 2월 15일 12:00 사이 취소

3가지 정책

VOID — 전액 환불 (CANCEL_POLICY_VOID_V1)

  • 적용: 공동구매 성공 전(succeededAt == null) / 판매자 귀책
  • 수수료 0%, 전액 환불
return new RefundAmount(paidAmount, 0L, 0L);

REVERSAL — 부분 환불 (CANCEL_POLICY_REVERSAL_V1)

  • 적용: 공동구매 성공 시각(succeededAt) 기준 48시간 이내 취소
  • 수수료 20%, 80% 환불
long fee    = (long)(paidAmount * 0.20);
long refund = paidAmount - fee;
return new RefundAmount(refund, fee, 0L);

REFUND — 반품 (CANCEL_POLICY_REFUND_V1)

  • 적용: 공동구매 성공 시각(succeededAt) 기준 48시간 초과 ~ 2주 이내 취소
  • 수수료 20% + 택배비 6,000원
long fee    = (long)(paidAmount * 0.20);
long refund = paidAmount - fee - 6000L;
return new RefundAmount(refund, fee, 6000L);

스냅샷 저장

환불 정책은 시간이 지나면 바뀔 수 있다. 취소 시점의 정책 내용을 CanceledOrder에 JSON으로 저장한다.

policyId:     "CANCEL_POLICY_REVERSAL_V1"
policySnapshot: { "refundAmount": 80000, "cancelFeeRate": 0.20, ... }

재시도 시에도 당시 정책 기준으로 처리 가능하다.


5. 판매자 귀책 플로우

판매자 승인

PATCH /api/orders/cancel/{orderId}/approve

@Transactional
public OrderCancelInfo approvePendingOrder(UUID memberId, UUID orderId) {
    CanceledOrder canceledOrder = canceledOrderRepository.findByOrderId(orderId).orElseThrow(...);
    Order order = orderRepository.findById(orderId).orElseThrow(...);

    if (!order.getSellerId().equals(memberId)) {
        throw new CustomException(NON_SELLER_ACCESS_DENIED);
    }

    // 승인 시점에 수량 감소
    groupPurchaseQuantityService.decreaseQuantity(order.getGroupPurchaseId(), order.getQuantity());

    // markApproved() 내부에서 status != PENDING이면 예외 발생
    // → 이중 승인 시 decreaseQuantity 이미 실행된 상태에서 두 번째 호출이 차단됨
    canceledOrder.markApproved();  // PENDING → APPROVED
    publishCancellationEvent(canceledOrder, productName);
    return OrderCancelInfo.toOrderCancelInfo(canceledOrder);
}

markApproved() 상태 가드:

public void markApproved() {
    if (this.status != CancelStatus.PENDING) {
        throw new CustomException(EntityErrorCode.INVALID_CANCEL_STATUS);
    }
    this.status = CancelStatus.APPROVED;
}

엔티티 레벨에서 PENDING이 아닐 때 예외를 던진다.
이미 APPROVED/REJECTED인 건에 대해 승인 API를 한 번 더 호출해도 markApproved()에서 차단된다.
따라서 decreaseQuantity() 호출이 중복되는 경우는 발생하지 않는다.

판매자 거부

PATCH /api/orders/cancel/{orderId}/reject

@Transactional
public OrderCancelInfo rejectPendingOrder(UUID memberId, UUID orderId) {
    CanceledOrder canceledOrder = canceledOrderRepository.findByOrderId(orderId).orElseThrow(...);

    if (!canceledOrder.getSellerId().equals(memberId)) {
        throw new CustomException(NON_SELLER_ACCESS_DENIED);
    }

    canceledOrder.markRejected();  // PENDING → REJECTED (PENDING 아니면 예외)
    return OrderCancelInfo.toOrderCancelInfo(canceledOrder);
}

REJECTED는 종료 상태다. 수량 감소 없음, 이벤트 발행 없음.

cancelOrder() 시점에 이미 order.requestCanceledAt()이 호출되어 Order.status = CANCELLED로 변경된다.
판매자가 거부해도 Order 상태를 되돌리는 로직이 없으므로, REJECTED 이후에도 Order는 CANCELLED 상태로 남는다.


6. 이벤트 기반 환불 처리

환불은 Point 모듈이 담당한다. Commerce → Point → Commerce 간 Kafka 이벤트로 통신한다.

환불 요청 흐름 (Commerce → Point)

CanceledOrderService.publishCancellationEvent()
    ↓
ApplicationEventPublisher.publishEvent(OrderCancelProcessedEvent)
    ↓ (트랜잭션 커밋 후)
@TransactionalEventListener(AFTER_COMMIT)
OrderCanceledEventListener.cancelOrder()
    ↓
OrderCanceledKafkaEventPublisher.publish()
    ↓
Kafka Topic: ORDER_CANCELED

@TransactionalEventListener(AFTER_COMMIT)을 사용하므로 트랜잭션이 롤백되면 이벤트가 발행되지 않는다.

Point 모듈 소비 및 환불 처리

@RetryableTopic(exclude = {DuplicateKeyException.class, CustomException.class})
@KafkaListener(topics = KafkaTopics.ORDER_CANCELED, ...)
public void handleOrderCanceledEvent(OrderCanceledEvent event) {
    switch (event.getMethod()) {
        case POINT -> pointReturnService.returnPoints(event.getMemberId(), command);
        case PG    -> pgCancelFacade.refundPayment(event.getMemberId(), command);
    }
}

결제 수단에 따라 분기한다:

  • POINT: 포인트 반환
  • PG: PG사 결제 취소

DuplicateKeyExceptionCustomException은 재시도 제외다. 중복 처리나 비즈니스 오류는 재시도해도 의미 없기 때문이다.

환불 결과 흐름 (Point → Commerce)

Point 모듈은 Commerce DB를 직접 수정하지 않는다. 환불 처리 결과를 Kafka 이벤트로 발행하면, Commerce가 그 이벤트를 소비해서 상태를 갱신한다.

Point 환불 처리 완료
    ↓
Kafka Topic: PAYMENT_CHANGED(REFUNDED) 또는 POINT_CHANGED(REFUNDED) 발행
    ↓
Commerce: PaymentChangedKafkaListener / PointChangedKafkaListener 소비
    ↓
OrderPaymentProcessorService.processPaymentStatusUpdate() 또는 processPointStatusUpdate()
    ↓
case REFUNDED:
    canceledOrder = canceledOrderRepository.findByOrderId(order.getOrderId())
    orderSettlementService.saveCanceledOrderSettlement(order, canceledOrder)
    canceledOrder.markCompleted()  // APPROVED → COMPLETED
// Commerce 서비스 내부 처리 (OrderPaymentProcessorService)
case REFUNDED -> {
    if (order.getStatus() == OrderStatus.CANCELLED) {
        CanceledOrder canceledOrder = canceledOrderRepository
                .findByOrderId(order.getOrderId()).orElseThrow(...);
        orderSettlementService.saveCanceledOrderSettlement(order, canceledOrder);
        canceledOrder.markCompleted();
    }
}

이 구조로 Point와 Commerce 서비스 간 DB 직접 참조 없이 모듈 독립성을 유지한다.


7. 크론잡 — 재시도와 자동 승인

retryCancelOrder — 환불 재시도

POST /api/orders/cancel/retry (K8s CronJob HTTP 트리거)

@Transactional
public void retryCancelOrder() {
    // REQUESTED 또는 APPROVED 상태에서 15분 이상 지난 건
    OffsetDateTime minutesAgo = OffsetDateTime.now().minusMinutes(15);
    List<CanceledOrder> pendingOrders = canceledOrderRepository
            .findAllByStatusInAndCanceledAtBefore(
                List.of(CancelStatus.REQUESTED, CancelStatus.APPROVED),
                minutesAgo
            );

    for (CanceledOrder canceledOrder : pendingOrders) {
        Order order = orderRepository.findById(canceledOrder.getOrderId()).orElse(null);
        if (order == null) continue;

        OrderCancellationPolicy policy = orderCancellationPolicyResolver
                .resolveByPolicyId(canceledOrder.getPolicyId());
        if (policy == null) continue;

        publishCancellationEvent(canceledOrder, productName);
    }
}

REQUESTED/APPROVED인데 15분이 지났다 = 다음 중 하나의 원인으로 COMPLETED로 전환되지 못한 것이다:

원인설명
Kafka 이벤트 유실ORDER_CANCELED 이벤트가 Point에 도달하지 못함
Point 처리 실패PG 일시 장애, 포인트 처리 오류 등으로 환불 실패
Commerce 소비 실패PAYMENT_CHANGED(REFUNDED) 이벤트 소비 중 오류

이벤트를 재발행해서 환불 플로우를 다시 시작한다. Point 측 @RetryableTopic과 함께 동작해 장애 복구를 이중으로 보장한다.

autoCancelOrder — 자동 승인

POST /api/orders/cancel/auto (K8s CronJob HTTP 트리거)

@Transactional
public void autoCancelOrder() {
    // PENDING 상태에서 2일 이상 지난 건
    OffsetDateTime twoDaysAgo = OffsetDateTime.now().minusDays(2);
    List<CanceledOrder> pendingOrders = canceledOrderRepository
            .findAllByStatusInAndCanceledAtBefore(
                List.of(CancelStatus.PENDING),
                twoDaysAgo
            );

    for (CanceledOrder canceledOrder : pendingOrders) {
        Order order = orderRepository.findById(canceledOrder.getOrderId()).orElse(null);
        if (order == null) continue;

        OrderCancellationPolicy policy = orderCancellationPolicyResolver
                .resolveByPolicyId(canceledOrder.getPolicyId());
        if (policy == null) continue;

        groupPurchaseQuantityService.decreaseQuantity(
                order.getGroupPurchaseId(), order.getQuantity()
        );
        // markApproved() 내부에서 status != PENDING이면 예외 발생
        // → 자동 승인 중복 실행 방지 (CronJob 재처리 시 안전)
        canceledOrder.markApproved();  // PENDING → APPROVED
        publishCancellationEvent(canceledOrder, productName);
    }
}

판매자가 2일 안에 응답하지 않으면 자동으로 승인 처리한다. 이때도 수량 감소는 승인 시점(여기)에 한다.

markApproved()의 PENDING 상태 가드 덕분에 CronJob이 재실행되어도 이미 APPROVED/COMPLETED가 된 건은 중복 처리되지 않는다.

왜 2일인가

  • 1일: 판매자 확인 시간 부족
  • 3일+: 고객 불만
  • 2일: 판매자에게 충분한 시간을 주면서 고객 경험 보호

8. 멱등성 설계

같은 주문에 대해 취소가 중복으로 들어오면 어떻게 처리할까.

구매자 귀책 — 2단계 멱등성

1단계 — 애플리케이션 레벨

if (canceledOrderRepository.existsByOrderId(command.orderId())) {
    return;  // 이미 취소 레코드 있으면 즉시 반환
}

2단계 — DB 유니크 제약조건

@Column(name = "order_id", unique = true)
private UUID orderId;

@Column(name = "idempotency_key", unique = true)
private String idempotencyKey;
try {
    canceledOrderRepository.save(canceledOrder);
} catch (DataIntegrityViolationException e) {
    return;  // 레이스 컨디션으로 동시 삽입 시 → DB에서 차단
}

1단계는 빠른 탈출, 2단계는 동시 요청에 대한 안전망이다. 이 두 단계 덕분에 구매자 귀책에서 decreaseQuantity()는 정확히 1번만 실행된다.

판매자 귀책 — 엔티티 상태 가드

판매자 귀책은 cancelOrder() 시점에 수량 감소가 없다. 수량 감소는 approvePendingOrder() 또는 autoCancelOrder()에서 실행된다.

이 경로에서의 멱등성은 CanceledOrder.markApproved()의 엔티티 레벨 상태 가드가 담당한다.

public void markApproved() {
    if (this.status != CancelStatus.PENDING) {
        throw new CustomException(EntityErrorCode.INVALID_CANCEL_STATUS);
    }
    this.status = CancelStatus.APPROVED;
}
  • 첫 번째 승인 호출: status == PENDINGdecreaseQuantity() 실행 → markApproved() 성공 → status = APPROVED
  • 두 번째 승인 호출: status == APPROVEDdecreaseQuantity() 실행 → markApproved() 예외 발생 → @Transactional 롤백 → decreaseQuantity() 무효화

결과적으로 판매자 귀책에서도 decreaseQuantity()는 정확히 1번만 반영된다.


9. 전체 플로우 다이어그램

sequenceDiagram
    participant C as Client
    participant O as CanceledOrderService
    participant DB as Commerce DB
    participant K as Kafka
    participant P as Point Service

    Client->>O: POST /orders/cancel/{orderId}
    O->>DB: existsByOrderId() 체크
    O->>DB: Order 조회 & 유효성 검증
    O->>O: 환불 정책 결정 (VOID/REVERSAL/REFUND)
    O->>O: RefundAmount 계산 (기준: GroupPurchase.succeededAt)

    alt 구매자 귀책 (CHANGE_OF_MIND)
        O->>DB: CanceledOrder 저장 (REQUESTED)
        O->>DB: decreaseQuantity() 즉시 실행
        O->>K: ORDER_CANCELED 이벤트 발행

        K->>P: OrderCanceledEvent 수신
        alt POINT 결제
            P->>P: PointReturnService.returnPoints()
        else PG 결제
            P->>P: PgCancelFacade.refundPayment()
        end
        P->>K: POINT_CHANGED(REFUNDED) 또는 PAYMENT_CHANGED(REFUNDED) 발행

        K->>O: 환불 결과 이벤트 소비
        O->>DB: saveCanceledOrderSettlement()
        O->>DB: CanceledOrder → COMPLETED

    else 판매자 귀책 (PRODUCT_DEFECT 등)
        O->>DB: CanceledOrder 저장 (PENDING)
        Note over DB: 수량 감소 없음

        alt 판매자 승인
            C->>O: PATCH /orders/cancel/{id}/approve
            O->>DB: decreaseQuantity() (승인 시점)
            O->>DB: CanceledOrder → APPROVED (markApproved: PENDING 검증)
            O->>K: ORDER_CANCELED 이벤트 발행
            K->>P: 환불 처리
            P->>K: PAYMENT_CHANGED(REFUNDED) 또는 POINT_CHANGED(REFUNDED)
            K->>O: 환불 결과 이벤트 소비
            O->>DB: CanceledOrder → COMPLETED
        else 판매자 거부
            C->>O: PATCH /orders/cancel/{id}/reject
            O->>DB: CanceledOrder → REJECTED (markRejected: PENDING 검증)
        else 2일 경과 (자동 승인)
            Note over O: K8s CronJob → POST /api/orders/cancel/auto
            O->>DB: decreaseQuantity() (승인 시점)
            O->>DB: CanceledOrder → APPROVED (markApproved: PENDING 검증)
            O->>K: ORDER_CANCELED 이벤트 발행
            K->>P: 환불 처리
            P->>K: PAYMENT_CHANGED(REFUNDED) 또는 POINT_CHANGED(REFUNDED)
            K->>O: 환불 결과 이벤트 소비
            O->>DB: CanceledOrder → COMPLETED
        end
    end

    loop 하루마다 (환불 재시도 CronJob)
        Note over O: K8s CronJob → POST /api/orders/cancel/retry
        Note over O: 원인: Kafka 유실 / Point 처리 실패 / 소비 오류
        O->>K: REQUESTED/APPROVED 건 ORDER_CANCELED 재발행
    end

상태 전이 요약

구매자 귀책:
  REQUESTED ──→ COMPLETED

판매자 귀책:
  PENDING ──→ APPROVED ──→ COMPLETED
           └─→ REJECTED   (종료)

코드 링크

항목위치
취소 상태/사유common/.../domain/order/CancelStatus.java, common/.../domain/order/CancelReason.java
취소 엔티티common/.../domain/order/CanceledOrder.java
취소 서비스commerce/.../application/order/CanceledOrderService.java
정책 리졸버commerce/.../application/order/OrderCancellationPolicyResolver.java
Kafka Publishercommerce/.../infrastructure/kafka/publisher/OrderCanceledKafkaEventPublisher.java
환불 결과 소비 (Commerce)commerce/.../application/order/OrderPaymentProcessorService.java
REST APIcommerce/.../presentation/order/CanceledOrderController.java
CronJob 트리거commerce/.../presentation/order/OrderCronController.java

0개의 댓글