🗄️ TypeORM5. Transaction & DataSource06 — 분산 트랜잭션 & 사가

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에서는 거의 쓰지 않는다.

이유:

  1. Coordinator가 단일 장애점 — coordinator가 죽으면 모든 참여자가 영원히 prepared 상태에서 멈춤.
  2. 블로킹 프로토콜 — 다른 트랜잭션이 그 동안 기다린다. 가용성 ↓.
  3. 마이크로서비스 시대에는 서로 다른 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 DBStrong ACID낮음⭐⭐⭐⭐⭐ 항상 시도
2PCN DBStrong ACID매우 높음⭐ (TypeORM 미지원)
사가 — OrchestrationN 서비스Eventual중간⭐⭐⭐⭐ 흐름이 명확할 때
사가 — ChoreographyN 서비스Eventual높음⭐⭐⭐ 결합도 낮추고 싶을 때
Outbox + 워커 pollingN 서비스 (메시징)Eventual중간⭐⭐⭐⭐ 표준
Outbox + CDC (Debezium)N 서비스 (메시징)Eventual높음⭐⭐⭐⭐⭐ 대규모

What-if — 결과적 일관성을 거부하면

“우리는 진짜 ACID가 필요하다”는 요구는 대개 두 가지 중 하나다:

  1. 단일 DB로 충분한데 과도하게 분리했다 → 도메인을 재설계해서 한 DB에 합치는 게 더 싸다.
  2. 진짜로 다른 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 안의 트랜잭션만 보장한다.

  1. 2PC는 TypeORM이 지원하지 않는다. 이건 한계가 아니라 생태계의 합의다.
  2. 여러 서비스에 걸친 일관성은 사가 패턴 — 보상 트랜잭션의 체인. Orchestration vs Choreography 중 선택.
  3. DB와 메시지 큐를 묶는 건 outbox 패턴 — 이벤트를 같은 DB 트랜잭션에 저장, 별도 워커가 발행.
  4. 사가의 강건성은 멱등성 + retry + DLQ. 완전 자동화는 사기다.
  5. 단일 DB로 풀 수 있으면 언제나 그게 답이다 — 분리하기 전에 합칠 수 없는지 먼저 물어라.

이 챕터가 끝나면 “TypeORM에서 트랜잭션을 어떻게 시작하지” 대신 *“이 일관성 경계는 단일 DB 안에 있는가, 그 바깥에 있는가 — 그렇다면 어떤 패턴으로 우회하는가”*라는 질문을 던지게 된다. 다음 챕터(06-migration-schema)는 이 모든 약속이 돌아가는 토대스키마를 어떻게 진화시키는가를 다룬다.

다음 챕터: 06 — Migration & Schema — 약속의 토대인 스키마.