이벤트 드리븐 아키텍처 — 이벤트 소싱과 CQRS의 설계 원리
주문이 들어왔을 때 "주문 테이블에 INSERT"하는 것과 "주문 생성 이벤트를 발행"하는 것, 뭐가 다를까요?
시스템이 커질수록 컴포넌트 간 결합도를 낮추는 게 중요해집니다. 이벤트 드리븐 아키텍처(EDA)는 이 문제를 "이벤트"라는 메시지 중심으로 풀어내는 접근입니다. 그 안에서 이벤트 소싱과 CQRS는 가장 자주 언급되는 두 가지 패턴입니다.
이게 뭔가요?
이벤트 드리븐 아키텍처(EDA)
시스템 내에서 일어나는 상태 변화를 이벤트 로 표현하고, 이 이벤트를 발행(publish)하면 관심 있는 소비자(subscriber)가 비동기적으로 처리하는 구조입니다.
- 이벤트: "과거에 일어난 사실" (예:
OrderCreated,PaymentCompleted) - 발행자는 소비자가 누군지 모름 → 느슨한 결합
- 비동기 처리가 기본
이벤트 소싱(Event Sourcing)
현재 상태를 DB에 직접 저장하는 대신, 상태 변화의 이력(이벤트)을 순서대로 저장 하는 방식입니다.
- 현재 상태 = 이벤트를 처음부터 순서대로 재생(replay)한 결과
- 한번 저장된 이벤트는 수정/삭제하지 않음 (append-only)
CQRS (Command Query Responsibility Segregation)
데이터를 쓰는 모델(Command) 과 읽는 모델(Query) 을 분리하는 패턴입니다.
- 쓰기: 비즈니스 로직이 담긴 도메인 모델
- 읽기: 조회에 최적화된 별도 모델 (비정규화된 뷰)
왜 필요한가요?
전통적인 CRUD의 한계
일반적인 CRUD 방식은 현재 상태만 저장합니다.
// 전통 CRUD: 현재 잔액만 저장
계좌 잔액: 50,000원
// 질문: 이 잔액이 어떻게 만들어졌는지 알 수 있나요?
// → 별도의 이력 테이블을 만들지 않는 한 알 수 없음
이벤트 소싱은 이 문제를 근본적으로 해결합니다:
// 이벤트 소싱: 모든 변화를 기록
1. AccountOpened { amount: 0 }
2. MoneyDeposited { amount: 100,000 }
3. MoneyWithdrawn { amount: 30,000 }
4. MoneyWithdrawn { amount: 20,000 }
// → 현재 잔액: 50,000원 (이벤트를 순서대로 적용한 결과)
읽기/쓰기 요구사항의 차이
대부분의 시스템은 읽기와 쓰기의 특성이 다릅니다:
- 쓰기: 복잡한 비즈니스 규칙 검증이 필요
- 읽기: 다양한 형태의 조회가 필요 (목록, 통계, 검색 등)
하나의 모델로 둘 다 만족시키려면 타협이 생깁니다. CQRS는 아예 모델을 분리해서 각각 최적화합니다.
어떻게 동작하나요?
이벤트 소싱의 흐름
[커맨드] → [도메인 모델] → [이벤트 생성] → [이벤트 스토어에 저장]
↑ |
| ↓
[이벤트 재생] ←──────────────── [이벤트 스토어]
- 커맨드(명령)가 들어오면 도메인 모델이 비즈니스 규칙을 검증
- 검증을 통과하면 이벤트를 생성
- 이벤트를 이벤트 스토어에 append
- 현재 상태가 필요하면 이벤트를 처음부터 재생
간단한 코드로 보는 이벤트 소싱
// 이벤트 정의
public sealed interface AccountEvent {
record AccountOpened(String accountId, BigDecimal initialBalance) implements AccountEvent {}
record MoneyDeposited(String accountId, BigDecimal amount) implements AccountEvent {}
record MoneyWithdrawn(String accountId, BigDecimal amount) implements AccountEvent {}
}
// 도메인 모델: 이벤트를 적용해서 상태를 만든다
public class Account {
private String id;
private BigDecimal balance = BigDecimal.ZERO;
// 이벤트 재생으로 현재 상태 복원
public static Account replay(List<AccountEvent> events) {
Account account = new Account();
events.forEach(account::apply);
return account;
}
// 각 이벤트에 따라 상태 변경
private void apply(AccountEvent event) {
switch (event) {
case AccountOpened e -> {
this.id = e.accountId();
this.balance = e.initialBalance();
}
case MoneyDeposited e -> this.balance = this.balance.add(e.amount());
case MoneyWithdrawn e -> this.balance = this.balance.subtract(e.amount());
}
}
// 비즈니스 규칙 검증 후 이벤트 생성
public MoneyWithdrawn withdraw(BigDecimal amount) {
if (this.balance.compareTo(amount) < 0) {
throw new IllegalStateException("잔액 부족");
}
return new MoneyWithdrawn(this.id, amount);
}
}
CQRS의 흐름
[클라이언트] ─── 쓰기 요청 ──→ [Command Model] ──→ [Write DB]
|
이벤트 발행
↓
[클라이언트] ←── 읽기 응답 ─── [Query Model] ←── [Read DB]
// 쓰기 측: 복잡한 비즈니스 로직
public class OrderCommandService {
public void placeOrder(PlaceOrderCommand cmd) {
// 재고 확인, 가격 검증, 쿠폰 적용 등 비즈니스 규칙
Order order = Order.create(cmd);
orderRepository.save(order);
eventPublisher.publish(new OrderPlaced(order.getId(), order.getItems()));
}
}
// 읽기 측: 조회에 최적화된 비정규화 모델
public class OrderQueryService {
// 읽기용 DB에서 바로 조회 (조인 없이)
public OrderSummaryDto getOrderSummary(String orderId) {
return orderReadRepository.findSummaryById(orderId);
}
// 이벤트를 받아서 읽기 모델 업데이트
@EventListener
public void on(OrderPlaced event) {
OrderSummary summary = new OrderSummary(
event.orderId(),
event.items(),
calculateTotal(event.items())
);
orderReadRepository.save(summary);
}
}
스냅샷: 재생 성능 문제 해결
이벤트가 수만 개 쌓이면 매번 처음부터 재생하는 게 느려집니다. 이때 스냅샷 을 사용합니다.
이벤트 1 → 이벤트 2 → ... → 이벤트 999 → [스냅샷 저장]
↓
새로운 조회 시: 스냅샷 + 이벤트 1000 ~ 현재만 재생
일정 간격(예: 100개마다)으로 현재 상태를 스냅샷으로 저장해두면, 전체 재생 없이 빠르게 복원할 수 있습니다.
자주 헷갈리는 포인트
이벤트 소싱과 CQRS는 반드시 함께 써야 하나요?
아닙니다. 독립적인 패턴입니다.
- CQRS만 단독으로 사용 가능 (쓰기/읽기 모델만 분리)
- 이벤트 소싱만 단독으로 사용 가능 (이벤트 저장 + 재생)
- 다만 함께 쓰면 시너지가 큼: 이벤트 소싱의 이벤트를 읽기 모델에 투영(projection)하면 CQRS가 자연스럽게 구현됨
이벤트 소싱에서 데이터 수정/삭제는?
이벤트는 수정하거나 삭제하지 않습니다. 대신 보상 이벤트 를 추가합니다.
OrderPlaced → OrderCancelled // 주문을 취소하는 게 아니라, 취소 이벤트를 추가
GDPR 같은 법적 삭제 요구가 있을 경우에는 암호화 삭제(Crypto Shredding) 기법을 사용합니다. 이벤트 데이터를 암호화해두고, 삭제 요청 시 암호화 키를 삭제하는 방식입니다.
결과적 일관성(Eventual Consistency)
CQRS에서 쓰기 DB와 읽기 DB 사이에는 시간차가 있습니다.
- 주문을 생성한 직후, 주문 목록 조회에 바로 안 나타날 수 있음
- 이 시간차는 보통 밀리초 단위이지만, 비즈니스 로직에서 이를 고려해야 함
- "방금 주문했는데 목록에 없어요" 같은 UX 문제를 별도로 처리해야 함
이벤트 스키마 변경은 어떻게?
이벤트가 누적되면 스키마 버전 관리가 중요해집니다.
- 업캐스팅(Upcasting): 이전 버전 이벤트를 읽을 때 최신 버전으로 변환
- 버전 필드 추가: 이벤트에
version필드를 포함해서 구분
언제 도입해야 하나요?
| 적합한 경우 | 부적합한 경우 |
|---|---|
| 변경 이력이 비즈니스적으로 중요 (금융, 의료) | 단순 CRUD 애플리케이션 |
| 읽기와 쓰기의 부하 차이가 큰 시스템 | 데이터 일관성이 즉시 필요한 시스템 |
| 감사 로그가 필수인 도메인 | 팀이 이벤트 소싱 경험이 없는 초기 단계 |
| 이벤트 기반 통합이 필요한 분산 시스템 | 단순한 조회 위주 시스템 |
정리
- 이벤트 드리븐 아키텍처는 이벤트를 중심으로 컴포넌트 간 결합도를 낮추는 설계 방식
- 이벤트 소싱은 상태 변화를 이벤트로 저장하여 전체 이력을 보존하는 패턴
- CQRS는 쓰기 모델과 읽기 모델을 분리하여 각각 최적화하는 패턴
- 두 패턴은 독립적이지만, 함께 쓰면 이벤트 기반으로 읽기 모델을 자연스럽게 구성 가능
- 스냅샷으로 이벤트 재생 성능 문제를 해결하고, 보상 이벤트로 삭제/수정을 처리
- 결과적 일관성과 이벤트 스키마 진화는 도입 전 반드시 고려해야 할 포인트