Distributed Transaction Patterns

· 4분 읽기

모놀리식 아키텍처에서는 하나의 DB 트랜잭션으로 정합성을 보장할 수 있다. 그러나 서비스가 분리되면 “서비스 A에서 잔고 차감하고, 서비스 B에서 잔고 추가하는데, 중간에 실패하면?”이라는 문제가 생긴다.

이 문제를 해결하는 세 가지 패턴을 다룬다.

Saga 패턴

분산 환경에서 하나의 비즈니스 트랜잭션을 여러 로컬 트랜잭션의 시퀀스로 분해한다. 중간에 실패하면 보상 트랜잭션(compensation) 을 실행하여 이전 단계를 되돌린다.

flowchart LR
    A["계좌 A<br/>10만원 차감"] -->|성공| B["계좌 B<br/>10만원 추가"]
    B -->|실패| C["보상: 계좌 A<br/>10만원 복원"]

보상 트랜잭션은 “실행 취소(undo)“가 아니라 새로운 트랜잭션이다. 차감을 취소하는 것이 아니라, 동일 금액을 다시 입금하는 별도의 트랜잭션을 실행하는 것이다.

두 가지 구현 방식이 있다.

Choreography (이벤트 기반)

각 서비스가 이벤트를 발행하고, 다음 서비스가 구독하여 처리한다.

sequenceDiagram
    participant A as 계좌 서비스 A
    participant MQ as Message Queue
    participant B as 계좌 서비스 B

    A->>A: 10만원 차감
    A->>MQ: "차감 완료" 이벤트 발행
    MQ->>B: 이벤트 전달
    B->>B: 10만원 추가
    Note over B: 실패 시
    B->>MQ: "추가 실패" 이벤트 발행
    MQ->>A: 이벤트 전달
    A->>A: 보상 - 10만원 복원
  • 서비스 간 직접 의존이 없다
  • 단순한 플로우에 적합하다
  • 단계가 많아지면 흐름 추적이 어려워진다. 어떤 단계에서 실패했는지, 보상이 제대로 실행되었는지 추적하려면 별도의 모니터링이 필요하다

Orchestration (중앙 조율자)

Saga Orchestrator가 전체 흐름을 제어한다.

sequenceDiagram
    participant O as Saga Orchestrator
    participant A as 계좌 서비스 A
    participant B as 계좌 서비스 B

    O->>A: 10만원 차감 요청
    A->>O: 성공
    O->>B: 10만원 추가 요청
    B->>O: 실패
    O->>A: 보상 - 10만원 복원 요청
    A->>O: 완료
  • 흐름이 한곳에 명시되어 추적이 쉽다
  • Orchestrator가 상태 머신으로 동작하여 각 단계의 성공/실패/보상을 관리한다
  • Orchestrator 자체의 가용성을 확보해야 한다

어떤 방식을 선택할 것인가

ChoreographyOrchestration
결합도낮음 (이벤트 기반)Orchestrator에 의존
흐름 추적어려움명확함
복잡한 플로우부적합적합
장애 추적어디서 멈췄는지 파악 어려움상태 머신으로 즉시 파악

단계가 2~3개인 단순 플로우라면 Choreography, 그 이상이면 Orchestration이 관리하기 수월하다.

Outbox 패턴

문제

DB에 데이터를 저장하고 이벤트도 발행해야 하는 상황에서, 둘 중 하나만 성공할 수 있다.

  1. DB에 주문 저장 → 성공
  2. Kafka에 이벤트 발행 → 실패
  3. DB에는 있지만 다른 서비스는 이 주문을 모른다

DB 커밋과 메시지 발행은 서로 다른 시스템이므로, 하나의 트랜잭션으로 묶을 수 없다. 이것이 dual write 문제이다.

해결

비즈니스 데이터와 이벤트를 같은 DB 트랜잭션으로 묶는다.

flowchart LR
    subgraph "하나의 DB 트랜잭션"
        W1["주문 테이블에 저장"]
        W2["outbox 테이블에 이벤트 저장"]
    end
    W1 --> W2
    W2 --> R["Relay 프로세스"]
    R -->|발행| K["Kafka"]
    R -->|발행 완료 후| D["outbox에서 삭제/마킹"]
  1. 주문 데이터와 이벤트를 같은 DB 트랜잭션으로 저장한다. DB 트랜잭션이 보장되니 둘 다 저장되거나 둘 다 안 된다
  2. 별도의 Relay 프로세스가 outbox 테이블을 폴링하여 Kafka에 발행한다
  3. 발행 완료 후 outbox에서 삭제하거나 처리 완료로 마킹한다

CDC (Change Data Capture)

폴링 대신 CDC를 사용하면 더 실시간에 가까운 전달이 가능하다.

Debezium 같은 도구가 DB의 WAL(Write-Ahead Log)을 감시하여, outbox 테이블에 변경이 생기면 즉시 Kafka에 전달한다. 폴링 간격에 의한 지연이 없고, DB에 추가 부하를 주지 않는다는 장점이 있다.

멱등성 키 (Idempotency Key)

필요한 이유

at-least-once 환경에서는 네트워크 타임아웃, 재시도 등으로 같은 요청이 두 번 이상 도달할 수 있다. 특히 금융 시스템에서 송금이 두 번 실행되면 치명적이다.

동작 방식

flowchart TB
    C["Client<br/>idempotency_key: txn-abc-123"] --> S["Server"]
    S --> Check{"이미 처리된<br/>key인가?"}
    Check -->|아니오| Exec["실행 후<br/>key + 결과 저장"]
    Check -->|예| Return["저장된 결과<br/>그대로 반환"]
  1. Client가 요청에 고유한 idempotency_key를 포함하여 보낸다
  2. Server는 해당 key로 이미 처리된 요청인지 조회한다
  3. 처리된 적 없으면 실행 후 결과와 함께 key를 저장한다
  4. 이미 처리되었으면 저장된 결과를 그대로 반환한다

구현 시 고려사항

  • key 저장소: Redis(조회 속도)나 DB(영속성) 모두 가능하다. 금융에서는 DB에 저장하되 Redis를 캐시로 앞에 두는 방식이 일반적이다
  • TTL: key를 영구 저장하면 저장소가 무한히 커지므로, 적절한 TTL을 설정한다. 보통 24시간~7일
  • race condition: 같은 key로 동시에 두 요청이 들어올 수 있다. key 저장 시 DB의 unique constraint나 Redis의 SET NX로 원자적으로 처리해야 한다

References