모놀리스에서 익숙했던 ACID 트랜잭션은 분산 환경에서 자연스럽지 않다. “결제와 재고 차감을 하나로 묶는다” 가 한 DB 안에서는 한 줄이지만, 두 서비스 사이에서는 어느 쪽도 보장이 약한 상태가 된다. 단일 트랜잭션의 A(Atomicity) 가 서비스 경계 너머에서 사라진다.

분산 트랜잭션은 단일 ACID 트랜잭션이 어떻게 분해되고 그 조각을 어떻게 재조립하는가의 문제다. 크게 두 갈래다. 분산된 commit 을 동기 합의로 한 번에 묶거나, 각자 로컬에서 commit 하고 실패 시 보상으로 되돌리거나.

flowchart LR
  A[모놀리스 단일 트랜잭션
ACID] --> B[서비스 경계로 분해] B --> C{재조립 전략} C -->|동기 합의| D[2PC] C -->|로컬 commit + 보상| E[Saga / TCC] E --> F[Outbox
DB ↔ 이벤트 발행 일관성]

2PC

2PC(Two-Phase Commit) 는 분산된 조각을 한 번에 commit 하려는 시도다. coordinator 가 모든 참여자에게 “준비됐냐” 를 묻고(prepare), 모두가 ok 면 “commit 해라” 를 보내는 두 단계 흐름이다.

sequenceDiagram
    participant C as Coordinator
    participant P1 as 참여자 1
    participant P2 as 참여자 2
    participant P3 as 참여자 3

    Note over C,P3: 1단계 — Prepare (준비)
    C->>P1: prepare
    C->>P2: prepare
    C->>P3: prepare
    P1-->>C: vote yes
    P2-->>C: vote yes
    P3-->>C: vote yes

    Note over C,P3: 2단계 — Commit (결정)
    C->>P1: commit
    C->>P2: commit
    C->>P3: commit
    P1-->>C: ack
    P2-->>C: ack
    P3-->>C: ack

작동은 깔끔하지만 비용이 크다. prepare 단계 후 각 참여자는 commit 또는 abort 를 받을 때까지 자원을 잠그고 기다린다. 잠금 대기가 길어진다. coordinator 가 이 사이에 중단되면 참여자들은 무한히 대기 상태가 될 수 있다. 단일 장애점이다. 그리고 두 번의 round trip 으로 인한 지연이 모든 트랜잭션마다 누적된다.

실패 모드의 구조는 분명하다. prepare 에서 누군가 vote no 또는 응답이 없으면 coordinator 가 abort 를 브로드캐스트하면서 정상 종료한다. vote yes 는 자기 WAL 에 prepared 상태를 기록하고 “이후 commit 을 받으면 무조건 수행한다” 는 계약을 진다. 그래서 commit 단계는 가벼운 작업이고, 참여자가 prepared 상태에서 죽었다 부활해도 WAL 과 coordinator 조회로 복구된다.

진짜 깨지는 사례는 두 갈래다. coordinator 가 commit 을 일부에게만 보내고 죽으면 blocking 이 발생한다. vote yes 후 디스크 결함 등으로 실제 commit 이 실패하는 경우는 프로토콜 보장을 벗어난 데이터 손상 영역이라, 애플리케이션이나 운영 차원에서 수동 복구해야 한다.

2PC 는 강한 일관성을 확보하지만 가용성과 성능을 일관성에 지불하는 트레이드오프가 명확하다. 그래서 현대 MSA 에서는 자주 쓰이지 않고, 강한 일관성이 꼭 필요한 좁은 경로에만 한정되는 편이다.

Saga / TCC

Saga 는 하나의 비즈니스 트랜잭션을 여러 서비스의 로컬 트랜잭션으로 나누고 순차적으로 진행한다. 어느 단계에서 실패하면 보상 트랜잭션으로 이전 단계들을 되돌린다.

예를 들어 주문 생성 → 결제 → 재고 차감 → 배송 등록의 흐름이 있다고 하자. 재고 차감에서 실패하면, 결제는 환불(보상), 주문은 취소(보상). 단일 트랜잭션의 rollback 이 여러 서비스에 걸친 명시적 보상 로직으로 대체된다.

Saga 는 결과적 일관성을 받아들인다. 각 단계 사이에는 짧지만 빈 시간이 있고, 그 시간 동안 시스템은 일시적으로 불일치 상태에 있을 수 있다. 이 불일치가 비즈니스 측면에서 허용 가능한지가 Saga 채택의 핵심 조건이다.

보상 트랜잭션이 실패할 때가 Saga 의 어려운 부분이다. 한번 forward 단계에 진입하면 그에 대응하는 보상은 반드시 끝나야 한다. Saga 에는 “포기” 상태가 없다. 그래서 보상은 멱등하게 설계되어 재시도가 가능해야 한다. 영구 재시도가 안 되면 다른 유효한 종착점으로 밀어붙이거나 (forward recovery — 카드 환불 실패 시 적립금으로 대체), 운영 차원의 수동 개입으로 넘긴다.

Choreography

각 서비스가 이벤트를 발행하고, 다른 서비스가 그 이벤트를 구독해서 자기 단계를 진행하는 방식이다. 중앙 조율자가 없다. “OrderCreated” 이벤트가 발행되면 결제 서비스가 그것을 받아 결제하고 “PaymentCompleted” 를 발행한다. 재고 서비스가 그것을 받고 차감 후 “InventoryReserved” 발행. 각 서비스가 자기 트리거와 보상 로직을 안다.

sequenceDiagram
    participant O as 주문
    participant P as 결제
    participant I as 재고
    participant S as 배송

    O->>P: OrderCreated
    P->>I: PaymentCompleted
    I->>S: InventoryReserved

    Note over O,S: 실패 시 — 역순으로 보상 이벤트
    S-->>I: ShipmentFailed
    I-->>P: InventoryReleased
    P-->>O: PaymentRefunded

Orchestration

중앙 조율자(Saga state machine 또는 orchestrator) 가 흐름을 명시적으로 통제한다. 조율자가 결제 서비스에 명령을 보내고 응답을 받으면 다음으로 재고 서비스에 명령을 보낸다. 실패가 발생하면 조율자가 보상 명령을 역순으로 발행한다.

sequenceDiagram
    participant Or as 조율자
    participant P as 결제
    participant I as 재고
    participant S as 배송

    Or->>P: 결제
    P-->>Or: 성공
    Or->>I: 재고 차감
    I-->>Or: 성공
    Or->>S: 배송 등록
    S-->>Or: 실패

    Note over Or,S: 실패 응답 시 — 역순 보상 명령
    Or->>I: 재고 복구
    Or->>P: 환불

두 변형의 트레이드오프

  • 결합도. Choreography 는 서비스 간 직접 의존이 없지만, 이벤트 시퀀스가 여러 서비스에 분산되어 있다. Orchestration 은 조율자가 모든 서비스를 알지만 서비스들끼리는 서로 모른다.
  • 가시성. 한 트랜잭션이 어디까지 진행됐는지 보려면, Choreography 에서는 여러 서비스의 로그를 추적해야 하고, Orchestration 에서는 조율자의 상태만 보면 된다.
  • 디버깅. 실패 케이스의 보상 흐름을 따라가는 것은 Orchestration 이 훨씬 직관적이다. Choreography 는 이벤트 그래프가 복잡해질수록 추적이 어려워진다.
  • 비즈니스 로직의 응집성. 한 비즈니스 트랜잭션의 흐름이 한곳에 모이는 것은 Orchestration 의 강점이다. Choreography 는 흐름이 여러 서비스로 나뉘어 있다.

작은 시스템에서는 Choreography 가 가볍게 시작하기 좋고, 흐름이 복잡해지면 Orchestration 으로 옮기는 패턴이 잘 맞는다고 본다. 단, 조율자가 또 하나의 서비스라 그 복잡도를 추가로 가져온다는 점은 의식해야 한다.

TCC

TCC(Try-Confirm-Cancel) 는 같은 보상 원리를 비즈니스 레벨 예약으로 변형한 것이다. Try 단계에서 자원을 예약하고, 모든 Try 가 성공하면 Confirm, 하나라도 실패하면 Cancel(보상) 을 호출한다.

sequenceDiagram
    participant C as Coordinator
    participant P1 as 참여자 1
    participant P2 as 참여자 2
    participant P3 as 참여자 3

    Note over C,P3: 1단계 — Try (예약)
    C->>P1: try
    C->>P2: try
    C->>P3: try
    P1-->>C: reserved
    P2-->>C: reserved
    P3-->>C: reserved

    Note over C,P3: 2단계 — Confirm (확정)
    C->>P1: confirm
    C->>P2: confirm
    C->>P3: confirm
    P1-->>C: ack
    P2-->>C: ack
    P3-->>C: ack

Saga 와의 차이는 잠금 길이다. Saga 는 각 단계가 로컬 트랜잭션으로 즉시 commit 되어 다른 트랜잭션이 그 상태를 자유롭게 본다. TCC 는 예약이 걸려 있는 동안 그 자원의 일부 — “예약된 좌석”, “잠금 처리 중 잔액” 처럼 — 가 비즈니스 의미상 잠긴다. 실제 DB lock 은 아니지만 짧은 의미적 lock 이 걸리는 셈이다.

대가는 명확하다. 모든 참여 서비스가 reserve/confirm/cancel 세 가지 API 를 일관되게 노출해야 한다. Saga 의 단순함을 일부 포기하고 잠금 시간을 짧게 가져가는 트레이드오프다.

Outbox

Saga 든 다른 이벤트 기반 패턴이든 모두 DB write 와 이벤트 발행이 한 트랜잭션이 아니라는 본질적 한계를 공유한다.

상태를 DB 에 저장하고 그 사실을 이벤트로 발행한다고 하자. 두 작업이 분리되어 있다는 점이 문제다. DB write 후 이벤트 발행 직전에 서비스가 중단되면 이벤트가 유실된다. 반대로 이벤트 발행 후 DB write 가 실패하면 일어나지 않은 일을 알린 셈이 된다. 두 작업을 어떤 순서로 두든 일관성이 깨질 수 있다.

Outbox Pattern 은 이 문제를 같은 DB 트랜잭션 안에서 두 작업을 묶어 해결한다. 비즈니스 데이터를 쓰는 동시에 발행할 이벤트를 outbox 라는 별도 테이블에 같은 트랜잭션으로 기록한다. 트랜잭션이 commit 되면 outbox 에 이벤트가 안전하게 남는다. 별도의 publisher 프로세스가 outbox 를 polling 하거나 CDC(Change Data Capture) 로 변경을 감지해서 메시지 broker (흔히 Kafka) 에 발행한다.

이 구조는 at-least-once delivery 를 자연스럽게 받아들인다. publisher 가 발행 후 outbox 에서 지우기 전에 중단되면 같은 이벤트가 다시 발행될 수 있다. 그래서 consumer 측은 멱등성을 가져야 한다. 같은 이벤트를 두 번 처리해도 결과가 같도록 설계한다.

Outbox 는 Kafka 환경에서 DB 와 broker 의 일관성을 보장한다.

패턴 선택 기준

분해 기준 — 도메인 경계, 데이터 소유권, 스케일 패턴, 장애 경계 — 이 분산 트랜잭션 패턴 선택의 출발점이 된다.

  • 도메인 경계로 분할했는데 두 도메인이 강한 일관성을 요구한다면 그 경계가 잘못 그어졌다는 신호다. 정말 강한 일관성이 필요하면 한 도메인으로 합치는 게 낫고, 그래도 분리가 필수면 2PC 를 좁은 경로에 한정해서 쓴다.
  • 데이터 소유권 기준에서 한 서비스의 변경이 다른 서비스의 뷰/캐시를 갱신해야 한다면 Saga (Choreography) + Outbox 의 조합이 잘 맞는다. 이벤트로 갱신을 전파하고 Outbox 로 일관성을 보장한다.
  • 스케일 패턴 기준에서 폭증 트래픽을 흡수해야 한다면 Saga (Choreography) 가 큐에 의존해서 부하를 평탄화하기 좋다.
  • 장애 경계 기준에서 critical/non-critical 을 분리했다면 non-critical 경로는 Saga 로 결과적 일관성을 받아들이고, critical 경로는 강한 일관성 또는 단일 트랜잭션이 가능한 경계로 재설계한다.
  • 여러 트랜잭션이 같은 자원을 두고 경합하고 over-allocation 을 허용할 수 없다면 TCC 의 예약이 적합. Saga 보다 짧은 의미적 lock 으로 정확성을 확보하면서, 2PC 의 DB lock 까지는 가지 않는 중간 지점이다.

패턴 선택을 기술의 선호로 보지 않고 경계 결정의 자연스러운 귀결로 보면, “이 시스템에 Saga 를 쓰는 게 맞나” 같은 질문이 “분해 기준이 이 패턴을 요구하나” 로 바뀐다.

단순함의 비용

분산 트랜잭션은 정상 흐름의 설계가 아니라 실패 시의 회복 설계다. 정상 흐름은 어느 패턴이든 단순해 보인다. 차이가 드러나는 곳은 부분 실패, 메시지 유실, 중복 처리, 데이터 불일치 같은 실패 케이스다. 패턴을 평가할 때 정상 흐름이 아니라 실패 흐름을 머리에 그려보는 것이 정직한 평가 방법이라고 본다.

모놀리스에서는 단일 ACID 트랜잭션이 이 실패 회복을 기본으로 제공해줬다. 분산 환경에서는 그 단순함이 명시적 비용을 지불한 결과가 된다. 보상 로직 구현, 멱등성 설계, outbox 인프라, orchestrator 운영, 디버깅 도구. 이 비용을 의식하지 않고 패턴부터 꺼내면 시스템이 정상 흐름에서는 잘 동작하다가 첫 부분 장애에서 일관성이 깨진다.

그래서 패턴 선택은 곧 회복 전략 설계의 다른 이름이다. 어떤 실패가 일어날 수 있고, 그때 시스템이 어떤 상태로 회복되어야 하는가. 그 그림을 먼저 그리고 그에 맞는 패턴을 고르는 순서가 자연스럽다.

참고

  • Microservices Architecture — 분해 기준(도메인 경계, 데이터 소유권, 스케일, 장애)과 서비스 간 통신 결정. 분산 트랜잭션 패턴 선택의 전제.
  • Kafka 기초와 KRaft 모드 — Kafka producer/consumer 동작과 partition/offset 의미. Outbox 가 DB 와 broker 사이의 일관성을 보장하는 배경.