12/11

졸용·2025년 12월 11일

TIL

목록 보기
133/144

🔹 delivery-server 보상 트랜잭션

“주문 + 배송 생성”을 하나의 비즈니스 트랜잭션으로 봤을 때 order-server 쪽 실패를 delivery-server에서 어떻게 되돌릴지를 설계하는 거니까, 사실상 작은 Saga 설계라고 생각하면서 고민해봤다.

아래 순서로 정리해보면:

  1. 어디서 실패가 나냐에 따라 케이스 나누기
  2. 간단하면서도 실용적인 보상 전략 (이벤트 기반 보상)
  3. 필수로 넣어야 할 것들: 상태 모델, 이벤트, outbox, idempotency
  4. 조금 더 진하게 가고 싶으면: Orchestrator 기반 설계 방향


🔹 실패 위치별로 케이스 먼저 정리

현재 그림(대략):

  1. order-server에서 주문 생성
  2. OrderAfterCreateV1 Kafka 발행
  3. hub-server에서 허브 경로 계산 후 HubRouteAfterCreateV1 발행
  4. delivery-server에서 HubDelivery / FirmDelivery 생성

여기서 “order-server가 실패했다”를 조금 더 쪼개면:

🔸 (A) 주문 생성 트랜잭션 안에서 실패 (DB insert도 안 됨)

  • 예: 유효성 검증 실패, DB 제약조건 위반 등으로 주문 자체가 롤백.
  • 이 경우엔 애초에 OrderAfterCreate 이벤트가 발행되지 않도록 트랜잭션을 묶어야 함.
    ⇒ 보상 트랜잭션 필요 X (이벤트 안 나갔으니 delivery-server도 아무것도 안 함)

🔸 (B) 주문은 생성됐는데, 이후 단계에서 비즈니스적으로 실패

대표 예:

  • 결제 실패
  • 사장님이 주문 직후 “바로 취소”
  • 재고 체크 후 불가 판정

이 경우에는 이미:

  • OrderAfterCreate가 발행되어,
  • 허브 경로 계산 + HubRouteAfterCreate
  • delivery-server에서 HubDelivery/FirmDelivery까지 만들어졌을 가능성이 높음.

👉 이 상황에서 보상 트랜잭션 = “이미 만들어진 배송을 취소/무효화” 하는 것.

그래서 내가 설계해야 할 건 주로 (B) 케이스다.



🔹 가장 현실적인 전략: “주문 상태 이벤트 기반 보상”

핵심 아이디어는 간단해:

“주문이 실패/취소로 바뀌면, 그걸 이벤트로 흘려보내고,
delivery-server는 그걸 받아서 관련 배송들을 전부 ‘취소’로 바꾼다.”


🔸 주문 상태 모델 정리

order-server에서:

public enum OrderStatus {
    CREATED,     // 생성 완료 (아직 결제 전 혹은 준비 상태)
    CONFIRMED,   // 결제/검증까지 OK
    FAILED,      // 비즈니스적으로 실패 (결제 실패 등)
    CANCELED     // 사용자 취소
}

그리고 주문 상태가 FAILED, CANCELED로 전이될 때마다 이벤트를 발생:

  • OrderFailedV1
  • OrderCanceledV1

이벤트 payload 예:

public record OrderStatusChangedV1(
    UUID orderId,
    OrderStatus status,
    String reason
) {}

혹은 이벤트 타입을 분리해도 되고, status 필드로 한 번에 처리해도 됨.


🔸 delivery-server에서의 보상 트랜잭션

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());
    }
}

🔸 배송 상태 Enum에 “취소” 추가

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 같은 중간 상태를 둘지,
    • 아니면 그냥 무시할지(“이미 나간 건 도로 못 가져온다”).
  • 이건 도메인 정책이니까, 나중에 고도화 단계에서 결정해도 됨.



🔹 이 때 꼭 챙겨야 할 것들

🔸 Outbox 패턴 (order-server 쪽)

“주문 row는 생성됐는데, 이벤트 발행이 실패하면?”
→ 또 다른 일관성 깨짐.

그래서 order-server에서는:

  1. 주문 테이블 insert/update
  2. 같은 트랜잭션에서 outbox 테이블에 메시지 row insert
  3. 별도 Outbox Publisher가 outbox 테이블에서 읽어서 Kafka로 발행 후, 성공 시 outbox row 삭제

이렇게 하면:

  • DB에 있는 상태(주문 상태)와
  • 밖으로 나가는 이벤트
    최소한 같은 DB 트랜잭션으로 보장할 수 있음.

delivery-server 쪽도 중요 이벤트라면 동일하게 outbox 써도 좋고.


🔸 보상 로직의 “멱등성(idempotency)”

Kafka는 기본적으로 “at-least-once” 쪽이라
OrderStatusChangedV1가 중복으로 올 수 있음.

그러면 cancel() 여러 번 호출해도 문제가 없어야 함:

  • status가 이미 CANCELLED이면 그냥 return
  • 이벤트 처리할 때 이벤트 ID 기준으로 처리 여부 기록하는 것도 한 방법

간단하게는:

public void cancel(String reason) {
    if (this.status == DeliveryStatus.CANCELLED) return;
    ...
}

이 정도만 해도 MVP/고도화 1단계에서는 충분함.



🔹 “조금 더 제대로” 하고 싶을 때: Orchestrator 기반

지금 구조는 사실상 Choreography Saga에 가깝다:

  • order-server → 이벤트 → hub-server → 이벤트 → delivery-server

근데 나중에:

  • “주문 생성 + 허브 경로 계산 + 배송 생성 + AI + Slack/Discord”
    이렇게 플로우가 더 복잡해지면,
  • orchestrator-service를 하나 두고 중앙에서 단계별로 명령/보상하는 방식도 고려할 수 있다.

예를 들면:

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로

이 패턴에서는:

  • 각 스텝마다 “정방향 작업” + “보상 작업”을 쌍으로 정의

    • 예: createDeliveriescancelDeliveries
    • 예: createOrdermarkOrderFailed
  • Orchestrator가 상태 머신처럼
    “어디까지 성공했는지” 기억하고,
    중간에 실패하면 역순으로 보상 호출.

다만, 지금은 Kafka 이벤트 기반 설계로 많이 가고 있어서
“주문 상태 이벤트 기반 보상(위 2번 방안)”을 먼저 적용하고,
나중에 진짜 복잡해졌을 때 orchestrator-service로 승격하는 구조가 현실적인 단계 같다.



🔹 정리

  1. 진짜로 보상 필요한 구간은 “주문은 살아 있는데, 배송이 이미 생성된 후 주문이 실패/취소되는 구간”이다.

  2. 그때는 order-server를 진실의 근원(Single Source of Truth)로 두고,
    주문이 FAILED/CANCELED로 바뀔 때마다 이벤트 → delivery-server에서 관련 배송 전체 CANCELLED로 만드는 보상 로직을 두는 게 가장 깔끔.

  3. 이를 위해:

    • 주문/배송 상태 Enum 정리
    • OrderStatusChanged 이벤트 정의
    • delivery-server 보상 서비스 구현
    • outbox + idempotency 챙기기
  4. 나중에 플로우가 더 복잡해지면 orchestrator-service로 승격해서
    진짜 Saga 상태 머신을 도입하면 됨.

profile
꾸준한 공부만이 답이다

0개의 댓글