Choreography vs. Orchestration — 분산 트랜잭션을 직접 구현해보고 나서야 보이는 차이
마이크로서비스를 처음 설계할 때 "서비스끼리 DB를 공유하지 않으면 어떻게 트랜잭션을 맞추냐"는 질문이 저를 꽤 오래 괴롭혔습니다. 나중에 알고 보니 이 질문 자체가 Saga 패턴의 존재 이유이기도 했고요.
Saga 패턴은 분산 트랜잭션을 일련의 로컬 트랜잭션 시퀀스로 분해하고, 각 단계에 실패 시 이전 상태로 되돌리는 보상 트랜잭션을 붙이는 설계 패턴입니다. 그리고 Saga를 구현하는 방법이 두 가지라는 게 이 글의 핵심입니다. Choreography(코레오그래피)와 Orchestration(오케스트레이션) — 둘 다 "Saga를 구현한다"고 말할 수 있지만, 실제 선택은 팀의 상황과 워크플로 복잡도에 따라 전혀 다른 결과를 낳습니다.
이 글에서는 두 방식의 작동 원리와 코드 수준의 차이, 그리고 2026년 현재 실무에서 어떤 판단 기준으로 선택하는지를 구체적으로 풀어볼게요.
왜 Saga인가 — 2PC의 한계부터 짚고 넘어가기
2PC가 마이크로서비스에서 동작하지 않는 이유
전통적인 분산 트랜잭션 해법인 2PC(Two-Phase Commit)는 준비(Prepare) 단계 동안 관련된 모든 리소스를 블로킹합니다. 모든 서비스가 동시에 가용해야 하고, 코디네이터에 장애가 생기면 전체 시스템이 교착 상태에 빠집니다. 마이크로서비스 환경에서 수십 개 서비스가 동시에 완벽하게 가용한 상태를 가정하는 건 현실적이지 않습니다.
Saga는 이 제약 없이 **최종 일관성(Eventual Consistency)**을 보장하는 접근입니다. 완벽한 ACID를 포기하는 대신, 각 단계마다 보상 트랜잭션을 정의해서 실패를 우아하게 처리합니다.
위 그림처럼, 재고 차감 단계에서 실패하면 이미 완료된 결제 처리를 보상 트랜잭션으로 되돌립니다. 이 흐름을 "누가 지휘하느냐"에 따라 Choreography와 Orchestration이 나뉩니다.
두 방식의 작동 원리
Choreography — 이벤트가 스스로 흐른다
Choreography는 중앙 지휘자가 없습니다. 각 서비스가 자신의 작업을 마치면 이벤트를 발행하고, 관심 있는 다른 서비스가 그 이벤트를 구독해서 다음 작업을 수행합니다.
Kafka는 푸시 브로커가 아니라 컨슈머가 폴링해서 가져가는 pull 기반이라는 점을 다이어그램에도 반영했습니다. 초심자가 자주 혼동하는 지점이라 명시적으로 그려두는 편이 낫습니다.
코드로 보면 이런 식입니다. 각 서비스는 자신이 관심 있는 이벤트만 처리하면 됩니다.
// 결제 서비스 — OrderPlaced 이벤트를 구독해서 결제 처리 후 이벤트 발행
@KafkaListener(topics = "order-placed", groupId = "payment-service")
public void handleOrderPlaced(OrderPlacedEvent event) {
try {
paymentService.processPayment(event.getOrderId(), event.getAmount());
kafkaTemplate.send("payment-processed", new PaymentProcessedEvent(event.getOrderId()));
} catch (PaymentFailedException e) {
kafkaTemplate.send("payment-failed", new PaymentFailedEvent(event.getOrderId()));
}
}
// 재고 서비스 — PaymentProcessed 이벤트를 구독해서 재고 차감
@KafkaListener(topics = "payment-processed", groupId = "inventory-service")
public void handlePaymentProcessed(PaymentProcessedEvent event) {
try {
inventoryService.reserve(event.getOrderId());
kafkaTemplate.send("inventory-reserved", new InventoryReservedEvent(event.getOrderId()));
} catch (InsufficientStockException e) {
kafkaTemplate.send("inventory-failed", new InventoryFailedEvent(event.getOrderId()));
}
}
// 결제 서비스 — 재고 실패 이벤트를 구독해서 보상 처리
@KafkaListener(topics = "inventory-failed", groupId = "payment-service")
public void handleInventoryFailed(InventoryFailedEvent event) {
paymentService.refund(event.getOrderId()); // 멱등성 보장 필수
}Orchestration — 지휘자가 전체를 조율한다
Orchestration은 중앙 오케스트레이터가 각 서비스에게 무엇을 실행할지 직접 지시합니다. 오케스트레이터가 전체 워크플로 상태를 추적하고, 실패 시 보상 명령을 내립니다.
Temporal을 사용한 오케스트레이션의 개념적 예시입니다. 활동 함수 정의 한 개를 함께 보여줘야 임포트의 역할이 드러나므로 그렇게 구성했습니다.
# Temporal 워크플로 — 개념적 예시 (Temporal Python SDK 기반)
from datetime import timedelta
from temporalio import workflow, activity
from temporalio.common import RetryPolicy
@activity.defn
async def process_payment(order_id: str, amount: float) -> None:
# 실제 결제 게이트웨이 호출 지점 (개념적 예시)
...
@workflow.defn
class OrderSagaWorkflow:
@workflow.run
async def run(self, order_id: str, amount: float) -> str:
payment_completed = False
inventory_reserved = False
try:
await workflow.execute_activity(
process_payment,
args=[order_id, amount],
start_to_close_timeout=timedelta(seconds=30),
retry_policy=RetryPolicy(maximum_attempts=3),
)
payment_completed = True
await workflow.execute_activity(
reserve_inventory,
args=[order_id],
start_to_close_timeout=timedelta(seconds=30),
)
inventory_reserved = True
await workflow.execute_activity(
schedule_shipment,
args=[order_id],
start_to_close_timeout=timedelta(minutes=5),
)
return "ORDER_COMPLETED"
except Exception:
# 완료된 단계부터 역순으로 보상
if inventory_reserved:
await workflow.execute_activity(release_inventory, args=[order_id])
if payment_completed:
await workflow.execute_activity(refund_payment, args=[order_id])
return "ORDER_FAILED"Temporal이 눈에 띄는 이유가 여기 있습니다. 재시도 정책, 타임아웃, 내구성 있는 실행 상태 모두 플랫폼이 관리합니다. 서버가 중간에 죽어도 워크플로는 중단된 지점부터 재개됩니다.
코드 다음에 반드시 붙는 세 가지 — Outbox, DLQ, 관측 가능성
두 코드 예시를 보고 나면 "이걸 그대로 프로덕션에 올릴 수 있나?"라는 질문이 자연스럽게 따라옵니다. 답은 "아니오"이고, 이유는 세 가지입니다.
Transactional Outbox
로컬 DB 트랜잭션과 이벤트 발행을 원자적으로 처리하기 위한 패턴입니다. 위 Choreography 예시의 paymentService.processPayment()가 DB 커밋에 성공한 뒤 kafkaTemplate.send()가 네트워크 오류로 실패하면, DB 상태와 이벤트 스트림이 어긋납니다. Outbox 테이블에 이벤트를 함께 커밋하고, Debezium 같은 CDC 기반 도구나 Polling Publisher가 그 테이블을 읽어 Kafka로 옮기는 방식으로 해결합니다.
Dead Letter Queue
처리에 계속 실패하는 이벤트를 격리하고 나중에 재처리할 경로가 없으면, 프로덕션 장애 시 수동 복구 작업이 끔찍해집니다. 재시도 한계를 넘긴 메시지는 DLQ로 분리하고, 별도 콘솔에서 재처리·폐기·수동 개입을 결정할 수 있어야 합니다.
관측 가능성
Choreography에서 특히 나중에 추가하면 비용이 큽니다. OpenTelemetry 기반 분산 추적을 첫 설계 단계부터 넣고, saga_id를 모든 이벤트와 로그에 공통 컨텍스트로 전파하면 분산된 흐름을 하나의 트레이스로 연결할 수 있습니다. Orchestration에서는 오케스트레이터가 자연스럽게 상태를 중앙에 모아주지만, 그렇다고 각 서비스 내부 트레이스가 자동으로 이어지지는 않으니 동일하게 필요합니다.
트레이드오프 — 솔직하게 비교하면
저도 처음엔 "코레오그래피가 더 마이크로서비스답지 않나"라고 생각했습니다. 느슨한 결합, 서비스 자율성... 교과서적으로는 맞는 말인데, 프로덕션에서 "주문 #12345가 지금 어느 단계야?"라는 질문을 받는 순간 코레오그래피의 한계가 체감됩니다. Saga 상태가 여러 서비스에 분산되어 있어서 한눈에 파악하기가 어렵거든요.
| 구분 | Choreography | Orchestration |
|---|---|---|
| 결합도 | 이벤트 버스를 통한 느슨한 결합 | 오케스트레이터와 각 서비스 간 결합 |
| Saga 상태 가시성 | 분산 — 파악이 어렵다 | 중앙화 — 한 곳에서 모든 상태 확인 |
| 디버깅 | 여러 서비스 로그를 횡단해야 함 | 오케스트레이터 로그 한 곳에서 확인 |
| 복잡한 비즈니스 규칙 | 분기/루프 구현이 복잡해짐 | 오케스트레이터 코드에서 직접 표현 가능 |
| 변경 용이성 | 워크플로 수정 시 여러 서비스 동시 수정 필요 | 오케스트레이터 한 곳만 수정 |
| 운영 오버헤드 | 오케스트레이터 불필요 | 오케스트레이터 별도 관리 필요 |
| 서비스 자율성 | 높음 | 낮음 (오케스트레이터 의존) |
| 타임아웃/재시도 | 각 서비스마다 직접 구현 | 오케스트레이터 레벨에서 일괄 관리 |
적합한 상황 — 단계 수는 첫 번째 신호일 뿐입니다. 아래 표는 참고용 시작점이고, 최종 판단은 분기 조건의 복잡도, 팀의 이벤트 브로커 운영 역량, 감사 요건을 함께 봐야 합니다. 실제로는 3단계짜리 워크플로도 조건부 보상 로직이 얽히면 오케스트레이션이 나을 수 있고, 6단계짜리 선형 파이프라인이 이벤트 방식으로 깔끔한 경우도 있습니다.
| Choreography가 유리한 신호 | Orchestration이 유리한 신호 | |
|---|---|---|
| 단계 수 | 3~4단계 이하 | 5단계 이상 |
| 워크플로 변경 빈도 | 안정적, 자주 바뀌지 않음 | 비즈니스 요구사항이 자주 변경됨 |
| 감사/추적 요구 | 낮음 | 강한 감사 및 추적 필요 |
| 비즈니스 로직 복잡도 | 단순한 선형 흐름 | 분기, 루프, 조건부 보상 |
| 팀 역량 | 이벤트 브로커·CDC 운영 경험 있음 | 워크플로 엔진 운영에 익숙 |
흔한 실수들
코레오그래피에서 자주 보이는 실수 — Kafka 순서 보장 오해:
saga_id 또는 order_id를 메시지 키로 사용하면 동일 키의 이벤트가 동일 파티션으로 라우팅되어 단일 파티션 내 순서가 보장됩니다. 그 이상은 아닙니다. 컨슈머 그룹 리밸런싱 중에는 순서 보장이 흔들릴 수 있고, 서로 다른 토픽을 구독하는 서비스 간 순서(예: 결제 서비스와 재고 서비스가 각기 다른 토픽을 소비)는 이 방법으로 제어되지 않습니다. 전역 순서가 필요하다면 별도 시퀀스 관리나 상태 머신을 얹어야 합니다.
// 동일 orderId 이벤트를 동일 파티션으로 라우팅
kafkaTemplate.send(
new ProducerRecord<>("order-events", orderId, event) // orderId가 파티션 키
);오케스트레이션에서 자주 보이는 실수 — Fat Orchestrator:
오케스트레이터가 도메인 로직을 흡수하기 시작하면 점점 비대해집니다. 오케스트레이터는 "무엇을, 어떤 순서로"만 알아야 하고, "어떻게"는 각 서비스의 책임으로 남겨야 합니다.
공통 실수 — 멱등성을 나중에 생각하는 것:
멱등성(Idempotency)은 선택이 아닙니다. 보상 트랜잭션이 여러 번 실행되어도 동일한 결과를 반환해야 합니다. "환불 $50"을 두 번 실행해도 $50만 환불되어야 한다는 의미입니다. 설계 초기부터 넣지 않으면 나중에 이벤트 핸들러·보상 로직·저장소 스키마까지 함께 손봐야 하므로 수정 범위가 크게 벌어집니다.
// 멱등성 보장 예시 — 이미 처리된 환불은 스킵
public void refundPayment(String orderId) {
if (paymentRepository.isRefunded(orderId)) {
log.info("Already refunded for order: {}", orderId);
return;
}
paymentGateway.refund(orderId);
paymentRepository.markAsRefunded(orderId);
}실제 현장에서는 어떻게 쓰이나
아래 사례는 각사가 공개한 기술 블로그와 컨퍼런스 발표를 기준으로 정리한 것입니다. 이벤트 이름 등 구체적인 내부 계약은 2차 출처를 통한 재인용이 섞여 있어, 정확한 페이로드 구조는 각 조직 공식 자료로 확인하는 편이 좋습니다.
Choreography가 쓰인다고 알려진 사례:
- Uber는 실시간 차량 호출 흐름의 상당 부분을 이벤트 기반으로 구성하며, 도착·탑승 같은 상태 변화 이벤트가 알림·요금 계산 등 후속 액션을 자율적으로 트리거하는 것으로 알려져 있습니다. 서비스별 독립 스케일 아웃이 핵심 이유로 언급됩니다.
- Amazon은 재고 관련 도메인 이벤트를 브로드캐스트해 여러 하위 시스템이 재고 수준을 동적으로 조정하는 방식을 여러 발표에서 소개해왔습니다.
Orchestration이 쓰인다고 알려진 사례:
- Netflix는 동영상 인코딩·미디어 처리 워크플로에 워크플로 엔진 기반 오케스트레이션을 적용한다고 공개해왔습니다. Temporal과 자체 워크플로 시스템(Conductor 계열 포함)이 조직 안에서 병존해왔습니다.
- 이커머스 주문 처리에서 주문 생성 → 결제 → 재고 차감 → 배송 예약 → 고객 알림처럼 5단계 이상 워크플로는 오케스트레이션이 자주 선택됩니다. Temporal, AWS Step Functions 같은 매니지드 플랫폼이 오케스트레이터 자체의 고가용성 문제를 상당 부분 해결해주면서 "단일 장애 지점" 우려도 예전만큼 크지 않습니다.
하이브리드 전략 — 둘을 섞어 쓰는 게 현실적
체감상 단일 방식만 고집하는 팀보다, 크리티컬 워크플로에는 오케스트레이션을 쓰고 부수 흐름에는 코레오그래피를 끼워 쓰는 팀이 많습니다. 결제·주문 확정처럼 감사 추적이 중요한 흐름은 오케스트레이터에 맡기고, 알림·통계·검색 인덱싱처럼 실패해도 재처리 여유가 있는 부수 흐름은 이벤트로 흘려보내는 조합입니다.
오케스트레이션 도구 선택 기준
| 도구 | 강점 | 고려사항 |
|---|---|---|
| Temporal | 일반 코드(Go·Java·TypeScript·Python)로 워크플로 작성, 자동 재시도·타임아웃·버전 관리, Durable Execution | 별도 Temporal 서버 운영 필요 |
| AWS Step Functions | AWS 네이티브 통합, 서버리스, Standard/Express Workflows | AWS 종속성, ASL 학습 곡선 |
| Netflix Conductor | 오픈소스, 시각적 워크플로 정의, 다중 언어 워커 | 현재는 Netflix 주도 개발에서 커뮤니티·Orkes 주도로 전환된 상태 |
| Camunda | BPMN 기반 시각화, 복잡한 비즈니스 프로세스 표현 | 비BPMN 팀에게 러닝커브 존재 |
| Dapr | 클라우드 네이티브, 사이드카 패턴으로 언어 무관 | 인프라 복잡도 추가 |
2026년 기준으로 Temporal이 Durable Execution 패러다임의 유력한 선택지로 자리 잡았습니다. 일반 코드로 워크플로를 작성하고, 서버 재시작이나 장애에도 실행 상태가 유지된다는 점이 Saga 구현에 특히 잘 맞습니다.
마무리 — 남는 질문과 저의 실무 추천
두 패턴을 아무리 잘 골라도 Saga가 만능이 되지는 않습니다. 남는 문제가 몇 가지 있는데, 실무에서 가장 자주 부딪히는 건 이겁니다.
- 격리성의 부재. Saga 실행 중간 상태가 다른 트랜잭션에 노출됩니다. 결제는 됐지만 배송이 아직인 주문을 다른 요청이 조회할 수 있다는 뜻입니다. 이 지점은 상태 필드(
PENDING,CONFIRMED)와 애플리케이션 레벨 락으로 방어해야 하고, 두 패턴 모두 해결해주지 않습니다. - 디버깅의 근본적 어려움. 오케스트레이션은 상태를 한 곳에 모아 훨씬 낫지만, 서비스별 내부 실패·재시도·부분 성공이 얽히면 여전히 트레이스만으로는 원인 재현이 어렵습니다. 프로덕션에서 Saga를 오래 굴려보면 결국 이벤트/커맨드 자체를 재생(replay)할 수 있는 저장소가 있느냐가 조사 시간을 좌우합니다.
그래서 지금 어떤 팀에게 무엇을 권하냐고 물으면, 저는 이렇게 답합니다. 워크플로 엔진 운영 경험이 없고 첫 Saga를 만드는 팀이라면, 크리티컬 흐름 하나부터 Temporal 같은 매니지드 오케스트레이터로 시작하세요. 코레오그래피가 개념적으로 더 우아해 보여도, 첫 프로덕션 장애가 났을 때 상태를 한 화면에서 볼 수 있느냐가 야근 시간의 자릿수를 결정합니다. 반대로 이미 Kafka와 Outbox·DLQ·분산 추적을 안정적으로 운영 중인 팀이라면, 새 오케스트레이터를 도입하기 전에 기존 이벤트 인프라 위에서 코레오그래피로 먼저 시도해볼 만합니다.
어떤 쪽을 고르든, 멱등성·Outbox·DLQ·분산 추적을 설계 초기에 함께 넣는 것만은 타협하지 않는 편이 좋습니다. 이 네 가지가 없는 Saga는 잘 굴러가는 것처럼 보이다가 첫 장애에서 정체를 드러냅니다.
참고 자료
- Microservices.io — Pattern: Saga (Chris Richardson)
- Microsoft Azure — Saga Design Pattern
- AWS Prescriptive Guidance — Saga Choreography Pattern
- AWS Prescriptive Guidance — Implement the Serverless Saga Pattern by using AWS Step Functions
- Temporal — Mastering Saga Patterns for Distributed Transactions in Microservices
- ByteByteGo — Saga Pattern Demystified: Orchestration vs Choreography
- DEV Community — Saga Orchestration vs. Choreography: Making the Right Trade-off in Event-Driven Systems
- Conduktor — Saga Pattern for Microservices Explained
- Abstract Algorithms — Microservices Data Patterns: Saga, Transactional Outbox, CQRS, and Event Sourcing
- Donnchadh.dev — Orchestration vs. Choreography in Saga Patterns: A Detailed Comparison