분산 트랜잭션: 2PC, Saga, TCC
단일 DB 트랜잭션의 경계를 넘으면
결제 서비스와 재고 서비스가 각자의 데이터베이스를 가진 마이크로서비스 아키텍처에서 주문 하나를 처리한다고 하자. 결제는 성공했는데 재고 차감에 실패하면 어떻게 되는가? 단일 DB였다면 ROLLBACK 하나로 끝나지만, 두 서비스가 독립적인 DB를 쓴다면 그 단순한 보장이 사라진다.
분산 트랜잭션(distributed transaction) 문제는 여기서 시작한다. 여러 데이터 저장소에 걸쳐 원자성(atomicity)—전부 성공하거나 전부 없던 일로 만들거나—을 보장하는 것이다.
이 문제를 푸는 방법은 크게 세 가지다: 2PC(Two-Phase Commit), Saga 패턴, TCC(Try-Confirm-Cancel). 각각은 서로 다른 가정, 비용, 실패 처리 방식을 가지고 있다.
2PC: 코디네이터가 결정한다
Two-Phase Commit(2PC)은 Jim Gray가 1978년에 정립한 분산 원자 커밋 프로토콜이다. 여러 참여자(participant)를 하나의 코디네이터(coordinator)가 조율해 모두 커밋하거나 모두 롤백하도록 보장한다.
구조와 역할
| 역할 | 책임 |
|---|---|
| 코디네이터 | 프로토콜 시작, 투표 수집, 최종 결정, 결정 사항을 내구성 로그에 기록, 브로드캐스트 |
| 참여자 | 로컬 리소스 잠금 및 검증, 준비(PREPARE) 결과 투표, 코디네이터 결정 실행 |
XA 표준에서는 코디네이터를 TM(Transaction Manager), 참여자를 RM(Resource Manager)이라 부른다.
Phase 1: Prepare(준비)
- 코디네이터는 자신의 내구성 로그에
begin-commit T를 기록한다. - 모든 참여자에게
PREPARE T메시지를 보낸다. - 각 참여자는 다음을 수행한다.
- 필요한 모든 잠금을 획득한다. - 무결성 제약을 확인한다. - 준비(prepare) 레코드를 디스크에 강제 동기화(fsync)한다. 이 시점부터 롤백을 위한 undo 정보도 저장된다. - VOTE-YES 또는 VOTE-NO로 응답한다.
Phase 2: Commit/Abort(결정)
모든 참여자가 YES를 보낸 경우:
- 코디네이터가
commit T를 자신의 로그에 강제 동기화한다. 이 순간이 트랜잭션의 원자적 결정 지점이다. 이 기록이 디스크에 남은 이상, 코디네이터가 죽어도 재시작 후 커밋을 완료한다. - 모든 참여자에게
COMMIT T를 전송한다. - 참여자들이 로컬 변경을 적용하고 잠금을 해제한다.
하나라도 NO를 보내거나 타임아웃이 발생한 경우:
- 코디네이터가
abort T를 로그에 기록한다. - 모든 참여자에게
ABORT T를 전송한다. - YES를 보낸 참여자들은 undo 로그로 롤백하고 잠금을 해제한다.
2PC의 핵심 약점: 블로킹 문제
2PC에는 이론적으로 해결 불가능한 약점이 있다. 코디네이터가 Phase 2를 시작하기 직전에 죽으면, YES를 보낸 참여자들은 무한정 대기한다.
- 커밋해도 안 된다: 다른 참여자가 NO를 보냈을 수도 있다.
- 롤백해도 안 된다: 코디네이터가 이미 일부 참여자에게 COMMIT을 보냈을 수도 있다.
- 다른 참여자에게 물어봐도 안 된다: 아무도 전체 투표 결과를 모른다.
이 기간 동안 참여자들은 잠금을 계속 유지한다. 코디네이터가 복구될 때까지 그 리소스는 다른 트랜잭션에서 사용할 수 없다. 높은 처리량의 OLTP 환경에서 코디네이터 장애는 잠금 경합 폭증으로 이어진다.
3PC(Three-Phase Commit)가 이 문제를 이론적으로 해결하지만, 동기적이고 지연이 유계된 네트워크를 가정해야만 한다. 실제 분산 네트워크에서는 이 가정이 성립하지 않아 거의 사용되지 않는다.
MySQL XA 트랜잭션
MySQL InnoDB는 XA 표준을 구현해 분산 트랜잭션의 참여자 역할을 할 수 있다.
-- 1. XA 트랜잭션 시작 (gtrid: 글로벌 트랜잭션 ID, bqual: 브랜치 한정자)
XA START 'order-txn-001', 'payment-branch';
UPDATE accounts SET balance = balance - 10000 WHERE id = 42;
XA END 'order-txn-001', 'payment-branch';
-- 2. Phase 1: 준비 (잠금 유지, 로그 fsync)
XA PREPARE 'order-txn-001', 'payment-branch';
-- (외부 TM이 모든 브랜치의 YES를 수집 후 결정)
-- 3. Phase 2: 커밋 또는 롤백
XA COMMIT 'order-txn-001', 'payment-branch';
-- XA ROLLBACK 'order-txn-001', 'payment-branch';
-- 장애 복구: 준비(PREPARED) 상태로 멈춘 브랜치 확인
XA RECOVER;운영 주의사항: MySQL은 장애 후 PREPARED 상태로 남은 XA 브랜치를 자동으로 해소하지 않는다. 외부 TM이 없다면 DBA가 XA RECOVER로 직접 확인하고 XA COMMIT/XA ROLLBACK으로 수동 처리해야 한다. 그렇지 않으면 잠금이 영구적으로 유지된다.
Saga 패턴: 블로킹 없이 긴 트랜잭션 처리하기
Saga는 Hector Garcia-Molina와 Kenneth Salem이 1987년에 제안한 패턴이다. 긴 트랜잭션을 여러 개의 작은 로컬 트랜잭션 시퀀스로 분해하고, 실패 시 앞서 완료된 단계들을 보상 트랜잭션(compensating transaction)으로 되돌린다.
결제 차감
재고 차감
배송 요청
실패
결제 환불
재고 복구
보상 시작
구현 방식: 코레오그래피 vs 오케스트레이션
코레오그래피(Choreography): 각 서비스가 이벤트를 발행하고, 다음 서비스가 이벤트를 구독해 자신의 로컬 트랜잭션을 실행한다. 중앙 조율자가 없다.
결제 서비스: payment.success 이벤트 발행
→ 재고 서비스가 구독: inventory.reserved 발행
→ 배송 서비스가 구독: shipment.created 발행
→ 알림 서비스가 구독: notification.sent장점: 서비스 간 느슨한 결합. 단점: 전체 흐름을 한 곳에서 파악하기 어렵고, 실패 추적과 디버깅이 복잡하다.
오케스트레이션(Orchestration): Saga 오케스트레이터가 각 서비스에 명령을 보내고, 응답에 따라 다음 단계를 결정한다. 실패 시 오케스트레이터가 보상 트랜잭션을 직접 지시한다.
Saga 오케스트레이터
→ 결제 서비스: "결제하라" → 성공
→ 재고 서비스: "재고 차감하라" → 성공
→ 배송 서비스: "배송 요청하라" → 실패
→ 재고 서비스: "재고 복구하라" (보상)
→ 결제 서비스: "결제 환불하라" (보상)장점: 흐름이 한 곳에서 명확하게 보인다. 단점: 오케스트레이터가 단일 장애점이 될 수 있다.
Saga 설계 시 핵심 요구사항
멱등성(Idempotency): 네트워크 재시도로 같은 명령이 두 번 실행될 수 있다. 각 단계의 로컬 트랜잭션과 보상 트랜잭션이 멱등해야 한다. 중복 실행을 감지하는 유일 키(idempotency key)를 각 요청에 포함시키는 것이 일반적이다.
보상 트랜잭션의 한계: 이미 완료된 작업을 "없던 일"로 만드는 것은 데이터를 이전 상태로 되돌리는 것이 아니라, 효과를 상쇄하는 새로운 트랜잭션이다. 예를 들어 "결제 차감"의 보상은 "결제 환불"이다. 이 구분이 중요한 이유는 중간에 다른 트랜잭션이 그 데이터를 읽고 무언가를 했을 수 있기 때문이다—격리(isolation)가 없는 것이다.
부분 실패와 감사 로그: Saga는 중간 상태가 외부에 노출된다. T1과 T2가 완료된 후 T3가 실패해 보상이 진행되는 동안, 다른 시스템이 T1, T2의 결과를 이미 읽었을 수 있다. 이 가시성은 피할 수 없으므로, 상태 전이와 보상 결과를 감사 로그로 남겨야 한다.
Saga의 실세계 구현
Kafka를 메시지 버스로 사용하는 이벤트 드리븐 Saga가 가장 흔한 패턴이다. Axon Framework, Temporal, AWS Step Functions 같은 워크플로 엔진이 오케스트레이션 Saga를 지원한다. Temporal은 장기 실행 워크플로의 상태를 내구성 있게 관리해 Saga 구현 복잡성을 크게 낮춰준다.
TCC: Try-Confirm-Cancel
TCC(Try-Confirm-Cancel)는 비즈니스 트랜잭션을 세 단계로 나누는 패턴이다. 2PC처럼 참여자를 잠금으로 묶는 대신, 비즈니스 로직 레벨에서 예약(reservation)을 구현한다.
세 단계
| 단계 | 의미 | 예시 |
|---|---|---|
| Try | 비즈니스 리소스를 예약(소프트 잠금)한다. 실제 실행은 하지 않는다 | 잔액 확인 후 "10,000원 보류" 표시 |
| Confirm | 예약을 확정한다. 실제 변경이 일어난다 | "보류" → "차감 완료"로 전환 |
| Cancel | 예약을 취소하고 리소스를 되돌린다 | "보류" 표시 해제 |
보류: 10,000원
가용: 90,000원 리소스 예약
(실제 차감 없음)
보류: 0원
가용: 90,000원 예약 확정
(실제 변경 적용)
보류: 0원
가용: 100,000원 예약 취소
(Try의 역작업)
2PC와의 차이
2PC의 PREPARE는 DB 엔진이 잠금을 잡고 기다리는 기술적 잠금이다. TCC의 Try는 비즈니스 로직으로 구현된 예약이다.
2PC PREPARE: InnoDB가 행 레벨 잠금 → 다른 트랜잭션이 해당 행에 접근 불가
TCC Try: "보류 금액 10,000" 컬럼을 추가로 업데이트 → 다른 트랜잭션이 읽되 가용 잔액만 본다이 차이의 실용적 의미:
- 2PC는 블로킹: 코디네이터 장애 시 DB 잠금이 묶인다.
- TCC는 비블로킹: Try가 성공하면 그 서비스는 자체적으로 상태를 관리한다. 오케스트레이터가 죽어도 Confirm/Cancel을 재시도하면 된다.
- 2PC는 데이터베이스 기능에 의존: XA 호환 DB가 필요하다.
- TCC는 애플리케이션 레이어: 어떤 저장소든 Try/Confirm/Cancel 세 API를 구현하면 된다.
TCC 설계 요구사항
Try의 완전한 유효성 검사: Try 단계에서 Confirm이 반드시 성공할 수 있도록 모든 검사를 끝내야 한다. Confirm 단계에서 실패하면 처리하기 매우 어려워진다(Confirm과 Cancel이 동시에 실행되는 상황이 생길 수 있다).
Confirm과 Cancel의 멱등성: 네트워크 장애로 재시도가 발생할 수 있다. 같은 Confirm이나 Cancel이 두 번 실행되어도 결과가 같아야 한다.
공허한 Cancel(Empty Cancel) 처리: Try가 타임아웃으로 실행되지 않은 상태에서 Cancel이 먼저 도착할 수 있다. Try 없이 Cancel이 도착하면 아무것도 할 것이 없다는 것을 인식하고 정상 처리해야 한다.
현수 보상(Dangling Compensation) 방지: Cancel이 성공한 후 뒤늦게 Try가 도착하는 경우, 이미 Cancel된 트랜잭션에 Try가 리소스를 예약하면 영원히 해소되지 않는 보류 상태가 된다.
세 패턴 비교와 선택 기준
격리: 있음 (잠금)
블로킹: 있음
레이턴시: 높음 적합: 동일 기술 스택,
강한 일관성 필요,
마이크로서비스 수 적음
격리: 없음
블로킹: 없음
레이턴시: 낮음 적합: 마이크로서비스,
긴 비즈니스 워크플로,
중간 상태 노출 허용
격리: 부분 (예약)
블로킹: 없음
레이턴시: 중간 적합: 금융 결제,
예약 시스템,
중간 상태 최소화 필요
언제 무엇을 선택하는가
2PC를 선택할 때:
- 모든 참여자가 XA를 지원하는 동일한 기술 스택을 사용한다.
- 참여자 수가 적다(10개 이하).
- 잠금 기간이 짧다(밀리초 단위 트랜잭션).
- 강한 일관성과 격리가 비즈니스 요구사항이다.
실제로 2PC가 잘 작동하는 환경: 단일 DB 샤드 내 트랜잭션, 메시징과 DB를 묶는 로컬 트랜잭션(Outbox 패턴과 조합).
Saga를 선택할 때:
- 마이크로서비스 아키텍처에서 여러 서비스를 조율해야 한다.
- 트랜잭션 기간이 길다(수초~수분).
- 완벽한 격리보다 가용성과 확장성이 더 중요하다.
- 보상 로직을 명확히 정의할 수 있다.
TCC를 선택할 때:
- 금융, 항공권 예약 같이 중간 상태 노출을 최소화해야 한다.
- Try 단계에서 리소스를 예약하는 비즈니스 시맨틱이 자연스럽게 맞는다.
- 2PC의 블로킹 문제를 피하면서 더 강한 일관성을 원한다.
실무에서 자주 쓰는 조합: Outbox 패턴
분산 트랜잭션 문제를 가장 현실적으로 완화하는 방법 중 하나는 Transactional Outbox 패턴이다. 서비스는 DB 상태 변경과 메시지 발행을 하나의 로컬 트랜잭션으로 묶는다. 별도의 프로세스(Outbox Poller 또는 CDC)가 Outbox 테이블을 읽어 메시지를 발행한다. 이 방식은 분산 합의 없이도 "at-least-once" 수준의 이벤트 전달을 보장한다.
로컬 DB 트랜잭션:
BEGIN;
UPDATE orders SET status = 'CONFIRMED';
INSERT INTO outbox (event_type, payload) VALUES ('ORDER_CONFIRMED', {...});
COMMIT; -- 두 변경이 원자적으로 커밋됨
별도 프로세스:
outbox 테이블 폴링 (또는 CDC binlog 구독)
→ Kafka에 이벤트 발행
→ 발행 성공 시 outbox 레코드 삭제 (또는 processed 표시)운영 관점 판단 기준
2PC 운영 체크리스트:
XA RECOVER를 정기적으로 실행해 PREPARED 상태로 멈춘 브랜치가 없는지 확인한다.- 코디네이터 장애 복구 시 TM 로그를 먼저 확인하고 미결 트랜잭션을 해소한다.
- 2PC 참여 DB의 잠금 모니터링(
information_schema.innodb_locks)으로 의심스럽게 긴 잠금을 탐지한다.
Saga 운영 체크리스트:
- 각 단계의 실행 결과와 보상 상태를 Saga 상태 저장소에 기록한다.
- 보상 트랜잭션 실패 시 재시도 정책과 최대 재시도 횟수를 명확히 정의한다.
- 정해진 횟수 안에 보상에 실패하면 데드 레터 큐(DLQ)로 이동시켜 운영자가 수동 처리할 수 있게 한다.
TCC 운영 체크리스트:
- Try 단계의 예약 만료 기간을 명확히 설정한다(무한정 보류 방지).
- 공허한 Cancel과 현수 보상 케이스를 별도의 상태(
NOT_EXIST,CANCELED_BEFORE_TRY)로 구분 처리한다. - Confirm/Cancel 재시도 로그를 남겨 중복 실행 여부를 추적한다.
기억할 문장
분산 트랜잭션에는 공짜가 없다. 2PC는 강한 원자성을 주지만 블로킹 위험과 성능 비용을 가져온다. Saga는 블로킹 없이 장기 워크플로를 처리하지만 격리를 포기하고 보상 로직의 복잡성을 앱에 넘긴다. TCC는 비즈니스 레이어의 예약으로 절충점을 찾지만 Try/Confirm/Cancel 세 API를 모두 구현해야 한다.
어떤 패턴을 고르든 멱등성, 재시도 정책, 실패 추적은 필수다. 분산 환경에서 "한 번만 실행"은 보장되지 않는다.
다음 장에서는 분산 시스템에서 이벤트의 인과적 순서를 추적하는 벡터 클락과 분산 추적을 살펴본다.
References
- Garcia-Molina, H. & Salem, K. (1987). Sagas — https://dl.acm.org/doi/10.1145/38714.38742
- Gray, J. (1978). Notes on Data Base Operating Systems — https://jimgray.azurewebsites.net/papers/dbos.pdf
- Mohan, C., Lindsay, B., & Obermarck, R. (1986). Transaction Management in R* Distributed Database — https://dl.acm.org/doi/10.1145/7239.7266
- Richardson, C. Microservices Patterns: Saga pattern — https://microservices.io/patterns/data/saga.html
- Richardson, C. Microservices Patterns: Transactional Outbox — https://microservices.io/patterns/data/transactional-outbox.html
- MySQL 8.0 Reference, XA Transactions — https://dev.mysql.com/doc/refman/8.0/en/xa.html
- MySQL 8.0 Reference, XA Transaction Restrictions — https://dev.mysql.com/doc/refman/8.0/en/xa-restrictions.html
- Temporal, Workflow Execution — https://docs.temporal.io/workflows
- Microsoft Azure, Saga distributed transactions pattern — https://learn.microsoft.com/en-us/azure/architecture/reference-architectures/saga/saga
- Berenson, H. et al. (1995). A Critique of ANSI SQL Isolation Levels — https://www.microsoft.com/en-us/research/publication/a-critique-of-ansi-sql-isolation-levels/