06 — 분산 트랜잭션 & 사가
질문: 두 개의 DataSource(또는 두 개의 마이크로서비스)에 걸친 작업을 하나의 일관된 단위로 묶고 싶을 때, TypeORM은 무엇을 해주지 않으며 그 자리를 어떻게 우회해야 하는가? 한 줄 답: TypeORM은 2-phase commit(2PC)을 지원하지 않는다. 단일 DataSource의 트랜잭션만 보장한다. 여러 DB·여러 서비스의 일관성은 사가 패턴(보상 트랜잭션의 체인)과 outbox 패턴(이벤트 발행을 같은 트랜잭션에 묶기)으로 결과적 일관성을 만들어 우회한다.
Why — 왜 분산 트랜잭션은 어려운가
단일 DB의 ACID는 DB 한 대가 모든 책임을 진다. 두 대 이상의 DB가 끼면 — 두 명이 동시에 합의해야 한다.
이 부분 실패가 분산 트랜잭션의 본질적 문제다. 1980년대부터 **2-phase commit (2PC)**과 **3-phase commit (3PC)**이 이 문제를 풀려고 시도했지만 — 현실 production에서는 거의 쓰지 않는다.
이유:
- Coordinator가 단일 장애점 — coordinator가 죽으면 모든 참여자가 영원히 prepared 상태에서 멈춤.
- 블로킹 프로토콜 — 다른 트랜잭션이 그 동안 기다린다. 가용성 ↓.
- 마이크로서비스 시대에는 서로 다른 DB 엔진 사이의 2PC가 거의 불가능.
TypeORM이 2PC를 지원하지 않는 이유도 같다 — 현실에서 안 쓰는 기능을 지원할 이유가 없다.
How — TypeORM이 해주는 것과 해주지 않는 것
✅ 해주는 것
// 단일 DataSource 안에서 여러 테이블을 묶음
await AppDataSource.transaction(async (manager) => {
await manager.save(Order, /* ... */);
await manager.save(OrderItem, /* ... */);
await manager.save(Payment, /* ... */);
});❌ 해주지 않는 것
// 두 DataSource에 걸친 트랜잭션 — TypeORM은 이걸 *합치지 않는다*
async function transferAcrossDBs() {
await OrderDB.transaction(async (m1) => {
await m1.save(Order, /* ... */);
// ❌ 이 트랜잭션은 OrderDB와 *무관한 별개* 트랜잭션이다
await InventoryDB.transaction(async (m2) => {
await m2.update(Inventory, /* ... */);
});
// ❌ InventoryDB가 COMMIT된 후 OrderDB가 ROLLBACK되면 — 재고만 빠짐
});
}What — 사가 패턴 (Saga)
사가는 긴 비즈니스 트랜잭션을 로컬 트랜잭션의 체인으로 분해하고, 실패 시 보상 트랜잭션으로 되돌리는 패턴이다.
예시 — 주문 처리 사가
1. CreateOrder (DB-A에 INSERT)
2. ReserveInventory (DB-B에 UPDATE)
3. ChargePayment (외부 PG)
4. SendNotification (외부 메시징)각 단계가 자기만의 로컬 트랜잭션. 중간에 실패하면 역순으로 보상:
3 실패 → 2 보상(재고 원복) → 1 보상(주문 취소)
4 실패 → 3 보상(환불) → 2 보상(재고 원복) → 1 보상(주문 취소)두 가지 구현 스타일
A. Orchestration (중앙 조정자)
// 중앙에서 단계를 호출하고 실패 시 보상을 호출
class OrderSaga {
async execute(input: OrderInput) {
const steps: SagaStep[] = [];
try {
const order = await this.createOrder(input);
steps.push({ compensate: () => this.cancelOrder(order.id) });
const reservation = await this.reserveInventory(order);
steps.push({ compensate: () => this.releaseInventory(reservation.id) });
const payment = await this.chargePayment(order);
steps.push({ compensate: () => this.refundPayment(payment.id) });
return order;
} catch (err) {
// 역순으로 보상
for (const step of steps.reverse()) {
await step.compensate();
}
throw err;
}
}
}| 장점 | 단점 |
|---|---|
| 흐름이 한 곳에 보임 | 조정자가 단일 장애점 |
| 디버깅 용이 | 조정자 코드가 비대해짐 |
B. Choreography (이벤트 기반)
// 각 서비스가 다른 서비스의 이벤트를 듣고 자기 단계를 실행
// Order Service
on('OrderCreated', () => publish('ReserveInventoryRequested'));
// Inventory Service
on('ReserveInventoryRequested', async () => {
try {
await reserveInventory();
publish('InventoryReserved');
} catch {
publish('InventoryReservationFailed');
}
});
// Payment Service
on('InventoryReserved', () => chargePayment());
on('InventoryReservationFailed', () => publish('OrderShouldBeCancelled'));| 장점 | 단점 |
|---|---|
| 조정자 없음 — 분산 | 흐름이 보이지 않음 — 이벤트 추적 어려움 |
| 서비스 간 결합 ↓ | 보상 로직이 흩어짐 |
What — Outbox 패턴
분산 트랜잭션에서 가장 흔한 함정:
// ❌ 안티패턴
await AppDataSource.transaction(async (manager) => {
await manager.save(Order, order);
// ⚠️ DB 트랜잭션은 *Kafka에 발행한 사실*을 모른다
await kafka.publish('OrderCreated', order);
// 만약 여기서 throw가 나면 DB는 ROLLBACK되지만 Kafka는 *이미 발행됨*
validateSomething();
});DB 트랜잭션과 메시지 큐 발행은 별개의 시스템이라 원자적으로 묶을 수 없다.
Outbox 패턴의 답: 이벤트를 일단 같은 DB의 outbox 테이블에 INSERT. 같은 트랜잭션 안에서. 그 다음 별도 워커가 outbox를 읽어 메시지 큐로 publish.
구현
@Entity()
class OutboxEvent {
@PrimaryGeneratedColumn('uuid')
id: string;
@Column()
aggregateType: string;
@Column()
aggregateId: string;
@Column()
eventType: string;
@Column({ type: 'jsonb' })
payload: any;
@CreateDateColumn()
createdAt: Date;
@Column({ type: 'timestamp', nullable: true })
sentAt: Date | null;
}
// 비즈니스 트랜잭션
async function createOrder(input: OrderInput) {
return AppDataSource.transaction(async (manager) => {
const order = await manager.save(Order, input);
// ✅ 같은 트랜잭션에 outbox INSERT
await manager.save(OutboxEvent, {
aggregateType: 'Order',
aggregateId: order.id,
eventType: 'OrderCreated',
payload: { orderId: order.id, total: order.total },
});
return order;
});
}
// 별도 워커 — polling 또는 logical replication
async function outboxWorker() {
setInterval(async () => {
const unsent = await AppDataSource.getRepository(OutboxEvent).find({
where: { sentAt: IsNull() },
order: { createdAt: 'ASC' },
take: 100,
});
for (const event of unsent) {
try {
await kafka.publish(event.eventType, event.payload);
event.sentAt = new Date();
await AppDataSource.getRepository(OutboxEvent).save(event);
} catch (err) {
// retry on next tick
}
}
}, 1000);
}발전형 — Debezium / CDC
Polling 워커 대신 *DB의 WAL(Write-Ahead Log)*을 직접 읽는 **CDC (Change Data Capture)**를 쓰면 near real-time이다.
PostgreSQL WAL → Debezium → Kafka이 패턴이 production에서 가장 견고한 분산 일관성이다. Netflix·LinkedIn·Uber가 모두 outbox + CDC를 쓴다.
What — 분산 일관성 패턴 비교 매트릭스
| 패턴 | 적용 범위 | 일관성 모델 | 복잡도 | 권장 |
|---|---|---|---|---|
| 단일 DB 트랜잭션 | 1 DB | Strong ACID | 낮음 | ⭐⭐⭐⭐⭐ 항상 시도 |
| 2PC | N DB | Strong ACID | 매우 높음 | ⭐ (TypeORM 미지원) |
| 사가 — Orchestration | N 서비스 | Eventual | 중간 | ⭐⭐⭐⭐ 흐름이 명확할 때 |
| 사가 — Choreography | N 서비스 | Eventual | 높음 | ⭐⭐⭐ 결합도 낮추고 싶을 때 |
| Outbox + 워커 polling | N 서비스 (메시징) | Eventual | 중간 | ⭐⭐⭐⭐ 표준 |
| Outbox + CDC (Debezium) | N 서비스 (메시징) | Eventual | 높음 | ⭐⭐⭐⭐⭐ 대규모 |
What-if — 결과적 일관성을 거부하면
“우리는 진짜 ACID가 필요하다”는 요구는 대개 두 가지 중 하나다:
- 단일 DB로 충분한데 과도하게 분리했다 → 도메인을 재설계해서 한 DB에 합치는 게 더 싸다.
- 진짜로 다른 DB·서비스인데 불일치 윈도우를 못 받아들인다 → 재무·의료 도메인 정도.
후자의 경우에도 2PC를 직접 구현하지 말고, 원자적 broker(Kafka의 transactional producer + idempotent consumer)나 전용 DB(CockroachDB, Spanner, FaunaDB의 글로벌 트랜잭션)로 문제를 한 시스템에 다시 가둔다.
What-if — 사가의 보상이 실패하면
사가의 다크 사이드:
1. CreateOrder ✅
2. ReserveInventory ✅
3. ChargePayment ❌ FAILED
→ 2 보상: ReleaseInventory → ❌ FAILED (재고 서비스 죽음)
→ 1 보상: CancelOrder → 시도 못 함보상도 실패할 수 있다. 사가의 강건성은 보상 로직의 멱등성과 retry에 달려 있다.
멱등성 (Idempotency)
// ❌ 멱등하지 않음
async function refundPayment(paymentId: string) {
const p = await paymentRepo.findOne({ where: { id: paymentId } });
await pg.refund(p.externalId, p.amount); // 두 번 부르면 두 번 환불
}
// ✅ 멱등
async function refundPayment(paymentId: string) {
const p = await paymentRepo.findOne({ where: { id: paymentId } });
if (p.refundedAt) return; // 이미 환불됨
await pg.refund(p.externalId, p.amount, { idempotencyKey: paymentId });
await paymentRepo.update({ id: paymentId }, { refundedAt: new Date() });
}Dead Letter Queue
보상도 N번 실패하면 — 수동 개입을 위해 dead letter queue로 보낸다. 완전 자동화는 사기다 — 사가는 대부분의 실패를 자동화하고, 일부는 사람이 풀게 두는 패턴이다.
Insight — 분산 트랜잭션은 사라지지 않았다, 모양만 바뀌었다
분산 시스템 이론은 CAP 정리(Consistency · Availability · Partition tolerance — 둘 중 둘)와 PACELC(Partition 시 trade-off + Else Latency vs Consistency)으로 어떤 일관성을 어디서 포기할지를 강제한다.
현대 백엔드의 합의: 대부분의 비즈니스 도메인은 결과적 일관성으로 충분하며, 사가·outbox 패턴이 그 자리를 메운다.
TypeORM이 2PC를 지원하지 않는 건 기술적 한계가 아니라 생태계의 합의다 — “이거 안 쓴다, 다른 패턴이 더 낫다”.
흥미로운 이야기 — 사가는 1987년 호텔 예약 시스템에서 태어났다
**Hector Garcia-Molina와 Kenneth Salem의 1987년 논문 “Sagas”**가 원조다. 당시 풀려던 문제는 호텔 체인의 예약 시스템 — 체인의 여러 호텔에 동시 예약을 보내는 긴 트랜잭션을 2PC로 묶으면 너무 오래 lock이 걸려서 다른 예약을 받을 수 없었다.
해결책은 각 호텔의 예약을 로컬 트랜잭션으로 처리하고, 실패 시 보상 트랜잭션(예약 취소)을 호출하는 것 — 분리된 트랜잭션의 체인 + 보상. 이게 “사가”라는 이름의 유래다.
40년 후 2027년 직전, 마이크로서비스가 같은 문제에 봉착했다. 결과적으로 호텔 예약을 위해 만든 그 패턴이 마이크로서비스 분산 트랜잭션의 사실상 표준이 됐다.
요약 (Pyramid Top 재정렬)
TypeORM은 단일 DataSource 안의 트랜잭션만 보장한다.
- 2PC는 TypeORM이 지원하지 않는다. 이건 한계가 아니라 생태계의 합의다.
- 여러 서비스에 걸친 일관성은 사가 패턴 — 보상 트랜잭션의 체인. Orchestration vs Choreography 중 선택.
- DB와 메시지 큐를 묶는 건 outbox 패턴 — 이벤트를 같은 DB 트랜잭션에 저장, 별도 워커가 발행.
- 사가의 강건성은 멱등성 + retry + DLQ. 완전 자동화는 사기다.
- 단일 DB로 풀 수 있으면 언제나 그게 답이다 — 분리하기 전에 합칠 수 없는지 먼저 물어라.
이 챕터가 끝나면 “TypeORM에서 트랜잭션을 어떻게 시작하지” 대신 *“이 일관성 경계는 단일 DB 안에 있는가, 그 바깥에 있는가 — 그렇다면 어떤 패턴으로 우회하는가”*라는 질문을 던지게 된다. 다음 챕터(
06-migration-schema)는 이 모든 약속이 돌아가는 토대인 스키마를 어떻게 진화시키는가를 다룬다.
다음 챕터: 06 — Migration & Schema — 약속의 토대인 스키마.