- Published on
실무 MSA에서 SAGA vs Outbox 선택 가이드
- Authors
- Name
- 스타차일드
- https://x.com/ETFBITX
MSA에서 가장 자주 마주치는 난제 중 하나는 “서비스가 쪼개졌는데, 예전처럼 한 번에 커밋되는 트랜잭션은 어떻게 보장하지?”입니다. 결론부터 말하면, 대부분의 실무 시스템은 전통적인 분산 트랜잭션(2PC)을 피하고 일관성 모델을 재설계합니다. 이때 현장에서 가장 많이 등장하는 선택지가 SAGA 패턴과 Outbox 패턴입니다.
둘은 경쟁 관계처럼 보이지만, 실제로는 해결하려는 문제가 다릅니다.
- SAGA: 여러 서비스에 걸친 “업무 프로세스(상태 전이)”를 단계적으로 진행하고, 실패 시 보상(Compensation) 으로 되돌립니다.
- Outbox: 한 서비스 내부의 DB 변경과 이벤트 발행 사이의 원자성(Atomicity) 격차를 줄여 “커밋은 됐는데 이벤트가 유실되는” 문제를 막습니다.
즉, 많은 팀이 SAGA를 하면서 Outbox도 같이 씁니다. 다만 우선순위와 적용 범위가 달라서, 선택 가이드를 명확히 해두면 설계/운영 비용을 크게 줄일 수 있습니다.
실무에서 마주치는 실패 시나리오
1) DB는 커밋됐는데 이벤트 발행이 실패
예: 주문 서비스가 orders 테이블에 주문을 저장했는데, Kafka 발행이 타임아웃. 재시도 로직이 없다면 다른 서비스(결제/배송)가 주문 생성 사실을 영원히 모릅니다.
이 문제는 SAGA가 아니라 Outbox가 정면으로 해결합니다.
2) 이벤트는 발행됐는데 DB 커밋이 롤백
반대로 애플리케이션이 메시지를 먼저 발행하고, 이후 DB 트랜잭션이 실패하면 “존재하지 않는 주문” 이벤트가 퍼집니다. 소비자는 이를 처리하기 위해 더 복잡한 보정 로직이 필요해집니다.
이 또한 Outbox의 영역입니다.
3) 여러 서비스 단계 중 하나가 실패
예: 주문 생성(주문 서비스) → 결제 승인(결제 서비스) → 재고 차감(재고 서비스) 중 재고 차감이 실패. 이미 결제가 승인됐다면 환불(보상)이 필요합니다.
이 문제는 SAGA의 영역입니다.
Outbox 패턴: “이벤트 유실”을 없애는 최소 비용의 안전장치
Outbox는 서비스 내부에 outbox 테이블(또는 컬렉션)을 두고, 업무 데이터 변경과 Outbox 레코드 저장을 같은 DB 트랜잭션으로 묶습니다. 이후 별도 퍼블리셔가 Outbox를 폴링하거나 CDC로 읽어 메시지 브로커로 발행합니다.
Outbox의 핵심 가치
- DB 커밋과 이벤트 발행 사이의 간극을 제거(정확히는 “동일 트랜잭션으로 기록”)하여 유실을 방지
- 메시지 브로커 장애/네트워크 장애 시에도 Outbox에 남아 재시도 가능
- 서비스 간 트랜잭션을 강제하지 않고도, 이벤트 기반 동기화를 안정화
주의할 점(운영 포인트)
- 소비자는 기본적으로 at-least-once를 가정해야 하므로 중복 처리(idempotency) 가 필수
- Outbox 테이블은 쌓이기 쉬우므로 보관 정책(아카이빙/TTL)과 인덱스 튜닝이 필요
- 퍼블리셔가 장애 시 재기동 전략, 락/샤딩(다중 인스턴스)이 필요
Spring Boot + JPA Outbox 예시
아래 예시는 주문 생성 시 주문 저장과 Outbox 저장을 같은 트랜잭션으로 처리합니다.
@Entity
@Table(name = "outbox")
public class OutboxMessage {
@Id
private String id;
private String aggregateType; // 예: "Order"
private String aggregateId; // 예: orderId
private String eventType; // 예: "OrderCreated"
@Lob
private String payloadJson;
private Instant createdAt;
private Instant publishedAt;
}
@Service
public class OrderService {
private final OrderRepository orderRepository;
private final OutboxRepository outboxRepository;
@Transactional
public String createOrder(CreateOrderCommand cmd) {
Order order = Order.create(cmd);
orderRepository.save(order);
OutboxMessage msg = new OutboxMessage();
msg.setId(UUID.randomUUID().toString());
msg.setAggregateType("Order");
msg.setAggregateId(order.getId());
msg.setEventType("OrderCreated");
msg.setPayloadJson("{\"orderId\":\"" + order.getId() + "\"}");
msg.setCreatedAt(Instant.now());
outboxRepository.save(msg);
return order.getId();
}
}
그리고 별도 스케줄러가 미발행 Outbox를 읽어 브로커로 발행합니다.
@Component
public class OutboxPublisher {
private final OutboxRepository outboxRepository;
private final KafkaTemplate<String, String> kafkaTemplate;
@Scheduled(fixedDelayString = "1000")
@Transactional
public void publishBatch() {
List<OutboxMessage> batch = outboxRepository.findTop100ByPublishedAtIsNullOrderByCreatedAtAsc();
for (OutboxMessage msg : batch) {
kafkaTemplate.send("domain-events", msg.getAggregateId(), msg.getPayloadJson());
msg.setPublishedAt(Instant.now());
}
}
}
이때 @Transactional 경계를 어떻게 잡느냐가 중요합니다.
- 발행 성공 후
publishedAt업데이트까지 한 트랜잭션으로 묶으면, 재시도/중복 발행 시나리오를 단순화할 수 있습니다. - 다만 Kafka send 결과를 동기적으로 기다릴지, 비동기로 처리할지에 따라 구현이 달라집니다.
SAGA 패턴: “업무 프로세스”를 분산 환경에서 완주시키는 방법
SAGA는 여러 서비스에 걸친 로컬 트랜잭션들의 시퀀스입니다. 각 단계가 성공하면 다음 단계로 진행하고, 어느 단계에서 실패하면 이미 완료된 단계들을 보상 트랜잭션으로 되돌립니다.
SAGA의 두 가지 구현 방식
1) Choreography(이벤트 기반)
각 서비스가 이벤트를 발행하고, 다른 서비스가 이를 구독해 다음 액션을 수행합니다.
- 장점: 중앙 오케스트레이터가 없어 결합도가 낮아 보임
- 단점: 플로우가 커질수록 “어디서 무엇이 시작되어 어디로 가는지” 추적이 어려움(운영/디버깅 난이도 상승)
2) Orchestration(오케스트레이터 기반)
중앙 오케스트레이터가 상태를 관리하며 각 서비스에 커맨드를 내려 단계별로 진행합니다.
- 장점: 흐름이 명확하고 가시성이 좋음(타임아웃/재시도/보상 정책을 한 곳에서 정의)
- 단점: 오케스트레이터가 도메인 흐름을 많이 알게 되어 변경 영향이 커질 수 있음
실무에서는 프로세스가 단순하면 Choreography, 결제/정산/계약처럼 실패 비용이 큰 프로세스는 Orchestration을 선호하는 경향이 있습니다.
SAGA 설계에서 가장 중요한 질문
- “보상 트랜잭션이 진짜 가능한가?”
- 예: 결제 승인 후 환불은 가능하지만, 외부 시스템 정산이 끝난 뒤에는 환불이 불가능할 수 있습니다.
- “보상은 강한 롤백이 아니라 ‘반대 방향의 새로운 업무’다”
- 따라서 보상도 실패할 수 있고, 재시도/수동 처리 큐가 필요합니다.
SAGA vs Outbox: 선택 기준을 표로 정리
| 기준 | Outbox 패턴 | SAGA 패턴 |
|---|---|---|
| 해결 문제 | DB 변경과 이벤트 발행의 원자성/유실 방지 | 다단계 분산 업무 프로세스의 완료/보상 |
| 적용 범위 | 단일 서비스 내부(퍼블리시 신뢰성) | 여러 서비스/외부 시스템 포함 |
| 실패 모델 | 중복 발행/지연 발행을 전제로 설계 | 단계 실패, 타임아웃, 보상 실패까지 고려 |
| 구현 난이도 | 중간(테이블/퍼블리셔/중복 처리) | 높음(상태 머신, 보상, 관측성) |
| 운영 포인트 | outbox 적재량, 퍼블리셔 장애, 재처리 | 프로세스 추적, 타임아웃, DLQ, 수동 오퍼레이션 |
| 권장 사용 | 이벤트 기반 통합을 시작하는 대부분의 서비스 | 결제/정산/예약 등 다단계 트랜잭션성 업무 |
핵심은 이겁니다.
- “이벤트 유실이 무섭다”면 Outbox가 1순위
- “업무 단계가 여러 서비스에 걸쳐 있고 실패 시 되돌려야 한다”면 SAGA가 1순위
- 그리고 실제로는 SAGA 단계 간 통신을 이벤트로 한다면 Outbox가 사실상 필수가 됩니다
함께 쓰는 현실적인 아키텍처(권장)
실무에서 가장 흔한 조합은 다음입니다.
- 각 서비스는 로컬 트랜잭션으로 도메인 상태를 변경
- 변경 사실을 Outbox에 기록
- Outbox 퍼블리셔가 브로커로 이벤트 발행
- SAGA 오케스트레이터(또는 이벤트 기반 참여 서비스)가 이벤트를 받아 다음 단계를 진행
이 구조는 “각 서비스의 이벤트 발행 신뢰성”과 “프로세스 완주”를 분리해 복잡도를 낮춥니다.
꼭 넣어야 하는 실무 체크리스트
1) Idempotency(멱등성) 전략
Outbox든 SAGA든 메시지는 중복될 수 있습니다. 소비자는 아래 중 하나를 가져야 합니다.
eventId기반 처리 이력 테이블(Processed Events)- 비즈니스 키 기반 업서트(upsert)
- 상태 머신에서 이미 처리된 전이를 무시
2) 메시지 순서와 재처리
- 동일
aggregateId에 대해서는 파티셔닝 키를 고정해 순서 보장을 강화 - 재처리를 위해 “이벤트 스키마 버전”과 “역호환” 정책을 명확히
3) 관측성(Observability)
SAGA는 특히 추적성이 생명입니다.
correlationId/traceId를 커맨드와 이벤트 모두에 포함- 단계별 타임아웃, 재시도 횟수, 보상 실행 여부를 대시보드로 노출
운영 환경에서 장애를 빠르게 좁히는 능력은 결국 인프라/배포 파이프라인과도 연결됩니다. 예를 들어 EKS에서 노드 상태나 네트워크 이슈로 컨슈머가 멈추면 “Outbox 적체”가 바로 발생합니다. 이런 진단 루틴은 별도로 갖춰두는 게 좋습니다. 관련해서는 EKS 노드 NotReady일 때 CNI·IP 고갈 진단 같은 체크리스트형 글이 도움이 됩니다.
또한 이벤트 퍼블리셔/컨슈머가 Spring Boot라면 스레드/동시성 설정에 따라 지연이 크게 달라질 수 있습니다. 가상 스레드 적용 시 트러블슈팅 포인트는 Spring Boot 3 가상스레드 적용 트러블슈팅도 참고할 만합니다.
어떤 경우에 무엇을 먼저 도입할까
케이스 A: “이벤트 기반 통합을 막 시작”
- 우선: Outbox
- 이유: SAGA를 얹기 전에, 이벤트 발행의 신뢰성이 먼저 확보돼야 운영이 가능합니다.
케이스 B: “주문-결제-재고-배송이 이미 분리되어 있고 장애가 잦음”
- 우선: SAGA 오케스트레이션(또는 최소한의 상태 머신)
- 병행: 각 서비스 Outbox
- 이유: 프로세스 실패 시 수습이 가능해야 하고, 그 기반이 되는 이벤트 발행도 유실되면 안 됩니다.
케이스 C: “외부 결제/외부 재고 같은 제어 불가능한 시스템이 포함”
- 우선: SAGA(타임아웃/보상/수동 처리 큐 포함)
- 이유: 외부 시스템은 재시도/중복에 민감할 수 있어, 단계별 정책을 중앙에서 명확히 가져가는 편이 안전합니다.
마무리: 선택이 아니라 ‘레이어링’에 가깝다
SAGA와 Outbox는 같은 문제를 두고 경쟁하는 패턴이라기보다, 서로 다른 레이어를 담당합니다.
- Outbox는 “이벤트를 잃지 않게 하는 안전장치”
- SAGA는 “분산된 업무 흐름을 끝까지 완주시키는 프로세스 엔진”
따라서 실무에서는 보통 Outbox로 이벤트 신뢰성을 확보한 뒤, 필요한 업무 흐름에만 SAGA를 선택적으로 적용하는 것이 비용 대비 효과가 좋습니다. 특히 보상 트랜잭션이 애매한 도메인이라면, SAGA를 성급히 도입하기보다 “취소 가능 시간”, “정산 전 상태”, “보류 상태” 같은 도메인 정책으로 일관성 모델을 먼저 다듬는 것이 더 큰 안정성을 가져옵니다.