Software Architecture/Architectural Styles
Architectural Styles - 09. 이벤트 소싱
이벤트 소싱 — 상태를 이벤트의 누적으로 저장하기
은행 통장을 펼쳐보자. 잔액은 단 하나의 숫자로 표시되지만, 그 숫자가 어떻게 됐는지를 보여주는 건 '거래 내역'이다. 7월 1일 입금 100만 원, 7월 5일 출금 30만 원, 7월 10일 입금 50만 원. 이 내역들을 순서대로 적용하면 잔액 120만 원이 나온다. 잔액은 '계산된 결과'고, 거래 내역이 '진짜 데이터'다. 은행이 잔액만 저장하고 내역을 버리면, "왜 120만 원이지?"라는 질문에 답할 수 없다. 이벤트 소싱(event sourcing)은 이 은행 통장 모델을 소프트웨어에 적용한 것이다 — 현재 상태를 덮어쓰지 않고, 상태 변경을 이벤트로 append-only 저장한다. 현재 상태는 이벤트를 재생해 계산한다.
이 글이 다루는 질문: 상태를 매번 덮어쓰는 게 항상 자연스러운가. 답부터 말하면 — 대부분의 CRUD에선 자연스럽다. 하지만 감사·복원·시점 조회가 비즈니스 핵심이면, 이벤트를 저장하는 쪽이 자연스러워진다.
비유로 감 잡기 — 은행 통장과 의료 기록
앞서 든 은행 통장 비유를 좀 더 파고가자. 잔액 120만 원이라는 '상태'는 거래 내역이라는 '이벤트의 누적'을 계산한 결과다. 은행이 잔액을 따로 저장하더라도(성능을 위해), 진짜 원천(source of truth)은 거래 내역이다. 잔액이 의심스러우면 내역에서 다시 계산하면 된다. 반대로 내역이 의심스러우면 답이 없다 — 그게 진짜 원천이므로.
의료 기록도 같은 구조다. 혈압 120/80이라는 '현재 상태'만 저장하면, "언제부터 올랐나?", "약 바꾼 뒤로 어떻게 됐나?" 같은 질문에 답할 수 없다. 의사가 필요한 건 혈압의 변화 이력(이벤트)이다. 현재 상태는 그 이력의 마지막 값을 읽은 것에 불과하다.
이벤트 소싱은 이런 시스템을 모델링하는 자연스러운 형태다 — '현재 상태'를 따로 저장하지 않고(또는 캐시로만 두고), 상태 변경을 이벤트로 영구 저장한다. 현재 상태가 필요하면 이벤트를 처음부터 재생해 계산한다.
비유의 한계 — 은행·의료 비유는 '이력의 가치'를 강조하지만, 이벤트 소싱의 진짜 도전은 이력이 아니라 '스키마 진화'에 있다. 5년 전 이벤트 스키마를 지금 코드가 읽을 수 있어야 한다 — 이건 은행 통장엔 없는 문제다. 비유는 '시간이 지남에 따른 스키마 변화'에서 무너진다.
이벤트 소싱의 기본 형태
Pat Helland이 분산 시스템 담론에서 자주 강조하듯, 이벤트는 "비즈니스에서 일어난 사실(fact)"이다. 이벤트 소싱은 이 사실들을 append-only 로그로 저장한다. 한 번 쓴 이벤트는 수정되지 않는다 — 잘못됐으면 '보상 이벤트'를 추가로 쓴다. 회계 장부의 원칙(지우지 않고 정정 부기를 추가)과 같다.
flowchart LR
C[Command] --> AGG[Aggregate]
ES[(Event Store<br/>append-only log)]
AGG <-->|이벤트 로드/저장| ES
ES --> PROJ[Projection]
PROJ --> RM[읽기 모델<br/>현재 상태 뷰]
ES --> SNAP[Snapshot<br/>주기적 상태 캡처]
aggregate(애그리거트)는 자기 상태를 이벤트에서 복원한다. 새 명령이 오면 (1) 지금까지의 이벤트를 로드해 현재 상태를 계산하고, (2) 명령을 처리해 새 이벤트를 생성하고, (3) 이벤트를 스토어에 append한다. 이게 이벤트 소싱의 기본 사이클이다.
설계 사례 — 은행 계좌 애그리거트
은행 계좌를 이벤트 소싱으로 모델링해 본다. 이건 이벤트 소싱을 설명할 때 가장 흔히 쓰이는 예시인데, 이유가 있다 — 은행이 원래 이런 식으로 동작하기 때문이다.
이벤트 정의
// 계좌에 발생할 수 있는 이벤트들 — 불변, 과거형
public sealed interface AccountEvent permits AccountOpened, MoneyDeposited, MoneyWithdrawn, AccountClosed {}
public record AccountOpened(AccountId accountId, String owner, Instant openedAt) implements AccountEvent {}
public record MoneyDeposited(AccountId accountId, Money amount, Instant at) implements AccountEvent {}
public record MoneyWithdrawn(AccountId accountId, Money amount, Instant at) implements AccountEvent {}
public record AccountClosed(AccountId accountId, Instant at) implements AccountEvent {}
이벤트는 과거형(MoneyDeposited, MoneyWithdrawn)이다 — '일어난 일'을 서술한다. 현재형이나 명령형(Deposit, Withdraw)이 아니다.
애그리거트 — 이벤트로 상태 복원
public class Account {
private AccountId id;
private String owner;
private Money balance;
private boolean closed;
private List<AccountEvent> changes = new ArrayList<>(); // 새로 발생한 이벤트
// 빈 계좌에서 시작해 이벤트를 하나씩 적용해 현재 상태 복원
public static Account fromEvents(List<AccountEvent> history) {
Account account = new Account();
history.forEach(account::apply); // '적용'만, 새 이벤트 생성 안 함
account.changes.clear(); // 복원된 상태는 변경 이벤트 없음
return account;
}
// 명령: 입금
public void deposit(Money amount) {
if (closed) throw new IllegalStateException("닫힌 계좌");
if (amount.isNegative()) throw new IllegalArgumentException("음수 입금 불가");
// 비즈니스 규칙 검증 후 새 이벤트 생성
raiseEvent(new MoneyDeposited(id, amount, Instant.now()));
}
// 명령: 출금
public void withdraw(Money amount) {
if (closed) throw new IllegalStateException("닫힌 계좌");
if (balance.lessThan(amount)) throw new InsufficientBalanceException();
raiseEvent(new MoneyWithdrawn(id, amount, Instant.now()));
}
// 이벤트를 상태에 적용 (비즈니스 규칙 없음, 단순 반영)
private void apply(AccountEvent event) {
switch (event) {
case AccountOpened e -> { id = e.accountId(); owner = e.owner(); balance = Money.ZERO; closed = false; }
case MoneyDeposited e -> { balance = balance.plus(e.amount()); }
case MoneyWithdrawn e -> { balance = balance.minus(e.amount()); }
case AccountClosed e -> { closed = true; }
}
}
private void raiseEvent(AccountEvent event) {
apply(event); // 먼저 상태에 적용
changes.add(event); // 새 이벤트로 기록
}
public List<AccountEvent> getChanges() { return List.copyOf(changes); }
}
이 코드의 핵심 — 비즈니스 규칙(출금 시 잔액 확인)은 withdraw 명령에 있고, 상태 변경은 apply 메서드에 있다. 명령은 검증 후 이벤트를 생성하고, 이벤트 적용은 부작용 없이 상태만 반영한다. 이 분리가 이벤트 소싱의 구조적 핵심이다.
이벤트 스토어와 리포지토리
// 이벤트 스토어 인터페이스
public interface EventStore {
void append(AccountId id, List<AccountEvent> events, long expectedVersion);
List<AccountEvent> load(AccountId id);
List<AccountEvent> loadSince(AccountId id, long version);
}
// 리포지토리 — 애그리거트 저장/로드
public class AccountRepository {
private final EventStore store;
public Account findById(AccountId id) {
List<AccountEvent> history = store.load(id);
if (history.isEmpty()) throw new AccountNotFoundException(id);
return Account.fromEvents(history); // 이벤트 재생으로 복원
}
public void save(Account account) {
List<AccountEvent> newEvents = account.getChanges();
if (newEvents.isEmpty()) return;
long expectedVersion = /* 현재 버전 */;
store.append(account.id(), newEvents, expectedVersion); // 낙관적 동시성
}
}
리포지토리는 '현재 상태'를 저장하지 않는다. 새 이벤트만 append한다. 로드할 땐 모든 이벤트를 읽어 재생한다. expectedVersion은 낙관적 동시성 제어 — 두 트랜잭션이 동시에 같은 애그리거트를 변경하려 하면, 둘 중 하나는 버전 충돌로 실패한다.
스냅샷 — 재생 비용 줄이기
계좌 하나에 10년 치 이벤트가 1만 개 쌓이면, 매번 전부 재생하는 건 비싸다. 그래서 주기적으로 '현재 상태 스냅샷'을 저장하고, 재생은 마지막 스냅샷 이후부터 한다.
// 스냅샷 저장
public record AccountSnapshot(AccountId id, String owner, Money balance, boolean closed, long version, Instant at) {}
public class SnapshottingAccountRepository {
public Account findById(AccountId id) {
Optional<AccountSnapshot> snapshot = snapshotStore.findLatest(id);
if (snapshot.isPresent()) {
// 스냅샷 이후 이벤트만 재생
Account account = Account.fromSnapshot(snapshot.get());
List<AccountEvent> recent = store.loadSince(id, snapshot.get().version());
recent.forEach(account::apply);
return account;
}
// 스냅샷 없으면 전부 재생
return Account.fromEvents(store.load(id));
}
}
보통 100~1000개 이벤트마다 스냅샷을 찍는다. 스냅샷 자체는 '캐시'라서 손실되거나 오래되도 시스템이 깨지지 않는다 — 그냥 처음부터 다시 재생하면 된다.
읽기 모델 — CQRS와 짝
이벤트 스토어는 "이 계좌의 잔액은?" 같은 질의에 부적합하다. 매번 재생해야 하므로. 그래서 읽기 전용 뷰를 별도로 구축한다. 이게 CQRS(08편)와 자연스럽게 짝이 되는 지점이다.
// 이벤트를 구독해 읽기 모델 갱신
@Component
class BalanceProjection {
private final BalanceViewRepository repo;
@KafkaListener(topics = "account-events")
void project(AccountEvent event) {
switch (event) {
case AccountOpened e -> repo.save(new BalanceView(e.accountId(), e.owner(), Money.ZERO));
case MoneyDeposited e -> repo.findById(e.accountId())
.ifPresent(v -> { v.setBalance(v.getBalance().plus(e.amount())); repo.save(v); });
case MoneyWithdrawn e -> repo.findById(e.accountId())
.ifPresent(v -> { v.setBalance(v.getBalance().minus(e.amount())); repo.save(v); });
case AccountClosed e -> repo.findById(e.accountId())
.ifPresent(v -> { v.setClosed(true); repo.save(v); });
}
}
}
// 읽기 API — 읽기 모델에서 빠르게 조회
@RestController
class AccountQueryController {
@GetMapping("/accounts/{id}/balance")
BalanceView balance(@PathVariable String id) {
return balanceRepo.findById(AccountId.of(id)).orElseThrow();
}
}
이벤트 소싱 시스템은 사실상 CQRS를 강제한다 — 이벤트 로그는 쓰기에만 좋고 읽기엔 부적합하므로, 읽기 모델을 따로 둬야 한다. 09편과 08편이 자연스럽게 연결되는 지점이다.
이벤트 소싱의 이점 — 덮어쓰기에서 얻지 못하는 것
| 이점 | 설명 | 예시 |
|---|---|---|
| 완전한 감사 로그 | 모든 상태 변경이 이벤트로 영구 저장 | "이 계좌에 1년 전에 무슨 일이?" 질문에 답 가능 |
| 시점 복원 (time travel) | 특정 시점의 상태로 복원 | "장애 직전 상태에서 다시 시작" |
| 비즈니스 의미 보존 | 'withdrawal'이벤트가 단순 balance 변경보다 많은 정보 | "사용자가 왜 이 행위를 했나" 분석 가능 |
| 이벤트 재생으로 복구 | 읽기 모델 손상 시 이벤트에서 재구축 | 읽기 DB 장애 → 이벤트 로그에서 다시 투영 |
| 자연스러운 이벤트 알림 | 상태 변경 자체가 이벤트이므로 통합·분석 시스템에 전달 쉬움 | "출금 발생" 이벤트를 사기 탐지 시스템에 전달 |
| 낙관적 동시성 자연 지원 | expectedVersion으로 동시 변경 충돌 감지 | 두 사용자가 동시에 같은 계좌 변경 시도 |
이 표의 이점들은 전통적인 상태 덮어쓰기 모델에서는 얻기 어렵다. 예를 들어 전통적 모델에선 "잔액 100→80으로 바뀜"만 저장되지, "왜 바뀌었나"는 보통 별도 로그로 남겨야 한다. 이벤트 소싱은 그 '왜'를 자연스럽게 저장한다.
이벤트 소싱의 비용 — 덮어쓰기에선 없던 것
| 비용 | 설명 | 완화 |
|---|---|---|
| 스키마 진화 | 5년 전 이벤트를 지금 코드가 읽을 수 있어야 함 | 버전 필드, upcaster(이벤트 변환기) |
| 재생 비용 | 애그리거트 로드마다 이벤트 재생 | 스냅샷 |
| 쿼리 불가 | 이벤트 로그는 "잔액 얼마?" 같은 질의에 부적합 | CQRS — 별도 읽기 모델 |
| 복잡성 | 이벤트/명령/프로젝션/스냅샷 등 학습 곡선 | 단순 도메인엔 적용 금지 |
| eventual consistency | 읽기 모델이 이벤트 로그보다 늦음 | UI에서 명확한 상태 표시 |
| 버그 난 이벤트 처리 | 이미 쓴 잘못된 이벤트 수정 불가 | 보상 이벤트(compensating event) 추가 |
스키마 진화가 가장 까다롭다. 예를 들어 MoneyDeposited 이벤트가 처음엔 amount 필드 하나였다가, 2년 뒤에 currency 필드가 추가됐다고 하자. 예전 이벤트엔 currency가 없다. 이걸 지금 코드가 읽으려면 — upcaster라는 변환 계층을 둬서 예전 이벤트를 새 스키마로 변환해 읽어야 한다. 이건 전통적 모델에선 없는, 이벤트 소싱 특유의 복잡성이다.
"버그 난 이벤트"도 흥미로운 케이스다. 전통적 모델에선 잘못된 데이터를 UPDATE로 고치면 끝이다. 이벤트 소싱에선 이미 쓴 이벤트를 지울 수 없다(append-only). 대신 '보상 이벤트'를 추가로 쓴다 — 잘못된 출금 이벤트에 대응하는 '정정 입금' 이벤트를 추가. 원래 이벤트는 여전히 로그에 남아있고(감사), 시스템 상태는 보상 이벤트로 정정된다. 이게 은행이 지우지 않고 정정 부기를 추가하는 방식과 같다.
언제 이벤트 소싱이 적합한가
이벤트 소싱은 '기본'이 아니다. Fowler가 "대부분의 시스템에선 오버엔지니어링"이라고 경고하는 패턴이다. 적합한 조건은 뚜렷하다.
적합:
- 감사 로그가 비즈니스 핵심 — 금융, 의료, 법률 문서, 규제 산업. "왜 이 상태가 됐나" 추적이 의무.
- 시점 복원이 필요 — 장애 복구, 시뮬레이션, 과거 분석.
- 도메인 이벤트가 풍부 — 비즈니스 행위 자체가 이벤트 중심 (주문 처리, 예약 시스템).
- 다운스트림 시스템에 이벤트 전파가 자연스러운 경우 — 분석, 통합, 알림 시스템이 이벤트를 필요로 함.
부적합:
- 단순 CRUD 도메인 — 상태 변경의 '이유'가 중요하지 않음. 사용자 프로필 업데이트 같은.
- 강한 일관성 쿼리가 필수 — 읽기 모델의 최종 일관성을 감당 못 함.
- 팀이 작고 학습 곡선이 부담 — 이벤트 소싱의 복잡성은 작은 팀에 과함.
- 스키마가 자주 바뀜 — 진화 비용이 매번 발생.
이벤트 소싱과 DDD의 관계
이벤트 소싱은 DDD(특히 전술 패턴)와 자연스럽게 짝한다. DDD의 '애그리거트'가 이벤트 소싱의 단위가 되고, '도메인 이벤트'가 이벤트 소싱의 저장 단위가 된다. Evans가
05편(ddd-and-patterns)에서 DDD를 깊이 다룰 때 이 연결이 다시 등장한다. 여기서는 — 이벤트 소싱이 DDD와 짝하기 쉬운 패턴이라는 점만 짚고 넘어간다.
다음으로 — 파이프라인과 마이크로커널
이벤트 소싱까지 다섯 편에 걸쳐 주요 아키텍처 스타일을 다뤘다. 모놀리스·계층형·헥사고날·클린·마이크로서비스·이벤트 기반·CQRS·이벤트 소싱. 다음 두 편(10, 11)은 다른 축을 본다 — 시스템을 어떻게 자를까(역할별·도메인별)가 아니라, 시스템을 어떻게 '흐름'으로 또는 '코어+확장'으로 모델링할까. 파이프라인(10편)은 데이터 처리 시스템에 자연스러운 형태고, 마이크로커널·서버리스(11편)는 플러그인 기반 확장이 핵심인 시스템의 형태다.
이벤트 소싱의 핵심 통찰은 "현재 상태는 이벤트의 누적이라는 계산된 결과"라는 관점의 전환이다. 이 전환이 들어맞는 도메인(감사·복원·이벤트 풍부)에서, 이벤트 소싱은 다른 패턴이 주지 못하는 가치를 준다. 반대로 들어맞지 않는 도메인에선 비용만 배로 만드는 오버엔지니어링이 된다.
참고
- Fowler — "Event Sourcing" (martinfowler.com/eaaDev/EventSourcing.html, 2005) — 접근 2026-07-20
- Young, Greg — "A Decade of DDD, CQRS, Event Sourcing" (2016 강연 시리즈) — 접근 2026-07-20
- Vernon —
(Addison-Wesley, 2013), Ch.8 (이벤트 소싱)·Ch.A (애그리거트와 이벤트 소싱) - Evans —
(Addison-Wesley, 2003), 도메인 이벤트·애그리거트 장 - Kleppmann —
(O'Reilly, 2017), Ch.11 (스트림 처리 — 이벤트 소싱과 닿는 점) - Helland, Pat — "Immutability Changes Everything" (CIDR 2015) — 접근 2026-07-20
'Software Architecture > Architectural Styles' 카테고리의 다른 글
| Architectural Styles - 11. 마이크로커널과 서버리스 (0) | 2026.07.20 |
|---|---|
| Architectural Styles - 10. 파이프라인 (0) | 2026.07.20 |
| Architectural Styles - 08. CQRS (0) | 2026.07.20 |
| Architectural Styles - 07. 이벤트 기반 아키텍처 (0) | 2026.07.20 |
| Architectural Styles - 06. 마이크로서비스의 함정 (0) | 2026.07.20 |