MSA 데이터 정합성 패턴
Updated:
왜 분산 트랜잭션이 문제인가?
MSA를 운영하다 보면 주문은 생성됐는데, 재고는 그대로같은 상황이 자주 발생한다. 이를 해결하려면 먼저 모놀리식과 MSA의 차이를 알아야 한다.
모놀리식 vs MSA
모놀리식 아키텍처에서는 모든 코드가 하나의 애플리케이션 안에 있고, 하나의 데이터베이스를 공유한다. 그래서 @Transactional하나면 끝이다.

@Service
@RequiredArgsConstructor
public class OrderService {
private final OrderRepository orderRepository;
private final ProductRepository productRepository;
@Transactional // 이 어노테이션 하나로 모든 게 해결됨!
public void createOrder(OrderCreateRequest request) {
// 1. 주문 저장
Order order = Order.create(request.getProductId(), request.getQty());
orderRepository.save(order);
// 2. 재고 차감
Product product = productRepository.findById(req.getProductId())
.orElseThrow(() -> new RuntimeException("상품 없음"));
product.decreaseStock(req.getQty());
throw new RuntimeException("예외 발생!"); // 만약 여기서 예외가 터지면?
// → 1번 주문 저장도 자동으로 롤백됩니다!
// 왜? 같은 DB, 같은 트랜잭션이니까요.
}
}
-- 1. 트랜잭션 시작
START TRANSACTION;
-- 2. 상품 조회 (productRepository.findById)
-- 이후 수정 사항(stock 차감)은 메모리에만 저장됨
SELECT * FROM product WHERE id = 1;
-- 3. 예외 발생 (RuntimeException)
-- INSERT와 UPDATE 쿼리는 아직 DB에 보내지도 않았음 (쓰기 지연)
-- 4. 롤백
ROLLBACK;
성공의 경우 쿼리는 다음과 같다.
-- 1. 트랜잭션 시작
START TRANSACTION;
-- 2. 상품 조회 (조회는 즉시 실행됨)
SELECT * FROM product WHERE id = 1;
-- [이 시점까지 INSERT/UPDATE는 메모리에 대기 중]
-- 3. 메서드 종료 직전 (Flush & Commit 시점)
-- 쌓여있던 쿼리가 한꺼번에 나감
INSERT INTO orders (product_id, qty) VALUES (1, 10);
UPDATE product SET stock = 90 WHERE id = 1;
-- 4. 커밋
COMMIT;
orderRepository와 productRepository가 같은 DB에 접근하기 때문에, Spring의 @Transactional이 둘 다 한꺼번에 롤백해줄 수 있다.
MSA에서는 각 서비스가 자기만의 DB를 가지고 있다.

@Service
@RequiredArgsConstructor
public class OrderService {
private final OrderRepository orderRepository; // 주문 DB (DB A)
private final ProductClient productClient; // 상품 서비스 호출용 FeignClient
@Transactional // ⚠️ 이건 주문 DB(DB A)에만 적용됩니다!
public void createOrder(OrderCreateRequest request) {
// 1. 주문 저장
Order order = Order.create(request.getProductId(),
request.getQty(), OrderStatus.CREATED);
orderRepository.save(order);
// 2. 상품 서비스에 HTTP 요청으로 재고 차감 요청
productClient.decreaseStock(request.getProductId(), request.getQty());
// ⚠️ 여기서의 진짜 문제:
//
// [문제 1] 위 호출이 성공한 뒤, 이 아래에서 다른 로직이 실패하면?
// → 주문은 롤백되지만, 상품 서비스의 재고 차감은 이미 커밋됨!
// → "재고는 줄었는데 주문은 없는" 상태 발생
//
// [문제 2] 타임아웃이 발생하면?
// → 상품 서비스가 재고를 줄였는지 안 줄였는지 알 수 없음
// → 주문은 롤백되지만, 재고는 줄어있을 수도 있음
}
}
@Transactional은 같은 DB연결 안에서만 롤백이 가능하다. FeignClient 호출은 DB 쿼리가 아니라 HTTP 요청이다. 주문 DB 트랜잭션이 롤백해도 상품 DB가 롤백할 수 있는 방법이 없다. 이것이 분산 트랜잭션 문제이다.
@Transactional의 한계
FeignClient로 다른 서비스를 호출할 때 실패할 수 있는 3가지 상황이 있다.
상품 서비스가 죽어있음
주문 서비스 ──(HTTP 요청)──▶ 상품 서비스 (서버 다운 💀)
↓
FeignClient가 예외를 던짐
→ @Transactional이 주문도 롤백해줌
→ 이 경우는 사실 큰 문제 없음! (양쪽 다 반영 안 됨)
이 경우 그나마 안전하다. 예외가 바로 돌아오고, @Transactional 덕분에 주문도 롤백이 되기 때문이다. 하지만 상품 서비스 호출이 성공한 뒤에 다른 로직에서 예외가 발생한다면 문제가 시작된다.
네트워크 타임아웃
주문 서비스 ──(HTTP 요청)──▶ 상품 서비스
↓ (재고 차감 처리 중...)
↓ ↓ (처리 완료! 응답 전송!)
3초 대기... ↓
5초 대기... ↓ (응답이 네트워크에서 유실됨 😱)
타임아웃! ❌
요청을 보냈는데 응답이 안온 경우, 세 가지 가능성이 모두 존재한다.
- 상대가 처리 완료했는데 응답만 유실된 것인지
- 상대가 아직 처리중인지
- 상대가 요청을 아예 못 받은 것인지
타임아웃 예외가 발생하면 @Transactional이 주문을 롤백한다. 하지만 상품 서비스에서는 이미 재고를 차감하고 커밋했을 수 있다. 결과적으로 주문은 없는데 재고는 줄어든 상태가 된다.
만약 재시도를 하면 상품 서비스가 이미 처리한 경우에는 재고가 두 번 차감될 수 있고, 재시도를 하지 않으면 상품 서비르가 처리를 못한 경우 재고가 줄어들지 않는다. 이는 멱등성 키와 재시도로 해결할 수 있다.
상품 서비스 호출 성공 후 다른 로직에서 실패
주문 서비스 ──(HTTP 요청)──▶ 상품 서비스
↓
↓ 재고 차감 성공! (상품 DB 커밋 완료 ✅)
↓
후속 로직 실행 중... (예: 배송 서비스 호출, 포인트 차감 등)
예외 발생! ❌
↓
@Transactional이 주문을 롤백
→ 주문은 없는데, 상품 서비스의 재고는 이미 줄어든 상태!
→ 되돌릴 방법이 없음 ❌
@Transactional은 주문 DB만 롤백할 수 있다. 상품 서비스에서 이미 커밋된 재고 차감은 주문 서비스으 트랜잭션이 되돌릴 수 었다. 이것이 분산 트랜잭션의 근본적인 문제이다.
핵심적인 문제는 FeignClient가 실패할 때보다 FeignClient가 성공한 뒤에 다른 곳에서 실패할 때 더 심각하다. 상대 서비스에서 이미 커밋된 작업은 @Transactional로 되돌릴 수 없기 때문이다. 이를 해결하는 게 Saga 패턴이다.
흔한 실수(Anti-Pattern)
- 다른 서비스의 DB를 직접 조회(스카마 조인)
- 서비스 경계를 무시하고 다른 서비스의 테이블을 직접 JOIN하면, 상품 서비스가 테이블 구조를 변경할 때 주문 서비스도 같이 터진다.
```java
// ❌ 이러면 안 됩니다!
@Query(“SELECT o FROM Order o JOIN Product p ON o.productId = p.id”)
List
findOrdersWithStock();
@Entity @Table(name = “products”, schema = “product_db”) // ← 여기서 다른 스키마임을 명시 public class Product { @Id private Long id; private int stock; }
- 올바른 방법은 API를 통해 데이터 요청을 하는 것이다. ```java // ✅ 올바른 방법: API를 통해 데이터 요청 ProductResponse product = productClient.getProduct(productId); - 서비스 경계를 무시하고 다른 서비스의 테이블을 직접 JOIN하면, 상품 서비스가 테이블 구조를 변경할 때 주문 서비스도 같이 터진다.
```java
// ❌ 이러면 안 됩니다!
@Query(“SELECT o FROM Order o JOIN Product p ON o.productId = p.id”)
List
- @Transactional로 모든 걸 해결하려는 시도
- 이는 Saga패턴으로 분산 트랜잭션을 관리해서 해결할 수 있다.
@Transactional // ⚠️ 이건 로컬 DB에만 적용됩니다! public void createOrder(OrderRequest req) { orderRepository.save(order); // 로컬 DB → 트랜잭션 보호 ✅ productClient.decreaseStock(req); // HTTP 호출 → 트랜잭션 밖! ❌ deliveryClient.createDelivery(req); // HTTP 호출 → 트랜잭션 밖! ❌ }
- 이는 Saga패턴으로 분산 트랜잭션을 관리해서 해결할 수 있다.
- 보상 트랜잭션 없이 FeignClient만 호출
- 이는 모든 서비스 간 호출에 실패 시 보상 로직을 설계해야 한다.
public void createOrder(OrderRequest req) { orderRepository.save(order); productClient.decreaseStock(req); // 실패하면? deliveryClient.createDelivery(req); // 여기서 실패하면? // → 아무런 복구 로직이 없음! // → 재고는 줄었는데 배송은 안 만들어짐 // → 이런 불일치가 발견되지 않고 계속 쌓입니다... }
- 이는 모든 서비스 간 호출에 실패 시 보상 로직을 설계해야 한다.
- 멱등성 없는 API
- 모든 상태 변경 API에 멱등성 키를 적용해야 한다.
@PostMapping("/api/products/{id}/decrease") public void decreaseStock(@PathVariable Long id, @RequestBody int qty) { Product product = productRepository.findById(id).orElseThrow(); product.setStock(product.getStock() - qty); // 호출될 때마다 계속 차감! productRepository.save(product); } // 같은 요청이 2번 들어오면 재고가 2번 차감됩니다.
- 모든 상태 변경 API에 멱등성 키를 적용해야 한다.
CAP & 2PC
CAP
앞서 본 것처럼 서비스 간 호출은 실패할 수 있다. 이때 어떤 선택을 하느냐는 비즈니스마다 다르다.
- 일관성(Consistency) 우선
- “배송 서비스가 응답 없으면 주문 자체를 실패 처리하자”
- 장점: 데이터가 항상 정확함 (주문 있으면 배송도 반드시 있음)
- 단점: 배송 서비스가 1분만 다운돼도 그 사이 모든 주문이 실패해 사용자 이탈로 이어질 수 있음
- 가용성(Availability) 우선
- “일단 주문은 확정하고, 배송은 나중에 재시도하자”
- 장점: 사용자는 주문이 성공하고 서비스도 계속 운영된다.
- 단점: 잠시 동안 “주문은 있는데 배송은 없는”상태 발생
위 선택 사항의 판단 기준은 결제/재고처럼 돈이 걸린 구간은 일관성을 우선하고 알림/배송처럼 후속 처리가 가능한 구간은 가용성 우선으로 하면 된다.
분할 내성(Partition Tolerance)은 네트워크 단절(Partition)이 발생하도 시스템은 작동한다는 원칙이고, MSA는 여러 서비스가 네트워크로 연결되어 있가 때문에 P는 필수로 선택되고, C와 A 사이에서 선택해야 한다.
Eventually Consistent
MSA에서는 가용성을 선택하야 하는 구간이 반드시 생가고, 이를 안전하게 처리하는 것을 Eventually Consistent(결과적 일관성)이라고 한다. Eventually Consistent에서는 새로운 업데이트가 없다는 전제 하에, 충분한 시간이 지나면 모든 서비스의 데이터가 반드시 일치한다.
쿠팡에서 주문하고 “배송 추적” 버튼을 눌렀는데 “배송 정보가 아직 없습니다”라고 뜬 경험이 있다. 주문은 분명 완려됐는데 배송 정보가 몇 분 뒤에 나타난다. 이게 결과적 일관성이다. 정확히 말하면, 이것은 UI 표시 지연이 아니라 서비스 간 데이터 동기화 지연이다.
물류 프로젝트에서도 똑같은 상황이 발생한다.

핵심은 “불일치럴 허용한다”가 아니라 “불일치를 감지하고 복구하는 메커니즘을 반드시 함께 설치한다”는 것이다. Saga와 Outbox는 불일치를 감지하고 복구하는 정합성 보장 패턴이고, 멱등성은 그 복구 과정에서 재시도를 안전하게 만들어주는 전재 조건이다.
2PC의 한계
2PC는 분산 환경에서 “전부 성공 아니면 전부 취소”를 보장하려는 전통적인 방법이다. 예를 들어 배송 서비스를 시스템적으로 보자.
- Phase 1 - Prepare(준비 확인)
코디네이터 → 주문 서비스: "커밋할 준비 됐나?" → "Yes!" 코디네이터 → 상품 서비스: "커밋할 준비 됐나?" → "Yes!" 코디네이터 → 배송 서비스: "커밋할 준비 됐나?" → "Yes!" - Phase 2 - Commit / Rollback (확정 또는 취소)
전원 Yes → 코디네이터: "전부 커밋!" 하나라도 No → 코디네이터: "전부 롤백!"
2PC가 완전히 사리진 것은 아니다. 단일 시스템 내에서 두 개의 리소스를 묶어야 할 때는 여전히 쓰인다.
- DB + 메시지 큐를 동시에 커밋해야 할 때: 예를 들어 Oracle DB에 주문을 저장하면서 IBM MQ에 메시지를 발행해야 하는 레거시 금융 시스템. Java EE의 JTA(Java Transaction API)가 이 역할을 한다.
- 두 개의 DB를 하나의 트랜잭션으로 묶어야 할 때: 하나의 서버가 MySQL과 PostgreSQL 양쪽에 동시에 쓰기를 해야 하는 경우.
두 상황의 공통점은 전부 하나의 서버 안에서 두 리소스를 묶는 경우이다.
하지만 MSA에서는 각 서비스가 독립된 서버, 독립된 DB, 네트워크를 통해 통신하기 때문에 2PC의 전제 조건인 하나의 코디네티어가 모든 참여자를 제어하는 것이 맞지 않다. 그래서 MSA에서는 2PC 대신, “일단 하나씩 진행하고 실패하면 되돌리는 Saga 패턴을 사용한다.
Saga 패턴
Saga 패턴은 긴 트랜잭션을 여러 개의 로컬 트랜잭션으로 나누고, 실패 시 보상 트랜잭션을 역순으로 실행하여 데이터를 되돌리는 패턴이다.
쉽게 비유하면, 2PC는 여행 패키지 예약(비행기, 호텔, 렌터카를 한꺼번에 예약 확정하거나, 하나라도 안 되면 전부 취소)으로 Saga는 여행을 하나씩 예약(비행기 예약, 호털 예약, 렌터카 예약, 만약 렌터카 예약이 실패하면 역순으로 호텔 취소, 비행기 취소로 되돌림)으로 비유할 수 있다.

보상 트랜잭션
배송 서비스에서 “주문 저장 -> 재고 차감 -> 배송에서 실패 -> 재고 복원 -> 주문 취소”이 되돌리는 작업 하나하나가 보상 트랜잭션이다. 중요한 것은 보상은 단순 DELETE가 아니라는 것이다.
- 재고 복원:
DELETE가 아니라stock+=N - 주문 취소:
DELETE가 아니라status = CANCELLED로 변경
각 보상이 어떤 비즈니스 로직을 수행하는지 설계 단계에서 미리 정의해햐 한다.
보상이 역순으로 실행되는 이유는 “주문 -> 재고 -> 배송” 단계에서 배송이 실패하고 주문을 취소하면, 아직 차감된 재고가 복원되지 않은 상태에서 “주문은 취소됐는데 재고는 줄어든 채” 잠깐 존재하게 된다. 역순으로 하면 가장 최근에 변경된 것부터 되돌려서 불일치 구간을 최소화할 수 있다.
Choreography vs Orchestration
Saga를 이해하려면 주문이 어떤 상태를 거쳐가는지 먼저 파악해야 한다.
- FeignClient 기반(동기)
CREATED ──(T2 재고 차감 성공, T3 배송 성공)──▶ CONFIRMED ✅ │ │──(T2 재고 차감 실패)──▶ CANCELLED ❌ │ └──(T2 성공, T3 배송 실패)──▶ CANCELLED ❌ (C2 재고 복원 + C1 주문 취소) - Kafka 기반(비동기) - 상태가 더 세분화됨
CREATED │ ▼ STOCK_REQUESTED ──(재고 차감 성공)──▶ DELIVERY_REQUESTED │ │ │ ├──(배송 성공)──▶ CONFIRMED ✅ │ │ │ └──(배송 실패)──▶ ROLLBACK_PENDING │ │ └──(재고 차감 실패)──▶ CANCELLED ❌ (C2 재고 복원)──▶ CANCELLED ❌
FeignClient 방식은 한 메서드 안에서 try/catch로 흐름이 끝나지만, kafka 방식은 메시지를 보내고 나중에 응답을 받기 때문에 “지금 어디까지 진행했는지”를 DB에 기록해둬야 한다. 그래서 STOCK_REQUESTED, DELIVERY_REQUESTED, ROLLBACK_PENDING 같은 중간 상태가 필요해 FeignClient 방식보다 상태가 더 많다.
// domain/model/OrderStatus.java
public enum OrderStatus {
// === 정상 흐름 ===
CREATED, // 주문 생성됨 (초기 상태)
STOCK_REQUESTED, // 재고 차감 요청 보냄 (Kafka용)
DELIVERY_REQUESTED, // 배송 생성 요청 보냄 (Kafka용)
CONFIRMED, // 모든 단계 성공! (최종 성공)
// === 보상 흐름 ===
ROLLBACK_PENDING, // 보상 트랜잭션 진행 중 (Kafka용)
CANCELLED // 주문 취소됨 (최종 실패)
}
물류 프로젝트의 “주문 -> 재고 -> 배송” 시퀀스를 보자.
- 정상 흐름

- T3 실패 시 보상 트랜잭션 역순 실행


FeignClient 기반 Orchestration Saga 코드
Kafka 없이, FeignClient와 try/catch로 구현하는 가장 기본적인 Saga 패턴이다.
// application/saga/OrderSagaOrchestrator.java (주문 서비스)
@Service
@RequiredArgsConstructor
public class OrderSagaOrchestrator {
private final OrderRepository orderRepository;
private final ProductClient productClient;
private final DeliveryClient deliveryClient;
/**
* ⚠️ @Transactional 주의사항:
* 이 안에서 FeignClient(외부 HTTP 호출)를 하면,
* 호출이 오래 걸릴 때 DB 커넥션이 점유된 채로 대기합니다.
* 트래픽이 많으면 커넥션 풀 고갈로 이어질 수 있습니다.
*
* 현업에서는 주문 저장을 먼저 커밋한 뒤 별도 메서드에서
* FeignClient를 호출하는 방식을 쓰기도 합니다.
* 프로젝트 수준에서는 이 구조로 충분하지만, 알아두세요!
*/
@Transactional
public OrderResponse createOrder(OrderRequest req) {
// ========== T1: 주문 생성 ==========
Order order = orderRepository.save(
new Order(req.getProductId(), req.getQty(), OrderStatus.CREATED)
);
try {
// ========== T2: 재고 차감 ==========
String idempotencyKey = UUID.randomUUID().toString();
productClient.decreaseStock(
req.getProductId(),
new StockDecreaseRequest(req.getQty(), idempotencyKey),
idempotencyKey
);
try {
// ========== T3: 배송 생성 ==========
deliveryClient.createDelivery(order.getId(), req.getAddress());
} catch (Exception e) {
// T3(배송) 실패! → 보상 시작 (역순으로!)
// C2: 재고 복원 (T2의 보상) — 가장 최근 변경부터 되돌림
productClient.restoreStock(
req.getProductId(),
new StockRestoreRequest(req.getQty())
);
// C1: 주문 취소 (T1의 보상)
order.updateStatus(OrderStatus.CANCELLED);
throw new SagaRollbackException("배송 생성 실패로 전체 롤백", e);
}
} catch (SagaRollbackException e) {
throw e;
} catch (Exception e) {
// T2(재고 차감) 자체가 실패한 경우
// → 재고는 안 줄었으니 재고 복원은 필요 없음
// → 주문만 취소하면 됨
order.updateStatus(OrderStatus.CANCELLED);
throw new SagaRollbackException("재고 차감 실패", e);
}
// 모든 단계 성공!
order.updateStatus(OrderStatus.CONFIRMED);
return new OrderResponse(order);
}
}
Orchestration의 장점은 정상 흐름과 보상 흐름이 한 클래스 안에 모여있어서, 전체 흐름을 한눈에 파악할 수 있고 디버깅도 쉽다. Choreography처럼 여러 서비스에 흩어진 보상 리스너를 추적할 필요가 없다.
권장 순서는 Kafka 없이 시작한 후 FeignClient로 Orchestration 구현, 필요 시 Choreography로 전환하면 된다.
T1(주문 생성)
→ 성공 → T2(재고 차감) 시도
→ 성공 → T3(배송 생성) 시도
→ 성공 → 끝! 🎉
→ 실패 → C2(재고 복원) → C1(주문 취소)
→ 실패 → C1(주문 취소)
Retry와 Saga 보상은 어떤 순서로 실행되는지 알아보자.
productClient.decreaseStock() 호출
│
▼
[Resilience4j Retry가 감싸고 있음]
│
├─ Retry 1차 시도 → 타임아웃! → 재시도
├─ Retry 2차 시도 → 타임아웃! → 재시도
└─ Retry 3차 시도 → 타임아웃! → 최종 실패!
│
▼
fallback 메서드 호출
(SagaRollbackException 던짐)
│
▼
Saga Orchestrator의 catch 블록
→ C2 재고 복원 → C1 주문 취소
즉, Retry가 모든 재시도를 소진한 후에 Saga 보상이 시작된다. Retry 안에서 보상이 실행되는 것이 아니다.
Kafka 기반 Orchestration Saga 코드
Kafka를 사용하면 비동기로 Saga를 구현할 수 있다. Orchestrator가 Kafka Command/Reply 토픽으로 각 서비스에 명령을 보내고, 응답을 받아 다음 단계를 진행한다. FeignClient와 Kafka의 차이는 다음과 같다.
[동기 방식 - FeignClient]
주문 서비스 ──(HTTP 요청)──▶ 상품 서비스 ──(HTTP 응답)──▶ 다음 단계
↑ 이 동안 스레드가 대기하고 있음 (자원 낭비)
[비동기 방식 - Kafka]
주문 서비스 ──(메시지 발행)──▶ stock-commands 토픽
↓ 스레드 반환! 다른 요청 처리 가능
상품 서비스 ──(메시지 소비)──▶ 처리 후 ──(메시지 발행)──▶ saga-replies 토픽
주문 서비스 ──(메시지 소비)──▶ 다음 단계 진행
Saga Orchestrator(Command 발행 + Reply 수신)을 보자.
// ============================================================
// 주문 서비스 Saga Orchestrator (Kafka 비동기 버전)
//
// FeignClient 버전과 핵심 흐름은 동일합니다:
// T1(주문) → T2(재고) → T3(배송), 실패 시 C2(재고 복원) → C1(주문 취소)
//
// 다른 점은 "호출하고 기다리는" 대신 "메시지를 보내고 응답이 오면 처리"하는 구조입니다.
// - startSaga(): 주문을 생성하고 stock-commands 토픽으로 재고 차감 요청을 보냅니다.
// - handleReply(): saga-replies 토픽에서 각 서비스의 응답을 받아 다음 단계를 결정합니다.
//
// 모든 상태 변경은 DB에 저장되므로, 서버가 중간에 재시작돼도 이어갈 수 있습니다.
// ============================================================
// ============================================================
// application/saga/OrderSagaOrchestrator.java (순수 응용 계층)
// ============================================================
@Service
@RequiredArgsConstructor
public class OrderSagaOrchestrator {
private final OrderRepository orderRepository;
private final KafkaTemplate<String, Object> kafkaTemplate; // 직접 사용
// 1단계: Saga 시작
@Transactional
public void startSaga(Order order) {
order.reserveStock();
orderRepository.save(order);
kafkaTemplate.send("stock-commands",
new StockCommand(order.getId(), order.getProductId(), order.getQty(), "DECREASE"));
}
// 2단계: 모든 서비스의 응답을 여기서 처리
@KafkaListener(topics = "saga-replies", groupId = "order-saga-group")
@Transactional
public void handleReply(SagaReply reply) {
Order order = orderRepository.findById(reply.getOrderId())
.orElseThrow(() -> new OrderNotFoundException(reply.getOrderId()));
switch (reply.getType()) {
case "STOCK_SUCCESS" -> {
order.requestDelivery();
kafkaTemplate.send("delivery-commands",
new DeliveryCommand(order.getId(), "CREATE"));
}
case "DELIVERY_FAIL" -> {
order.startRollback();
kafkaTemplate.send("stock-commands",
new StockCommand(order.getId(), order.getProductId(), order.getQty(), "RESTORE"));
}
case "STOCK_FAIL", "STOCK_RESTORED" -> order.cancel();
case "DELIVERY_SUCCESS" -> order.complete();
}
}
}
핵심은 Orchestrator가 상태(OrderStatus)를 DB에 저장하고, Command/Reply 패턴으로 비동기 통신한다는 점이다. 서버가 중간에 재시작돼도 DB에서 상태를 읽어서 이어갈 수 있다.
헥사고날 refactoring을 살펴보자.
KafkaTemplate직접 사용 → 아웃바운드 포트(SagaMessagePort) + 어댑터 분리@KafkaListener직접 부착 → 인바운드 어댑터 분리
이렇게 바꾸는 이유는 테스트할 때 Kafka없이 Mock으로 대처할 수 있고, 나중에 Kafka를 RabbitMQ로 바꿔도 Orchestrator 코드를 수정할 필요가 없기 때문이다.
// ============================================================
// application/port/in/SagaReplyHandler.java (인바운드 포트)
// ============================================================
public interface SagaReplyHandler {
void handle(SagaReply reply);
}
// ============================================================
// application/port/out/SagaMessagePort.java (아웃바운드 포트)
// ============================================================
public interface SagaMessagePort {
void sendStockCommand(Order order, String type);
void sendDeliveryCommand(Order order);
}
// ============================================================
// application/saga/OrderSagaOrchestrator.java (순수 응용 계층)
// ============================================================
@Service
@RequiredArgsConstructor
public class OrderSagaOrchestrator implements SagaReplyHandler {
private final OrderRepository orderRepository;
private final SagaMessagePort messagePort;
// 1단계: Saga 시작
@Transactional
public void startSaga(Order order) {
order.reserveStock();
orderRepository.save(order);
messagePort.sendStockCommand(order, "DECREASE");
}
// 2단계: 모든 서비스의 응답을 통합 처리 (Kafka 의존성 제거됨)
@Override
@Transactional
public void handle(SagaReply reply) {
Order order = orderRepository.findById(reply.getOrderId())
.orElseThrow(() -> new OrderNotFoundException(reply.getOrderId()));
switch (reply.getType()) {
case "STOCK_SUCCESS" -> {
order.requestDelivery();
messagePort.sendDeliveryCommand(order);
}
// 배송 실패 → 보상 트랜잭션 시작 (재고 복원 요청)
case "DELIVERY_FAIL" -> {
order.startRollback();
messagePort.sendStockCommand(order, "RESTORE");
}
case "STOCK_FAIL", "STOCK_RESTORED" -> order.cancel();
case "DELIVERY_SUCCESS" -> order.complete();
}
}
}
// ============================================================
// infrastructure/messaging/KafkaSagaInboundAdapter.java (인바운드 어댑터)
// ============================================================
@Component
@RequiredArgsConstructor
public class KafkaSagaInboundAdapter {
private final SagaReplyHandler sagaReplyHandler;
@KafkaListener(topics = "saga-replies", groupId = "order-saga-group")
public void onSagaReply(SagaReply reply) {
sagaReplyHandler.handle(reply);
}
}
// ============================================================
// infrastructure/messaging/KafkaSagaOutboundAdapter.java (아웃바운드 어댑터)
// ============================================================
@Component
@RequiredArgsConstructor
public class KafkaSagaOutboundAdapter implements SagaMessagePort {
private final KafkaTemplate<String, Object> kafkaTemplate;
@Override
public void sendStockCommand(Order order, String type) {
kafkaTemplate.send("stock-commands",
new StockCommand(order.getId(),
order.getProductId(),
order.getQty(), type));
}
@Override
public void sendDeliveryCommand(Order order) {
kafkaTemplate.send("delivery-commands",
new DeliveryCommand(order.getId(), "CREATE"));
}
}
상품 서비스(Command 수신 + Reply 발행)을 살펴보자.
// ============================================================
// 상품 서비스: stock-commands 토픽을 듣고, 처리 후 saga-replies로 응답
// ============================================================
@Service
@RequiredArgsConstructor
public class ProductService {
private final ProductRepository productRepository;
private final KafkaTemplate<String, Object> kafkaTemplate; // 직접 사용
@KafkaListener(topics = "stock-commands", groupId = "stock-group")
@Transactional
public void handleStockCommand(StockCommand command) {
try {
Product product = productRepository.findById(command.getProductId())
.orElseThrow(() -> new ProductNotFoundException(command.getProductId()));
String resultStatus;
if ("DECREASE".equals(command.getType())) {
product.decreaseStock(command.getQty());
resultStatus = "STOCK_SUCCESS";
} else {
product.increaseStock(command.getQty());
resultStatus = "STOCK_RESTORED";
}
kafkaTemplate.send("saga-replies",
new SagaReply(command.getOrderId(), resultStatus));
} catch (NotEnoughStockException e) {
kafkaTemplate.send("saga-replies",
new SagaReply(command.getOrderId(), "STOCK_FAIL"));
} catch (Exception e) {
kafkaTemplate.send("saga-replies",
new SagaReply(command.getOrderId(), "STOCK_SYSTEM_ERROR"));
}
}
}
이를 헥사고날 Refactoring하면 다음과 같다.
// ============================================================
// 상품 서비스: stock-commands 토픽을 듣고, 처리 후 saga-replies로 응답
//
// 🏗️ 위의 코드를 DDD 4계층 + 헥사고날로 리팩터링합니다.
// 변경 포인트는 딱 2가지입니다:
// 1. KafkaTemplate 직접 사용 → 아웃바운드 포트(SagaReplyPort) + 어댑터 분리
// 2. @KafkaListener 직접 부착 → 인바운드 어댑터 분리
//
// 왜 이렇게 바꾸나요?
// - 테스트할 때 Kafka 없이 Mock으로 대체할 수 있습니다.
// - 나중에 Kafka를 RabbitMQ로 바꿔도 ProductService 코드를 수정할 필요가 없습니다.
//
// 구조 변화:
// [위의코드] ProductService → KafkaTemplate 직접 사용, @KafkaListener 직접 부착
// [리팩토링] ProductService → SagaReplyPort(인터페이스)에만 의존
// └→ KafkaSagaReplyAdapter (인프라 계층에서 구현)
// KafkaStockInboundAdapter → @KafkaListener를 여기서 부착, 포트로 전달
// ============================================================
// ============================================================
// application/port/in/StockCommandHandler.java (인바운드 포트)
// → "재고 커맨드를 처리한다"는 유스케이스를 인터페이스로 정의
// ============================================================
public interface StockCommandHandler {
void handle(StockCommand command);
}
// ============================================================
// application/port/out/SagaReplyPort.java (아웃바운드 포트)
// → "Saga 응답을 보낸다"는 행위를 인터페이스로 추상화
// → Step A에서 kafkaTemplate.send()를 직접 호출하던 부분이 여기로 옴
// ============================================================
public interface SagaReplyPort {
void sendReply(Long orderId, String status);
}
// ============================================================
// application/service/ProductService.java (순수 응용 계층)
// → KafkaTemplate도, @KafkaListener도 없음! 순수 비즈니스 로직만.
// → Step A와 비교하면: kafkaTemplate.send() → replyPort.sendReply()로 바뀐 것뿐
// ============================================================
@Service
@RequiredArgsConstructor
public class ProductService implements StockCommandHandler {
private final ProductRepository productRepository;
private final SagaReplyPort replyPort; // 인터페이스에만 의존
@Override
@Transactional
public void handle(StockCommand command) {
try {
Product product = productRepository
.findById(command.getProductId())
.orElseThrow(() -> new ProductNotFoundException(command.getProductId()));
String resultStatus;
if ("DECREASE".equals(command.getType())) {
product.decreaseStock(command.getQty());
resultStatus = "STOCK_SUCCESS";
} else {
product.increaseStock(command.getQty());
resultStatus = "STOCK_RESTORED";
}
replyPort.sendReply(command.getOrderId(), resultStatus);
} catch (NotEnoughStockException e) {
replyPort.sendReply(command.getOrderId(), "STOCK_FAIL");
} catch (Exception e) {
replyPort.sendReply(command.getOrderId(), "STOCK_SYSTEM_ERROR");
}
}
}
// ============================================================
// infrastructure/messaging/KafkaStockInboundAdapter.java (인바운드 어댑터)
// → @KafkaListener는 여기! Step A에서 ProductService에 직접 붙어있던 것을 분리
// → 외부 메시지를 받아서 포트(StockCommandHandler)로 전달만 함
// ============================================================
@Component
@RequiredArgsConstructor
public class KafkaStockInboundAdapter {
private final StockCommandHandler stockCommandHandler;
@KafkaListener(topics = "stock-commands", groupId = "stock-group")
public void onStockCommand(StockCommand command) {
stockCommandHandler.handle(command);
}
}
// ============================================================
// infrastructure/messaging/KafkaSagaReplyAdapter.java (아웃바운드 어댑터)
// → KafkaTemplate은 여기! Step A에서 ProductService가 직접 쓰던 것을 분리
// → SagaReplyPort 인터페이스의 실제 구현체
// ============================================================
@Component
@RequiredArgsConstructor
public class KafkaSagaReplyAdapter implements SagaReplyPort {
private final KafkaTemplate<String, Object> kafkaTemplate;
@Override
public void sendReply(Long orderId, String status) {
kafkaTemplate.send("saga-replies", new SagaReply(orderId, status));
}
}
이제 Choreography와 Orchestration의 차이를 표로 실펴보자.

Choreography 코드 예시
정상 흐름 코드를 살펴보자.
- 주문 서비스
// ============================================================ // [주문 서비스] Choreography 방식 — 주문 생성 후 이벤트 발행 // // Orchestration과 달리 중앙 관리자가 없습니다. // 주문 서비스는 "주문을 만들었다"는 이벤트만 발행하고, 끝입니다. // 누가 이 이벤트를 듣고 뭘 하는지는 주문 서비스가 알 필요 없습니다. // → 상품 서비스가 order-created를 듣고 재고를 차감하고, // → 배송 서비스가 stock-decreased를 듣고 배송을 생성합니다. // // 이것이 Choreography의 핵심: // 각 서비스가 자기와 관련된 이벤트를 듣고 스스로 다음 행동을 결정합니다. // ============================================================ @Service @RequiredArgsConstructor public class OrderService { private final OrderRepository orderRepository; private final KafkaTemplate<String, Object> kafkaTemplate; // 직접 사용 @Transactional public void createOrder(OrderRequest req) { Order order = Order.createNewOrder(req.getProductId(), req.getQty()); orderRepository.save(order); // 주문 생성 이벤트 발행 — 누가 듣는지는 주문 서비스가 모름 kafkaTemplate.send("order-created", new OrderCreatedEvent(order.getId(), order.getProductId(), order.getQty())); } }- 헥사고날 리팩토링
// ============================================================ // [주문 서비스] Choreography 방식 — 주문 생성 후 이벤트 발행 // // 🏗️ Step A를 DDD 4계층 + 헥사고날로 리팩터링합니다. // 변경 포인트는 딱 1가지입니다: // 1. KafkaTemplate 직접 사용 → 아웃바운드 포트(OrderEventPort) + 어댑터 분리 // // Orchestration과 달리 @KafkaListener가 없으므로 인바운드 어댑터 분리는 없습니다. // 주문 서비스는 이벤트를 "발행만" 하고, 다른 서비스가 알아서 구독합니다. // // 구조 변화: // [Step A] OrderService → kafkaTemplate.send("order-created", ...) 직접 호출 // [Step B] OrderService → eventPort.publishOrderCreated(order) 인터페이스 호출 // └→ KafkaOrderEventAdapter (인프라 계층에서 구현) // ============================================================ // ============================================================ // application/port/out/OrderEventPort.java (아웃바운드 포트) // → "주문 생성 이벤트를 발행한다"는 행위를 인터페이스로 추상화 // → Step A에서 kafkaTemplate.send()를 직접 호출하던 부분이 여기로 옴 // ============================================================ public interface OrderEventPort { void publishOrderCreated(Order order); } // ============================================================ // application/service/OrderService.java (순수 응용 계층) // → KafkaTemplate이 없음! 포트 인터페이스에만 의존 // → 기존 코드와 비교하면: kafkaTemplate.send() → eventPort.publishOrderCreated()로 바뀐 것뿐 // ============================================================ @Service @RequiredArgsConstructor public class OrderService { private final OrderRepository orderRepository; private final OrderEventPort eventPort; // 인터페이스에만 의존 @Transactional public void createOrder(OrderRequest req) { Order order = Order.createNewOrder(req.getProductId(), req.getQty()); orderRepository.save(order); eventPort.publishOrderCreated(order); } } // ============================================================ // infrastructure/messaging/KafkaOrderEventAdapter.java (아웃바운드 어댑터) // → KafkaTemplate은 여기! 기존 코드에서 OrderService가 직접 쓰던 것을 분리 // → OrderEventPort 인터페이스의 실제 구현체 // ============================================================ @Component @RequiredArgsConstructor public class KafkaOrderEventAdapter implements OrderEventPort { private final KafkaTemplate<String, Object> kafkaTemplate; @Override public void publishOrderCreated(Order order) { kafkaTemplate.send("order-created", new OrderCreatedEvent(order.getId(), order.getProductId(), order.getQty())); } }
- 헥사고날 리팩토링
- 상품 서비스
// ============================================================ // [상품 서비스] Choreography 방식 — 주문 생성 이벤트를 듣고 재고 차감 // // order-created 토픽에서 이벤트를 받아서: // - 재고 차감 성공 → stock-decreased 이벤트 발행 (배송 서비스가 이걸 듣고 배송 생성) // - 재고 차감 실패 → stock-decrease-failed 이벤트 발행 (주문 서비스가 이걸 듣고 주문 취소) // // Orchestration과 달리, 누구에게 "명령"하는 게 아니라 // "나는 이렇게 됐어"라는 사실(이벤트)만 발행합니다. // 다음에 뭘 할지는 이 이벤트를 듣는 서비스가 각자 판단합니다. // ============================================================ @Service @RequiredArgsConstructor public class ProductService { private final ProductRepository productRepository; private final KafkaTemplate<String, Object> kafkaTemplate; // 직접 사용 @KafkaListener(topics = "order-created", groupId = "stock-group") @Transactional public void onOrderCreated(OrderCreatedEvent event) { try { Product product = productRepository.findById(event.getProductId()) .orElseThrow(() -> new ProductNotFoundException(event.getProductId())); product.decreaseStock(event.getQty()); // 성공 → "재고 줄었어" 이벤트 발행 (배송 서비스가 이걸 듣고 배송 생성) kafkaTemplate.send("stock-decreased", new StockDecreasedEvent(event.getOrderId(), event.getProductId(), event.getQty())); } catch (NotEnoughStockException e) { // 실패 → "재고 부족이야" 이벤트 발행 (주문 서비스가 이걸 듣고 주문 취소) kafkaTemplate.send("stock-decrease-failed", new StockDecreaseFailedEvent(event.getOrderId(), e.getMessage())); } catch (Exception e) { kafkaTemplate.send("stock-decrease-failed", new StockDecreaseFailedEvent(event.getOrderId(), e.getMessage())); } } }- 헥사고날 리팩토링
// ============================================================ // [상품 서비스] Choreography 방식 — 주문 생성 이벤트를 듣고 재고 차감 // // 🏗️ Step A의 코드를 DDD 4계층 + 헥사고날로 리팩터링합니다. // 변경 포인트는 딱 2가지입니다: // 1. KafkaTemplate 직접 사용 → 아웃바운드 포트(StockEventPort) + 어댑터 분리 // 2. @KafkaListener 직접 부착 → 인바운드 어댑터 분리 // (+ 안티코럽션 레이어: 외부 이벤트 OrderCreatedEvent → 내부 커맨드 DecreaseStockCommand 변환) // // 왜 이렇게 바꾸나요? // - ProductService가 OrderCreatedEvent(주문 도메인 객체)를 직접 알 필요가 없습니다. // 인바운드 어댑터가 외부 이벤트를 내부 커맨드로 번역해주니까요. // - 테스트할 때 Kafka 없이 Mock으로 대체할 수 있습니다. // - Kafka를 RabbitMQ로 바꿔도 ProductService 코드를 수정할 필요가 없습니다. // // 구조 변화: // [Step A] ProductService // ├── @KafkaListener로 OrderCreatedEvent 직접 수신 // └── kafkaTemplate.send() 직접 호출 // // [Step B] ProductService (순수 응용 계층 — Kafka도, 주문 이벤트도 모름!) // ├── DecreaseStockUseCase (인바운드 포트) // │ └── KafkaStockInboundAdapter가 OrderCreatedEvent → DecreaseStockCommand 변환 후 호출 // └── StockEventPort (아웃바운드 포트) // └── KafkaStockOutboundAdapter (인프라 계층에서 구현) // ============================================================ // ============================================================ // application/port/in/DecreaseStockUseCase.java (인바운드 포트) // → "재고를 차감한다"는 유스케이스를 상품 도메인 언어로 정의 // → OrderCreatedEvent가 아니라 DecreaseStockCommand를 받음 (주문 이벤트에 의존하지 않음) // ============================================================ public interface DecreaseStockUseCase { void decrease(DecreaseStockCommand command); } // ============================================================ // application/port/in/DecreaseStockCommand.java // → 상품 컨텍스트 자체의 커맨드 객체 // → Step A에서는 OrderCreatedEvent를 그대로 받았지만, // Step B에서는 상품 도메인이 필요한 정보만 담은 커맨드로 변환됨 // ============================================================ @Getter @AllArgsConstructor public class DecreaseStockCommand { private final Long orderId; private final Long productId; private final int qty; } // ============================================================ // application/port/out/StockEventPort.java (아웃바운드 포트) // → "재고 변경 결과 이벤트를 발행한다"는 행위를 인터페이스로 추상화 // → Step A에서 kafkaTemplate.send()를 직접 호출하던 부분이 여기로 옴 // ============================================================ public interface StockEventPort { void publishStockDecreased(StockDecreasedEvent event); void publishStockDecreaseFailed(StockDecreaseFailedEvent event); } // ============================================================ // application/service/ProductService.java (순수 응용 계층) // → KafkaTemplate도, @KafkaListener도, OrderCreatedEvent도 없음! // → Step A와 비교하면: // - kafkaTemplate.send("stock-decreased", ...) → stockEventPort.publishStockDecreased() // - OrderCreatedEvent 직접 수신 → DecreaseStockCommand로 받음 // ============================================================ @Service @RequiredArgsConstructor public class ProductService implements DecreaseStockUseCase { private final ProductRepository productRepository; private final StockEventPort stockEventPort; // 인터페이스에만 의존 @Override @Transactional public void decrease(DecreaseStockCommand command) { try { Product product = productRepository.findById(command.getProductId()) .orElseThrow(() -> new ProductNotFoundException(command.getProductId())); product.decreaseStock(command.getQty()); stockEventPort.publishStockDecreased( new StockDecreasedEvent(command.getOrderId(), command.getProductId(), command.getQty())); } catch (NotEnoughStockException e) { stockEventPort.publishStockDecreaseFailed( new StockDecreaseFailedEvent(command.getOrderId(), e.getMessage())); } catch (Exception e) { stockEventPort.publishStockDecreaseFailed( new StockDecreaseFailedEvent(command.getOrderId(), e.getMessage())); } } } // ============================================================ // infrastructure/messaging/KafkaStockInboundAdapter.java (인바운드 어댑터) // → @KafkaListener는 여기! Step A에서 ProductService에 직접 붙어있던 것을 분리 // → 핵심: 안티코럽션 레이어 역할 // 외부 이벤트(OrderCreatedEvent)를 내부 커맨드(DecreaseStockCommand)로 변환 // → ProductService는 "주문"이라는 개념을 전혀 모르고, 순수하게 "재고 차감"만 처리 // ============================================================ @Component @RequiredArgsConstructor public class KafkaStockInboundAdapter { private final DecreaseStockUseCase decreaseStockUseCase; @KafkaListener(topics = "order-created", groupId = "stock-group") public void onOrderCreated(OrderCreatedEvent event) { // 안티코럽션 레이어: 외부 이벤트 → 내부 커맨드로 변환 DecreaseStockCommand command = new DecreaseStockCommand( event.getOrderId(), event.getProductId(), event.getQty() ); decreaseStockUseCase.decrease(command); } } // ============================================================ // infrastructure/messaging/KafkaStockOutboundAdapter.java (아웃바운드 어댑터) // → KafkaTemplate은 여기! Step A에서 ProductService가 직접 쓰던 것을 분리 // → StockEventPort 인터페이스의 실제 구현체 // ============================================================ @Component @RequiredArgsConstructor public class KafkaStockOutboundAdapter implements StockEventPort { private final KafkaTemplate<String, Object> kafkaTemplate; @Override public void publishStockDecreased(StockDecreasedEvent event) { kafkaTemplate.send("stock-decreased", event); } @Override public void publishStockDecreaseFailed(StockDecreaseFailedEvent event) { kafkaTemplate.send("stock-decrease-failed", event); } }
- 헥사고날 리팩토링
- 배송 서비스
// ============================================================ // [배송 서비스] Choreography 방식 — 재고 차감 이벤트를 듣고 배송 생성 // // stock-decreased 토픽에서 이벤트를 받아서: // - 배송 생성 성공 → delivery-created 이벤트 발행 (주문 서비스가 이걸 듣고 주문 확정) // - 배송 생성 실패 → delivery-create-failed 이벤트 발행 // (상품 서비스가 이걸 듣고 재고 복원 → 주문 서비스가 주문 취소) // // 배송 서비스도 상품 서비스와 마찬가지로 // "나는 이렇게 됐어"라는 사실(이벤트)만 발행하고, 다음은 다른 서비스가 판단합니다. // ============================================================ @Service @RequiredArgsConstructor public class DeliveryService { private final DeliveryRepository deliveryRepository; private final KafkaTemplate<String, Object> kafkaTemplate; // 직접 사용 @KafkaListener(topics = "stock-decreased", groupId = "delivery-group") @Transactional public void onStockDecreased(StockDecreasedEvent event) { try { Delivery delivery = Delivery.create(event.getOrderId()); deliveryRepository.save(delivery); // 성공 → "배송 만들었어" 이벤트 발행 (주문 서비스가 이걸 듣고 주문 확정) kafkaTemplate.send("delivery-created", new DeliveryCreatedEvent(event.getOrderId())); } catch (Exception e) { // 실패 → "배송 못 만들었어" 이벤트 발행 // (상품 서비스가 이걸 듣고 재고 복원 → 주문 서비스가 주문 취소) kafkaTemplate.send("delivery-create-failed", new DeliveryCreateFailedEvent(event.getOrderId(), e.getMessage())); } } }- 헥사고날 리팩토링
// ============================================================ // [배송 서비스] Choreography 방식 — 재고 차감 이벤트를 듣고 배송 생성 // // 🏗️ Step A의 코드를 DDD 4계층 + 헥사고날로 리팩터링합니다. // 변경 포인트는 딱 2가지입니다: // 1. KafkaTemplate 직접 사용 → 아웃바운드 포트(DeliveryEventPort) + 어댑터 분리 // 2. @KafkaListener 직접 부착 → 인바운드 어댑터 분리 // (+ 안티코럽션 레이어: 외부 이벤트 StockDecreasedEvent → 내부 커맨드 CreateDeliveryCommand 변환) // // 상품 서비스와 동일한 패턴입니다: // - DeliveryService는 StockDecreasedEvent(상품 도메인 객체)를 직접 알 필요 없음 // - 인바운드 어댑터가 외부 이벤트를 내부 커맨드로 번역해줌 // // 구조 변화: // [Step A] DeliveryService // ├── @KafkaListener로 StockDecreasedEvent 직접 수신 // └── kafkaTemplate.send() 직접 호출 // // [Step B] DeliveryService (순수 응용 계층 — Kafka도, 재고 이벤트도 모름!) // ├── CreateDeliveryUseCase (인바운드 포트) // │ └── KafkaDeliveryInboundAdapter가 StockDecreasedEvent → CreateDeliveryCommand 변환 후 호출 // └── DeliveryEventPort (아웃바운드 포트) // └── KafkaDeliveryOutboundAdapter (인프라 계층에서 구현) // ============================================================ // ============================================================ // application/port/in/CreateDeliveryUseCase.java (인바운드 포트) // → "배송을 생성한다"는 유스케이스를 배송 도메인 언어로 정의 // → StockDecreasedEvent가 아니라 CreateDeliveryCommand를 받음 (재고 이벤트에 의존하지 않음) // ============================================================ public interface CreateDeliveryUseCase { void create(CreateDeliveryCommand command); } // ============================================================ // application/port/in/CreateDeliveryCommand.java // → 배송 컨텍스트 자체의 커맨드 객체 // → Step A에서는 StockDecreasedEvent를 그대로 받았지만, // Step B에서는 배송 도메인이 필요한 정보만 담은 커맨드로 변환됨 // ============================================================ @Getter @AllArgsConstructor public class CreateDeliveryCommand { private final Long orderId; } // ============================================================ // application/port/out/DeliveryEventPort.java (아웃바운드 포트) // → "배송 결과 이벤트를 발행한다"는 행위를 인터페이스로 추상화 // → Step A에서 kafkaTemplate.send()를 직접 호출하던 부분이 여기로 옴 // ============================================================ public interface DeliveryEventPort { void publishDeliveryCreated(DeliveryCreatedEvent event); void publishDeliveryCreateFailed(DeliveryCreateFailedEvent event); } // ============================================================ // application/service/DeliveryService.java (순수 응용 계층) // → KafkaTemplate도, @KafkaListener도, StockDecreasedEvent도 없음! // → Step A와 비교하면: // - kafkaTemplate.send("delivery-created", ...) → deliveryEventPort.publishDeliveryCreated() // - StockDecreasedEvent 직접 수신 → CreateDeliveryCommand로 받음 // ============================================================ @Service @RequiredArgsConstructor public class DeliveryService implements CreateDeliveryUseCase { private final DeliveryRepository deliveryRepository; private final DeliveryEventPort deliveryEventPort; // 인터페이스에만 의존 @Override @Transactional public void create(CreateDeliveryCommand command) { try { Delivery delivery = Delivery.create(command.getOrderId()); deliveryRepository.save(delivery); deliveryEventPort.publishDeliveryCreated( new DeliveryCreatedEvent(command.getOrderId())); } catch (Exception e) { deliveryEventPort.publishDeliveryCreateFailed( new DeliveryCreateFailedEvent(command.getOrderId(), e.getMessage())); } } } // ============================================================ // infrastructure/messaging/KafkaDeliveryInboundAdapter.java (인바운드 어댑터) // → @KafkaListener는 여기! Step A에서 DeliveryService에 직접 붙어있던 것을 분리 // → 핵심: 안티코럽션 레이어 역할 // 외부 이벤트(StockDecreasedEvent)를 내부 커맨드(CreateDeliveryCommand)로 변환 // → DeliveryService는 "재고"라는 개념을 전혀 모르고, 순수하게 "배송 생성"만 처리 // ============================================================ @Component @RequiredArgsConstructor public class KafkaDeliveryInboundAdapter { private final CreateDeliveryUseCase createDeliveryUseCase; @KafkaListener(topics = "stock-decreased", groupId = "delivery-group") public void onStockDecreased(StockDecreasedEvent event) { // 안티코럽션 레이어: 외부 이벤트 → 내부 커맨드로 변환 CreateDeliveryCommand command = new CreateDeliveryCommand(event.getOrderId()); createDeliveryUseCase.create(command); } } // ============================================================ // infrastructure/messaging/KafkaDeliveryOutboundAdapter.java (아웃바운드 어댑터) // → KafkaTemplate은 여기! Step A에서 DeliveryService가 직접 쓰던 것을 분리 // → DeliveryEventPort 인터페이스의 실제 구현체 // ============================================================ @Component @RequiredArgsConstructor public class KafkaDeliveryOutboundAdapter implements DeliveryEventPort { private final KafkaTemplate<String, Object> kafkaTemplate; @Override public void publishDeliveryCreated(DeliveryCreatedEvent event) { kafkaTemplate.send("delivery-created", event); } @Override public void publishDeliveryCreateFailed(DeliveryCreateFailedEvent event) { kafkaTemplate.send("delivery-create-failed", event); } }
- 헥사고날 리팩토링
보상 흐름 코드를 살펴보자. 여기서 Choreography의 복잡함이 드러난다.
// ============================================================
// 보상 흐름 (Compensation Flow) — Choreography의 복잡함이 드러나는 구간
//
// 정상 흐름에서는 각 서비스가 "성공 이벤트"를 발행했습니다.
// 보상 흐름에서는 "실패 이벤트"를 듣고 되돌리는 작업을 합니다.
//
// [상품 서비스] delivery-create-failed를 듣고 → 재고 복원 → stock-restored 발행
// [주문 서비스] 2가지 경로로 "주문 취소"에 도달:
// 경로 1: stock-decrease-failed → 바로 주문 취소 (재고가 안 줄었으니 복원 필요 없음)
// 경로 2: stock-restored → 재고 복원 완료 후 주문 취소
//
// ⚠️ 여기서 Choreography의 복잡함이 드러납니다:
// 서비스가 3개뿐인데도 보상 이벤트가 3개(stock-decrease-failed, delivery-create-failed,
// stock-restored)나 필요하고, 각 서비스가 "나와 관련된 실패 이벤트"를 듣고 있어야 합니다.
// ============================================================
// === [상품 서비스] 배송 실패 → 재고 복원 ===
@Service
@RequiredArgsConstructor
public class ProductService {
private final ProductRepository productRepository;
private final KafkaTemplate<String, Object> kafkaTemplate;
// (정상 흐름의 onOrderCreated는 이전 코드와 동일하므로 생략)
// 배송 실패 이벤트를 듣고 → 재고 복원
@KafkaListener(topics = "delivery-create-failed", groupId = "stock-group")
@Transactional
public void onDeliveryCreateFailed(DeliveryCreateFailedEvent event) {
Product product = productRepository.findByOrderId(event.getOrderId());
product.increaseStock(event.getQty());
// "재고 복원 완료" 이벤트 발행 → 주문 서비스가 이걸 듣고 주문 취소
kafkaTemplate.send("stock-restored",
new StockRestoredEvent(event.getOrderId()));
}
}
// === [주문 서비스] 2가지 실패 경로가 동일한 "주문 취소"로 수렴 ===
@Service
@RequiredArgsConstructor
public class OrderService {
private final OrderRepository orderRepository;
// 케이스 1: 재고 차감 실패 → 바로 주문 취소
@KafkaListener(topics = "stock-decrease-failed", groupId = "order-group")
@Transactional
public void onStockDecreaseFailed(StockDecreaseFailedEvent event) {
cancelOrder(event.getOrderId());
}
// 케이스 2: 배송 실패 → 재고 복원 완료 → 그제서야 주문 취소
@KafkaListener(topics = "stock-restored", groupId = "order-group")
@Transactional
public void onStockRestored(StockRestoredEvent event) {
cancelOrder(event.getOrderId());
}
private void cancelOrder(Long orderId) {
Order order = orderRepository.findById(orderId)
.orElseThrow(() -> new OrderNotFoundException(orderId));
order.updateStatus(OrderStatus.CANCELLED);
}
}
이를 헥사고날 리펙토링을 진행하면 다음과 같다.
// ============================================================
// 보상 흐름 (Compensation Flow) — 헥사고날 리팩터링 버전
//
// 🏗️ Step A의 보상 코드를 DDD 4계층 + 헥사고날로 리팩터링합니다.
// 정상 흐름과 동일한 패턴입니다:
// 1. @KafkaListener 직접 부착 → 인바운드 어댑터 분리
// (+ 안티코럽션 레이어: 외부 실패 이벤트 → 내부 보상 커맨드 변환)
// 2. KafkaTemplate 직접 사용 → 아웃바운드 포트 + 어댑터 분리
//
// 구조 변화:
// [Step A] ProductService
// ├── @KafkaListener로 DeliveryCreateFailedEvent 직접 수신
// └── kafkaTemplate.send("stock-restored", ...) 직접 호출
//
// [Step B] ProductService (순수 응용 계층 — 배송 실패 이벤트를 모름!)
// ├── RestoreStockUseCase (인바운드 포트)
// │ └── KafkaStockInboundAdapter가 DeliveryCreateFailedEvent → RestoreStockCommand 변환 후 호출
// └── StockEventPort (아웃바운드 포트 — 정상 흐름과 동일한 포트 재사용)
// └── KafkaStockOutboundAdapter (인프라 계층에서 구현)
//
// [Step A] OrderService
// └── @KafkaListener 2개로 실패 이벤트 직접 수신
//
// [Step B] OrderService (순수 응용 계층)
// └── CancelOrderUseCase (인바운드 포트)
// └── KafkaOrderInboundAdapter가 2가지 실패 이벤트 → CancelOrderCommand 변환 후 호출
// ============================================================
// ============================================================
// [상품 서비스] 배송 실패 → 재고 복원 (보상)
// ============================================================
// application/port/in/RestoreStockUseCase.java (인바운드 포트)
// → "재고를 복원한다"는 보상 유스케이스를 상품 도메인 언어로 정의
// → DeliveryCreateFailedEvent가 아니라 RestoreStockCommand를 받음
public interface RestoreStockUseCase {
void restore(RestoreStockCommand command);
}
// application/port/in/RestoreStockCommand.java
// → Step A에서는 DeliveryCreateFailedEvent를 그대로 받았지만,
// Step B에서는 상품 도메인이 필요한 정보만 담은 커맨드로 변환됨
@Getter
@AllArgsConstructor
public class RestoreStockCommand {
private final Long orderId;
private final int qty;
}
// application/service/ProductService.java (순수 응용 계층)
// → KafkaTemplate도, @KafkaListener도, DeliveryCreateFailedEvent도 없음!
// → Step A와 비교하면: kafkaTemplate.send() → stockEventPort.publishStockRestored()
@Service
@RequiredArgsConstructor
public class ProductService implements DecreaseStockUseCase, RestoreStockUseCase {
private final ProductRepository productRepository;
private final StockEventPort stockEventPort; // 정상 흐름과 동일한 포트 재사용
@Override
@Transactional
public void restore(RestoreStockCommand command) {
Product product = productRepository.findByOrderId(command.getOrderId());
product.increaseStock(command.getQty());
stockEventPort.publishStockRestored(new StockRestoredEvent(command.getOrderId()));
}
// decrease()는 이전 코드와 동일하므로 생략
}
// infrastructure/messaging/KafkaStockInboundAdapter.java (인바운드 어댑터)
// → 안티코럽션 레이어: 배송 실패 이벤트(외부) → 재고 복원 커맨드(내부)로 변환
// → ProductService는 "배송"이라는 개념을 전혀 모르고, 순수하게 "재고 복원"만 처리
@Component
@RequiredArgsConstructor
public class KafkaStockInboundAdapter {
private final RestoreStockUseCase restoreStockUseCase;
@KafkaListener(topics = "delivery-create-failed", groupId = "stock-group")
public void onDeliveryCreateFailed(DeliveryCreateFailedEvent event) {
restoreStockUseCase.restore(
new RestoreStockCommand(event.getOrderId(), event.getQty()));
}
}
// ============================================================
// [주문 서비스] 재고 실패 or 재고 복원 완료 → 주문 취소 (보상)
// ============================================================
// application/port/in/CancelOrderUseCase.java (인바운드 포트)
// → "주문을 취소한다"는 보상 유스케이스를 주문 도메인 언어로 정의
public interface CancelOrderUseCase {
void cancel(CancelOrderCommand command);
}
@Getter
@AllArgsConstructor
public class CancelOrderCommand {
private final Long orderId;
}
// application/service/OrderService.java (순수 응용 계층)
// → Step A와 비교하면: @KafkaListener 2개가 사라지고, cancel() 비즈니스 로직만 남음
@Service
@RequiredArgsConstructor
public class OrderService implements CancelOrderUseCase {
private final OrderRepository orderRepository;
@Override
@Transactional
public void cancel(CancelOrderCommand command) {
Order order = orderRepository.findById(command.getOrderId())
.orElseThrow(() -> new OrderNotFoundException(command.getOrderId()));
order.updateStatus(OrderStatus.CANCELLED);
}
}
// infrastructure/messaging/KafkaOrderInboundAdapter.java (인바운드 어댑터)
// → 서로 다른 2개의 외부 이벤트가 동일한 "주문 취소"로 수렴
// → 이것이 Choreography의 복잡함: 누가 언제 보상하는지 코드를 흩어봐야 알 수 있다
// → 하지만 헥사고날 덕분에 OrderService는 "왜 취소하는지"를 알 필요 없음
@Component
@RequiredArgsConstructor
public class KafkaOrderInboundAdapter {
private final CancelOrderUseCase cancelOrderUseCase;
// 케이스 1: 재고 차감 자체가 실패 → 바로 주문 취소
@KafkaListener(topics = "stock-decrease-failed", groupId = "order-group")
public void onStockDecreaseFailed(StockDecreaseFailedEvent event) {
cancelOrderUseCase.cancel(new CancelOrderCommand(event.getOrderId()));
}
// 케이스 2: 배송 실패 → 재고 복원 완료 → 그제서야 주문 취소
@KafkaListener(topics = "stock-restored", groupId = "order-group")
public void onStockRestored(StockRestoredEvent event) {
cancelOrderUseCase.cancel(new CancelOrderCommand(event.getOrderId()));
}
}
위 코드를 보면 서비스가 3개뿐인데도 보상 이벤트가 3개(stock-decrease-failed, delivery-create-failed, stock-restored)나 필요하고, 각 서비스가 “나와 관련된 실패 이벤트”를 듣고 있어야 한다. 서비스가 10개로 늘어나면 “어떤 이벤트를 누가 발행하고, 누가 듣고, 뭘 되돌려야 하지?”를 추적하기가 매우 어려워진니다.

동기 호출 환경의 정합성
FeignClient + Resilience4j
프로젝트 요구사항에 “API 호출 실패 시 재시도 로직을 구현하여 통신의 신뢰성을 확보”하라고 한다. 먼저 알아야 할 것은 HTTP상태 코드별 에러 처리 전략을 알아야 한다. 현업에서는 모든 에러에 대해 재시도하면 안된다. 이 것을 구분하는게 중요하다.
[재시도해야 하는 에러 — 일시적 장애]
503 Service Unavailable → 서버가 잠깐 바쁨, 재시도하면 될 수 있음
408 Request Timeout → 타임아웃, 재시도하면 될 수 있음
429 Too Many Requests → 요청 너무 많음, 잠깐 쉬고 재시도
[재시도하면 안 되는 에러 — 비즈니스/요청 에러]
400 Bad Request → 요청 자체가 잘못됨 (재시도해도 계속 실패)
404 Not Found → 상품이 없음 (재시도해도 계속 실패)
409 Conflict → 이미 처리됨 (멱등성으로 처리)
422 Unprocessable → 재고 부족 등 비즈니스 에러 (재시도 의미 없음)
댓글남기기