“주문 + 배송 생성”을 하나의 비즈니스 트랜잭션으로 봤을 때 order-server 쪽 실패를 delivery-server에서 어떻게 되돌릴지를 설계하는 거니까, 사실상 작은 Saga 설계라고 생각하면서 고민해봤다.
아래 순서로 정리해보면:
현재 그림(대략):
order-server에서 주문 생성OrderAfterCreateV1 Kafka 발행hub-server에서 허브 경로 계산 후 HubRouteAfterCreateV1 발행delivery-server에서 HubDelivery / FirmDelivery 생성여기서 “order-server가 실패했다”를 조금 더 쪼개면:
OrderAfterCreate 이벤트가 발행되지 않도록 트랜잭션을 묶어야 함.대표 예:
이 경우에는 이미:
OrderAfterCreate가 발행되어,HubRouteAfterCreate👉 이 상황에서 보상 트랜잭션 = “이미 만들어진 배송을 취소/무효화” 하는 것.
그래서 내가 설계해야 할 건 주로 (B) 케이스다.
핵심 아이디어는 간단해:
“주문이 실패/취소로 바뀌면, 그걸 이벤트로 흘려보내고,
delivery-server는 그걸 받아서 관련 배송들을 전부 ‘취소’로 바꾼다.”
order-server에서:
public enum OrderStatus {
CREATED, // 생성 완료 (아직 결제 전 혹은 준비 상태)
CONFIRMED, // 결제/검증까지 OK
FAILED, // 비즈니스적으로 실패 (결제 실패 등)
CANCELED // 사용자 취소
}
그리고 주문 상태가 FAILED, CANCELED로 전이될 때마다 이벤트를 발생:
OrderFailedV1OrderCanceledV1이벤트 payload 예:
public record OrderStatusChangedV1(
UUID orderId,
OrderStatus status,
String reason
) {}
혹은 이벤트 타입을 분리해도 되고, status 필드로 한 번에 처리해도 됨.
delivery-server는 이미 orderId 기준으로 HubDelivery/FirmDelivery를 가지고 있으니,
그럼 간단하게:
@Service
@RequiredArgsConstructor
public class OrderCompensationService {
private final HubDeliveryRepository hubDeliveryRepository;
private final FirmDeliveryRepository firmDeliveryRepository;
@Transactional
public void compensateDeliveriesForOrder(UUID orderId, String reason) {
// 1) 허브 배송 취소
List<HubDelivery> hubDeliveries = hubDeliveryRepository.findByOrderId(orderId);
hubDeliveries.forEach(hd -> hd.cancel(reason)); // 엔티티 메서드에서 상태 변경 + 로그 남기기
// 2) 업체 배송 취소
List<FirmDelivery> firmDeliveries = firmDeliveryRepository.findByOrderId(orderId);
firmDeliveries.forEach(fd -> fd.cancel(reason));
}
}
그리고 Kafka Consumer 예시:
@KafkaListener(topics = "order-status-changed")
public void handleOrderStatusChanged(OrderStatusChangedV1 event) {
if (event.status() == OrderStatus.FAILED || event.status() == OrderStatus.CANCELED) {
compensationService.compensateDeliveriesForOrder(event.orderId(), event.reason());
}
}
DeliveryStatus에 최소 이 정도:
public enum DeliveryStatus {
CREATED, // 생성됨
READY, // 출고 준비
IN_TRANSIT, // 이동 중
COMPLETED, // 배송 완료
CANCELLED // 주문 취소/실패로 인한 취소
}
그리고 엔티티 메서드:
public void cancel(String reason) {
if (this.status == DeliveryStatus.IN_TRANSIT || this.status == DeliveryStatus.COMPLETED) {
// 이미 너무 진행된 건 어떻게 할지 정책 결정 필요
return;
}
this.status = DeliveryStatus.CANCELLED;
this.cancellationReason = reason; // 컬럼 추가
}
정책 포인트
이미 IN_TRANSIT 이상이면 어떻게 할지?
CANCEL_REQUESTED 같은 중간 상태를 둘지,이건 도메인 정책이니까, 나중에 고도화 단계에서 결정해도 됨.
“주문 row는 생성됐는데, 이벤트 발행이 실패하면?”
→ 또 다른 일관성 깨짐.
그래서 order-server에서는:
이렇게 하면:
delivery-server 쪽도 중요 이벤트라면 동일하게 outbox 써도 좋고.
Kafka는 기본적으로 “at-least-once” 쪽이라
OrderStatusChangedV1가 중복으로 올 수 있음.
그러면 cancel() 여러 번 호출해도 문제가 없어야 함:
status가 이미 CANCELLED이면 그냥 return간단하게는:
public void cancel(String reason) {
if (this.status == DeliveryStatus.CANCELLED) return;
...
}
이 정도만 해도 MVP/고도화 1단계에서는 충분함.
지금 구조는 사실상 Choreography Saga에 가깝다:
근데 나중에:
예를 들면:
sequenceDiagram
participant Orchestrator
participant Order as order-server
participant Delivery as delivery-server
Orchestrator->>Order: createOrder()
Order-->>Orchestrator: OrderCreated(orderId)
Orchestrator->>Delivery: createDeliveries(orderId)
Delivery-->>Orchestrator: DeliveriesCreated
Orchestrator->>Orchestrator: Saga 완료 처리
%% 실패 케이스
Orchestrator->>Delivery: createDeliveries(orderId)
Delivery-->>Orchestrator: CreateFailed
Orchestrator->>Order: compensateOrder(orderId) // 주문 상태 FAILED/CANCELED로
이 패턴에서는:
각 스텝마다 “정방향 작업” + “보상 작업”을 쌍으로 정의
createDeliveries ↔ cancelDeliveriescreateOrder ↔ markOrderFailedOrchestrator가 상태 머신처럼
“어디까지 성공했는지” 기억하고,
중간에 실패하면 역순으로 보상 호출.
다만, 지금은 Kafka 이벤트 기반 설계로 많이 가고 있어서
“주문 상태 이벤트 기반 보상(위 2번 방안)”을 먼저 적용하고,
나중에 진짜 복잡해졌을 때 orchestrator-service로 승격하는 구조가 현실적인 단계 같다.
진짜로 보상 필요한 구간은 “주문은 살아 있는데, 배송이 이미 생성된 후 주문이 실패/취소되는 구간”이다.
그때는 order-server를 진실의 근원(Single Source of Truth)로 두고,
주문이 FAILED/CANCELED로 바뀔 때마다 이벤트 → delivery-server에서 관련 배송 전체 CANCELLED로 만드는 보상 로직을 두는 게 가장 깔끔.
이를 위해:
OrderStatusChanged 이벤트 정의나중에 플로우가 더 복잡해지면 orchestrator-service로 승격해서
진짜 Saga 상태 머신을 도입하면 됨.