Distributed Transaction Patterns
모놀리식 아키텍처에서는 하나의 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 자체의 가용성을 확보해야 한다
어떤 방식을 선택할 것인가
| Choreography | Orchestration | |
|---|---|---|
| 결합도 | 낮음 (이벤트 기반) | Orchestrator에 의존 |
| 흐름 추적 | 어려움 | 명확함 |
| 복잡한 플로우 | 부적합 | 적합 |
| 장애 추적 | 어디서 멈췄는지 파악 어려움 | 상태 머신으로 즉시 파악 |
단계가 2~3개인 단순 플로우라면 Choreography, 그 이상이면 Orchestration이 관리하기 수월하다.
Outbox 패턴
문제
DB에 데이터를 저장하고 이벤트도 발행해야 하는 상황에서, 둘 중 하나만 성공할 수 있다.
- DB에 주문 저장 → 성공
- Kafka에 이벤트 발행 → 실패
- DB에는 있지만 다른 서비스는 이 주문을 모른다
DB 커밋과 메시지 발행은 서로 다른 시스템이므로, 하나의 트랜잭션으로 묶을 수 없다. 이것이 dual write 문제이다.
해결
비즈니스 데이터와 이벤트를 같은 DB 트랜잭션으로 묶는다.
flowchart LR
subgraph "하나의 DB 트랜잭션"
W1["주문 테이블에 저장"]
W2["outbox 테이블에 이벤트 저장"]
end
W1 --> W2
W2 --> R["Relay 프로세스"]
R -->|발행| K["Kafka"]
R -->|발행 완료 후| D["outbox에서 삭제/마킹"]
- 주문 데이터와 이벤트를 같은 DB 트랜잭션으로 저장한다. DB 트랜잭션이 보장되니 둘 다 저장되거나 둘 다 안 된다
- 별도의 Relay 프로세스가 outbox 테이블을 폴링하여 Kafka에 발행한다
- 발행 완료 후 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/>그대로 반환"]
- Client가 요청에 고유한
idempotency_key를 포함하여 보낸다 - Server는 해당 key로 이미 처리된 요청인지 조회한다
- 처리된 적 없으면 실행 후 결과와 함께 key를 저장한다
- 이미 처리되었으면 저장된 결과를 그대로 반환한다
구현 시 고려사항
- key 저장소: Redis(조회 속도)나 DB(영속성) 모두 가능하다. 금융에서는 DB에 저장하되 Redis를 캐시로 앞에 두는 방식이 일반적이다
- TTL: key를 영구 저장하면 저장소가 무한히 커지므로, 적절한 TTL을 설정한다. 보통 24시간~7일
- race condition: 같은 key로 동시에 두 요청이 들어올 수 있다. key 저장 시 DB의 unique constraint나 Redis의
SET NX로 원자적으로 처리해야 한다