현재 상태만 저장하지 말고 모든 변화를 기록하라, 이벤트소싱으로 데이터 히스토리 완벽 관리하기
“데이터베이스에 현재 상태만 저장하는 게 당연하다고 생각했다면, 이 글을 끝까지 읽어보세요.”
들어가며: 왜 이벤트소싱을 알아야 할까요?
주니어 개발자 시절, 저는 항상 이런 의문이 있었습니다.
-
“계좌 잔액이 10만 원인 건 알겠는데, 이 돈이 언제 어떻게 들어왔는지는 왜 따로 관리해야 하지?”
-
“사용자 프로필이 변경됐을 때, 이전 상태는 왜 날려버리는 거지?”
바로 이런 의문에서 시작되는 것이 이벤트소싱(Event Sourcing)입니다.
1. 이벤트소싱이란? — 동영상 vs 스냅샷의 차이
전통적인 방식: 스냅샷 저장
대부분의 시스템은 데이터의 현재 상태만 저장합니다.
-- 일반적인 계좌 테이블
CREATE TABLE accounts (
id BIGINT,
balance DECIMAL(15,2) -- 현재 잔액만 저장
);
INSERT INTO accounts VALUES (1, 100000);
-- 10만 원이 있다는 것만 알 수 있음
이는 마치 현재 모습의 사진 한 장만 저장하는 것과 같습니다.
이벤트소싱 방식: 모든 변화 기록
이벤트소싱은 지금까지 일어난 모든 사건(이벤트)을 순서대로 저장합니다.
-- 이벤트소싱 방식
CREATE TABLE account_events (
id BIGINT,
account_id BIGINT,
event_type VARCHAR(50),
amount DECIMAL(15,2),
timestamp TIMESTAMP,
event_data JSON
);
-- 실제 저장되는 이벤트들
INSERT INTO account_events VALUES
(1, 100, 'AccountCreated', 0, '2025-08-01 09:00:00', '{"initial_balance": 0}'),
(2, 100, 'MoneyDeposited', 50000, '2025-08-01 10:30:00', '{"source": "salary"}'),
(3, 100, 'MoneyDeposited', 30000, '2025-08-05 14:20:00', '{"source": "bonus"}'),
(4, 100, 'MoneyDeposited', 20000, '2025-08-07 16:45:00', '{"source": "refund"}');
현재 잔액을 알고 싶다면? 이벤트들을 순서대로 재생하면 됩니다.
- 0 + 50,000 + 30,000 + 20,000 = 100,000원
이는 처음부터 지금까지의 모든 동영상을 저장하는 것과 같습니다.
2. 왜 필요한가요? — 실무에서 마주치는 문제들
시나리오 1: 고객 문의 상황
“어? 제 계좌에서 3만 원이 사라졌어요. 언제 어떻게 빠진 건가요?”
전통적인 방식의 한계:
SELECT balance FROM accounts WHERE id = 100;
-- 결과: 70000
-- 3만 원이 줄어든 건 맞는데... 언제? 왜?
이벤트소싱의 답:
SELECT * FROM account_events WHERE account_id = 100 ORDER BY timestamp;
-- 2025-08-10 15:30:00: MoneyWithdrawn, -30000, {"reason": "ATM_withdrawal", "location": "강남역"}
-- 명확한 추적 가능!
시나리오 2: 버그 발생 시 복구
시스템 버그로 인해 2025년 8월 8일부터 잘못된 계산이 적용되었다면?
전통적인 방식:
- 현재 상태만 있으므로 복구 불가능
- 백업에서 복원해야 함 (데이터 손실 발생)
이벤트소싱 방식:
// 8월 7일까지의 이벤트만 재생하여 올바른 상태로 복구
List<Event> eventsUntilAugust7 = getEventsUntil("2025-08-07");
AccountState correctState = replayEvents(eventsUntilAugust7);
3. 핵심 개념 정리
이벤트(Event)
시스템에서 발생한 의미있는 사건
- 과거형으로 표현:
UserRegistered,OrderPlaced,PaymentCompleted - 불변(Immutable): 한 번 발생한 이벤트는 수정되지 않음
- 시간순 정렬: 발생 순서가 매우 중요
이벤트 재생(Event Replay)
저장된 이벤트들을 순서대로 실행하여 현재 상태를 복원하는 과정
public class AccountAggregate {
private String accountId;
private BigDecimal balance = BigDecimal.ZERO;
public void apply(MoneyDepositedEvent event) {
this.balance = this.balance.add(event.getAmount());
}
public void apply(MoneyWithdrawnEvent event) {
this.balance = this.balance.subtract(event.getAmount());
}
}
이벤트 스토어(Event Store)
이벤트들을 저장하는 특수한 데이터베이스
- 일반 RDBMS, NoSQL, 또는 전용 Event Store 사용
- Append-only: 새로운 이벤트만 추가, 기존 이벤트는 수정/삭제 금지
4. 장점 — 왜 복잡해 보이는데 쓸까요?
완벽한 감사 추적(Audit Trail)
// 특정 기간의 모든 거래 내역 조회
List<Event> transactions = eventStore.getEvents(
accountId,
LocalDate.of(2025, 8, 1),
LocalDate.of(2025, 8, 31)
);
활용 사례:
- 금융 시스템의 규제 준수
- 게임에서 치팅 방지
- 의료 시스템의 환자 기록 추적
타임머신 기능 — 과거 상태 재현
// 2025년 8월 5일 시점의 계좌 상태 확인
AccountState pastState = replayEventsUntil(accountId, "2025-08-05");
System.out.println("8월 5일 잔액: " + pastState.getBalance());
실무 활용:
- A/B 테스트 결과 분석
- 과거 시점 기준 리포트 생성
- 버그 재현 및 디버깅
자연스러운 이벤트 발행
@EventHandler
public void handle(MoneyDepositedEvent event) {
// 다른 서비스에 즉시 알림
emailService.sendDepositNotification(event.getAccountId(), event.getAmount());
loyaltyService.addPoints(event.getAccountId(), calculatePoints(event.getAmount()));
}
마이크로서비스 환경에서 서비스 간 데이터 동기화가 자연스럽게 해결됩니다.
5. 단점과 해결책 — 현실적인 고민들
데이터 저장소 사용량 증가
문제:
-- 1년간 거래가 많은 계좌의 이벤트
SELECT COUNT(*) FROM account_events WHERE account_id = 100;
-- 결과: 50,000개 이벤트
해결책: 스냅샷(Snapshot) 기법
// 1000개 이벤트마다 스냅샷 생성
if (eventCount % 1000 == 0) {
Snapshot snapshot = new Snapshot(aggregateId, currentState, eventCount);
snapshotStore.save(snapshot);
}
// 상태 복원 시 최신 스냅샷부터 시작
Snapshot latestSnapshot = snapshotStore.getLatest(aggregateId);
AccountState state = latestSnapshot.getState();
List<Event> eventsAfterSnapshot = eventStore.getEventsAfter(aggregateId, latestSnapshot.getVersion());
return replayEvents(state, eventsAfterSnapshot);
이벤트 재생 성능 이슈
문제: 이벤트가 많아지면 상태 복원이 느려짐
해결책:
- CQRS 패턴 적용 — 읽기 전용 뷰 모델 분리
- 캐싱 — 자주 조회되는 상태는 메모리에 캐싱
- 이벤트 압축 — 중요하지 않은 중간 이벤트 제거
개발 복잡도 증가
전통적 CRUD:
public void updateBalance(String accountId, BigDecimal newBalance) {
accountRepository.updateBalance(accountId, newBalance);
}
이벤트소싱:
public void deposit(String accountId, BigDecimal amount) {
Account account = loadAccount(accountId);
MoneyDepositedEvent event = account.deposit(amount);
eventStore.append(accountId, event);
account.apply(event);
}
해결책: 프레임워크 활용 (Axon Framework, EventStore 등)
6. 실전 적용: 언제 사용하면 좋을까요?
강력 추천 사례
금융/결제 시스템
public class PaymentAggregate {
public PaymentInitiatedEvent initiatePayment(PaymentCommand cmd) {
// 결제 시작 이벤트
}
public PaymentCompletedEvent completePayment(String paymentId) {
// 결제 완료 이벤트
}
public PaymentFailedEvent failPayment(String paymentId, String reason) {
// 결제 실패 이벤트
}
}
이유: 금융 감독 기관의 추적 가능성 요구사항을 자연스럽게 만족
전자상거래 주문 시스템
// 주문 생명주기 추적
OrderCreatedEvent -> OrderPaidEvent -> OrderShippedEvent -> OrderDeliveredEvent
이유: 고객 문의 대응과 배송 추적이 필수
게임 서비스
// 플레이어 행동 기록
PlayerJoinedEvent -> ItemPurchasedEvent -> LevelUpEvent -> GameCompletedEvent
이유: 치팅 방지와 게임 밸런싱 분석에 활용
신중하게 고려해야 할 사례
단순한 CRUD 애플리케이션
- 사용자 프로필 관리
- 상품 카탈로그 관리
- 정적 컨텐츠 관리
이유: 이력 추적의 비즈니스 가치가 낮고 복잡도만 증가
실시간 성능이 중요한 시스템
- 고빈도 거래 시스템 (HFT)
- 실시간 게임 서버
- IoT 센서 데이터 처리
이유: 이벤트 재생으로 인한 지연시간 문제
7. 실제 구현 예시 — Spring Boot + JPA
@Entity
public class EventEntity {
@Id
@GeneratedValue
private Long id;
private String aggregateId;
private String eventType;
private String eventData; // JSON 형태로 저장
private LocalDateTime occurredAt;
private Long version; // 낙관적 락을 위한 버전
}
@Service
public class EventStore {
@Autowired
private EventRepository eventRepository;
@Autowired
private ObjectMapper objectMapper;
@Transactional
public void append(String aggregateId, DomainEvent event) {
EventEntity entity = new EventEntity();
entity.setAggregateId(aggregateId);
entity.setEventType(event.getClass().getSimpleName());
entity.setEventData(objectMapper.writeValueAsString(event));
entity.setOccurredAt(LocalDateTime.now());
eventRepository.save(entity);
}
public List<DomainEvent> getEvents(String aggregateId) {
List<EventEntity> entities = eventRepository
.findByAggregateIdOrderByOccurredAt(aggregateId);
return entities.stream()
.map(this::deserialize)
.collect(toList());
}
}
8. 마무리: 이벤트소싱을 시작하기 전에
스스로에게 물어보세요
- “우리 시스템에서 데이터 변경 이력이 중요한가?”
- 중요하다면 → 이벤트소싱 고려
- 중요하지 않다면 → 전통적 방식으로 충분
- “감사 추적이나 규제 준수가 필요한가?”
- 필요하다면 → 이벤트소싱 강력 추천
- 필요없다면 → 다른 아키텍처 패턴 고려
- “팀이 복잡도 증가를 감당할 수 있는가?”
- 가능하다면 → 점진적 도입
- 어렵다면 → 충분한 학습 후 적용
시작하기 좋은 방법
- 작은 도메인부터: 전체 시스템이 아닌 한 개 도메인에만 적용
- 프레임워크 활용: Axon Framework, EventStore 등으로 러닝커브 단축
- 하이브리드 접근: 핵심 도메인만 이벤트소싱, 나머지는 전통적 방식
한 줄 요약
이벤트소싱은 “모든 변화를 기록하여 완벽한 추적성을 제공하는” 아키텍처 패턴입니다.
단순한 현재 상태 저장이 아닌 변화의 역사를 보존함으로써, 더 강력한 감사 추적, 디버깅, 복구 능력을 제공합니다.
복잡해 보이지만, 비즈니스에 진짜 가치를 제공하는 영역에서는 그 복잡함을 상쇄하고도 남을 만큼 강력한 도구입니다.
이벤트소싱이 모든 문제의 해답은 아닙니다. 하지만 언제 써야 하고 언제 쓰지 말아야 하는지를 아는 것만으로도 더 나은 아키텍처 결정을 내릴 수 있을 것입니다.
Enjoy Reading This Article?
Here are some more articles you might like to read next: