
주문 취소는 단순히 주문 레코드를 지우는 게 아니다.
취소 사유마다 환불 금액이 다르다
결제 수단마다 환불 방식이 다르다 (포인트 vs PG)
판매자 귀책이면 판매자가 먼저 승인해야 한다
이벤트가 유실되면 환불이 안 된다
이 복잡한 플로우를 어떻게 관리할 것인가가 핵심 설계 과제였다.
public enum CancelStatus {
REQUESTED, // 취소 요청 (구매자 귀책)
PENDING, // 판매자 승인 대기 (판매자 귀책)
APPROVED, // 판매자 승인
REJECTED, // 판매자 거부
COMPLETED // 환불 완료
}
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 (취소 거부, 종료)
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 가능성 → 보상 트랜잭션 불필요 |
취소 시점과 사유에 따라 환불 금액이 달라진다. 전략 패턴으로 분리했다.
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 사이 취소
VOID — 전액 환불 (CANCEL_POLICY_VOID_V1)
succeededAt == null) / 판매자 귀책return new RefundAmount(paidAmount, 0L, 0L);
REVERSAL — 부분 환불 (CANCEL_POLICY_REVERSAL_V1)
succeededAt) 기준 48시간 이내 취소long fee = (long)(paidAmount * 0.20);
long refund = paidAmount - fee;
return new RefundAmount(refund, fee, 0L);
REFUND — 반품 (CANCEL_POLICY_REFUND_V1)
succeededAt) 기준 48시간 초과 ~ 2주 이내 취소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, ... }
재시도 시에도 당시 정책 기준으로 처리 가능하다.
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 상태로 남는다.
환불은 Point 모듈이 담당한다. Commerce → Point → Commerce 간 Kafka 이벤트로 통신한다.
CanceledOrderService.publishCancellationEvent()
↓
ApplicationEventPublisher.publishEvent(OrderCancelProcessedEvent)
↓ (트랜잭션 커밋 후)
@TransactionalEventListener(AFTER_COMMIT)
OrderCanceledEventListener.cancelOrder()
↓
OrderCanceledKafkaEventPublisher.publish()
↓
Kafka Topic: ORDER_CANCELED
@TransactionalEventListener(AFTER_COMMIT)을 사용하므로 트랜잭션이 롤백되면 이벤트가 발행되지 않는다.
@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);
}
}
결제 수단에 따라 분기한다:
DuplicateKeyException과 CustomException은 재시도 제외다. 중복 처리나 비즈니스 오류는 재시도해도 의미 없기 때문이다.
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 직접 참조 없이 모듈 독립성을 유지한다.
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과 함께 동작해 장애 복구를 이중으로 보장한다.
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가 된 건은 중복 처리되지 않는다.
같은 주문에 대해 취소가 중복으로 들어오면 어떻게 처리할까.
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 == PENDING → decreaseQuantity() 실행 → markApproved() 성공 → status = APPROVEDstatus == APPROVED → decreaseQuantity() 실행 → markApproved() 예외 발생 → @Transactional 롤백 → decreaseQuantity() 무효화결과적으로 판매자 귀책에서도 decreaseQuantity()는 정확히 1번만 반영된다.
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 (종료)