bedaily.me
Backend2026년 7월 2일29분 읽기

MSA 분산 트랜잭션과 SAGA 패턴 — 이론부터 실습까지

서비스마다 DB를 따로 두는 순간, 모놀리식에서 공짜로 누리던 ACID는 사라진다. 2PC가 왜 안 통하는지부터 SAGA·Outbox·TCC, 그리고 Spring Boot + Kafka로 직접 돌려보는 실습까지 — 이론과 코드를 한 번에 정리한다.

MSA분산 트랜잭션SAGAOutboxKafka

Distributed Systems · MSA. 서비스마다 DB를 따로 두는 순간, 모놀리식에서 공짜로 누리던 ACID는 사라진다. 2PC가 왜 안 통하는지부터 SAGA·Outbox·TCC, 그리고 Spring Boot + Kafka로 직접 돌려보는 실습까지 — 이론과 코드를 한 번에 정리한다.

기준 — Spring Boot 3 · Kafka · JPA · PostgreSQL.

01 · 모놀리식의 ACID, MSA에서 깨지다

모놀리식에서 트랜잭션은 고민거리가 아니다. 하나의 DB, 하나의 트랜잭션 경계 안이라면 여러 테이블을 건드려도 어노테이션 하나로 원자성·일관성이 보장된다. 실패하면 DB가 통째로 롤백한다.

📄모놀리식 — 단일 트랜잭션
@Transactional
public void placeOrder(Long userId, long amount) {
    orderRepository.save(new Order(userId, amount)); // 같은 DB
    accountRepository.withdraw(userId, amount);       // 같은 DB
    pointRepository.earn(userId, amount / 100);       // 같은 DB
    // 어느 한 줄이라도 실패하면 세 작업 모두 자동 롤백
}

MSA는 Database per Service를 기본 전제로 삼는다. 주문 서비스와 결제 서비스가 물리적으로 다른 DB를 쓴다. 이 순간 "여러 DB를 가로지르는 단일 트랜잭션"이라는 개념 자체가 성립하지 않는다. 위 코드는 더 이상 한 트랜잭션으로 묶이지 않는다.

Monolith — 단일 트랜잭션 경계주문 로직결제 로직ONE DBCOMMIT / ROLLBACK 한 방MSA — 경계가 쪼개짐주문 서비스DB A결제 서비스DB B단일 트랜잭션 불가
트랜잭션 경계가 서비스 단위로 쪼개진다 — 공유 DB가 사라지면 ACID도 사라진다

02 · 왜 분산 트랜잭션이 까다로운가

단순히 "DB가 여러 개라서"가 아니다. 본질은 네트워크로 분리된 노드를 원자적으로 묶을 방법이 없다는 것이다.

  • 부분 실패 — 주문은 성공했는데 결제가 실패하는 상태가 정상적으로 발생한다. 이미 커밋된 주문을 자동으로 되돌릴 표준 메커니즘이 없다.
  • 타임아웃의 모호함 — 응답이 안 오는 것과 실패한 것을 구분할 수 없다. "결제 요청은 갔는데 응답이 없다" — 차감됐는지 아닌지 호출 측은 모른다.
  • 가용성 비용 — 일관성을 위해 락을 걸고 모두를 기다리면, 한 노드의 지연이 전체 처리량을 끌어내린다. 가용성과 일관성이 정면으로 충돌한다.
  • 이질성 — RDB·NoSQL·외부 결제 API가 섞여 있다. 단일 프로토콜로 묶기 어렵고, 외부 API는 애초에 롤백 개념이 없다.
  • 이중 쓰기(dual-write) — "내 DB를 바꾸고 + 메시지를 발행한다"는 두 동작이 한 트랜잭션이 아니다. 둘 사이에서 죽으면 DB와 메시지 상태가 어긋난다. → 05장에서 본격적으로 다룬다.

관점 전환 — 분산 환경에서 "전체를 한 번에 되돌린다"는 발상 자체를 버려야 한다. 되돌리는 대신 되돌리는 행위를 또 하나의 작업으로 추가하는 것 — 이것이 SAGA의 출발점이다.

03 · 전통적 해법 2PC / 3PC, 그리고 한계

**2PC(Two-Phase Commit)**는 코디네이터가 모든 참여자를 두 단계로 묶어 원자적 커밋을 보장하려는 프로토콜이다. JTA / XA가 이를 표준화한 구현이다.

  • Phase 1 · Prepare(투표) — 코디네이터가 전 참여자에게 "커밋 가능?"을 묻는다. 각 참여자는 변경을 준비하고 락을 잡은 채 yes/no로 응답한다.
  • Phase 2 · Commit(결정) — 전원이 yes면 commit, 하나라도 no면 전원 abort를 지시한다.
CoordinatorService AService BPhase 1prepareprepare🔒 락 점유yesyesPhase 2commitcommit코디네이터가 죽으면 → 참여자는 락을 쥔 채 무한 대기 (블로킹)
2PC — Prepare 시점부터 Commit까지 모든 참여자가 락을 점유한 채 동기 대기

이론은 깔끔하지만 MSA에는 거의 쓰이지 않는다.

  • 동기 블로킹 — 모든 참여자가 프로토콜이 끝날 때까지 락을 잡고 기다린다. 한 노드만 느려도 전체가 멈춘다 → 처리량·가용성 붕괴.
  • 코디네이터 SPOF — Phase 2 직전 코디네이터가 죽으면 참여자는 commit인지 abort인지 모른 채 락을 쥐고 영원히 대기한다.
  • MSA 부적합 — HTTP·비동기 메시징 기반, 이종 DB, 외부 API가 섞인 환경에서 XA 지원 자체가 비현실적이다.

**3PC(Three-Phase Commit)**는 CanCommit → PreCommit → DoCommit의 중간 단계를 끼워 블로킹을 완화한 변형이다. 코디네이터 장애 시 참여자가 타임아웃으로 자체 판단할 여지를 준다. 다만 왕복 단계가 늘어 지연이 커지고, 네트워크 분단 상황에서는 여전히 불일치가 생길 수 있어 실무에서는 거의 채택되지 않는다.

04 · SAGA 패턴의 핵심

SAGA는 분산 트랜잭션을 하나의 거대한 원자적 작업으로 보지 않는다. 대신 각자 자기 DB에만 커밋하는 로컬 트랜잭션들의 시퀀스로 본다. 한 단계가 끝나면 메시지/이벤트로 다음 단계를 트리거한다. 중간에 실패하면 분산 롤백 대신 **보상 트랜잭션(compensating transaction)**을 앞 단계들에 역순으로 실행해 효과를 의미적으로 상쇄한다.

정상 흐름 (forward)T1 주문생성T2 결제T3 재고차감T4 배송✕ T3에서 실패 발생보상 흐름 (compensation, 역순)C1 주문취소C2 결제환불
T3 실패 시 — T1·T2를 롤백하는 게 아니라 C2·C1이라는 새 트랜잭션으로 효과를 상쇄

SAGA를 구현하기 전 알아야 할 개념

  • 격리성 포기 (ACID → ACD) — SAGA에는 I(Isolation)가 없다. 각 로컬 트랜잭션이 커밋되는 즉시 중간 상태가 외부에 노출된다. 결과적으로 보장되는 건 **최종 일관성(eventual consistency)**뿐이다.
  • 보상 ≠ 롤백 — 보상은 "없던 일로" 만드는 게 아니라 "반대 효과를 가진 작업을 추가로 실행"하는 것이다. 이미 외부에 보인 효과는 사라지지 않는다.
  • 멱등성 (idempotency) — 메시지 중복·재시도가 기본 전제다. 같은 메시지를 여러 번 처리해도 결과가 같도록 설계해야 한다.
  • 커맨드 vs 이벤트 — 다음 단계를 "명령(command)"으로 지시할지, "발생 사실(event)"을 알리고 구독자가 알아서 반응하게 할지 — 이 선택이 곧 Orchestration / Choreography를 가른다.

멱등성 — 같은 메시지를 두 번 받아도 안전하게

Kafka는 기본이 at-least-once다. 같은 메시지가 두 번 올 수 있으니, 처리 이력을 남겨 중복을 걸러낸다.

📄멱등 처리 — 처리 이력 테이블
@Entity
@Table(name = "processed_message")
public class ProcessedMessage {
    @Id
    private String messageId;        // 이벤트의 고유 키 (sagaId + 단계명 등)
    private Instant processedAt;
}

@Service
@RequiredArgsConstructor
public class PaymentService {
    private final ProcessedMessageRepository processed;
    private final AccountRepository accountRepository;

    @Transactional
    public void charge(String sagaId, Long orderId, Long userId, long amount) {
        // 이미 처리한 메시지면 무시 — 같은 saga를 두 번 받아도 1회만 차감
        if (processed.existsById(sagaId)) return;

        Account account = accountRepository.findByUserIdForUpdate(userId);
        account.withdraw(amount);                     // 잔액 부족 시 예외
        processed.save(new ProcessedMessage(sagaId, Instant.now()));
    }
}

위는 별도 이력 테이블을 쓰는 일반형이다. 결제처럼 처리 결과 자체가 sagaId로 유일하게 식별되는 경우엔 그 결과 행을 곧 중복 표식으로 삼을 수 있다 — 실습(10장)에서는 paymentRepository.findBySagaId()로 같은 효과를 낸다.

격리성 부재 대응 — Semantic Lock

중간 상태가 외부에 노출되는 게 문제라면, 미확정 데이터에 PENDING 플래그를 달아 다른 트랜잭션이 그 값을 "확정된 것"으로 읽지 못하게 막는다. DB 락이 아니라 애플리케이션 레벨의 상태 플래그다.

📄Order.java — 상태로 가시성 통제
public enum OrderStatus { PENDING, APPROVED, REJECTED }

@Entity
@Table(name = "orders")
public class Order {
    @Id @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;
    private Long userId;
    private long amount;
    private String sagaId;
    @Enumerated(EnumType.STRING)
    private OrderStatus status;      // 사가 완료 전까지 PENDING — 확정으로 취급 금지
    private String rejectReason;

    public static Order create(Long userId, long amount, String sagaId) {
        Order o = new Order();
        o.userId = userId; o.amount = amount; o.sagaId = sagaId;
        o.status = OrderStatus.PENDING;
        return o;
    }
    public void approve()              { this.status = OrderStatus.APPROVED; }
    public void reject(String reason)  { this.status = OrderStatus.REJECTED; this.rejectReason = reason; }
    public boolean canApprove()        { return this.status == OrderStatus.PENDING; }
}

05 · 메시지 신뢰성 — 이중 쓰기와 Outbox 패턴

SAGA의 모든 단계는 "내 DB를 바꾸고 + 다음 단계를 메시지로 알린다"로 이뤄진다. 그런데 이 둘은 한 트랜잭션이 아니다. DB 커밋 직후 메시지 발행 전에 프로세스가 죽으면 — 주문은 저장됐는데 이벤트는 안 나갔다. 결제 서비스는 영원히 모른다. 이것이 이중 쓰기(dual-write) 문제다.

📄위험한 코드 — DB 커밋과 발행이 분리됨
@Transactional
public Long placeOrder(PlaceOrderRequest req) {
    Order order = orderRepository.save(Order.create(...));   // (1) DB 커밋
    // ── 여기서 프로세스가 죽으면? 주문은 남고 이벤트는 영영 안 나간다 ──
    kafka.send("order.created", new OrderCreatedEvent(...));  // (2) 메시지 발행
    return order.getId();
}

현장 사례@TransactionalEventListener(AFTER_COMMIT)로 발행하는 방식도 같은 함정이 있다. 커밋은 됐는데 발행 직전 인스턴스가 내려가는 롤링 배포 구간에서 이벤트가 유실된다. 결국 DB가 진실의 원천이 되도록, 발행할 메시지도 같은 트랜잭션 안에서 DB에 기록해야 한다.

Transactional Outbox는 발행할 이벤트를 비즈니스 데이터와 같은 트랜잭션에서 outbox 테이블에 적재한 뒤, 별도 릴레이가 그 테이블을 읽어 브로커로 내보내는 패턴이다. "DB 저장"과 "이벤트 적재"가 한 커밋으로 묶이니 둘 다 되거나 둘 다 안 된다.

주문 서비스ordersoutbox1 TXPostgreSQLWAL / outboxcommitDebeziumCDC relaytail logKafkaorder.created비즈니스 데이터와 이벤트가 하나의 커밋으로 묶인다 → 유실 없음
Outbox + CDC — DB 커밋이 곧 이벤트 기록, 릴레이가 로그를 따라 브로커로 전달
📄OutboxEvent.java + 발행 안전한 placeOrder
@Entity
@Table(name = "outbox")
public class OutboxEvent {
    @Id @UuidGenerator                 // Hibernate 6 UUID 생성 (plain @GeneratedValue 지양)
    private UUID id;
    private String aggregateType;      // "order"  -> Debezium EventRouter 라우팅 키
    private String aggregateId;        // 주문 ID   -> 메시지 key (파티션/순서)
    private String type;               // "OrderCreated"
    @JdbcTypeCode(SqlTypes.JSON)       // 없으면 'jsonb vs varchar' 바인딩 에러로 실제로 터진다
    @Column(columnDefinition = "jsonb")
    private String payload;
    private Instant createdAt;
}

@Transactional
public Long placeOrder(PlaceOrderRequest req) {
    String sagaId = UUID.randomUUID().toString();
    Order order = orderRepository.save(Order.create(req.userId(), req.amount(), sagaId));

    // 같은 트랜잭션 안에서 이벤트도 DB에 적재 — 커밋되면 둘 다, 실패하면 둘 다 무효
    outboxRepository.save(OutboxEvent.of(
        "order", order.getId().toString(), "OrderCreated",
        objectMapper.writeValueAsString(
            new OrderCreatedEvent(sagaId, order.getId(), req.userId(), req.amount()))));
    return order.getId();
}

릴레이는 두 갈래다. 폴링 퍼블리셔(주기적으로 outbox를 SELECT → 발행 → 삭제)는 단순하지만 폴링 지연·부하가 있다. **CDC(Debezium)**는 PostgreSQL의 WAL(논리 복제)을 따라 outbox 변경을 실시간으로 Kafka에 흘려 애플리케이션에 발행 코드 자체가 필요 없다.

📄debezium-outbox-connector.json
{
  "name": "outbox-connector",
  "config": {
    "connector.class": "io.debezium.connector.postgresql.PostgresConnector",
    "database.hostname": "postgres",
    "database.dbname": "saga",
    "plugin.name": "pgoutput",
    "table.include.list": "public.outbox",
    "transforms": "outbox",
    "transforms.outbox.type": "io.debezium.transforms.outbox.EventRouter",
    "transforms.outbox.route.by.field": "aggregate_type",
    "transforms.outbox.table.field.event.payload": "payload"
  }
}

주의 — Outbox는 중복 발행을 막지 못한다(at-least-once). 릴레이가 발행 후 오프셋 커밋 전에 죽으면 같은 이벤트가 또 나간다. 그래서 04장의 소비자 멱등성이 항상 짝으로 따라온다.

06 · 구현의 두 갈래 — Choreography vs Orchestration

SAGA를 굴리는 방식은 크게 둘이다. 흐름의 제어권을 참여자들에게 분산하느냐, 중앙에 집중하느냐의 차이다.

Choreography — 공화제 (분산형)

중앙 조정자가 없다. 각 서비스가 관심 있는 이벤트를 구독하다가, 반응하고, 자기 이벤트를 발행한다. 흐름은 이벤트의 연쇄로 자연스럽게 흘러간다.

주문결제포인트OrderCreated발행 → 결제가 구독PaymentCompleted발행 → 포인트가 구독중앙 조정자 없음 · 각 서비스가 이벤트로 자율 연결
Choreography — 이벤트가 서비스들 사이를 릴레이처럼 타고 흐른다

장점

  • 서비스 간 느슨한 결합, 직접 의존이 없음
  • 단일 조정자가 없어 SPOF가 없음
  • 단계가 적을 때(2~4개) 구조가 단순함

단점

  • 전체 흐름이 코드에 명시되지 않아 추적이 어려움
  • 단계가 늘면 이벤트 의존이 얽히고 순환 위험
  • 로직이 분산되어 디버깅·통합 테스트 난이도 상승

Orchestration — 군주제 (중앙형)

오케스트레이터가 사가의 상태를 직접 관리한다. 각 서비스에 커맨드를 보내고, 응답을 받아 다음에 무엇을 할지 결정한다. 실패하면 보상 커맨드를 역순으로 발행하는 책임도 오케스트레이터가 진다.

Orchestrator주문결제포인트command ↓reply ↑상태·순서·보상을 오케스트레이터 한곳에서 통제
Orchestration — 흐름과 보상의 책임이 오케스트레이터에 집중된다

장점

  • 흐름이 한곳에 명시되어 추적·모니터링이 쉬움
  • 단계 추가·변경이 오케스트레이터 수정으로 끝남
  • 서비스 간 순환 의존이 생기지 않음

단점

  • 오케스트레이터라는 컴포넌트가 추가됨
  • 로직이 과집중되면 god object가 될 위험
  • 참여 서비스는 커맨드 핸들러를 갖춰야 함

선택 기준 — 단계가 적고 흐름이 단순하면 Choreography, 단계가 많고 흐름의 가시성·복잡한 보상 제어가 중요하면 Orchestration. 실무에서는 서비스가 늘면 추적성 때문에 Orchestration으로 기우는 경우가 많다.

07 · Rollback vs Compensation

둘 다 "되돌린다"지만 메커니즘이 근본적으로 다르다.

  • Rollback — 커밋 전 변경을 DB가 폐기한다. 외부에 노출된 적 없으니 흔적이 남지 않는다. 단, 단일 DB·단일 트랜잭션 안에서만 가능.
  • Compensation — 이미 커밋된 로컬 트랜잭션을 반대 효과의 새 트랜잭션으로 상쇄한다. "환불"은 "결제가 없던 일"이 아니라 "결제 후 환불"이라는 두 기록을 남긴다.
📄PaymentService.java — 보상은 별도 트랜잭션
@Transactional
public Payment refund(String sagaId) {              // 보상 트랜잭션
    Payment p = paymentRepository.findBySagaId(sagaId).orElseThrow();
    if (p.isRefunded()) return p;                     // 멱등 — 두 번 환불 방지
    accountRepository.findByUserIdForUpdate(p.getUserId()).deposit(p.getAmount());
    p.markRefunded();                                 // 차감을 '없애는' 게 아니라 '되돌리는' 기록
    return p;
}

보상 설계 시 반드시 챙길 것

  • 비가역 작업 — 발송된 이메일, 외부 결제 confirm처럼 물리적으로 못 되돌리는 작업이 있다. 이런 단계는 보상 가능한 단계보다 뒤로 미루거나, "취소 통보" 같은 best-effort 보상으로만 처리한다.
  • 피벗 트랜잭션 (pivot) — 이 지점을 넘으면 보상이 불가능해 무조건 전진(재시도)해야 하는 단계. 사가를 보상 가능 구간 → 피벗 → 재시도 가능 구간으로 나눠 설계한다.
  • 멱등·역순 — 보상은 실패 단계의 직전부터 역순으로, 중복 실행돼도 안전하게.

08 · 대안 TCC, 그리고 기법 비교

**TCC(Try-Confirm-Cancel)**는 각 서비스가 자원을 먼저 예약한 뒤 확정/취소하는 방식이다. SAGA의 "일단 실행하고 틀리면 보상"과 달리, "예약해 두고 전원 OK면 확정"에 가깝다.

Try자원 예약 (잔액 동결)Confirm예약 확정 (실제 차감)Cancel예약 해제 (동결 복구)전원 Try 성공 → Confirm하나라도 실패 → Cancel
TCC — Try 단계에서 미리 예약해 두므로 중간 상태 노출(격리성 문제)을 줄인다
  • SAGA와의 차이 — SAGA는 실제 변경을 먼저 하고 틀리면 보상한다. TCC는 예약 → 확정이라 확정 전까지 실제 값이 바뀌지 않아 격리성 측면에서 유리하다. 대신 모든 서비스가 Try·Confirm·Cancel 3개 연산과 예약 모델을 구현해야 해 부담이 크다.
  • 설계 핵심 — 잔액을 available / frozen 컬럼으로 분리한다. Try는 available을 frozen으로 옮기고, Confirm은 frozen을 소멸, Cancel은 frozen을 available로 되돌린다.
📄schema.sql — PostgreSQL TCC 설계
CREATE TABLE account (
    user_id   BIGINT PRIMARY KEY,
    available BIGINT NOT NULL,            -- 사용 가능 잔액
    frozen    BIGINT NOT NULL DEFAULT 0   -- 예약(동결)된 금액
);

CREATE TABLE reservation (
    saga_id    TEXT PRIMARY KEY,          -- 멱등 키
    user_id    BIGINT NOT NULL,
    amount     BIGINT NOT NULL,
    status     TEXT   NOT NULL,           -- RESERVED / CONFIRMED / CANCELLED
    expires_at TIMESTAMPTZ NOT NULL       -- 만료된 예약은 배치로 자동 회수
);

-- Try : 예약 (available -> frozen). 잔액 부족이면 0 rows -> 실패 처리
UPDATE account SET available = available - :amt, frozen = frozen + :amt
WHERE user_id = :uid AND available >= :amt;

-- Confirm : 확정 (frozen 소멸)
UPDATE account SET frozen = frozen - :amt WHERE user_id = :uid;

-- Cancel : 취소 (frozen -> available 복구)
UPDATE account SET available = available + :amt, frozen = frozen - :amt
WHERE user_id = :uid;

한눈 비교

기법일관성격리성블로킹구현 난이도적합한 상황
2PC강한 일관성높음(락)동기 블로킹단일 조직·동종 DB, 짧은 트랜잭션
3PC강한 일관성높음완화높음(지연↑)실무 채택 드묾
SAGA최종 일관성없음(보완 필요)없음장기 흐름·이종 서비스, 보상 정의 가능
TCC최종 일관성예약으로 확보없음높음(3연산)자원 예약 모델이 자연스러운 도메인(결제·재고)

09 · 실습 ① 아키텍처와 환경 구성

이제 전자 결제 시나리오를 세 서비스로 쪼개 직접 돌려본다. 사가 흐름은 주문 생성 → 결제 → 포인트 적립이다.

  • order-service — 출입구이자 탈출구 — 모든 요청의 진입점. 사가를 시작하고 최종 결과(APPROVED/REJECTED)를 클라이언트에 응답한다. Orchestration에서는 이 서비스가 오케스트레이터를 겸한다.
  • payment-service — 중간 처리, 보상 대상 — 잔액을 차감한다. 실패 시 앞 단계 보상을 유발하고, 자신이 보상되면 환불한다.
  • point-service — 유실 허용 단계 — 포인트를 적립한다. 실패하거나 누락돼도 전체 정합성에 치명적이지 않다. 보상을 두지 않고 best-effort로만 처리해 사가를 단순하게 유지한다.
Clientorder-service:8081 · 출입구payment-service:8082 · 보상 대상point-service:8083 · 유실 허용Kafka (KRaft)PostgreSQLorder_dbpayment_dbpoint_dbDB per service (스키마 분리)
실습 토폴로지 — 3개 Spring Boot 서비스 + Kafka(KRaft) + PostgreSQL(스키마 분리)

docker-compose로 경량 환경 구축

Zookeeper 없이 KRaft 단일 브로커, PostgreSQL 한 대(서비스별 스키마), 그리고 세 서비스를 한 번에 띄운다.

📄docker-compose.yml
services:
  kafka:
    image: apache/kafka:3.7.0
    ports: ["9092:9092"]
    environment:
      KAFKA_NODE_ID: 1
      KAFKA_PROCESS_ROLES: broker,controller
      KAFKA_CONTROLLER_QUORUM_VOTERS: 1@kafka:9093
      KAFKA_LISTENERS: PLAINTEXT://:9092,CONTROLLER://:9093
      KAFKA_ADVERTISED_LISTENERS: PLAINTEXT://kafka:9092
      KAFKA_CONTROLLER_LISTENER_NAMES: CONTROLLER
      KAFKA_LISTENER_SECURITY_PROTOCOL_MAP: CONTROLLER:PLAINTEXT,PLAINTEXT:PLAINTEXT
      KAFKA_OFFSETS_TOPIC_REPLICATION_FACTOR: 1

  postgres:
    image: postgres:16
    environment:
      POSTGRES_DB: saga
      POSTGRES_PASSWORD: saga
    ports: ["5432:5432"]
    volumes: ["./init:/docker-entrypoint-initdb.d"]   # 스키마 + 시드 데이터

  order-service:    { build: ./order-service,   depends_on: [kafka, postgres], ports: ["8081:8080"] }
  payment-service:  { build: ./payment-service, depends_on: [kafka, postgres], ports: ["8082:8080"] }
  point-service:    { build: ./point-service,   depends_on: [kafka, postgres], ports: ["8083:8080"] }

Common 모듈의 필요성과 한계

세 서비스가 주고받는 이벤트/커맨드 계약(DTO)을 saga-common 모듈에 모은다. 단, 메시지 계약 수준으로만 제한해야 한다 — 비즈니스 로직·엔티티까지 공유하면 서비스가 다시 결합되어 MSA의 의미가 퇴색한다.

📄saga-common — 이벤트/커맨드 계약
package com.bedaily.saga.common.event;

// 이벤트 — "이미 일어난 사실"을 알림 (Choreography)
public record OrderCreatedEvent(String sagaId, Long orderId, Long userId, long amount) {}
public record PaymentCompletedEvent(String sagaId, Long orderId, Long paymentId, long amount) {}
public record PaymentFailedEvent(String sagaId, Long orderId, String reason) {}
public record PaymentRefundedEvent(String sagaId, Long orderId) {}

// 커맨드 — "이것을 하라"는 지시 (Orchestration)
public record ChargePaymentCommand(String sagaId, Long orderId, Long userId, long amount) {}
public record RefundPaymentCommand(String sagaId) {}
public record EarnPointCommand(String sagaId, Long orderId, long point) {}

public final class Topics {
    public static final String ORDER_CREATED      = "order.created";
    public static final String PAYMENT_COMPLETED   = "payment.completed";
    public static final String PAYMENT_FAILED      = "payment.failed";
    public static final String PAYMENT_REFUNDED    = "payment.refunded";
    public static final String CMD_CHARGE_PAYMENT  = "cmd.payment.charge";
    public static final String CMD_REFUND_PAYMENT  = "cmd.payment.refund";
    public static final String CMD_EARN_POINT      = "cmd.point.earn";
    private Topics() {}
}
📄application.yml — Kafka JSON 직렬화 (각 서비스 공통)
spring:
  kafka:
    bootstrap-servers: kafka:9092
    consumer:
      auto-offset-reset: earliest
      key-deserializer: org.apache.kafka.common.serialization.StringDeserializer
      value-deserializer: org.springframework.kafka.support.serializer.JsonDeserializer
      properties:
        spring.json.trusted.packages: "com.bedaily.saga.common.event"
    producer:
      key-serializer: org.apache.kafka.common.serialization.StringSerializer
      value-serializer: org.springframework.kafka.support.serializer.JsonSerializer

10 · 실습 ② Choreography 구현

중앙 조정자 없이, 각 서비스가 이벤트를 구독하고 자기 이벤트를 발행한다. 흐름: OrderCreated → PaymentCompleted/Failed → 주문 확정/거절, 그리고 포인트는 완료 이벤트에 얹혀 간다.

① order-service — 사가 시작 (출입구)

📄order-service / OrderCommandService.java
@Service
@RequiredArgsConstructor
public class OrderCommandService {
    private final OrderRepository orderRepository;
    private final KafkaTemplate<String, Object> kafka;

    @Transactional
    public Long placeOrder(PlaceOrderRequest req) {
        String sagaId = UUID.randomUUID().toString();
        Order order = orderRepository.save(
                Order.create(req.userId(), req.amount(), sagaId));   // status = PENDING

        // 실무에선 Outbox로 (05장) — 데모 단순화를 위해 직접 발행
        // key=sagaId : 같은 사가는 같은 파티션으로 → 이벤트 순서 보장 + 동시 처리 방지
        kafka.send(Topics.ORDER_CREATED, sagaId,
                new OrderCreatedEvent(sagaId, order.getId(), req.userId(), req.amount()));
        return order.getId();
    }
}

② payment-service — 결제 후 결과 이벤트 발행 (중간)

📄payment-service / PaymentEventListener.java
@Component
@RequiredArgsConstructor
public class PaymentEventListener {
    private final PaymentService paymentService;
    private final KafkaTemplate<String, Object> kafka;

    @KafkaListener(topics = Topics.ORDER_CREATED, groupId = "payment-service")
    public void on(OrderCreatedEvent e) {
        try {
            Payment p = paymentService.charge(e.sagaId(), e.orderId(), e.userId(), e.amount());
            kafka.send(Topics.PAYMENT_COMPLETED, e.sagaId(),
                    new PaymentCompletedEvent(e.sagaId(), e.orderId(), p.getId(), e.amount()));
        } catch (InsufficientBalanceException ex) {
            // 실패도 '사실'이므로 이벤트로 알린다 — 주문 서비스가 보고 거절 처리
            kafka.send(Topics.PAYMENT_FAILED, e.sagaId(),
                    new PaymentFailedEvent(e.sagaId(), e.orderId(), ex.getMessage()));
        }
    }
}

@Service
@RequiredArgsConstructor
public class PaymentService {
    private final AccountRepository accountRepository;
    private final PaymentRepository paymentRepository;

    @Transactional
    public Payment charge(String sagaId, Long orderId, Long userId, long amount) {
        // 멱등성 — 같은 saga로 이미 결제했으면 기존 결과 반환
        return paymentRepository.findBySagaId(sagaId).orElseGet(() -> {
            Account account = accountRepository.findByUserIdForUpdate(userId);
            account.withdraw(amount);                       // 잔액 부족 시 예외
            return paymentRepository.save(Payment.completed(sagaId, orderId, userId, amount));
        });
    }
}

③ order-service — 결과를 받아 최종 확정 (탈출구)

📄order-service / OrderSagaListener.java
@Component
@RequiredArgsConstructor
public class OrderSagaListener {
    private final OrderRepository orderRepository;

    @KafkaListener(topics = Topics.PAYMENT_COMPLETED, groupId = "order-service")
    @Transactional
    public void onPaymentCompleted(PaymentCompletedEvent e) {
        orderRepository.findById(e.orderId()).ifPresent(Order::approve);   // PENDING -> APPROVED
    }

    @KafkaListener(topics = Topics.PAYMENT_FAILED, groupId = "order-service")
    @Transactional
    public void onPaymentFailed(PaymentFailedEvent e) {
        // T1(주문 PENDING)에 대한 보상 = 주문 거절
        orderRepository.findById(e.orderId()).ifPresent(o -> o.reject(e.reason()));
    }
}

④ point-service — 유실 허용 (best-effort)

📄point-service / PointEventListener.java
@Component
@RequiredArgsConstructor
public class PointEventListener {
    private final PointService pointService;

    // 결제 완료에 얹혀 적립. 실패해도 보상하지 않음 — 재시도(best-effort)만.
    @KafkaListener(topics = Topics.PAYMENT_COMPLETED, groupId = "point-service")
    public void on(PaymentCompletedEvent e) {
        pointService.earn(e.sagaId(), e.orderId(), e.amount() / 100);   // 1% 적립
    }
}

보상의 위치 — 이 데모에서 결제 실패는 차감 이전의 거절이라 환불이 필요 없다. 만약 결제 뒤에 또 다른 필수 단계가 실패한다면, 그 단계가 XxxFailed 이벤트를 발행하고 payment-service가 이를 구독해 refund()를 실행한다 — 이것이 Choreography의 보상 방식이다. 흐름이 길어질수록 "누가 무엇을 구독하는지"가 머릿속에만 있게 되는 게 단점.

11 · 실습 ③ Orchestration 구현

같은 흐름을 오케스트레이터로 바꾼다. order-service 안의 오케스트레이터가 사가 상태를 들고, 커맨드를 발행하고, 응답을 받아 다음을 결정한다. 보상도 여기서 명령한다.

STARTEDPAYMENT_PENDINGCOMPLETEDCOMPENSATINGREJECTEDchargecompletedfailedrefundSagaState 한 곳에 전이가 명시된다 → 추적과 보상 제어가 쉽다
오케스트레이터의 상태 머신 — 모든 전이와 보상 분기가 한 곳에 모인다

① 사가 상태 + 오케스트레이터

📄order-service / SagaState.java + Orchestrator
public enum SagaStep { STARTED, PAYMENT_PENDING, COMPLETED, COMPENSATING, REJECTED }

@Entity
@Table(name = "saga_state")
public class SagaState {
    @Id private String sagaId;
    private Long orderId;
    @Enumerated(EnumType.STRING) private SagaStep step;
    @Version private long version;      // 낙관적 락 — 같은 사가 동시 갱신 시 충돌 감지
    private String lastError;
    private Instant updatedAt;
    // start(), markPaymentPending(), complete(), compensating(), reject() ...
}

@Service
@RequiredArgsConstructor
public class OrderSagaOrchestrator {
    private final SagaStateRepository sagaRepo;
    private final OrderRepository orderRepo;
    private final KafkaTemplate<String, Object> kafka;

    @Transactional
    public void start(Long orderId, Long userId, long amount) {
        String sagaId = UUID.randomUUID().toString();
        sagaRepo.save(SagaState.start(sagaId, orderId));        // STARTED -> PAYMENT_PENDING
        kafka.send(Topics.CMD_CHARGE_PAYMENT, sagaId,          // 커맨드도 sagaId 키로
                new ChargePaymentCommand(sagaId, orderId, userId, amount));
    }

    @KafkaListener(topics = Topics.PAYMENT_COMPLETED, groupId = "orchestrator")
    @Transactional
    public void onPaymentCompleted(PaymentCompletedEvent e) {
        SagaState s = sagaRepo.findById(e.sagaId()).orElseThrow();
        // 유실 허용 단계 — fire-and-forget, 응답을 기다리지 않는다
        kafka.send(Topics.CMD_EARN_POINT, e.sagaId(),
                new EarnPointCommand(e.sagaId(), e.orderId(), e.amount() / 100));
        try {
            finalizeOrder(e.orderId());                        // 최종 검증/확정 (재고 확인 등)
            s.complete();                                      // -> COMPLETED
        } catch (OrderFinalizeException ex) {
            // 결제(피벗 이후)는 이미 끝났으므로 되돌릴 수 없다 -> 보상 = 환불 커맨드 발행
            kafka.send(Topics.CMD_REFUND_PAYMENT, e.sagaId(), new RefundPaymentCommand(e.sagaId()));
            orderRepo.findById(e.orderId()).ifPresent(o -> o.reject(ex.getMessage()));
            s.compensating();                                  // -> COMPENSATING
        }
    }

    // 결제 자체가 실패(차감 전) -> 보상할 게 없으므로 주문만 거절
    @KafkaListener(topics = Topics.PAYMENT_FAILED, groupId = "orchestrator")
    @Transactional
    public void onPaymentFailed(PaymentFailedEvent e) {
        SagaState s = sagaRepo.findById(e.sagaId()).orElseThrow();
        orderRepo.findById(e.orderId()).ifPresent(o -> o.reject(e.reason()));
        s.reject(e.reason());                                  // -> REJECTED
    }

    // 환불 완료 통보 -> COMPENSATING 을 REJECTED 로 확정
    @KafkaListener(topics = Topics.PAYMENT_REFUNDED, groupId = "orchestrator")
    @Transactional
    public void onPaymentRefunded(PaymentRefundedEvent e) {
        sagaRepo.findById(e.sagaId()).ifPresent(s -> s.reject("compensated"));
    }

    // 최종 확정 — 검증 실패 시 OrderFinalizeException 으로 보상을 유발
    private void finalizeOrder(Long orderId) {
        Order order = orderRepo.findById(orderId).orElseThrow();
        if (!order.canApprove()) throw new OrderFinalizeException("최종 검증 실패");
        order.approve();                                       // PENDING -> APPROVED
    }
}

② payment-service — 커맨드 핸들러 (지시받아 실행)

Choreography에서는 OrderCreated라는 "사실"을 구독했지만, Orchestration에서는 ChargePaymentCommand라는 "지시"를 받는다. 이 차이가 두 방식의 본질이다.

📄payment-service / PaymentCommandHandler.java
@Component
@RequiredArgsConstructor
public class PaymentCommandHandler {
    private final PaymentService paymentService;
    private final KafkaTemplate<String, Object> kafka;

    @KafkaListener(topics = Topics.CMD_CHARGE_PAYMENT, groupId = "payment-service")
    public void onCharge(ChargePaymentCommand c) {
        try {
            Payment p = paymentService.charge(c.sagaId(), c.orderId(), c.userId(), c.amount());
            kafka.send(Topics.PAYMENT_COMPLETED, c.sagaId(),
                    new PaymentCompletedEvent(c.sagaId(), c.orderId(), p.getId(), c.amount()));
        } catch (InsufficientBalanceException ex) {
            kafka.send(Topics.PAYMENT_FAILED, c.sagaId(),
                    new PaymentFailedEvent(c.sagaId(), c.orderId(), ex.getMessage()));
        }
    }

    // 보상 커맨드 — 오케스트레이터가 피벗 이후 실패 시 발행
    @KafkaListener(topics = Topics.CMD_REFUND_PAYMENT, groupId = "payment-service")
    public void onRefund(RefundPaymentCommand c) {
        Payment p = paymentService.refund(c.sagaId());         // 멱등 환불 (07장)
        kafka.send(Topics.PAYMENT_REFUNDED, c.sagaId(),
                new PaymentRefundedEvent(c.sagaId(), p.getOrderId()));
    }
}

두 방식의 코드 차이 — 비즈니스 로직(PaymentService.charge)은 그대로다. 바뀐 건 **"무엇을 트리거로 호출되는가"**뿐 — 이벤트 구독(OrderCreated)이냐 커맨드 수신(ChargePaymentCommand)이냐. 그래서 두 방식은 같은 도메인 코드 위에서 갈아끼울 수 있다.

12 · 실습 ④ 테스트 시나리오

환경을 띄우고 진입점(order-service)에 주문을 던져 흐름을 확인한다.

📄실행 & 호출
docker compose up -d        # kafka + postgres + 3 services

# 시드: user 1 = 잔액 100,000 / user 2 = 잔액 0

# 시나리오 A) 성공 — 잔액 충분
curl -X POST localhost:8081/orders \
  -H 'Content-Type: application/json' \
  -d '{"userId":1,"amount":10000}'
# order.created -> 결제 차감 -> payment.completed
#   -> order APPROVED, point 100 적립

# 시나리오 B) 실패 — 잔액 부족
curl -X POST localhost:8081/orders \
  -H 'Content-Type: application/json' \
  -d '{"userId":2,"amount":5000}'
# order.created -> 잔액 부족 -> payment.failed
#   -> order REJECTED, 차감 없음(보상 불필요), 포인트 미적립

# 결과 확인
curl localhost:8081/orders/1     # { "status": "APPROVED" }
curl localhost:8082/accounts/1   # { "balance": 90000 }
curl localhost:8083/points/1     # { "point": 100 }

꼭 확인할 동작

  • 멱등성 — 같은 sagaId 이벤트를 수동으로 재발행해도 잔액이 두 번 차감되지 않는지 — findBySagaId 가드가 동작하는지.
  • 최종 일관성 — 결제 완료 직후 주문 조회 시 잠깐 PENDING일 수 있다. 이벤트가 소비되면 APPROVED로 수렴한다 — 즉시 일관성이 아님을 눈으로 확인.
  • 유실 허용 — point-service를 내린 채 주문해 본다. 주문·결제는 정상 완료되고, 포인트만 미적립으로 남는다 — 전체 사가는 실패하지 않는다.
  • 보상(Orchestration) — finalize 단계에 강제 예외를 넣어 CMD_REFUND_PAYMENT가 발행되고 잔액이 복구되는지, 상태가 COMPENSATING → REJECTED로 가는지.

정리 — "유실이 허용되는 마지막 단계"를 일부러 둔 건, 모든 단계에 같은 강도의 일관성을 요구하지 않는다는 걸 보여주기 위해서다. 어디까지 엄격히 보상하고 어디부터 느슨하게 둘지 — 그 경계를 긋는 게 사가 설계의 실전이다. 그리고 그 경계를 코드로 강제하는 두 방식이 Choreography와 Orchestration이다.

13 · 현업 적용 체크리스트 — 데모와 프로덕션의 간극

앞의 구조(SAGA·Outbox+CDC·TCC)는 실제 현업에서 쓰는 것들이 맞다. 다만 코드 스니펫은 흐름을 보이기 위한 교육용이라, 그대로 올리면 버그가 되는 지점들이 있다. 프로덕션으로 가려면 아래를 반드시 채운다.

  • 메시지 키 = sagaId — 키 없이 발행하면 같은 사가 이벤트가 여러 파티션에 흩어져 순서가 깨지고 동시 처리된다. 반드시 send(topic, sagaId, event)로 키를 준다 → 같은 파티션·순차 처리 (본문 코드 반영됨).
  • Outbox는 모든 발행 hop에 — 진입 서비스만이 아니라 이벤트를 내보내는 모든 서비스(payment도)가 Outbox를 써야 한다. 그렇지 않으면 그 hop에서 이중 쓰기 유실이 그대로 남는다. 데모에선 order만 언급했지만 실제론 payment의 PaymentCompleted 발행도 Outbox 대상.
  • 타임아웃/데드라인 — 커맨드를 보냈는데 응답 이벤트가 영영 안 오면? 오케스트레이터는 사가가 PAYMENT_PENDING에 멈춘 채 방치된다. 스케줄러로 미완료 사가를 주기 점검해 재시도하거나 보상으로 몰아야 한다 — 직접 구현 사가의 최대 함정.
  • 소비자 에러 처리 / DLT@KafkaListener에서 비즈니스 외 예외(DB 다운 등)는 무한 재시도로 파티션을 막는다. DefaultErrorHandler + 백오프 + Dead Letter Topic으로 독성 메시지를 격리한다.
  • SagaState 동시성 — 같은 사가에 이벤트가 겹쳐 들어오면 상태가 lost update 된다. @Version 낙관적 락(본문 반영) + 키 파티셔닝으로 방어한다.
  • 잔액 갱신 방식SELECT ... FOR UPDATE 후 차감은 인기 계정에서 락 핫스팟이 된다. 고빈도라면 조건부 원자 UPDATE(UPDATE account SET balance = balance - :amt WHERE id = :id AND balance >= :amt)로 바꿔 갱신 행 수(0/1)로 성패를 판정한다.
  • 관측성 (observability)sagaId를 correlation id로 로그·트레이스(OTLP)에 전파해야 흩어진 서비스에서 한 사가를 이어 볼 수 있다. 이게 없으면 실패한 사가의 디버깅이 사실상 불가능하다.
  • 테스트 — curl 수동 확인 대신 Testcontainers(Kafka + PostgreSQL)로 통합 테스트를 띄우고, 최종 일관성은 Awaitility로 "결국 APPROVED가 되는가"를 검증한다.

직접 구현 vs 프레임워크 — 단계 2~3개의 단순 사가라면 위처럼 @KafkaListener + SagaState로 직접 짜는 게 합리적이다. 하지만 단계·보상·타임아웃이 얽히기 시작하면 상태 관리와 재시도를 직접 감당하기 버겁다. 이때는 Eventuate Tram Saga, Axon, Temporal, Camunda(Zeebe) 같은 검증된 사가/워크플로 엔진을 얹는 편이 낫다 — 순서·타임아웃·재시도·멱등을 프레임워크가 책임진다.

한 줄 결론 — "SAGA로 로컬 트랜잭션을 잇고, Outbox로 발행을 보장하고, 소비자는 멱등하게, 메시지는 키로 순서를 잡고, 못 되돌리는 건 피벗 뒤로" — 이 골격은 현업 그대로다. 데모 코드에서 생략된 건 신뢰성·동시성·운영 레이어이며, 위 체크리스트가 그 레이어다.

주간 기술 뉴스레터

Backend · AI · Java 핵심 내용을 매주 이메일로 보내드립니다.