마이크로서비스 데이터 일관성: Saga로 보상하고 Event Sourcing으로 되감는다
서비스별 DB 분리가 만드는 분산 트랜잭션 문제를 Saga의 보상 흐름과 CQRS의 읽기·쓰기 분리, Event Sourcing의 이벤트 로그로 다루는 법을 정리한다
2026-08-13 · 최초 발행 2025-10-14
서비스를 도메인 단위로 쪼개고 각자 DB를 갖게 하는 순간, 여러 서비스에 걸친 트랜잭션을 하나의 분산 락으로 묶을 방법이 사라진다. 주문·재고·결제가 각각 다른 DB에 있으면 "전부 성공하거나 전부 실패"를 보장하던 ACID 트랜잭션 대신 다른 메커니즘이 필요해진다. Saga, CQRS, Event Sourcing은 이 문제에 각자 다른 각도에서 답하는 패턴이다.
서비스 경계를 나누면 데이터 일관성 문제가 넘어온다
마이크로서비스 아키텍처는 비즈니스 기능을 작은 독립 서비스로 분해해 자율적으로 배포·확장하는 구조이며, 데이터베이스 분리 원칙과 서비스 간 통신의 신뢰성·일관성 설계가 필수로 따라붙는다.
Saga는 분산 트랜잭션을 일련의 로컬 트랜잭션과 보상 트랜잭션으로 관리하는 패턴으로, 오케스트레이션(Orchestration) 또는 코레오그래피(Choreography) 방식을 지원한다.
CQRS(Command Query Responsibility Segregation)는 쓰기(Command)와 읽기(Query) 모델을 분리해 스키마·저장소·확장 전략을 나누고, 읽기 모델은 이벤트 기반 Projection으로 비동기 갱신한다.
Event Sourcing은 애그리게이트 상태를 이벤트의 Append-only 로그로 저장하고 리플레이로 상태를 재구성하며, 스냅샷과 버전 관리로 성능·동시성을 제어한다.
서비스 경계·메시징·일관성 모델을 먼저 정한다
서비스 경계는 Bounded Context로 정의하고, API·스키마 버전 관리와 스키마 호환성(Backward/Forward) 준수, 이벤트 명세서(이름·스키마·인과관계·불변성) 표준화가 뒤따라야 한다.
비동기 메시징은 Kafka·RabbitMQ 같은 브로커를 기반으로 At-least-once 전달을 전제하고 멱등 처리를 설계한다. Outbox/Inbox, DLQ, 재시도·백오프 정책도 여기 포함된다.
일관성 모델은 강한 일관성 대신 최종 일관성(Eventual Consistency)을 채택하고, 실패 경로에는 보상 트랜잭션과 타임아웃·사건 정리(Compensation)를 설계해둔다.
관측성은 트레이싱(Trace/Span), 상관관계 ID, 이벤트 상관키(Correlation/Conversation ID)를 통일하고, SLO 기반 경보와 재처리 안전성(Idempotency Key) 점검이 필요하다. 보안 측면에서는 Zero-trust, mTLS, 토픽 ACL, 스키마 레지스트리 권한 제어와 함께 데이터 수명(보존·파기), PII 마스킹·암호화 정책을 적용한다.
주문이 실패하면 어떻게 되돌리나
재고 확보에는 성공했지만 결제가 실패하는 경우, Saga 오케스트레이터는 이미 진행된 단계를 보상 트랜잭션으로 되돌린다.
Saga는 오케스트레이션이냐 코레오그래피냐
Saga의 절차는 비즈니스 명령(CreateOrder) 입력으로 시작해 로컬 트랜잭션 실행→이벤트 발행→하위 서비스 호출→단계별 성공 시 다음 단계 진행으로 이어지고, 성공 이벤트(OrderCompleted)나 보상 완료 이벤트(OrderCompensated)로 끝난다.
타임아웃이 발생하면 보상 트랜잭션을 실행하고, 재시도는 지수 백오프와 최대 횟수 제한을 둔다. 멱등성 키로 중복 이벤트를 막고, 순서 보장은 키 파티셔닝으로 유지한다. 각 단계는 자체 DB에 로컬 커밋하며 분산 락은 쓰지 않는다는 원칙이 있고, 전체 일관성은 보상으로 회복하는 최종 일관성이다.
오케스트레이션은 중앙 조정자에 로직을 집중시켜 가시성·제어가 쉽지만 단일 구성요소의 복잡도가 늘어난다. 코레오그래피는 결합도가 낮고 확장이 쉽지만 흐름 파악과 분산 디버깅 난이도가 올라간다. 이 선택으로 얻는 효과는 로컬 트랜잭션 전환에 따른 분산 락 경합 6080% 감소, 장애 격리·보상 트랜잭션에 따른 평균복구시간(MTTR) 3050% 단축이다.
CQRS로 읽기와 쓰기를 가른다
CQRS의 절차는 Command 수신(CreateOrder)으로 시작해 쓰기 모델을 검증·커밋한 뒤 도메인 이벤트를 발행하고, Projection 컨슈머가 읽기 모델을 갱신하는 흐름이다. Command 결과는 동기로, 읽기 모델 반영은 비동기로 나타난다.
Projection이 장애를 일으키면 재처리 큐·DLQ로 격리하고 오프셋 관리로 정확하게 재구축한다. 읽기 모델은 최종 일관성이므로 클라이언트에 Staleness 계약을 명시해야 한다. 쓰기 경로는 강한 일관성을, 읽기 경로는 최종 일관성을 가지며 락 경합이 줄고 읽기 확장성이 개선된다. 읽기·쓰기 분리로 얻는 효과는 읽기 처리량이 3~10배까지 향상될 수 있다는 점이다.
Event Sourcing: 상태 대신 이벤트를 저장한다
Event Sourcing의 절차는 Command 처리로 도메인 이벤트를 생성하는 데서 시작해 이벤트 스토어에 Append-only로 저장하고 스냅샷을 주기적으로 만드는 흐름이며, 결과물은 이벤트 스트림과 스냅샷 포인터다.
동시성은 버전 체크(Optimistic Lock)로 관리해 실패 시 리트라이하고, 이벤트 역호환성은 업캐스터(Upcaster)나 버전드 이벤트로 지원한다. 이벤트 저장은 단일 원자적 커밋이고 상태는 리플레이로 재구성하며, 정확히 한 번 처리를 보장하기는 어려워 멱등 리스너 구현이 필요하다. 이벤트 소싱을 도입하면 감사와 리플레이로 데이터 복구 성공률이 향상된다.
Outbox로 로컬 트랜잭션과 이벤트 발행을 묶는 예제
전제조건은 Java 17, Spring Boot 3.3+, Kafka 3.x, PostgreSQL 14+이며, 목적은 로컬 트랜잭션 안에서 도메인 데이터와 Outbox 이벤트를 동시에 커밋하고 외부 발행은 비동기로 처리하는 것이다.
// build.gradle: spring-boot-starter-data-jpa, spring-kafka, postgresql 의존성 가정
// Outbox 엔티티
@Entity
@Table(name = "outbox_event")
class OutboxEvent {
@Id @GeneratedValue private Long id;
private String type; // e.g., "OrderCreated"
@Column(columnDefinition = "jsonb") private String payload;
private String status; // NEW, SENT, ERROR
private String aggregateId; // 멱등 키
private Instant createdAt = Instant.now();
}
// 도메인 + Outbox 쓰기
@Service
class OrderService {
private final OrderRepo orderRepo; private final OutboxRepo outboxRepo;
@Transactional
public Long createOrder(CreateOrder cmd) {
Order order = orderRepo.save(new Order(cmd)); // 로컬 트랜잭션
OutboxEvent evt = new OutboxEvent();
evt.setType("OrderCreated");
evt.setAggregateId(order.getId().toString());
evt.setPayload(toJson(Map.of("orderId", order.getId(), "amount", order.getAmount())));
evt.setStatus("NEW");
outboxRepo.save(evt); // 같은 트랜잭션 커밋
return order.getId();
}
}
// Outbox 폴링 후 Kafka 발행
@Component
class OutboxPublisher {
private final KafkaTemplate<String, String> kafka;
private final OutboxRepo outboxRepo;
@Scheduled(fixedDelay = 500)
@Transactional
public void publish() {
List<OutboxEvent> batch = outboxRepo.lockFetchNew(100); // FOR UPDATE SKIP LOCKED
for (OutboxEvent e : batch) {
kafka.send("order-events", e.getAggregateId(), e.getPayload()).completable()
.whenComplete((r, ex) -> {
if (ex == null) e.setStatus("SENT"); else e.setStatus("ERROR");
});
}
// 트랜잭션 종료 시 상태 업데이트 커밋
}
}
핵심은 SELECT … FOR UPDATE SKIP LOCKED로 멀티 인스턴스 간 경쟁을 방지하는 것, 메시지 키를 애그리게이트 ID로 잡아 파티션 순서를 보장하고 컨슈머가 멱등 처리하는 것, 실패 시 status=ERROR로 남기고 별도 리커버리 잡이나 DLQ로 라우팅하는 것이다.
각 패턴을 지표로 비교하면
| 패턴 | 성능 | 확장성 | 일관성 | 안정성 | 운영 편의 |
|---|---|---|---|---|---|
| Saga | 단계별 로컬 커밋로 지연 최소화, 보상 비용 존재 | 서비스별 독립 확장 용이 | 최종 일관성, 설계 정확성 요구 | 보상 로직 품질에 의존, 부분 실패 복구 가능 | 오케스트레이터/이벤트 추적 필요, 관측성 중요 |
| CQRS | 읽기 경로 최적화, 캐시·인덱스 특화 | 읽기·쓰기 독립 확장 | 쓰기 강한 일관성, 읽기 최종 일관성 | Projection 재처리로 복원 용이 | 모델 이원화로 운영 복잡성 상승 |
| Event Sourcing | Append-only I/O 효율, 스냅샷 필요 | 이벤트 리플레이·샤딩으로 수평 확장 | 이벤트가 단일 진실, 리더 모델 재구성 | 버전 충돌 관리 필요, 재생으로 복구 용이 | 툴체인·스키마 버전 관리 요구 |
운영 원칙: 메시징·스키마·관측성·보안
메시징·일관성 측면에서는 Outbox/Inbox 패턴, 멱등 키, 재시도·DLQ 설계가 권장되며, 오케스트레이션은 제어가 쉽고 코레오그래피는 결합이 느슨하니 조직 규모와 도메인 복잡도에 따라 고른다.
데이터·스키마 측면에서는 이벤트 스키마 레지스트리(Avro/JSON Schema)와 호환성 규율을 적용하고, Event Sourcing은 업캐스터와 스냅샷 주기(예: 500~1000 이벤트) 정책이 필요하다.
관측성·신뢰성 측면에서는 분산 트레이싱과 SLO(예: Saga 성공률 ≥ 99.5%, 보상 지연 p95 ≤ 2s) 지표를 정의하고, 회로 차단기·타임아웃·Rate Limit으로 장애 전파를 차단한다.
보안·컴플라이언스 측면에서는 mTLS, OAuth2/JWT, 토픽 ACL, 데이터 암호화(KMS)를 적용하고 이벤트 보존·파기 정책, PII 마스킹, 감사 추적을 유지한다.
실무 사례: 커머스·금융·물류
커머스 주문 파이프라인에서는 Saga로 재고→결제→배송 단계를 오케스트레이션하고 실패 시 보상하며, CQRS로 주문 조회 트래픽에 대응해 인기 상품 필터·정렬을 최적화한 Projection을 두고, Event Sourcing으로 주문 이력 감사와 분쟁 대응을 강화한다.
금융 결제 승인에서는 한도 체크·리스크 평가·원장 반영을 Saga로 분리해 지연을 최소화하고, Event Sourcing 기반 원장 이벤트를 보존하며 스냅샷으로 조회 성능을 확보한다.
물류·재고 동기화에서는 센터 간 재고 이동을 이벤트로 모델링해 오프라인 상태에서도 재생으로 복원하고, CQRS 읽기 모델로 실시간 대시보드를 구현한다.