Post

현재 상태만 저장하지 말고 모든 변화를 기록하라, 이벤트소싱으로 데이터 히스토리 완벽 관리하기

이벤트소싱 완벽 가이드, 현재 상태만 저장하는 방식의 한계를 벗어나 모든 데이터 변화를 기록하는 아키텍처 패턴. 실무 예제와 Spring Boot 구현 코드로 배우는 Event Sourcing 입문서

현재 상태만 저장하지 말고 모든 변화를 기록하라, 이벤트소싱으로 데이터 히스토리 완벽 관리하기

“데이터베이스에 현재 상태만 저장하는 게 당연하다고 생각했다면, 이 글을 끝까지 읽어보세요.”

들어가며: 왜 이벤트소싱을 알아야 할까요?

주니어 개발자 시절, 저는 항상 이런 의문이 있었습니다.

  • “계좌 잔액이 10만 원인 건 알겠는데, 이 돈이 언제 어떻게 들어왔는지는 왜 따로 관리해야 하지?”

  • “사용자 프로필이 변경됐을 때, 이전 상태는 왜 날려버리는 거지?”

바로 이런 의문에서 시작되는 것이 이벤트소싱(Event Sourcing)입니다.


1. 이벤트소싱이란? — 동영상 vs 스냅샷의 차이

전통적인 방식: 스냅샷 저장

대부분의 시스템은 데이터의 현재 상태만 저장합니다.

1
2
3
4
5
6
7
8
-- 일반적인 계좌 테이블
CREATE TABLE accounts (
    id BIGINT,
    balance DECIMAL(15,2)  -- 현재 잔액만 저장
);

INSERT INTO accounts VALUES (1, 100000);
-- 10만 원이 있다는 것만 알 수 있음

이는 마치 현재 모습의 사진 한 장만 저장하는 것과 같습니다.

이벤트소싱 방식: 모든 변화 기록

이벤트소싱은 지금까지 일어난 모든 사건(이벤트)을 순서대로 저장합니다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
-- 이벤트소싱 방식
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만 원이 사라졌어요. 언제 어떻게 빠진 건가요?”

전통적인 방식의 한계:

1
2
3
SELECT balance FROM accounts WHERE id = 100;
-- 결과: 70000
-- 3만 원이 줄어든 건 맞는데... 언제? 왜?

이벤트소싱의 답:

1
2
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일부터 잘못된 계산이 적용되었다면?

전통적인 방식:

  • 현재 상태만 있으므로 복구 불가능
  • 백업에서 복원해야 함 (데이터 손실 발생)

이벤트소싱 방식:

1
2
3
// 8월 7일까지의 이벤트만 재생하여 올바른 상태로 복구
List<Event> eventsUntilAugust7 = getEventsUntil("2025-08-07");
AccountState correctState = replayEvents(eventsUntilAugust7);

3. 핵심 개념 정리

이벤트(Event)

시스템에서 발생한 의미있는 사건

  • 과거형으로 표현: UserRegistered, OrderPlaced, PaymentCompleted
  • 불변(Immutable): 한 번 발생한 이벤트는 수정되지 않음
  • 시간순 정렬: 발생 순서가 매우 중요

이벤트 재생(Event Replay)

저장된 이벤트들을 순서대로 실행하여 현재 상태를 복원하는 과정

1
2
3
4
5
6
7
8
9
10
11
12
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)

1
2
3
4
5
6
// 특정 기간의 모든 거래 내역 조회
List<Event> transactions = eventStore.getEvents(
    accountId,
    LocalDate.of(2025, 8, 1),
    LocalDate.of(2025, 8, 31)
);

활용 사례:

  • 금융 시스템의 규제 준수
  • 게임에서 치팅 방지
  • 의료 시스템의 환자 기록 추적

타임머신 기능 — 과거 상태 재현

1
2
3
// 2025년 8월 5일 시점의 계좌 상태 확인
AccountState pastState = replayEventsUntil(accountId, "2025-08-05");
System.out.println("8월 5일 잔액: " + pastState.getBalance());

실무 활용:

  • A/B 테스트 결과 분석
  • 과거 시점 기준 리포트 생성
  • 버그 재현 및 디버깅

자연스러운 이벤트 발행

1
2
3
4
5
6
@EventHandler
public void handle(MoneyDepositedEvent event) {
    // 다른 서비스에 즉시 알림
    emailService.sendDepositNotification(event.getAccountId(), event.getAmount());
    loyaltyService.addPoints(event.getAccountId(), calculatePoints(event.getAmount()));
}

마이크로서비스 환경에서 서비스 간 데이터 동기화가 자연스럽게 해결됩니다.


5. 단점과 해결책 — 현실적인 고민들

데이터 저장소 사용량 증가

문제:

1
2
3
-- 1년간 거래가 많은 계좌의 이벤트
SELECT COUNT(*) FROM account_events WHERE account_id = 100;
-- 결과: 50,000개 이벤트

해결책: 스냅샷(Snapshot) 기법

1
2
3
4
5
6
7
8
9
10
11
// 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);

이벤트 재생 성능 이슈

문제: 이벤트가 많아지면 상태 복원이 느려짐

해결책:

  1. CQRS 패턴 적용 — 읽기 전용 뷰 모델 분리
  2. 캐싱 — 자주 조회되는 상태는 메모리에 캐싱
  3. 이벤트 압축 — 중요하지 않은 중간 이벤트 제거

개발 복잡도 증가

전통적 CRUD:

1
2
3
public void updateBalance(String accountId, BigDecimal newBalance) {
    accountRepository.updateBalance(accountId, newBalance);
}

이벤트소싱:

1
2
3
4
5
6
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. 실전 적용: 언제 사용하면 좋을까요?

강력 추천 사례

금융/결제 시스템

1
2
3
4
5
6
7
8
9
10
11
12
13
public class PaymentAggregate {
    public PaymentInitiatedEvent initiatePayment(PaymentCommand cmd) {
        // 결제 시작 이벤트
    }

    public PaymentCompletedEvent completePayment(String paymentId) {
        // 결제 완료 이벤트
    }

    public PaymentFailedEvent failPayment(String paymentId, String reason) {
        // 결제 실패 이벤트
    }
}

이유: 금융 감독 기관의 추적 가능성 요구사항을 자연스럽게 만족

전자상거래 주문 시스템

1
2
// 주문 생명주기 추적
OrderCreatedEvent -> OrderPaidEvent -> OrderShippedEvent -> OrderDeliveredEvent

이유: 고객 문의 대응과 배송 추적이 필수

게임 서비스

1
2
// 플레이어 행동 기록
PlayerJoinedEvent -> ItemPurchasedEvent -> LevelUpEvent -> GameCompletedEvent

이유: 치팅 방지게임 밸런싱 분석에 활용

신중하게 고려해야 할 사례

단순한 CRUD 애플리케이션

  • 사용자 프로필 관리
  • 상품 카탈로그 관리
  • 정적 컨텐츠 관리

이유: 이력 추적의 비즈니스 가치가 낮고 복잡도만 증가

실시간 성능이 중요한 시스템

  • 고빈도 거래 시스템 (HFT)
  • 실시간 게임 서버
  • IoT 센서 데이터 처리

이유: 이벤트 재생으로 인한 지연시간 문제


7. 실제 구현 예시 — Spring Boot + JPA

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
@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. 마무리: 이벤트소싱을 시작하기 전에

스스로에게 물어보세요

  1. “우리 시스템에서 데이터 변경 이력이 중요한가?”
    • 중요하다면 → 이벤트소싱 고려
    • 중요하지 않다면 → 전통적 방식으로 충분
  2. “감사 추적이나 규제 준수가 필요한가?”
    • 필요하다면 → 이벤트소싱 강력 추천
    • 필요없다면 → 다른 아키텍처 패턴 고려
  3. “팀이 복잡도 증가를 감당할 수 있는가?”
    • 가능하다면 → 점진적 도입
    • 어렵다면 → 충분한 학습 후 적용

시작하기 좋은 방법

  1. 작은 도메인부터: 전체 시스템이 아닌 한 개 도메인에만 적용
  2. 프레임워크 활용: Axon Framework, EventStore 등으로 러닝커브 단축
  3. 하이브리드 접근: 핵심 도메인만 이벤트소싱, 나머지는 전통적 방식

한 줄 요약

이벤트소싱은 “모든 변화를 기록하여 완벽한 추적성을 제공하는” 아키텍처 패턴입니다.

단순한 현재 상태 저장이 아닌 변화의 역사를 보존함으로써, 더 강력한 감사 추적, 디버깅, 복구 능력을 제공합니다.

복잡해 보이지만, 비즈니스에 진짜 가치를 제공하는 영역에서는 그 복잡함을 상쇄하고도 남을 만큼 강력한 도구입니다.

이벤트소싱이 모든 문제의 해답은 아닙니다. 하지만 언제 써야 하고 언제 쓰지 말아야 하는지를 아는 것만으로도 더 나은 아키텍처 결정을 내릴 수 있을 것입니다.

This post is licensed under CC BY 4.0 by the author.