ArchitectureMar 21, 2026

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

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

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

분산 트랜잭션 문제는 MSA에서만 생기지 않는다. 핵심은 서버 개수가 아니라 동기화해야 할 상태(State) 경계가 몇 개인가다. 단일 앱이라도 RDB/Redis/Elasticsearch 같은 다중 저장소, 애그리거트 경계 분리, 외부 API 연동, CQRS를 채택하는 순간 단일 트랜잭션이 깨질 수 있다.

이 글은 다음 질문을 주문-결제-재고 시나리오로 재현하고, Saga Orchestration + Outbox + Idempotency로 복구하는 방법을 정리한다.

  • 왜 분산 환경에서 데이터 불일치가 발생하는가?
  • Saga Orchestration은 무엇을 보장하고 무엇을 포기하는가?
  • Outbox와 Idempotency는 왜 같이 설계해야 하는가?
  • 실패 이후 시스템은 어떻게 "최종적으로" 일관된 상태로 수렴하는가?


목차



문제 정의: 왜 단일 트랜잭션이 깨지는가

주문 생성과 결제 승인, 재고 차감이 서로 다른 상태 경계에 걸치면 다음 성질이 동시에 등장한다.

  • 네트워크 호출은 실패하거나 지연될 수 있다.
  • 원격 서비스는 성공했지만 응답이 유실될 수 있다.
  • 일부 단계만 성공한 "중간 상태"가 남을 수 있다.

즉, 분산 트랜잭션에서 핵심은 "모든 단계가 항상 즉시 성공"이 아니라, 실패를 가정하고도 시스템을 최종적으로 수렴시키는 설계다.



분산 트랜잭션이 발생하는 다양한 시나리오

  1. Polyglot Persistence: RDB, Redis, Elasticsearch 등 여러 저장소를 쓰면, 각 저장소가 독립된 트랜잭션 경계를 가진다. 예를 들어 주문 상태는 RDB에, 검색 색인은 Elasticsearch에 저장하면, 주문 생성과 색인 업데이트가 같은 트랜잭션으로 묶이지 않는다.
  2. DDD 애그리거트 경계: Order와 Inventory를 서로 다른 애그리거트로 분리하면, 두 애그리거트를 한 트랜잭션으로 동시에 수정하지 않는 선택을 하게 된다. 예를 들어 주문을 먼저 커밋하고 이벤트로 재고를 갱신하는 방식은 최종 일관성 모델로 들어온다.
  3. 외부 API 연동: 결제 승인/취소 API, 알림 API, 물류 API는 모두 네트워크 너머의 별도 상태 저장소다. 내 DB 트랜잭션이 성공한 뒤 외부 API 호출이 타임아웃으로 실패하면, 롤백할지 재시도할지 보상할지 결정해야 한다. 이 상태 정합성 문제 자체가 분산 트랜잭션의 난제다.
  4. CQRS에서의 읽기/쓰기 시간차: Command 모델과 Query 모델을 분리하면 반영 지연은 구조적으로 발생한다. 예를 들어 사용자가 글을 작성했는데 메인 목록에는 0.5초 뒤 보이는 경우, 이 간극을 UX로 어떻게 안내하고, 기술적으로 어떤 재시도/동기화 전략으로 메울지 설계하는 것이 최종 일관성 관리다.

이처럼 MSA뿐 아니라 단일 앱에서도 Polyglot Persistence, 애그리거트 분리, 외부 API 연동, CQRS를 채택하는 순간 분산 트랜잭션 문제에 직면한다.



실패 재현: 나이브 분산 처리의 한계

아래 코드는 많이 보이는 형태다. 주문 DB 트랜잭션 안에서 외부 결제/재고 서비스를 순차 호출한다.

ts
@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. 주문 실패 응답이 나가도 결제 예약은 이미 성공할 수 있다.
  2. 실패 이후 상태를 자동으로 복구하는 규칙이 없다.
  3. 재시도 시 중복 결제/중복 재고 차감 가능성이 남는다.


해결 1: Saga Orchestration으로 실패를 관리하기

아래 코드는 Saga Orchestration으로 중앙 Orchestrator가 단계 실행과 보상(Compensation) 순서를 통제하는 방식을 보여준다. 핵심은 "실패를 제거"하는 것이 아니라 실패 시나리오를 명시적으로 관리하는 것이다.

ts
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 테이블을 폴링해서 메시지 브로커에 발행하는 구조다.

ts
@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와 보상 트랜잭션으로 수렴시키기

최종 일관성은 "일시적으로 불일치가 생길 수 있음"을 인정하되, 다음 조건으로 수렴을 보장한다.

  1. 실패 시 재시도 정책이 있고, 재시도는 멱등하다.
  2. 되돌릴 수 있는 단계에는 보상 트랜잭션이 정의되어 있다.
  3. 보상 실패도 관측 가능하고 재처리 가능하다(알람/재시도 큐/수동 개입).

실무에서는 다음 운영 규칙이 자주 필요하다.

  • 보상 트랜잭션 타임아웃 기준과 최대 재시도 횟수
  • Dead Letter Queue 전송 조건
  • Saga 인스턴스 상태 추적(Started, Compensating, Failed)

즉, 데이터 일관성 문제는 "코드 한 함수"가 아니라 상태 머신 + 메시징 + 운영 정책의 조합으로 해결된다.



패턴 비교 요약

패턴해결하는 문제장점주의점적합한 상황
Saga Orchestration분산 단계 실패 제어실패/보상 흐름이 명시적Orchestrator 복잡도 증가단계가 명확한 주문/결제 플로우
Outbox PatternDB 변경 후 이벤트 유실로컬 트랜잭션 기반 안정성퍼블리셔 운영 필요이벤트 기반 후속 처리가 많은 시스템
Idempotency중복 메시지/중복 요청재시도 안전성 확보키 설계/저장 비용at-least-once 브로커 사용 환경
Compensation Transaction부분 성공 후 복구비즈니스 관점 복원 가능보상 자체 실패 가능성강한 원자성 대신 수렴을 선택한 환경


마무리

분산 트랜잭션에서 중요한 것은 "실패를 없애는 기술"이 아니라 실패를 다루는 구조를 먼저 설계하는 것이다.

아래 3가지 요소가 함께 작동할 때 최종 일관성 모델이 현실적으로 구현된다.

  1. Saga Orchestration으로 단계/보상 순서를 명확히 한다.
  2. Outbox Pattern으로 상태 변경과 이벤트 발행 사이의 유실을 제거한다.
  3. Idempotency로 재시도와 중복 전달을 안전하게 만든다.

결국 최종 일관성은 "조금 기다리면 맞아진다"는 낙관이 아니라, 수렴 규칙을 코드와 운영 정책으로 명시했을 때만 성립한다.

Share this post

N