주문이 들어왔을 때 "주문 테이블에 INSERT"하는 것과 "주문 생성 이벤트를 발행"하는 것, 뭐가 다를까요?

시스템이 커질수록 컴포넌트 간 결합도를 낮추는 게 중요해집니다. 이벤트 드리븐 아키텍처(EDA)는 이 문제를 "이벤트"라는 메시지 중심으로 풀어내는 접근입니다. 그 안에서 이벤트 소싱과 CQRS는 가장 자주 언급되는 두 가지 패턴입니다.

이게 뭔가요?

이벤트 드리븐 아키텍처(EDA)

시스템 내에서 일어나는 상태 변화를 이벤트 로 표현하고, 이 이벤트를 발행(publish)하면 관심 있는 소비자(subscriber)가 비동기적으로 처리하는 구조입니다.

  • 이벤트: "과거에 일어난 사실" (예: OrderCreated, PaymentCompleted)
  • 발행자는 소비자가 누군지 모름 → 느슨한 결합
  • 비동기 처리가 기본

이벤트 소싱(Event Sourcing)

현재 상태를 DB에 직접 저장하는 대신, 상태 변화의 이력(이벤트)을 순서대로 저장 하는 방식입니다.

  • 현재 상태 = 이벤트를 처음부터 순서대로 재생(replay)한 결과
  • 한번 저장된 이벤트는 수정/삭제하지 않음 (append-only)

CQRS (Command Query Responsibility Segregation)

데이터를 쓰는 모델(Command) 과 읽는 모델(Query) 을 분리하는 패턴입니다.

  • 쓰기: 비즈니스 로직이 담긴 도메인 모델
  • 읽기: 조회에 최적화된 별도 모델 (비정규화된 뷰)

왜 필요한가요?

전통적인 CRUD의 한계

일반적인 CRUD 방식은 현재 상태만 저장합니다.

PLAINTEXT
// 전통 CRUD: 현재 잔액만 저장
계좌 잔액: 50,000원

// 질문: 이 잔액이 어떻게 만들어졌는지 알 수 있나요?
// → 별도의 이력 테이블을 만들지 않는 한 알 수 없음

이벤트 소싱은 이 문제를 근본적으로 해결합니다:

PLAINTEXT
// 이벤트 소싱: 모든 변화를 기록
1. AccountOpened  { amount: 0 }
2. MoneyDeposited { amount: 100,000 }
3. MoneyWithdrawn { amount: 30,000 }
4. MoneyWithdrawn { amount: 20,000 }
// → 현재 잔액: 50,000원 (이벤트를 순서대로 적용한 결과)

읽기/쓰기 요구사항의 차이

대부분의 시스템은 읽기와 쓰기의 특성이 다릅니다:

  • 쓰기: 복잡한 비즈니스 규칙 검증이 필요
  • 읽기: 다양한 형태의 조회가 필요 (목록, 통계, 검색 등)

하나의 모델로 둘 다 만족시키려면 타협이 생깁니다. CQRS는 아예 모델을 분리해서 각각 최적화합니다.

어떻게 동작하나요?

이벤트 소싱의 흐름

PLAINTEXT
[커맨드] → [도메인 모델] → [이벤트 생성] → [이벤트 스토어에 저장]
                ↑                                    |
                |                                    ↓
           [이벤트 재생] ←──────────────── [이벤트 스토어]
  1. 커맨드(명령)가 들어오면 도메인 모델이 비즈니스 규칙을 검증
  2. 검증을 통과하면 이벤트를 생성
  3. 이벤트를 이벤트 스토어에 append
  4. 현재 상태가 필요하면 이벤트를 처음부터 재생

간단한 코드로 보는 이벤트 소싱

JAVA
// 이벤트 정의
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의 흐름

PLAINTEXT
[클라이언트] ─── 쓰기 요청 ──→ [Command Model] ──→ [Write DB]
                                                        |
                                                   이벤트 발행

[클라이언트] ←── 읽기 응답 ─── [Query Model]  ←── [Read DB]
JAVA
// 쓰기 측: 복잡한 비즈니스 로직
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);
    }
}

스냅샷: 재생 성능 문제 해결

이벤트가 수만 개 쌓이면 매번 처음부터 재생하는 게 느려집니다. 이때 스냅샷 을 사용합니다.

PLAINTEXT
이벤트 1 → 이벤트 2 → ... → 이벤트 999 → [스냅샷 저장]

새로운 조회 시: 스냅샷 + 이벤트 1000 ~ 현재만 재생

일정 간격(예: 100개마다)으로 현재 상태를 스냅샷으로 저장해두면, 전체 재생 없이 빠르게 복원할 수 있습니다.

자주 헷갈리는 포인트

이벤트 소싱과 CQRS는 반드시 함께 써야 하나요?

아닙니다. 독립적인 패턴입니다.

  • CQRS만 단독으로 사용 가능 (쓰기/읽기 모델만 분리)
  • 이벤트 소싱만 단독으로 사용 가능 (이벤트 저장 + 재생)
  • 다만 함께 쓰면 시너지가 큼: 이벤트 소싱의 이벤트를 읽기 모델에 투영(projection)하면 CQRS가 자연스럽게 구현됨

이벤트 소싱에서 데이터 수정/삭제는?

이벤트는 수정하거나 삭제하지 않습니다. 대신 보상 이벤트 를 추가합니다.

PLAINTEXT
OrderPlaced → OrderCancelled  // 주문을 취소하는 게 아니라, 취소 이벤트를 추가

GDPR 같은 법적 삭제 요구가 있을 경우에는 암호화 삭제(Crypto Shredding) 기법을 사용합니다. 이벤트 데이터를 암호화해두고, 삭제 요청 시 암호화 키를 삭제하는 방식입니다.

결과적 일관성(Eventual Consistency)

CQRS에서 쓰기 DB와 읽기 DB 사이에는 시간차가 있습니다.

  • 주문을 생성한 직후, 주문 목록 조회에 바로 안 나타날 수 있음
  • 이 시간차는 보통 밀리초 단위이지만, 비즈니스 로직에서 이를 고려해야 함
  • "방금 주문했는데 목록에 없어요" 같은 UX 문제를 별도로 처리해야 함

이벤트 스키마 변경은 어떻게?

이벤트가 누적되면 스키마 버전 관리가 중요해집니다.

  • 업캐스팅(Upcasting): 이전 버전 이벤트를 읽을 때 최신 버전으로 변환
  • 버전 필드 추가: 이벤트에 version 필드를 포함해서 구분

언제 도입해야 하나요?

적합한 경우부적합한 경우
변경 이력이 비즈니스적으로 중요 (금융, 의료)단순 CRUD 애플리케이션
읽기와 쓰기의 부하 차이가 큰 시스템데이터 일관성이 즉시 필요한 시스템
감사 로그가 필수인 도메인팀이 이벤트 소싱 경험이 없는 초기 단계
이벤트 기반 통합이 필요한 분산 시스템단순한 조회 위주 시스템

정리

  • 이벤트 드리븐 아키텍처는 이벤트를 중심으로 컴포넌트 간 결합도를 낮추는 설계 방식
  • 이벤트 소싱은 상태 변화를 이벤트로 저장하여 전체 이력을 보존하는 패턴
  • CQRS는 쓰기 모델과 읽기 모델을 분리하여 각각 최적화하는 패턴
  • 두 패턴은 독립적이지만, 함께 쓰면 이벤트 기반으로 읽기 모델을 자연스럽게 구성 가능
  • 스냅샷으로 이벤트 재생 성능 문제를 해결하고, 보상 이벤트로 삭제/수정을 처리
  • 결과적 일관성과 이벤트 스키마 진화는 도입 전 반드시 고려해야 할 포인트

References

댓글 로딩 중...