Software Architecture/Distributed Systems

Distributed Systems - 07. 분산 트랜잭션

분산 트랜잭션 — 분산 환경에서 원자성을 어떻게 보장할까

2016년 한 항공 예약 시스템에서 사용자의 예약과 결제가 분리된 서비스로 옮겨갔다. 사용자가 예약을 하면 — 예약 서비스가 좌석을 잠그고, 결제 서비스에 결제를 요청했다.

어느 날 결제 서비스가 응답하다가 죽었다. 예약 서비스는 좌석을 잠갔지만, 결제가 됐는지 안 됐는지 몰랐다. 사용자는 돈은 안 빠졌는데 좌석이 잠겨 있다고 불만을 넣었다. 다른 사용자는 그 좌석을 예약하려다 실패했다. 한 트랜잭션으로 묶이지 않은 두 서비스의 변경이 — 중간 상태에 멈춘 것이다.

모놀리스였다면 — 예약과 결제가 한 DB 트랜잭션에 묶여 있었다. 둘 다 성공하거나 둘 다 실패했다. 분산에선 그게 안 된다. 두 서비스가 각자 DB를 가지니 한 트랜잭션으로 묶을 수 없다. 이 사태를 다루는 패턴들이 분산 트랜잭션이다.

비유로 감 잡기 — 다자 서명 계약

세 명이 서명해야 효력이 생기는 계약을 상상하자. 세 명 모두 서명하면 성사, 한 명이라도 안 하면 무효. 간단하다.

그런데 세 사람이 세 도시에 있다. 서명을 모으는 건 누군가가 중간에서 조정해야 한다. 이때 두 가지 방식이 있다.

첫째, 한 명이 중간에서 모든 서명을 모은다. 중간 조정자가 "이제 서명하자"고 세 사람에게 동시에 연락한다. 세 명 다 서명하면 "계약 성사"를 선언한다. 한 명이라도 거부하면 "무효"를 선언하고 서명을 회수한다. 단순하고 깔끔하다. 하지만 — 조정자가 연락하다가 실신하면 어떻게 되나. 세 사람은 "서명할까 말까"를 영원히 모른 채 대기한다. 계약은 멈춘다.

둘째, 각자 순서대로 서명한다. A가 서명 → B에게 전달 → B가 서명 → C에게 전달 → C가 서명. C가 거부하면? B에게 "취소해 줘"라고 한다. B는 자기 서명을 "취소"하는 게 아니라 "서명 취소 증서"를 새로 쓴다. A에게도 마찬가지. 중간 조정자가 실신해도 — 각자 자기가 서명했는지 알고 있으니 다음을 진행할 수 있다. 단, C가 거부하기 전까지 잠시 동안 "B와 C는 서명했는데 A는 취소 중"인 상태가 외부에 보인다.

이 두 방식이 분산 트랜잭션의 두 접근 — 2PC와 Saga — 의 본질이다.

비유의 한계 — 계약 비유는 '단발성 결정'을 가정한다. 실제 분산 트랜잭션은 연속적으로 일어나고, 여러 객체와 비즈니스 규칙을 다룬다. 또한 계약은 서명 자체가 중요하지만, 시스템은 서명의 효과(상태 변경)가 중요하다. 비유는 '상태 변경의 영속성'에서 무너진다.

"분산 트랜잭션"이란 — 원자성을 분산에서

트랜잭션(transaction)은 — 여러 연산이 하나의 단위로 처리되는 것. 한 트랜잭션의 연산들은 모두 성공하거나 모두 실패한다. 이 속성을 원자성(atomicity)이라 부른다. 모놀리스에선 DB 트랜잭션이 이걸 보장한다 (ACID의 'A').

분산 트랜잭션은 — 여러 서비스(또는 노드)에 걸친 연산의 원자성을 다룬다. 주문 서비스와 결제 서비스와 재고 서비스가 각자 DB를 가질 때, "주문 생성 → 결제 → 재고 감소"가 모두 성공하거나 모두 실패해야 한다. 이걸 분산 환경에서 어떻게 보장할까.

왜 어려운가. 각 서비스가 독립적인 DB를 가지니, 한 DB 트랜잭션에 묶을 수 없다. 서비스 간 통신은 네트워크를 거치고 (01편의 8가지 오해), 한 서비스가 실패하면 다른 서비스의 변경을 어떻게 되돌리나. 이 문제를 다루는 세 패턴이 2PC, Saga, Outbox다.

가장 강한 형태 — 2PC (Two-Phase Commit)

가장 강한 원자성을 추구하는 접근. Jim Gray가 1978년에 정리했다. 한 코디네이터(coordinator)가 여러 참여자(participant)를 통제해 원자적 커밋을 이끈다. 비유의 "중간 조정자가 모든 서명을 모은다"가 이 패턴이다.

두 단계로 진행된다.

첫 번째 단계 — Prepare (준비). 코디네이터가 모든 참여자에게 "커밋 준비됐나?"라고 묻는다. 각 참여자는 자기 로컬 상태를 확인하고 "Yes" 또는 "No"로 응답한다. 이때 참여자는 "Yes"라고 답하면 — 코디네이터의 결정이 올 때까지 자기 데이터에 잠금을 쥔 채 대기한다.

두 번째 단계 — Commit 또는 Abort. 모든 참여자가 "Yes"라고 답하면, 코디네이터는 "커밋해라"고 알린다. 한 명이라도 "No"면 "중단해라"고 알린다. 각 참여자는 그 지시를 따르고 잠금을 푼다.

sequenceDiagram
    participant C as Coordinator
    participant P1 as Participant 1
    participant P2 as Participant 2
    participant P3 as Participant 3
    C->>P1: Prepare (커밋 준비됐나?)
    C->>P2: Prepare
    C->>P3: Prepare
    P1-->>C: Yes (잠금 설정)
    P2-->>C: Yes
    P3-->>C: Yes
    C->>P1: Commit
    C->>P2: Commit
    C->>P3: Commit
    P1-->>C: Ack (잠금 해제)

2PC의 강점 — 강한 원자성. 참여자 전체가 커밋되거나 전체가 중단된다. 중간 상태가 외부에 안 보인다.

2PC의 치명적 약점 — 블로킹. 코디네이터가 죽으면 참여자들이 영원히 대기한다. 참여자는 "Yes"라고 답한 뒤 코디네이터의 결정을 기다리며 잠금을 쥔 채로 있는다. 코디네이터가 회복되기 전까지 그 데이터에 다른 트랜잭션이 접근 못 한다. 코디네이터 장애는 흔하다 — 네트워크 분단, 노드 크래시, 디스크 가득 참. 어느 쪽이든 2PC는 멈춘다.

그래서 2PC는 — 회사 내부망에서 짧은 트랜잭션에만 쓰인다. XA 표준, Java의 JTA, Oracle의 분산 트랜잭션이 이 형태. 인터넷 기반 서비스, 마이크로서비스에는 거의 안 쓰인다.

가장 느슨한 형태 — 중간 상태 허용

반대 극단은 — 분산 트랜잭션을 안 하는 것이다. 각 서비스가 자기 DB 트랜잭션만 보장하고, 서비스 간 원자성은 신경 쓰지 않는다.

앞서 항공 예약 사례가 이런 형태였다. 예약 서비스는 좌석을 잠그고 끝, 결제 서비스는 결제를 시도하고 끝. 둘 사이의 "둘 다 성공하거나 둘 다 실패"는 보장 안 함. 중간 상태("예약은 됐는데 결제는 안 됨")가 외부에 보인다.

이 형태는 단순하고 빠르지만 — 중간 상태가 비즈니스에 치명적이면 못 쓴다. 항공 예약 사례처럼 사용자 불만과 데이터 충돌을 만든다. 그래서 대부분의 시스템은 이 극단을 택하지 않는다. 하지만 출발점으로 중요하다 — "트랜잭션을 안 하면 이런 일이 생긴다"를 아는 것.

그 사이 — Saga (보상 트랜잭션)

양극단 사이의 실용적 접근이 Saga다. 1987년 Hector Garcia-Molina와 Kenneth Salem이 제안했다. 한 트랜잭션을 여러 로컬 트랜잭션으로 쪼개고, 각 단계가 실패하면 이전 단계를 보상(compensation)한다. 비유의 "각자 순서대로 서명, 안 하면 보상"이 이 패턴이다.

보상은 롤백이 아니다. 이게 Saga의 가장 중요한 특성이다. 2PC는 "커밋 전에 모든 참여자가 준비됐는지 확인"하는 거라, 실패하면 아무것도 안 일어난다 (롤백). Saga는 각 단계가 이미 커밋된 뒤에야 다음 단계가 진행되고, 실패하면 이전 단계를 "취소"하는 게 아니라 "반대 행위"를 한다.

예를 들어 — 주문 → 결제 → 재고 감소 → 배송 예약 흐름에서, 재고가 부족해 실패하면 결제를 취소하는 게 아니라 환불이라는 새 행위를 추가한다. 주문을 삭제하는 게 아니라 취소라는 새 상태로 바꾼다. 원래 상태로 되돌리는 게 아니라, 반대 행위를 한다는 의미에서 '보상'이다.

이미 결제가 성공했으면 사용자 계좌에 돈이 빠졌고, 그걸 "없었던 일로" 할 수 없다. 환불이라는 새 행위로 "정정"하는 수밖에 없다. 이 차이가 중요한 이유 — 중간 상태가 외부에 보인다. 잠시 동안 "결제는 됐는데 재고는 반영 안 된" 상태가 노출된다. 2PC는 이 중간 상태를 안 보이게 하지만, 그 대가로 블로킹 위험을 감수한다. Saga는 블로킹을 피하는 대가로 중간 상태 노출을 감수한다.

Saga의 두 형태

  • Orchestration Saga — 중앙 조정자(Saga Orchestrator)가 각 서비스를 호출하고, 실패 시 보상을 트리거. 흐름이 한 곳에 보인다. 단점은 조정자가 단일 장애점.
  • Choreography Saga — 각 서비스가 이벤트를 구독해 자율적으로 다음 단계를 트리거. 중앙 조정자 없음. 느슨한 결합이지만 흐름 파악이 어렵다.

Outbox 패턴 — DB-메시지 원자성

2PC, Saga와는 또 다른 차원의 원자성 문제. 비즈니스 데이터를 DB에 쓰면서 동시에 이벤트를 메시지 브로커에 발행해야 할 때 — 이 둘이 원자적이어야 한다. "주문을 DB에 저장하고 OrderPlaced 이벤트를 브로커에 발행"이 한 단위로 일어나야 한다. DB엔 저장됐는데 이벤트 발행이 실패하면, 다른 서비스들은 이 주문을 영영 모른다.

// 안티패턴 — DB와 메시지 발행이 별도
@Transactional
public void placeOrder(OrderRequest req) {
    orders.save(new Order(req));  // DB에 저장
    kafka.send(new OrderPlacedEvent(...));  // 이벤트 발행 — 여기서 실패하면?
}

DB 트랜잭션은 커밋됐는데 이벤트 발행이 실패하면 — 시스템이 불일치 상태에 빠진다.

Outbox 패턴은 — 비즈니스 데이터와 이벤트를 같은 DB 트랜잭션에 쓴다.

@Transactional
public void placeOrder(OrderRequest req) {
    Order order = new Order(req);
    orders.save(order);
    // 같은 트랜잭션에 outbox 테이블에 이벤트 저장
    outbox.save(new OutboxEvent("OrderPlaced", serialize(order)));
    // 커밋 — order와 outbox_event가 원자적으로 저장
}

// 별도 프로세스가 outbox를 읽어 브로커로 발행
@Scheduled(fixedDelay = 100)
void publishPending() {
    List<OutboxEvent> pending = outbox.findUnpublished();
    for (OutboxEvent e : pending) {
        kafka.send(e.topic(), e.payload());
        outbox.markPublished(e.id());  // 발행 완료 표시
    }
}

이 패턴의 핵심 — 비즈니스 상태 변경과 이벤트가 같은 DB 트랜잭션에 묶인다. 둘 다 성공하거나 둘 다 실패. 발행이 실패해도 outbox에 남아 재시도할 수 있다.

CDC(Change Data Capture) 기술(Debezium 등)이 outbox를 자동으로 브로커에 발행하는 형태로 자주 쓰인다.

세 패턴 비교

패턴 일관성 가용성 중간 상태 복잡성 적합
2PC 강한 (원자적) 약한 (블로킹) 안 보임 중간 회사 내부망, 짧은 트랜잭션
Saga 최종 (보상) 강한 보임 높음 마이크로서비스, 장기 흐름
Outbox 최종 (이벤트) 강한 보임 중간 DB-메시지 원자성

설계 사례 — 주문 처리 흐름의 Saga 구현

같은 "주문→결제→재고" 흐름을 Saga로 구현해 본다.

class OrderSaga {
    void placeOrder(OrderRequest req) {
        try {
            Order order = orderService.create(req);    // 1. 주문 생성
            paymentService.charge(order);                // 2. 결제
            inventoryService.reserve(req.items());      // 3. 재고 예약
            // 모든 단계 성공
        } catch (PaymentFailedException e) {
            orderService.cancel(req.orderId());  // 보상 1: 주문 취소
        } catch (InventoryShortageException e) {
            paymentService.refund(req.paymentId());    // 보상 2: 결제 환불
            orderService.cancel(req.orderId());        // 보상 1: 주문 취소
        }
    }
}

핵심 — 재고 부족으로 3단계가 실패하면, 2단계(결제)를 환불하고 1단계(주문)를 취소한다. 각 보상은 역순으로 실행되고, 각각 멱등(08편)이어야 한다.

분산 트랜잭션 위반/오용 감지

신호 의미
2PC를 인터넷 기반 서비스에 사용 블로킹 위험, 가용성 희생
Saga 보상이 롤백으로 구현 외부 시스템은 롤백 안 됨 — 보상으로
보상이 멱등하지 않음 중복 보상 시 잘못된 상태
중간 상태를 사용자가 안 보게 숨김 UI 설계 부재 — 사용자 경험 저하
Outbox 없이 DB-메시지 분리 이벤트 유실 위험
한 트랜잭션에 여러 서비스 DB 분산 트랜잭션 강제 — 느림

CAP와 분산 트랜잭션

02편(CAP)에서 본 trade-off가 여기서 다시 등장한다.

  • 2PC는 CP 시스템 — 강한 일관성(원자성)을 보장하지만, 가용성을 희생. 블로킹이 가용성 손실의 형태.
  • Saga는 AP 시스템 — 가용성을 유지하지만, 강한 일관성을 포기. 중간 상태 노출이 일관성 손실의 형태.

같은 트랜잭션에서 — 강한 일관성과 가용성을 동시에 가질 수 없다. CAP 정리의 구체적 발현. 설계자는 어느 쪽을 우선할지 결정해야 한다. 금융 원장 → 2PC 또는 단일 DB. 사용자 경험이 중심 → Saga.

다음으로 — 시간과 순서

분산 트랜잭션이 "여러 단계의 순서와 원자성"을 다뤘다면, 09편(시간·순서)은 "한 사건이 다른 사건보다 먼저인가"라는 더 근본적 질문을 다룬다. Saga의 보상이 제대로 동작하려면 — 보상이 원본보다 나중에 적용돼야 한다. 그 "나중"을 어떻게 정의할까. 분산 시스템의 시계는 물리적으로 안 맞고, NTP도 한계가 있다. Lamport 시계, 벡터 시계, TrueTime이 그 해법이다.

분산 트랜잭션의 핵심 통찰 — "원자성"은 단일 노드에선 자연스럽지만, 분산에선 본질적 비용이 따른다. 2PC는 그 비용을 가용성으로 치르고, Saga는 강한 일관성으로 치른다. 둘 다 "무료"가 아니다. 이 점을 모르고 "분산 트랜잭션을 쓰면 된다"고 믿으면 — 어느 날 시스템이 멈추거나, 데이터가 어긋나는 사태를 겪는다.


참고

  • Gray — "Notes on Data Base Operating Systems" (1978), 2PC의 원형 정리 — 접근 2026-07-20
  • Garcia-Molina, Salem — "Sagas" (SIGMOD 1987), Saga 원 논문 — 접근 2026-07-20
  • Kleppmann — (O'Reilly, 2017), Ch.7 (트랜잭션 — 분산 트랜잭션 절)
  • Fowler — "Compensating Transaction" (martinfowler.com/eaaDev/compensatingTransaction.html) — 접근 2026-07-20
  • Richards, Ford — (O'Reilly, 2020), 분산 트랜잭션 장
  • Debezium Documentation — Change Data Capture과 Outbox 패턴 — 접근 2026-07-20