- Published on
DDD에서 분산 트랜잭션 없이 SAGA 구현하기
- Authors
- Name
- 스타차일드
- https://x.com/ETFBITX
서로 다른 마이크로서비스(또는 바운디드 컨텍스트) 사이에서 하나의 비즈니스 흐름을 완결해야 할 때, 많은 팀이 가장 먼저 떠올리는 해법은 “그냥 분산 트랜잭션(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 패턴
가장 흔한 실패 시나리오는 이겁니다.
- 주문 DB 트랜잭션 커밋 성공
- 이벤트 발행(메시지 브로커 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: 주문-결제-재고
가상의 흐름을 정의해보겠습니다.
- Order Service: 주문 생성(PENDING)
- Payment Service: 결제 승인(성공/실패)
- Inventory Service: 재고 예약(성공/실패)
- 모두 성공 시 주문 CONFIRMED
- 중간 실패 시 보상:
- 결제 성공 후 재고 실패 → 결제 취소(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가지(요약)
- 애그리거트 경계를 넘어서는 강한 일관성 요구를 먼저 제거(정책/UX로 완화)
- Outbox로 DB 커밋과 이벤트 기록을 원자적으로
- Inbox(또는 processed_messages)로 컨슈머 멱등성 확보
- 메시지에는
messageId,correlationId(sagaId),causationId를 포함 - 오케스트레이션은 상태 머신 + 저장소로 “재시작 가능”하게
- 보상 트랜잭션은 비즈니스 행위로 모델링(취소/환불/예약해제)
- 타임아웃/만료 상태를 SAGA에 포함
- DLQ와 재처리(runbook) 절차를 준비
- 관측성을 위해 sagaId 기반 조회 API/대시보드 제공
- “완료”는 하나의 이벤트가 아니라, 각 로컬 트랜잭션의 결과가 모여 만들어지는 상태임을 팀에 공유
마무리
DDD에서 분산 트랜잭션 없이 SAGA를 구현한다는 것은 “트랜잭션을 포기한다”가 아니라, 일관성을 맞추는 책임을 데이터베이스가 아니라 애플리케이션/도메인 모델로 가져오는 것에 가깝습니다. Outbox/Inbox로 메시징의 신뢰성을 확보하고, 오케스트레이션(또는 코레오그래피)로 흐름을 명시하며, 보상/멱등성/타임아웃을 제품 요구사항 수준으로 끌어올리면 운영 가능한 SAGA가 됩니다.
다음 단계로는, 여러분의 도메인에서 “정말로 강한 일관성이 필요한 지점”과 “최종적 일관성으로도 충분한 지점”을 분리해 SAGA 범위를 최소화해보는 것을 권합니다.