
@Transactional
public void cancelOrder(OrderCancelCommand command) {
Order order = orderRepository.findById(command.orderId()).orElseThrow(...);
if (!command.memberId().equals(order.getMemberId())) {
throw new CustomException(ORDER_ACCESS_DENIED);
}
GroupPurchase groupPurchase = groupPurchaseService.findByGroupPurchase(order.getGroupPurchaseId());
String productName = productService.findByProductName(groupPurchase.getProductId());
groupPurchaseService.decreaseQuantity(...); // ← 맨 앞에서 수량부터 감소
if (order.getStatus() == OrderStatus.PAYMENT_COMPLETED) {
processCancellationBeforeSuccess(order, command.reason(), productName);
return;
}
if (groupPurchase.isInReversedPeriod()) {
processCancellationWithin48Hours(order, command.reason(), productName);
return;
}
if (groupPurchase.isInReturnedPeriod()) {
processReturnAfter48Hours(order, command.reason(), productName);
return;
}
throw new CustomException(ORDER_CANCELLATION_NOT_ALLOWED);
}
private void processCancellationBeforeSuccess(Order order, String reason, String productName) {
RefundAmount refundAmount = calculate(order, CancellationType.BEFORE_GROUP_PURCHASE_SUCCESS);
publishCancellationEvent(order, reason, refundAmount.refundAmount(), productName);
}
private void processCancellationWithin48Hours(Order order, String reason, String productName) {
RefundAmount refundAmount = calculate(order, CancellationType.WITHIN_48_HOURS);
publishCancellationEvent(order, reason, refundAmount.refundAmount(), productName);
}
private void processReturnAfter48Hours(Order order, String reason, String productName) {
RefundAmount refundAmount = calculate(order, CancellationType.AFTER_48_HOURS);
publishCancellationEvent(order, reason, refundAmount.refundAmount(), productName);
}
public class OrderCancellationPolicy {
private static final double CANCELLATION_FEE_RATE = 0.20;
private static final long SHIPPING_FEE = 6000L;
public static RefundAmount calculate(Order order, CancellationType type) {
long totalAmount = order.getPrice() * order.getQuantity();
return switch (type) {
case BEFORE_GROUP_PURCHASE_SUCCESS ->
new RefundAmount(totalAmount, 0L);
case WITHIN_48_HOURS ->
new RefundAmount(
(long) (totalAmount * (1 - CANCELLATION_FEE_RATE)),
(long) (totalAmount * CANCELLATION_FEE_RATE)
);
case AFTER_48_HOURS ->
new RefundAmount(
(long) (totalAmount * (1 - CANCELLATION_FEE_RATE)) - SHIPPING_FEE,
(long) (totalAmount * CANCELLATION_FEE_RATE)
);
};
}
public record RefundAmount(Long refundAmount, Long cancellationFee) {}
public enum CancellationType {
BEFORE_GROUP_PURCHASE_SUCCESS,
WITHIN_48_HOURS,
AFTER_48_HOURS
}
}
OrderCancellationPolicy는 인터페이스가 아니라 단일 유틸리티 클래스다. calculate()는 static 메서드이고, CancellationType enum으로 내부에서 switch 분기한다.
// 정책이 enum 값으로 표현됨
calculate(order, CancellationType.BEFORE_GROUP_PURCHASE_SUCCESS)
calculate(order, CancellationType.WITHIN_48_HOURS)
calculate(order, CancellationType.AFTER_48_HOURS)
새로운 정책이 생기면:
CancellationType enum에 값 추가calculate() switch에 case 추가세 군데를 동시에 수정해야 한다. 정책 클래스가 분리되어 있지 않으니 인터페이스도, 다형성도 없다.
취소 요청 자체를 저장하지 않는다. 이벤트만 발행한다. 이벤트가 유실되면 환불 내역을 다시 추적할 방법이 없다.
retryCancelOrder()가 전부 주석 처리된 이유가 여기 있다. 재시도하려면 "어떤 주문이 취소 요청 중인지"를 어딘가 기록해야 하는데 그게 없었다.
groupPurchaseService.decreaseQuantity(...); // ← 정책 결정 전, 저장 전
if (order.getStatus() == OrderStatus.PAYMENT_COMPLETED) { ... }
수량을 먼저 줄이고 이후 로직에서 예외가 나면 수량은 줄었는데 취소는 안 된 상태가 된다. 보상 트랜잭션이 없으면 정합성이 깨진다.
취소 요청이 두 번 오면 수량이 두 번 감소한다. 중복 방어 코드가 없다.
모든 취소가 동일한 흐름으로 처리된다. 판매자 귀책일 때 판매자가 먼저 승인해야 한다는 개념이 없다.

기존 구조는 enum 값을 받아 분기하는 단일 메서드였다. 정책마다 클래스를 분리하고 공통 인터페이스를 정의했다.
// 변경 전: enum dispatch
calculate(order, CancellationType.WITHIN_48_HOURS);
// 변경 후: 정책 객체가 직접 계산
OrderCancellationPolicy policy = resolver.resolve(groupPurchase, order, reason);
RefundAmount refundAmount = policy.calculate(order);
// 정책 인터페이스
public interface OrderCancellationPolicy {
RefundAmount calculate(Order order);
String getPolicyId();
PolicyType getPolicyType();
// 스냅샷은 기본 구현 제공 — 구체 정책이 오버라이드할 필요 없음
default String buildSnapshot(RefundAmount refundAmount) {
return String.format(
"{\"policyType\":\"%s\",\"policyId\":\"%s\","
+ "\"cancellationFee\":%d,\"shippingFee\":%d,\"refundAmount\":%d}",
getPolicyType().name(), getPolicyId(),
refundAmount.cancellationFee(), refundAmount.shippingFee(), refundAmount.refundAmount()
);
}
record RefundAmount(long refundAmount, long cancellationFee, long shippingFee) {}
enum PolicyType { VOID, REVERSAL, REFUND }
}
// 구체 정책
public class ReversalOrderCancellationPolicy implements OrderCancellationPolicy {
public RefundAmount calculate(Order order) {
long fee = (long)(order.getPaidPrice() * 0.20);
long refund = order.getPaidPrice() - fee;
return new RefundAmount(refund, fee, 0L);
}
public String getPolicyId() { return "CANCEL_POLICY_REVERSAL_V1"; }
public PolicyType getPolicyType() { return PolicyType.REVERSAL; }
}
새 정책이 생기면 클래스 하나 추가하고 리졸버에 분기 한 줄 추가하면 된다. 기존 정책 코드는 건드리지 않는다.
정책 선택을 담당하는 별도 컴포넌트를 만들었다.
public OrderCancellationPolicy resolve(GroupPurchase gp, Order order, CancelReason reason) {
if (reason.isBuyerFault()) {
if (gp.isInVoidPeriod()) return voidPolicy;
if (gp.isInReversedPeriod(...)) return reversalPolicy;
if (gp.isInReturnedPeriod(...)) return refundPolicy;
}
if (reason.isSellerFault()) return voidPolicy;
throw new CustomException(ORDER_CANCELLATION_NOT_ALLOWED);
}
CanceledOrderService는 정책 선택 방법을 모른다. 리졸버에 물어보고 결과를 쓸 뿐이다.
취소 요청을 별도 테이블에 저장한다. 이벤트가 유실되어도 어떤 주문이 어떤 정책으로 취소 요청 중인지 기록이 남는다.
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(), ...
);
policyId와 스냅샷을 함께 저장하므로, 재시도 시 저장된 정책 기준으로 처리할 수 있다.
// 변경 전: 모든 취소가 동일한 흐름
processCancellationBeforeSuccess(order, reason, productName);
// 변경 후: 귀책에 따라 초기 상태 분기
CancelStatus status = command.reason().isBuyerFault()
? CancelStatus.REQUESTED // 즉시 이벤트 발행
: CancelStatus.PENDING; // 판매자 승인 대기
판매자 귀책은 PENDING 상태로 저장되고, approvePendingOrder() 또는 autoCancelOrder()(2일 경과 시 자동 승인)에서 처리된다.
// 변경 전: 맨 앞에서 수량부터 감소
groupPurchaseService.decreaseQuantity(...);
if (order.getStatus() == ...) { ... }
// 변경 후: 취소 레코드 저장 성공 후 감소
try {
canceledOrderRepository.save(canceledOrder);
} catch (DataIntegrityViolationException e) {
return; // 중복 요청 차단
}
if (command.reason().isBuyerFault()) {
groupPurchaseQuantityService.decreaseQuantity(...); // 저장 확인 후
}
저장에 실패하면 수량 감소 자체가 실행되지 않는다.
// 1단계: 이미 취소 레코드가 있으면 즉시 반환
if (canceledOrderRepository.existsByOrderId(command.orderId())) {
return;
}
// 2단계: 동시 요청이 둘 다 1단계를 통과해도 DB 유니크 제약이 차단
try {
canceledOrderRepository.save(canceledOrder);
} catch (DataIntegrityViolationException e) {
return;
}
| 항목 | 이전 | 현재 |
|---|---|---|
| 정책 표현 | CancellationType enum | 정책 인터페이스 + 구체 클래스 |
| 정책 추가 방법 | enum 값 + calculate() 수정 | 클래스 추가 + 리졸버 분기 한 줄 |
| 취소 요청 저장 | 없음 (이벤트만 발행) | CanceledOrder 테이블에 저장 |
| 판매자 귀책 | 구분 없음 | PENDING → 승인 후 처리 |
| 수량 감소 타이밍 | 가장 앞 | save() 성공 후 |
| 멱등성 | 없음 | 2단계 (애플리케이션 + DB 제약) |
정책이 독립적으로 존재한다. Void, Reversal, Refund 각각이 자신의 계산 로직을 캡슐화한다. 다른 정책 코드를 읽지 않아도 하나의 정책을 이해할 수 있다.
새 정책 추가가 기존 코드에 영향을 주지 않는다. 새 클래스를 만들고 리졸버에 분기를 추가하면 된다. calculate() 분기문을 건드리지 않아도 된다.
재시도가 가능해졌다. CanceledOrder에 policyId를 저장해두면, 나중에 저장된 ID로 정책 객체를 복원할 수 있다. 이벤트가 유실된 경우에도 당시 정책 기준으로 재처리한다.