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

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

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

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

  • “계좌 잔액이 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);

이벤트 재생 성능 이슈

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

해결책:

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

개발 복잡도 증가

전통적 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. 마무리: 이벤트소싱을 시작하기 전에

스스로에게 물어보세요

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

시작하기 좋은 방법

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

한 줄 요약

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

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

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

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




    Enjoy Reading This Article?

    Here are some more articles you might like to read next:

  • 데이터베이스 인덱스 동작방식 그리고 최적화
  • 대용량 디비에 파티션을 활용해야 하는 이유
  • 무료 프록시, 크롤러 실패의 지름길, 유료 프록시가 필수인 7가지 기술적 이유
  • 파이썬 웹 크롤링 완벽 가이드 - 현업 데이터 엔지니어의 실전 노하우
  • AI 글쓰기 품질을 높이는 프롬프트 엔지니어링 8단계 (실전 템플릿 포함)