한양과학기술고등학교

에듀테크 프로젝트 대시보드

이벤트 소싱 & CQRS

📮 커맨드 (Command) 쓰기 요청
전통적인 CRUD는 UPDATE로 값을 덮어써 이전 상태가 사라집니다. 이벤트 소싱은 "무엇을 했는가"를 사실(Fact)로 하나씩 추가만 합니다.
📜 이벤트 스토어 Append-Only Log
// 아직 이벤트가 없습니다. 계좌를 개설하세요.
📊 읽기 모델 (Read Model) CQRS Query
계좌주 —
잔액 뷰 ₩ 0

📈 거래 통계 뷰 별도 프로젝션
입금 횟수 / 합계 0회 / ₩0
출금 횟수 / 합계 0회 / ₩0
두 뷰는 같은 이벤트 스토어를 각자 다르게 집계(project)한 것입니다. 요구사항이 늘면 기존 이벤트를 안 건드리고 새 뷰만 추가하면 됩니다.
⏱️ 시간 여행 — 과거 상태 재생 Replay
슬라이더로 이벤트 번호를 고르면, 0번부터 그 지점까지 이벤트를 순서대로 다시 적용해 "그 순간의 잔액"을 처음부터 재계산합니다. DB에 스냅샷이 없어도 로그만 있으면 언제든 재구성됩니다.
이벤트 0/0
재생된 시점의 잔액 ₩ 0
// 슬라이더를 움직여 보세요
🔀 CQRS 데이터 흐름
Command
→
Event Store
⇉
잔액 뷰
통계 뷰
쓰기 모델(Command)과 읽기 모델(Query)이 분리되어 있어, 읽기 뷰 갱신에는 약간의 지연(eventual consistency)이 생길 수 있습니다 — 이것이 CQRS의 대표적인 트레이드오프입니다.
🆚 전통적 CRUD vs 이벤트 소싱
CRUD (UPDATE)이벤트 소싱
저장 방식현재 상태만 덮어씀모든 변경을 사실로 추가
과거 이력유실됨전부 보존 (감사 로그)
"왜 이 값?" 설명알 수 없음이벤트 재생으로 재현 가능
새 통계 뷰 추가기존 테이블 구조 변경이벤트 재생으로 새 프로젝션만 추가
동시 쓰기 충돌Lost Update 위험이벤트 append로 충돌 최소화
🐍 개념 코드
# 커맨드 → 이벤트 기록 (쓰기 모델) def handle_withdraw(store, account_id, amount): events = store.load(account_id) balance = replay(events) # 현재 상태 재구성 if balance < amount: raise ValueError("잔액 부족") store.append(account_id, {"type": "MoneyWithdrawn", "amount": amount}) # 이벤트 재생 → 읽기 모델 (프로젝션) def replay(events): balance = 0 for e in events: if e["type"] == "MoneyDeposited": balance += e["amount"] elif e["type"] == "MoneyWithdrawn": balance -= e["amount"] return balance