Published on

DDD에서 분산 트랜잭션 없이 SAGA 구현하기

Authors

서로 다른 마이크로서비스(또는 바운디드 컨텍스트) 사이에서 하나의 비즈니스 흐름을 완결해야 할 때, 많은 팀이 가장 먼저 떠올리는 해법은 “그냥 분산 트랜잭션(2PC)으로 묶으면 되지 않나?”입니다. 하지만 DDD 관점에서는 애그리거트 경계를 넘어서는 강한 일관성을 강요하는 순간, 모델은 급격히 결합되고 운영 복잡도는 폭발합니다. 게다가 2PC는 네트워크/락/장애 복구 비용 때문에 현실적인 선택지가 되기 어렵습니다.

이 글은 DDD의 설계 원칙을 유지하면서 분산 트랜잭션 없이 SAGA를 구현하는 방법을, 구현 디테일(Outbox, 멱등성, 상태 머신, 보상 트랜잭션) 중심으로 정리합니다.

왜 DDD에서는 분산 트랜잭션을 피하려고 할까

DDD에서 애그리거트는 일관성 경계(Consistency Boundary) 입니다. 애그리거트 내부는 단일 트랜잭션으로 강하게 일관되게 만들 수 있지만, 애그리거트 밖(다른 애그리거트/다른 BC)은 결국 비동기 + 최종적 일관성(Eventual Consistency) 로 풀어야 모델이 건강해집니다.

분산 트랜잭션을 도입하면 다음 문제가 생깁니다.

  • 경계 붕괴: 여러 애그리거트를 한 트랜잭션으로 묶고 싶어져 경계가 흐려짐
  • 운영 리스크: 타임아웃/락 경합/부분 장애에서 복구가 어려움
  • 확장성 저하: 참여 노드가 늘수록 지연과 실패 확률이 증가

SAGA는 이런 현실을 받아들이고, “여러 로컬 트랜잭션을 비동기로 엮고 실패 시 보상으로 되돌리자”는 접근입니다.

SAGA 핵심 개념: 로컬 트랜잭션 + 이벤트 + 보상

SAGA는 보통 아래 요소로 구성됩니다.

  • 로컬 트랜잭션(Local Transaction): 각 서비스/BC의 DB에서 단독으로 커밋되는 트랜잭션
  • 이벤트(Event): 다음 단계를 트리거하는 메시지(도메인 이벤트/통합 이벤트)
  • 보상 트랜잭션(Compensation): 중간 실패 시 이전 단계의 효과를 “반대로” 되돌리는 작업

여기서 중요한 점은 “롤백”이 아니라 새로운 비즈니스 행위로서의 취소/환불/예약해제처럼 보상 동작을 모델링한다는 것입니다.

코레오그래피 vs 오케스트레이션: 무엇을 선택할까

SAGA 구현은 크게 두 가지 스타일이 있습니다.

코레오그래피(Choreography)

서비스들이 이벤트를 발행/구독하며 자율적으로 다음 단계를 진행합니다.

  • 장점: 중앙 조정자 없이 느슨한 결합
  • 단점: 플로우가 커질수록 “이벤트 스파게티”가 되기 쉬움, 관측/디버깅 난이도 상승

오케스트레이션(Orchestration)

중앙의 SAGA 오케스트레이터가 상태를 가지고 각 서비스에 커맨드를 보내 진행합니다.

  • 장점: 흐름이 명시적, 상태 머신으로 관리 가능, 관측/리트라이/타임아웃 통제가 쉬움
  • 단점: 오케스트레이터가 복잡해지고 SPOF가 되지 않도록 설계 필요

실무 팁:

  • 단순한 2~3단계 흐름: 코레오그래피도 충분
  • 결제/재고/배송처럼 실패/보상이 많은 흐름: 오케스트레이션이 유지보수에 유리

분산 트랜잭션 없이 “원자성”을 흉내 내려면: Outbox 패턴

가장 흔한 실패 시나리오는 이겁니다.

  1. 주문 DB 트랜잭션 커밋 성공
  2. 이벤트 발행(메시지 브로커 publish) 실패

그러면 주문은 생성됐는데 다음 단계가 진행되지 않습니다. 이를 막기 위해 DB 커밋과 이벤트 기록을 한 트랜잭션으로 묶는 Outbox 패턴을 사용합니다.

Outbox 테이블 예시(PostgreSQL)

CREATE TABLE outbox (
  id            UUID PRIMARY KEY,
  aggregate_id  UUID NOT NULL,
  event_type    TEXT NOT NULL,
  payload       JSONB NOT NULL,
  occurred_at   TIMESTAMPTZ NOT NULL DEFAULT now(),
  published_at  TIMESTAMPTZ
);

CREATE INDEX idx_outbox_unpublished ON outbox(published_at) WHERE published_at IS NULL;

주문 생성 + Outbox 기록을 같은 트랜잭션으로

(예시는 TypeScript + node-postgres 스타일)

import { randomUUID } from "crypto";

type OrderCreated = {
  orderId: string;
  userId: string;
  totalAmount: number;
};

async function createOrderWithOutbox(client: any, userId: string, totalAmount: number) {
  const orderId = randomUUID();
  const event: OrderCreated = { orderId, userId, totalAmount };

  await client.query("BEGIN");
  try {
    await client.query(
      `INSERT INTO orders(id, user_id, total_amount, status)
       VALUES ($1, $2, $3, 'PENDING')`,
      [orderId, userId, totalAmount]
    );

    await client.query(
      `INSERT INTO outbox(id, aggregate_id, event_type, payload)
       VALUES ($1, $2, $3, $4)`,
      [randomUUID(), orderId, "OrderCreated", event]
    );

    await client.query("COMMIT");
    return orderId;
  } catch (e) {
    await client.query("ROLLBACK");
    throw e;
  }
}

이후 별도 퍼블리셔(배치/워커)가 outbox를 읽어 브로커에 발행하고 published_at을 업데이트합니다. 이 구조가 **“DB와 메시지의 이중 쓰기 문제”**를 실무적으로 해결합니다.

SAGA에서 제일 자주 터지는 문제: 중복 처리(멱등성)

메시지 브로커는 보통 at-least-once 전달이 기본입니다. 즉 이벤트/커맨드가 중복으로 올 수 있습니다. 따라서 각 단계는 반드시 멱등해야 합니다.

  • 동일한 messageId/eventId를 여러 번 처리해도 결과가 같아야 함
  • “보상 트랜잭션”도 중복 실행될 수 있음

이 주제는 SAGA의 생명줄이라서 별도로 더 깊게 다룬 글을 함께 보는 것을 권합니다: Saga 패턴 보상 트랜잭션 중복 실행 방지법

처리한 메시지 기록(Inbox)으로 멱등성 확보

CREATE TABLE inbox (
  message_id   UUID PRIMARY KEY,
  received_at  TIMESTAMPTZ NOT NULL DEFAULT now()
);
async function handleMessageIdempotently(client: any, messageId: string, handler: () => Promise<void>) {
  await client.query("BEGIN");
  try {
    const res = await client.query(
      "INSERT INTO inbox(message_id) VALUES ($1) ON CONFLICT DO NOTHING RETURNING message_id",
      [messageId]
    );

    // 이미 처리한 메시지면 조용히 종료
    if (res.rowCount === 0) {
      await client.query("COMMIT");
      return;
    }

    await handler();
    await client.query("COMMIT");
  } catch (e) {
    await client.query("ROLLBACK");
    throw e;
  }
}

핵심은 “멱등성 체크도 로컬 트랜잭션 안에서” 처리해 중복 메시지 레이스 컨디션을 제거하는 것입니다.

예제로 보는 오케스트레이션 SAGA: 주문-결제-재고

가상의 흐름을 정의해보겠습니다.

  1. Order Service: 주문 생성(PENDING)
  2. Payment Service: 결제 승인(성공/실패)
  3. Inventory Service: 재고 예약(성공/실패)
  4. 모두 성공 시 주문 CONFIRMED
  5. 중간 실패 시 보상:
    • 결제 성공 후 재고 실패 → 결제 취소(Refund/Cancel)
    • 재고 성공 후 이후 실패 → 재고 예약 해제

SAGA 상태 머신(간단 모델)

type SagaState =
  | "STARTED"
  | "PAYMENT_AUTHORIZED"
  | "INVENTORY_RESERVED"
  | "COMPLETED"
  | "COMPENSATING"
  | "FAILED";

type Saga = {
  sagaId: string;
  orderId: string;
  state: SagaState;
  paymentId?: string;
  reservationId?: string;
  updatedAt: string;
};

오케스트레이터의 이벤트 핸들링(의사 코드)

async function onOrderCreated(evt: { sagaId: string; orderId: string; amount: number }) {
  // 1) saga 저장(STARTED)
  // 2) Payment 서비스에 AuthorizePayment 커맨드 발행
}

async function onPaymentAuthorized(evt: { sagaId: string; paymentId: string }) {
  // saga state -> PAYMENT_AUTHORIZED
  // Inventory 서비스에 ReserveStock 커맨드 발행
}

async function onInventoryReserveFailed(evt: { sagaId: string; reason: string }) {
  // saga state -> COMPENSATING
  // Payment 서비스에 CancelPayment 커맨드 발행
}

async function onPaymentCanceled(evt: { sagaId: string }) {
  // 주문을 FAILED로 마감(또는 CANCELED)
  // saga state -> FAILED
}

async function onInventoryReserved(evt: { sagaId: string; reservationId: string }) {
  // 주문을 CONFIRMED로 업데이트
  // saga state -> COMPLETED
}

여기서 “주문 CONFIRMED 업데이트”는 Order Service의 로컬 트랜잭션이며, 오케스트레이터는 이를 직접 DB로 하지 않고 커맨드/이벤트로 요청하는 편이 경계에 맞습니다.

보상 트랜잭션 설계 체크리스트

보상은 단순히 반대로 빼는 로직이 아닙니다. 아래를 반드시 점검해야 합니다.

  • 비가역성: 이미 외부로 배송된 상품은 “취소”가 아니라 “반품 프로세스”가 됨
  • 부분 보상: 쿠폰/포인트/현금영수증 등 부수 효과를 함께 되돌려야 함
  • 시간 제약: 일정 시간이 지나면 취소 불가(정책) → SAGA 타임아웃/만료 상태 필요
  • 중복 실행: 보상 커맨드도 중복 수신 가능 → 멱등 처리

특히 “보상이 중복 실행되면 돈이 두 번 환불되는가?” 같은 사고는 치명적입니다. 앞서 링크한 글처럼 중복 보상 방지를 설계 초기에 포함하세요.

실패, 재시도, 타임아웃: 운영 관점에서의 SAGA

SAGA는 구현보다 운영이 더 어렵습니다. 다음을 시스템 기능으로 제공해야 합니다.

  • 재시도(Retry): 일시 장애(네트워크, DB 커넥션)에는 지수 백오프
  • 데드레터 큐(DLQ): 영구 실패 메시지 격리 + 수동 처리
  • 타임아웃: 특정 단계가 N분 내 완료되지 않으면 보상으로 전환
  • 관측성(Observability): sagaId로 전 구간 트레이싱, 상태 조회 API

운영 환경이 Kubernetes/EKS라면, 워커의 OOM/스케줄링 이슈로 outbox 퍼블리셔나 컨슈머가 멈추면서 “흐름이 정체”되는 일이 흔합니다. 이런 경우 노드/파드 단의 메모리 압박을 추적하는 방법도 알아두면 좋습니다: 리눅스 OOM Kill 원인 추적 - dmesg·cgroup·journalctl

DDD 관점에서 이벤트를 어떻게 나눌까: 도메인 이벤트 vs 통합 이벤트

실무에서 자주 헷갈리는 지점입니다.

  • 도메인 이벤트(Domain Event): 애그리거트 내부에서 의미 있는 사실(예: OrderPlaced)
  • 통합 이벤트(Integration Event): 다른 BC/서비스에 공개하기 위한 계약(예: OrderCreatedV1)

권장 패턴:

  • 애그리거트는 도메인 이벤트를 만들고,
  • 애플리케이션 서비스가 이를 받아 통합 이벤트로 변환해 Outbox에 기록합니다.

이렇게 하면 내부 모델 변경이 외부 계약을 바로 깨지 않게 완충층이 생깁니다.

실전 구현 팁 10가지(요약)

  1. 애그리거트 경계를 넘어서는 강한 일관성 요구를 먼저 제거(정책/UX로 완화)
  2. Outbox로 DB 커밋과 이벤트 기록을 원자적으로
  3. Inbox(또는 processed_messages)로 컨슈머 멱등성 확보
  4. 메시지에는 messageId, correlationId(sagaId), causationId를 포함
  5. 오케스트레이션은 상태 머신 + 저장소로 “재시작 가능”하게
  6. 보상 트랜잭션은 비즈니스 행위로 모델링(취소/환불/예약해제)
  7. 타임아웃/만료 상태를 SAGA에 포함
  8. DLQ와 재처리(runbook) 절차를 준비
  9. 관측성을 위해 sagaId 기반 조회 API/대시보드 제공
  10. “완료”는 하나의 이벤트가 아니라, 각 로컬 트랜잭션의 결과가 모여 만들어지는 상태임을 팀에 공유

마무리

DDD에서 분산 트랜잭션 없이 SAGA를 구현한다는 것은 “트랜잭션을 포기한다”가 아니라, 일관성을 맞추는 책임을 데이터베이스가 아니라 애플리케이션/도메인 모델로 가져오는 것에 가깝습니다. Outbox/Inbox로 메시징의 신뢰성을 확보하고, 오케스트레이션(또는 코레오그래피)로 흐름을 명시하며, 보상/멱등성/타임아웃을 제품 요구사항 수준으로 끌어올리면 운영 가능한 SAGA가 됩니다.

다음 단계로는, 여러분의 도메인에서 “정말로 강한 일관성이 필요한 지점”과 “최종적 일관성으로도 충분한 지점”을 분리해 SAGA 범위를 최소화해보는 것을 권합니다.