이벤트 기반 아키텍처와 CQRS — Outbox 패턴으로 쓰기와 읽기를 가른다
이벤트 발행과 구독, Outbox·Saga로 정합성을 지키는 법, CQRS로 읽기 모델을 분리하는 이유를 Spring Boot·Kafka·PostgreSQL 예제로 정리한다
2026-08-12 · 최초 발행 2025-12-13
서비스 하나가 주문을 저장했는데 그 사실을 알리는 이벤트가 발행되지 않으면, 재고도 결제도 그 주문의 존재를 모른 채 남는다. 마이크로서비스가 도메인 단위로 쪼개질수록 이런 틈이 생기기 쉽고, 이벤트 기반 아키텍처(EDA)와 CQRS는 각각 이 틈을 메우는 방식과 읽기·쓰기를 가르는 방식으로 답한다.
각 요소가 나눠 맡는 역할
Microservices가 조직·경계를 나누는 구조라면, EDA는 그 서비스들이 서로 상호작용하는 메커니즘이고, CQRS는 데이터 접근 책임을 나누는 패턴이다. EDA는 상태 변경을 이벤트로 발행하고 구독자가 이를 비동기로 처리하는 스타일로, 생산자-소비자 결합도를 낮추는 대신 최종 일관성을 받아들인다. CQRS는 명령(Command)과 조회(Query)를 서로 다른 모델·저장소로 분리해 쓰기 경로의 트랜잭션 제어와 읽기 경로의 조회 최적화를 따로 가져간다. 셋은 상호 배타적이지 않고, 결합했을 때 고성능·고가용·유연한 확장 구조로 이어진다. 서비스 단위로 독립 배포·릴리스하면 주당 배포 횟수가 310배까지 늘 수 있고, 독립 스키마 덕에 기능 실험 리드타임도 2040% 단축된다(다만 이 수치는 조직·도메인·성숙도에 따라 편차가 있다).
경계는 도메인이, 통신은 상황이 정한다
도메인 주도 설계(DDD) 기반의 경계 컨텍스트로 서비스 범위를 정하고, API Gateway가 유입을 제어한다. 동기 호출은 지연·결함이 전체로 퍼지는 걸 막기 위해 핵심 경로로 제한하고, 비핵심 흐름은 비동기 이벤트로 돌린다.
이벤트가 흐르는 인프라: 브로커와 Outbox
Kafka·RabbitMQ·Redpanda 같은 로그 기반 브로커를 두고 보존 기간·압축·파티션 전략을 설계하는 게 메시징 인프라의 기본이다. 여기에 정합성을 더하려면 Outbox/Inbox 패턴, Idempotency-Key, 재처리·DLQ 정책이 필요하다. Outbox 패턴은 DB 상태 변경과 이벤트 발행을 같은 로컬 트랜잭션 안에서 처리해 원자성을 확보하고, 중복 방지 키로 멱등성을 보장한다 — DB에 저장은 됐는데 이벤트만 유실되는, 혹은 그 반대의 상황을 막는 지점이 여기다.
정합성은 사가로 채운다
여러 서비스에 걸친 트랜잭션은 로컬 트랜잭션과 사가(Saga)의 조합으로 처리하는 게 실무에서 분산 트랜잭션을 대체하는 방식이다. Outbox로 DB 상태와 이벤트 발행의 원자성을 확보한 뒤, 사가가 단계별로 보상 흐름을 이어간다. 이 접근은 강한 일관성이 아니라 최종 일관성을 전제로 하므로, UX와 업무 프로세스 쪽에서 지연을 얼마나 허용할지 미리 설계해둬야 나중에 문제가 되지 않는다.
관측성을 표준화해야 이 구조가 감당된다
분산 트레이싱(OpenTelemetry), 중앙 로깅, SLO/SLA 기준 메트릭을 표준화하고, 서비스 메시(Istio 등)로 트래픽 정책·서킷 브레이커·리트라이/타임아웃 일관성을 유지한다. 이벤트가 여러 서비스를 거쳐 흐르는 구조에서는 이 관측성 표준화가 없으면 장애 원인을 추적할 방법이 없다. 폴트 아이솔레이션과 자동 롤백·리트라이를 갖추면 MTTR이 30~50% 줄어드는 게 기대된다.
CQRS가 읽기 모델을 따로 두는 이유
읽기 저장소를 검색·리스트·집계 같은 목적별로 분리하고, 조회에 유리하도록 스키마를 비정규화하거나 캐시 친화적으로 설계한다. 이 읽기 모델은 이벤트를 소비하는 Read Model Updater가 지연 동기화 방식으로 채우며, 재생 가능한 이벤트 로그를 유지해두면 필요할 때 읽기 모델을 처음부터 다시 만들 수 있다. 읽기·쓰기 분리와 이벤트 기반 수평 확장을 결합하면 피크 처리량이 2~5배 향상되는 것으로 나타난다.
실제로 어떻게 쓰이나
전자상거래 주문/결제에서는 주문 생성을 Command 경로에서 처리하고 Outbox → Kafka로 발행해 재고·결제 서비스가 이를 구독한다. 배송·알림은 후행 이벤트로 비동기 처리하고, Read DB는 주문 상세·상태 집계에 맞춰 최적화한다.
IoT 텔레메트리 수집은 디바이스 데이터를 스트림으로 받아 Ingestion 서비스가 이벤트로 브로커에 게시하는 구조다. 이상 탐지·집계 서비스는 독립적으로 확장하고, 조회는 시계열 Read DB로 따로 뺀다.
금융 거래 원장은 거래 입력 단계에서 강한 검증과 원자성이 필요해 Outbox와 사가로 내부 서비스 간 정합을 유지한다. 읽기 모델은 계좌잔액·거래내역·리스크 지표로 나뉘고, 롤업과 스냅샷으로 운용한다.
| 아키텍처 | 성능(지연) | 확장성 | 일관성 | 안정성/복구 | 운영 편의 |
|---|---|---|---|---|---|
| Microservices | 중 | 상 | 중 | 상 | 중 |
| Event-Driven | 중 | 상 | 중(최종 일관성) | 상 | 중 |
| CQRS | 상(읽기) / 중(쓰기) | 상(읽기 분리) | 중(모델 분리) | 중 | 중 |
구현 품질, 데이터 모델, 브로커·스토리지 선택에 따라 이 지표는 달라질 수 있다.
설계 순서
요구사항·경계부터 정한다. 비즈니스 목표·SLO·데이터 일관성 요구를 입력으로 삼아 경계 컨텍스트와 상호작용 다이어그램, 성공 메트릭을 뽑아낸다. 다음은 통신·메시징 설계로, 트래픽 특성과 지연·처리량 목표를 바탕으로 동기/비동기 경계와 브로커 토픽·파티션·보존 정책, DLQ/리트라이 전략을 정한다. 데이터 일관성 전략 단계에서는 트랜잭션 경계와 보상 동작을 근거로 사가 정의, Outbox/Inbox 스키마, 멱등 키 규약, 정합성 SLA를 만든다. CQRS 모델링 단계는 조회 패턴과 인덱싱·집계 요구에서 출발해 Read DB 스키마, 업데이트 파이프라인, 재생/리빌드 절차를 설계한다. 운영·관측성 체계는 가용성 목표와 장애 시나리오를 바탕으로 트레이싱/메트릭/로그 표준, 카나리아·블루그린, 오토스케일 규칙을 정하고, 마지막으로 보안·거버넌스 단계에서는 규제와 데이터 민감도를 근거로 제로트러스트, 토픽·스키마 레지스트리 ACL, 비밀관리, 데이터 보존·파기 정책을 세운다.
모범사례와 트레이드오프
Outbox·Idempotency·DLQ를 함께 쓰면 적어도 한 번(at-least-once) 전달을 보장할 수 있고, 타임아웃·리트라이·서킷 브레이커를 표준화하면서 멱등 처리를 필수화하는 게 실무 기본기다. 스키마 레지스트리(Avro·JSON Schema)로 호환성을 관리하고 이벤트 버전닝 룰을 세워두면 스키마가 바뀔 때 소비자가 깨지는 걸 막을 수 있다. 읽기 모델 재빌드를 스냅샷+리플레이로 자동화해두면 장애 복구 시간도 단축된다.
그 대가는 복잡성이다. 데이터 중복, 운영 표면 확대, 팀 간 계약 관리 비용이 늘어나고, 비동기 흐름에서는 원인과 결과를 추적하기 어려워 강력한 트레이싱이 필수가 된다. 최종 일관성을 받아들여야 하는 만큼 UX와 업무 프로세스에서 지연 허용 범위를 사전에 설계해둬야 한다.
Outbox → Kafka 발행 예제 (Spring Boot 3.3, Java 17, PostgreSQL 15, Kafka 3.x)
트랜잭션 격리는 READ COMMITTED를 전제로 하고, Outbox 테이블은 파티셔닝을 선택적으로 적용할 수 있다.
-- PostgreSQL: outbox 테이블
CREATE TABLE outbox (
id UUID PRIMARY KEY,
aggregate_type TEXT NOT NULL,
aggregate_id TEXT NOT NULL,
event_type TEXT NOT NULL,
payload JSONB NOT NULL,
occurred_at TIMESTAMPTZ NOT NULL DEFAULT now(),
published BOOLEAN NOT NULL DEFAULT FALSE,
unique_key TEXT NOT NULL -- 멱등성 키
);
CREATE UNIQUE INDEX ux_outbox_unique_key ON outbox(unique_key);
도메인 변경과 Outbox 기록은 같은 트랜잭션 안에서 이뤄진다.
// Spring Data JPA 예시: 트랜잭션 내 도메인 변경 + Outbox 기록
@Transactional
public UUID placeOrder(OrderCommand cmd) {
Order order = orderService.create(cmd); // 도메인 검증/상태 변경
OutboxEvent evt = OutboxEvent.of(
"Order", order.getId().toString(), "OrderPlaced",
payloadFrom(order), cmd.getIdempotencyKey());
outboxRepo.save(evt); // 동일 트랜잭션
return order.getId();
}
Outbox Poller는 스케줄로 돌리거나 Debezium 같은 CDC로 대체할 수 있다.
// Outbox Poller (스케줄 또는 Debezium 대체 가능)
@Scheduled(fixedDelay = 200)
public void publish() {
List<OutboxEvent> batch = outboxRepo.findTop100ByPublishedFalseOrderByOccurredAtAsc();
for (OutboxEvent e : batch) {
try {
kafkaTemplate.executeInTransaction(kt -> {
kt.send("order.events", e.getAggregateId(), e.getPayload());
return true;
});
outboxRepo.markPublished(e.getId());
} catch (Exception ex) {
// 재시도, DLQ 기준 적용
}
}
}
Producer key로 멱등성을 보장하고, 토픽 파티션은 처리량·순서 요구를 기준으로 설계한다. Debezium CDC로 Outbox → Kafka를 오프로드할 수도 있는데, 이 경우 퍼포먼스와 구현 단순성 사이의 트레이드오프를 감안해야 한다.
Microservices·EDA·CQRS의 결합은 도메인 경계를 명확히 하고 비동기 확장성과 읽기 성능을 동시에 얻는 실용적인 접근이다. 다만 그 실용성은 통신·일관성·관측성을 표준화하고 Outbox·사가·멱등성으로 운영 복잡성을 통제할 때만 성립하며, 단계적 도입과 지표 기반 검증으로 리스크를 관리하는 게 현실적인 경로다.