단일 DB를 사용하는 모놀리식 구조와 달리, 마이크로서비스 아키텍처(MSA)는 각 서비스가 독립된 DB를 가집니다. 서비스 A에서 주문이 성공했는데 서비스 B에서 결제가 실패하면 데이터는 엉망이 됩니다. 분산 환경에서는 강한 정합성을 포기하고 최종 일관성(Eventual Consistency)을 선택해야 합니다.
이 글은 Saga 패턴으로 서비스 간 워크플로우를 관리하고, Transactional Outbox 패턴으로 메시지 발행의 원자성을 보장하여 데이터 결손을 0%로 만드는 아키텍처를 다룹니다.
TL;DR — 서비스 간 비즈니스 흐름은 Saga 패턴으로 제어하고, 로컬 DB 트랜잭션과 이벤트 발행은 Outbox 패턴으로 묶어 시스템 전체의 정합성을 보장합니다.
1. 분산 트랜잭션의 구원자: Saga와 Outbox 패턴
💡 비유로 이해하기: Saga 패턴은 '릴레이 경주'와 같습니다. 앞 주자가 바통을 넘기다 넘어지면(실패), 이전 주자들이 다시 돌아가 원상복구(보상 트랜잭션)를 하는 방식입니다. Outbox 패턴은 바통을 넘겼다는 사실을 '공식 장부'에 적어두어, 심판이 언제든 확인하고 재전송하게 만드는 장치입니다.
Saga 패턴은 각 마이크로서비스의 로컬 트랜잭션을 순차적으로 실행합니다. 만약 중간에 실패가 발생하면 이미 성공한 서비스들에게 보상 트랜잭션(Compensating Transaction)을 던져 롤백을 수행합니다. 최신 표준은 분산 락을 사용하는 2PC(Two-Phase Commit)보다 가용성이 높은 Saga를 선호합니다.
Outbox 패턴은 "DB 업데이트"와 "메시지 발행"이 동시에 성공하거나 실패해야 하는 Dual Write 문제를 해결합니다. 비즈니스 로직을 처리하는 동일한 DB 트랜잭션 안에 outbox 테이블에 메시지를 함께 저장합니다. 이후 별도의 릴레이 프로세스가 이 테이블을 읽어 메시지 브로커(Kafka 등)로 확실히 전달합니다.
2. 실무에서 이 조합이 반드시 필요한 상황
이커머스 결제 시스템이 대표적입니다. 사용자가 주문 버튼을 누르면 '주문 생성', '결제 처리', '재고 차감'이 일어납니다. 만약 재고가 부족해 실패했는데 결제만 완료된 상태라면 심각한 CS가 발생합니다. Saga 패턴은 이 과정을 체인으로 엮어 결제 취소 로직을 자동 실행합니다.
하지만 단순히 Kafka로 메시지를 쏘는 방식은 위험합니다. DB 트랜잭션은 성공했는데 네트워크 오류로 메시지 전송이 실패하면, 다음 서비스는 영원히 호출되지 않습니다. Outbox 패턴은 이러한 메시지 유실을 원천적으로 차단하여 시스템 간 상태 불일치를 막습니다.
3. 단계별 구현 가이드: Saga + Outbox 아키텍처
가장 권장되는 방식인 'CDC(Change Data Capture) 기반 Outbox'와 'Orchestration Saga'를 기준으로 설명합니다.
Step 1. Outbox 테이블 설계 및 로직 통합
비즈니스 데이터를 저장할 때 동일한 로컬 트랜잭션 안에서 outbox 테이블에 전송할 이벤트 정보를 삽입합니다.
BEGIN;
-- 1. 주문 데이터 저장
INSERT INTO orders (id, user_id, amount) VALUES (101, 1, 50000);
-- 2. 동일 트랜잭션 내 Outbox 기록
INSERT INTO outbox (id, aggregate_type, aggregate_id, type, payload)
VALUES (uuid(), 'Order', 101, 'OrderCreated', '{"amount": 50000}');
COMMIT;
Step 2. 메시지 릴레이(Debezium 활용)
어플리케이션이 직접 메시지를 보내지 않습니다. Debezium 같은 CDC 툴이 DB의 Binlog를 감시하다가 outbox 테이블에 새 행이 생기면 Kafka로 즉시 발행합니다.
{
"name": "outbox-connector",
"config": {
"connector.class": "io.debezium.connector.postgresql.PostgresConnector",
"table.include.list": "public.outbox",
"transforms": "outbox",
"transforms.outbox.type": "io.debezium.transforms.outbox.EventRouter"
}
}
Step 3. 오케스트레이터의 보상 트랜잭션 처리
중앙 제어기(Orchestrator)가 각 서비스의 응답을 확인하고, 실패 응답이 오면 역순으로 취소 메시지를 발행합니다.
// pseudocode: Saga Orchestrator logic
if (paymentStatus == "FAILED") {
outboxRepository.save(new OutboxEvent("CancelOrder", orderId));
outboxRepository.save(new OutboxEvent("RestoreStock", productId));
}
4. Orchestration vs Choreography 방식 비교
Saga 패턴을 구현하는 두 가지 방식의 핵심 차이점입니다.
| 기준 | Orchestration (중앙 제어) | Choreography (이벤트 기반) |
|---|---|---|
| 성능 | 중앙 통제로 인한 약간의 오버헤드 | 높은 처리량, 지연 시간 낮음 |
| 복잡도 | 비즈니스 로직 파악이 쉬움 | 서비스가 많아지면 흐름 추적 어려움 |
| 유지보수 | 중앙에서 상태 관리 용이 | 서비스 간 순환 참조 위험 있음 |
| 적합 규모 | 복잡한 비즈니스 워크플로우 | 단순하고 빠른 반응형 시스템 |
워크플로우가 4단계 이상이거나 비즈니스 로직이 자주 변경된다면 Orchestration을, 극도의 성능과 단순한 흐름이 중요하다면 Choreography를 선택하세요.
5. 주의사항 및 트러블슈팅
⚠️ 가장 자주 하는 실수: 소비자의 멱등성(Idempotency) 처리를 누락하는 경우입니다. Outbox 패턴은 '최소 한 번 전송(At-least-once)'을 보장하므로, 동일한 메시지가 중복 전달될 수 있습니다.
메시지 브로커가 재시도 로직에 의해 같은 이벤트를 두 번 보내면, 재고가 두 번 깎이는 대참사가 발생합니다. 수신 측에서는 반드시 Unique Key(예: 주문 ID)를 체크하여 이미 처리된 요청인지 확인해야 합니다.
에러 메시지별 해결법
MessageDeliveryException: Failed to publish message to Kafka...
원인: 브로커 다운 또는 네트워크 타임아웃
해결: Outbox 패턴을 사용 중이라면 걱정 없습니다. CDC 릴레이 프로세스가
브로커 복구 후 마지막 오프셋부터 자동으로 재전송합니다.
6. 실전 운영 팁
Outbox 테이블은 시간이 지나면 비대해집니다. 처리 완료된 행은 정기적으로 삭제(Archiving)하는 배치를 운영하세요. 또한, 전체 트랜잭션 추적을 위해 Correlation ID를 모든 메시지 헤더에 포함시켜 분산 로그 추적 시스템(Jaeger, Zipkin)에서 한눈에 볼 수 있게 구성해야 합니다.
📌 핵심 요약
- Saga는 보상 트랜잭션으로 분산 환경의 롤백을 구현합니다.
- Outbox는 DB 트랜잭션과 이벤트 발행을 원자적으로 묶어 데이터 유실을 방지합니다.
- 수신 측의 멱등성 보장은 아키텍처 완성의 필수 조건입니다.
Frequently Asked Questions
Q. Saga 패턴과 2PC의 차이점은 무엇인가요?
A. 2PC는 모든 서비스의 잠금(Lock)을 유지해 강한 정합성을 보장하나 가용성이 낮고, Saga는 로컬 트랜잭션 기반으로 최종 일관성을 추구하며 높은 가용성을 제공합니다.
Q. Outbox 테이블을 Polling 방식으로 읽어도 되나요?
A. 가능합니다. 하지만 쿼리 부하와 지연 시간이 발생하므로, 고성능이 필요하다면 DB 로그를 직접 읽는 CDC 방식을 권장합니다.
Q. 보상 트랜잭션 자체가 실패하면 어떻게 하나요?
A. 보상 트랜잭션은 성공할 때까지 재시도하는 것이 원칙입니다. 지속적인 실패 시 데드 레터 큐(DLQ)에 적재 후 담당자가 수동 개입해야 합니다.
Post a Comment