분산 트랜잭션과 최종 일관성: 왜 깨지고 어떻게 복구할까

분산 트랜잭션과 최종 일관성: 왜 깨지고 어떻게 복구할까
분산 트랜잭션 문제는 MSA에서만 생기지 않는다. 핵심은 서버 개수가 아니라 동기화해야 할 상태(State) 경계가 몇 개인가다. 단일 앱이라도 RDB/Redis/Elasticsearch 같은 다중 저장소, 애그리거트 경계 분리, 외부 API 연동, CQRS를 채택하는 순간 단일 트랜잭션이 깨질 수 있다.
이 글은 다음 질문을 주문-결제-재고 시나리오로 재현하고, Saga Orchestration + Outbox + Idempotency로 복구하는 방법을 정리한다.
- 왜 분산 환경에서 데이터 불일치가 발생하는가?
- Saga Orchestration은 무엇을 보장하고 무엇을 포기하는가?
- Outbox와 Idempotency는 왜 같이 설계해야 하는가?
- 실패 이후 시스템은 어떻게 "최종적으로" 일관된 상태로 수렴하는가?
목차
- 문제 정의: 왜 단일 트랜잭션이 깨지는가
- 분산 트랜잭션이 발생하는 다양한 시나리오
- 실패 재현: 나이브 분산 처리의 한계
- 해결 1: Saga Orchestration으로 실패를 관리하기
- 해결 2: Outbox Pattern으로 이벤트 유실 막기
- 해결 3: Idempotency와 보상 트랜잭션으로 수렴시키기
- 패턴 비교 요약
- 마무리
문제 정의: 왜 단일 트랜잭션이 깨지는가
주문 생성과 결제 승인, 재고 차감이 서로 다른 상태 경계에 걸치면 다음 성질이 동시에 등장한다.
- 네트워크 호출은 실패하거나 지연될 수 있다.
- 원격 서비스는 성공했지만 응답이 유실될 수 있다.
- 일부 단계만 성공한 "중간 상태"가 남을 수 있다.
즉, 분산 트랜잭션에서 핵심은 "모든 단계가 항상 즉시 성공"이 아니라, 실패를 가정하고도 시스템을 최종적으로 수렴시키는 설계다.
분산 트랜잭션이 발생하는 다양한 시나리오
- Polyglot Persistence: RDB, Redis, Elasticsearch 등 여러 저장소를 쓰면, 각 저장소가 독립된 트랜잭션 경계를 가진다. 예를 들어 주문 상태는 RDB에, 검색 색인은 Elasticsearch에 저장하면, 주문 생성과 색인 업데이트가 같은 트랜잭션으로 묶이지 않는다.
- DDD 애그리거트 경계: Order와 Inventory를 서로 다른 애그리거트로 분리하면, 두 애그리거트를 한 트랜잭션으로 동시에 수정하지 않는 선택을 하게 된다. 예를 들어 주문을 먼저 커밋하고 이벤트로 재고를 갱신하는 방식은 최종 일관성 모델로 들어온다.
- 외부 API 연동: 결제 승인/취소 API, 알림 API, 물류 API는 모두 네트워크 너머의 별도 상태 저장소다. 내 DB 트랜잭션이 성공한 뒤 외부 API 호출이 타임아웃으로 실패하면, 롤백할지 재시도할지 보상할지 결정해야 한다. 이 상태 정합성 문제 자체가 분산 트랜잭션의 난제다.
- CQRS에서의 읽기/쓰기 시간차: Command 모델과 Query 모델을 분리하면 반영 지연은 구조적으로 발생한다. 예를 들어 사용자가 글을 작성했는데 메인 목록에는 0.5초 뒤 보이는 경우, 이 간극을 UX로 어떻게 안내하고, 기술적으로 어떤 재시도/동기화 전략으로 메울지 설계하는 것이 최종 일관성 관리다.
이처럼 MSA뿐 아니라 단일 앱에서도 Polyglot Persistence, 애그리거트 분리, 외부 API 연동, CQRS를 채택하는 순간 분산 트랜잭션 문제에 직면한다.
실패 재현: 나이브 분산 처리의 한계
아래 코드는 많이 보이는 형태다. 주문 DB 트랜잭션 안에서 외부 결제/재고 서비스를 순차 호출한다.
@Injectable()
export class OrderFacade {
constructor(
private readonly orderRepository: OrderRepository,
private readonly paymentClient: PaymentClient,
private readonly inventoryClient: InventoryClient,
private readonly uow: UnitOfWork,
) {}
async placeOrder(cmd: PlaceOrderCommand): Promise<string> {
return this.uow.transaction(async (tx) => {
const order = await this.orderRepository.insertPending(tx, cmd);
await this.paymentClient.reserve({
orderId: order.id,
userId: cmd.userId,
amount: cmd.amount,
});
await this.inventoryClient.reserve({
orderId: order.id,
items: cmd.items,
});
await this.orderRepository.markConfirmed(tx, order.id);
return order.id;
});
}
}
이 코드는 "로컬 DB" 안에서는 트랜잭션이지만, 원격 결제/재고는 같은 트랜잭션 경계에 묶여 있지 않다. 결제 성공 후 재고 실패가 발생하면 주문은 실패인데 결제는 잡혀 있는 상태가 만들어진다.
위 시퀀스에서 문제는 3가지다.
- 주문 실패 응답이 나가도 결제 예약은 이미 성공할 수 있다.
- 실패 이후 상태를 자동으로 복구하는 규칙이 없다.
- 재시도 시 중복 결제/중복 재고 차감 가능성이 남는다.
해결 1: Saga Orchestration으로 실패를 관리하기
아래 코드는 Saga Orchestration으로 중앙 Orchestrator가 단계 실행과 보상(Compensation) 순서를 통제하는 방식을 보여준다. 핵심은 "실패를 제거"하는 것이 아니라 실패 시나리오를 명시적으로 관리하는 것이다.
type SagaStep = "PAYMENT" | "INVENTORY";
@Injectable()
export class OrderSagaOrchestrator {
constructor(
private readonly paymentClient: PaymentClient,
private readonly inventoryClient: InventoryClient,
private readonly orderRepository: OrderRepository,
) {}
async execute(ctx: {
orderId: string;
userId: string;
amount: number;
items: Array<{ sku: string; qty: number }>;
}): Promise<void> {
const completed: SagaStep[] = [];
try {
await this.paymentClient.reserve({
orderId: ctx.orderId,
userId: ctx.userId,
amount: ctx.amount,
});
completed.push("PAYMENT");
await this.inventoryClient.reserve({
orderId: ctx.orderId,
items: ctx.items,
});
completed.push("INVENTORY");
await this.orderRepository.markConfirmed(ctx.orderId);
} catch (error) {
await this.compensateInReverse(ctx.orderId, completed);
await this.orderRepository.markFailed(ctx.orderId, String(error));
throw error;
}
}
private async compensateInReverse(
orderId: string,
completed: SagaStep[],
): Promise<void> {
for (const step of [...completed].reverse()) {
if (step === "INVENTORY") {
await this.inventoryClient.release({ orderId });
}
if (step === "PAYMENT") {
await this.paymentClient.cancel({ orderId });
}
}
}
}
중요한 점은 보상이 "원상복구와 동일"하지 않을 수 있다는 점이다. 예를 들어 결제 취소 API가 즉시 환불이 아닌 별도 승인 취소 프로세스를 가질 수 있다. 그래서 보상 트랜잭션은 비즈니스 의미 단위로 정의해야 한다.
해결 2: Outbox Pattern으로 이벤트 유실 막기
하지만 Saga만으로 충분하지 않을 수 있다. 로컬 DB 갱신 후 이벤트 발행 전에 프로세스가 죽으면, 상태는 바뀌었는데 후속 서비스는 아무것도 모르는 상황이 된다.
이에, Outbox Pattern은 "비즈니스 데이터 변경과 이벤트 레코드 적재를 같은 로컬 트랜잭션으로 묶는" 방식으로 이 문제를 해결한다. 즉, 주문 상태 변경과 "주문 생성됨" 이벤트를 같은 트랜잭션으로 처리하고, 별도 퍼블리셔가 Outbox 테이블을 폴링해서 메시지 브로커에 발행하는 구조다.
@Injectable()
export class OrderApplicationService {
constructor(private readonly uow: UnitOfWork) {}
async confirmOrder(orderId: string): Promise<void> {
await this.uow.transaction(async (tx) => {
await tx.query(
`UPDATE orders SET status = 'CONFIRMED' WHERE id = $1`,
[orderId]
);
await tx.query(
`
INSERT INTO outbox_events
(event_id, aggregate_id, event_type, payload, published, created_at)
VALUES
(gen_random_uuid(), $1, 'OrderConfirmed', jsonb_build_object('orderId', $1), false, NOW())
`,
[orderId],
);
});
}
}
@Injectable()
export class PaymentEventConsumer {
constructor(private readonly uow: UnitOfWork) {}
async handleOrderConfirmed(evt: {
eventId: string;
orderId: string;
amount: number;
}): Promise<void> {
await this.uow.transaction(async (tx) => {
const already = await tx.query(
`SELECT 1 FROM processed_events WHERE event_id = $1 LIMIT 1`,
[evt.eventId],
);
if (0 < already.length) {
return;
}
await tx.query(
`INSERT INTO processed_events(event_id, processed_at) VALUES ($1, NOW())`,
[evt.eventId],
);
await tx.query(
`UPDATE payment_reservations SET status = 'CAPTURED' WHERE order_id = $1`,
[evt.orderId],
);
});
}
}
여기서 Idempotency는 선택이 아니라 필수다. 브로커는 at-least-once 전달을 기본으로 하므로, 소비자는 중복 메시지를 정상 경로로 처리할 수 있어야 한다.
해결 3: Idempotency와 보상 트랜잭션으로 수렴시키기
최종 일관성은 "일시적으로 불일치가 생길 수 있음"을 인정하되, 다음 조건으로 수렴을 보장한다.
- 실패 시 재시도 정책이 있고, 재시도는 멱등하다.
- 되돌릴 수 있는 단계에는 보상 트랜잭션이 정의되어 있다.
- 보상 실패도 관측 가능하고 재처리 가능하다(알람/재시도 큐/수동 개입).
실무에서는 다음 운영 규칙이 자주 필요하다.
- 보상 트랜잭션 타임아웃 기준과 최대 재시도 횟수
- Dead Letter Queue 전송 조건
- Saga 인스턴스 상태 추적(Started, Compensating, Failed)
즉, 데이터 일관성 문제는 "코드 한 함수"가 아니라 상태 머신 + 메시징 + 운영 정책의 조합으로 해결된다.
패턴 비교 요약
| 패턴 | 해결하는 문제 | 장점 | 주의점 | 적합한 상황 |
|---|---|---|---|---|
| Saga Orchestration | 분산 단계 실패 제어 | 실패/보상 흐름이 명시적 | Orchestrator 복잡도 증가 | 단계가 명확한 주문/결제 플로우 |
| Outbox Pattern | DB 변경 후 이벤트 유실 | 로컬 트랜잭션 기반 안정성 | 퍼블리셔 운영 필요 | 이벤트 기반 후속 처리가 많은 시스템 |
| Idempotency | 중복 메시지/중복 요청 | 재시도 안전성 확보 | 키 설계/저장 비용 | at-least-once 브로커 사용 환경 |
| Compensation Transaction | 부분 성공 후 복구 | 비즈니스 관점 복원 가능 | 보상 자체 실패 가능성 | 강한 원자성 대신 수렴을 선택한 환경 |
마무리
분산 트랜잭션에서 중요한 것은 "실패를 없애는 기술"이 아니라 실패를 다루는 구조를 먼저 설계하는 것이다.
아래 3가지 요소가 함께 작동할 때 최종 일관성 모델이 현실적으로 구현된다.
- Saga Orchestration으로 단계/보상 순서를 명확히 한다.
- Outbox Pattern으로 상태 변경과 이벤트 발행 사이의 유실을 제거한다.
- Idempotency로 재시도와 중복 전달을 안전하게 만든다.
결국 최종 일관성은 "조금 기다리면 맞아진다"는 낙관이 아니라, 수렴 규칙을 코드와 운영 정책으로 명시했을 때만 성립한다.