
이벤트 소싱(Event Sourcing)은 애플리케이션 상태를 현재 값으로 저장하는 대신, 상태 변화를 일으킨 이벤트의 연속으로 저장하는 아키텍처 패턴입니다. 현재 잔액 대신 "입금 10만원", "출금 3만원" 같은 기록을 쌓아가는 방식이라고 이해하면 직관적입니다. 이 글에서는 이벤트 소싱의 핵심 개념부터 Java 기반의 실제 구현 예시, 그리고 운영 환경에서 고려해야 할 설계 포인트까지 다룹니다.
이벤트 소싱 패턴의 구조 이해
이벤트 소싱의 핵심은 이벤트 스토어(Event Store)입니다. 기존 CRUD 방식은 레코드를 덮어씁니다. 반면 이벤트 소싱은 도메인에서 발생한 모든 변경 사항을 불변(immutable) 이벤트로 저장하고, 현재 상태는 이 이벤트들을 순서대로 재생(replay)하여 도출합니다.
주요 구성 요소는 다음과 같습니다.
- Event: 과거에 일어난 사실. 불변 객체로 표현됩니다 (예:
OrderPlaced,PaymentProcessed). - Aggregate: 이벤트를 수신하고 상태를 변경하는 도메인 객체.
- Event Store: 이벤트를 시간 순서대로 저장하는 영속성 레이어.
- Projection: 이벤트 스트림을 특정 읽기 모델로 변환하는 컴포넌트.
이벤트는 append-only 방식으로 저장되므로 감사 추적(audit trail)이 자연스럽게 확보되고, 특정 시점의 상태로 되돌아가는 시간 여행(time travel) 쿼리도 가능해집니다.
Java로 구현하는 이벤트 소싱 기본 구조
이벤트 소싱 구현의 출발점은 도메인 이벤트 인터페이스와 Aggregate 베이스 클래스를 정의하는 것입니다. 아래 예시는 간단한 계좌(Account) 도메인을 기준으로 작성되었습니다.
// 1. 도메인 이벤트 기본 인터페이스
public interface DomainEvent {
String aggregateId();
Instant occurredAt();
}
// 2. 구체적인 이벤트 정의 (Java Record 활용)
public record MoneyDeposited(
String aggregateId,
Instant occurredAt,
long amount
) implements DomainEvent {}
public record MoneyWithdrawn(
String aggregateId,
Instant occurredAt,
long amount
) implements DomainEvent {}
// 3. Aggregate 베이스 클래스
public abstract class AggregateRoot {
private final List<DomainEvent> uncommittedEvents = new ArrayList<>();
// 이벤트를 적용하고 미커밋 목록에 추가
protected void apply(DomainEvent event) {
handle(event); // 상태 변경
uncommittedEvents.add(event); // 저장 대기 목록에 추가
}
// 이벤트 스토어에서 재생할 때 사용 (상태만 변경, 목록에는 추가 안 함)
public void replayEvent(DomainEvent event) {
handle(event);
}
protected abstract void handle(DomainEvent event);
public List<DomainEvent> getUncommittedEvents() {
return Collections.unmodifiableList(uncommittedEvents);
}
public void clearUncommittedEvents() {
uncommittedEvents.clear();
}
}
// 4. Account Aggregate 구현
public class Account extends AggregateRoot {
private String id;
private long balance;
// 정적 팩토리: 새 계좌 생성
public static Account create(String id, long initialDeposit) {
Account account = new Account();
account.apply(new MoneyDeposited(id, Instant.now(), initialDeposit));
return account;
}
// 이벤트 스토어에서 복원
public static Account reconstitute(String id, List<DomainEvent> events) {
Account account = new Account();
account.id = id;
events.forEach(account::replayEvent); // 이벤트 재생으로 현재 상태 도출
return account;
}
public void deposit(long amount) {
if (amount <= 0) throw new IllegalArgumentException("입금액은 양수여야 합니다.");
apply(new MoneyDeposited(id, Instant.now(), amount));
}
public void withdraw(long amount) {
if (amount > balance) throw new IllegalStateException("잔액이 부족합니다.");
apply(new MoneyWithdrawn(id, Instant.now(), amount));
}
@Override
protected void handle(DomainEvent event) {
if (event instanceof MoneyDeposited e) {
this.id = e.aggregateId();
this.balance += e.amount();
} else if (event instanceof MoneyWithdrawn e) {
this.balance -= e.amount();
}
}
public long getBalance() { return balance; }
}// 실행 시나리오
Account account = Account.create("acc-001", 100_000L);
account.deposit(50_000L);
account.withdraw(30_000L);
System.out.println("현재 잔액: " + account.getBalance()); // 현재 잔액: 120000
System.out.println("미커밋 이벤트 수: " + account.getUncommittedEvents().size()); // 3apply()와 replayEvent()를 분리하는 것이 핵심입니다. apply()는 새로운 이벤트를 생성할 때, replayEvent()는 저장된 이벤트로 상태를 복원할 때 각각 호출됩니다. 이 구분이 없으면 복원 시 이벤트가 이중으로 기록되는 문제가 발생합니다.
스냅샷과 Projection 설계
이벤트가 수천, 수만 개로 쌓이면 매번 전체를 재생하는 것은 성능 병목이 됩니다. 이를 해결하기 위한 표준적인 접근이 스냅샷(Snapshot)입니다.
스냅샷 전략
스냅샷은 특정 시점의 Aggregate 상태를 그대로 저장해 두고, 이후 복원 시 스냅샷 이후의 이벤트만 재생하는 방식입니다. 일반적으로 이벤트가 N개(예: 50~100개) 쌓일 때마다 스냅샷을 생성합니다.
public class AccountRepository {
private final EventStore eventStore;
private final SnapshotStore snapshotStore;
private static final int SNAPSHOT_THRESHOLD = 50;
public Account load(String accountId) {
// 1. 최신 스냅샷 조회
Optional<AccountSnapshot> snapshot = snapshotStore.findLatest(accountId);
long fromVersion = 0;
Account account;
if (snapshot.isPresent()) {
// 스냅샷이 있으면 스냅샷 이후 이벤트만 재생
account = snapshot.get().toAccount();
fromVersion = snapshot.get().version() + 1;
} else {
account = new Account();
}
// 2. 스냅샷 버전 이후의 이벤트만 로드하여 재생
List<DomainEvent> events = eventStore.loadEvents(accountId, fromVersion);
events.forEach(account::replayEvent);
return account;
}
public void save(Account account) {
List<DomainEvent> newEvents = account.getUncommittedEvents();
eventStore.appendEvents(account.getId(), newEvents);
// 임계값 초과 시 스냅샷 자동 생성
long totalEvents = eventStore.countEvents(account.getId());
if (totalEvents % SNAPSHOT_THRESHOLD == 0) {
snapshotStore.save(AccountSnapshot.from(account, totalEvents));
}
account.clearUncommittedEvents();
}
}Projection을 활용한 읽기 모델 분리
이벤트 소싱은 CQRS(Command Query Responsibility Segregation)와 함께 쓰이는 경우가 많습니다. 쓰기 모델은 이벤트 스토어에, 읽기 모델은 별도의 Projection으로 유지합니다. 예를 들어, 계좌 목록 화면에 필요한 AccountSummaryView는 MoneyDeposited, MoneyWithdrawn 이벤트를 구독하여 별도 테이블에 집계해 둡니다. 이렇게 하면 조회 성능과 쓰기 확장성을 독립적으로 튜닝할 수 있습니다.
운영 환경에서의 주요 고려 사항
이벤트 소싱을 실제 프로젝트에 적용할 때 자주 마주치는 문제와 대응 방법을 정리합니다.
이벤트 스키마 진화(Schema Evolution)
이벤트는 한번 저장되면 수정할 수 없습니다. 필드가 추가되거나 타입이 바뀌는 경우에는 업캐스터(Upcaster)를 두어 구버전 이벤트를 현재 버전으로 변환하는 파이프라인을 유지합니다. 이벤트 클래스에 version 필드를 처음부터 포함시키는 것을 권장합니다.
멱등성(Idempotency) 보장
메시지 브로커와 연동할 경우 같은 이벤트가 두 번 처리될 수 있습니다. Projection 핸들러에서 처리된 이벤트의 ID를 별도로 추적하거나, 이벤트 ID를 기준으로 중복을 걸러내는 로직을 반드시 구현해야 합니다.
이벤트 스토어 선택
EventStoreDB는 이벤트 소싱을 위해 설계된 전용 데이터베이스로, 스트림 구독과 낙관적 동시성 제어를 기본으로 지원합니다. PostgreSQL이나 MySQL을 사용할 경우에는 aggregate_id, version, event_type, payload, occurred_at 컬럼을 갖는 단일 events 테이블을 append-only로 운용하고, (aggregate_id, version) 복합 유니크 키로 낙관적 잠금을 구현하는 방식이 일반적입니다.
맺음말
이벤트 소싱은 도입 초기에 설계 복잡도가 증가하지만, 완전한 감사 추적, 상태 복원 유연성, 이벤트 기반 통합이라는 강점을 제공합니다. 금융, 이커머스, 물류처럼 데이터 변경 이력이 비즈니스 가치를 갖는 도메인에서 특히 효과적입니다.
반면 단순한 CRUD 위주 도메인이나 팀이 이벤트 중심 사고에 익숙하지 않은 경우에는 오히려 오버엔지니어링이 될 수 있습니다. CQRS와 함께 도입을 검토한다면 Axon Framework나 Eventuate 같은 프레임워크를 활용해 보일러플레이트를 줄이는 것도 좋은 선택입니다. 이벤트 소싱의 진입점은 작은 바운디드 컨텍스트 하나에서 시작하여 팀이 패턴에 익숙해지는 과정을 거치는 것이 안전합니다.
'프로그래밍 PROGRAMMING > 아키텍쳐' 카테고리의 다른 글
| Sidecar 패턴으로 크로스커팅 관심사 분리하기 (0) | 2026.04.06 |
|---|---|
| Strangler Fig 패턴으로 레거시 모놀리스 전환하기 (0) | 2026.04.04 |
| Transactional Outbox 패턴으로 분산 시스템 이벤트 신뢰성 보장하기 (0) | 2026.04.03 |
| [SPRING/JAVA] Spring Boot 멀티모듈 프로젝트 구조 설계하기 (0) | 2026.04.02 |
| Event-Driven Architecture 적용하기 (0) | 2026.03.31 |