Today I Learned

(25.02.24) Event Sourcing

Genie; 2025. 2. 24. 17:42

기업에서 인턴으로 업무를 지내면서 만들어야 될 POC 단계의 툴의 아키텍쳐를 기본적인 CRUD를 만드려고 했지만, 

복잡한 Relation 과 구현으로 POC단계와 부합하지 않다는 의견을 받아,

아카이빙하고 업데이트 하는 구조가 아닌 Event Sourcing 기반의 데이터 수정 업데이트 툴로 바꾸고자 했다.

그렇게 많은 양의 데이터를 다루는 툴이 아니기 때문에 DB에 무리가 가지 않기 때문에 하나의 DB에서 업데이트를 진행하기 때문에 해당 패턴이 선정이 되었다.

툴을 개발하기에 앞서 생소할 수 있는 Event Sourcing패턴의 정의와 의의를 공부하면서 기록했다.


Event Sourcing

  • 데이터를 변경된 최종 상태만을 저장하는 것이 아닌 이벤트(event) 순서대로 저장 → 재구성하는 패턴
  • 기존의 CRUD 방식과 다르게, 모든 변경 사항이 이벤트 로그로 저장되므로 과거 데이터를 쉽게 추적

장점

  • 데이터 변경 이력을 유지
  • 시스템 상태를 과거 특정 시점으로 복원 가능(Versioning 유용)
    • CRUD일 경우에는 덮어쓰기를 기본으로 하기 때문에 데이터 복구가 어려울 수 있음
  • 이벤트 로그를 이용한 분석 및 감사(Audit) 가능
    • Audit : Logging 처럼 Application 상태 기록 기준 보다 누군가에 의해 Update를 추적하는 기능

단점

  • 기존 SQL 기반 RDBMS와 방식이 다름
  • 이벤트 저장소가 커질 경우 성능 최적화 필요
  • 데이터 조회 시 모든 이벤트를 모두 조회해야하므로 오버헤드 발생이 있음

Event Sourcing FLOW

  • 이벤트(Event)
    • 데이터 변경 사항(Update 내용) 기록 (ex) "사용자 A가 100원을 출금함")
    • 이벤트는 **불변(Immutable)**이며, 수정하거나 삭제할 수 없음
      • 단 건의 이벤트를 업데이트 할 수 없는 것을 기본 개념으로 둠
  • 이벤트 로그(Event Log)
    • 발생한 이벤트들은 이벤트 로그 순차적으로 기록되며, 과거 이벤트들이 쌓여 현재 시스템 상태를 형성
  • 이벤트 저장소(Event Store)
    • 이벤트를 저장하는 데이터베이스 역할 (MongoDB, PostgreSQL, EventStore 등)ㅇ
      • NoSQL을 사용해도 되지만, RDBMS를 사용할 수도 있음 무조건 NoSQL로 국한되어 있지 않음
      • Transaction 처리 등 필요에 따라 바꿀 수 있기 때문에
  • 이벤트 핸들러(Event Handler)
    • 이벤트가 발생하면 해당 이벤트를 처리하는 로직 (RabbitMQ/Kafka Consumer)
      • 순차적으로 처리하기 위해
    • 이벤트를 처리한 후, 조회용 DB에 반영 (CQRS 적용 가능)
  • 이벤트 리플레이(Event Replay), 처리
    • 과거 이벤트를 순서대로 재실행하여 현재 상태를 재구성하는 과정
      • 데이터 조회는 특정 현재 상태를 조회한다는 의미에 가깝지만, 리플레이는 ORDER BY 를 통해서 보든 이벤트를 순차적으로 실행하면서 최종 상태를 파악하는 행위
    • 장애 발생 시 복구 또는 신규 시스템 구축 시 유용

CQRS, Command Query Responsibility Segregation

  • Event Sourcing 과 같이 사용될 수 있는 명령(Command)과 조회(Query)를 분리하는 패턴
    • 이벤트 소싱에서는 명령이 이벤트로 변환 →  조회는 이벤트가 쌓여서 재구성된 상태를 조회하는 방식
  • Event Sourcing 은 쓰기 작업만을 담당, 읽기 작업은 별도의 쿼리 모델을 사용해서 분리하야 최적화
  • 단, 3 Layered Architecture에서는 쓰기 읽기 각각의 로직이 서비스 계층에서 처리되므로 (API 엔드포인트로 확실히 구분이 되므로) CQRS를 구분하여 구성할 필요는 없음
    • 단, 비지니스 로직이 과도하게 복잡해질 경우, 또는 조회에 비해서 많은 리소스를 잡을 경우 따로 분리해야함
    • 캐싱이 필요한 경우에 복합적으로 사용

Java Application 에서의 Event Sourcing + CQRS Example

  • Application 은 일종의 계정 생성 + 계정 변경(이메일 예시)에 대한 Event 에 대해서 기록/조회를 Event Sourcing 패턴으로 구현
  1. Event 정의
// 사용자 계정 생성 이벤트
public class AccountCreatedEvent {
    private String userId;
    private String username;
    private String email;
		
		// 생성자
    public AccountCreatedEvent(String userId, String username, String email) {
        this.userId = userId;
        this.username = username;
        this.email = email;
    }

    ...
}

// 사용자 계정 이메일 변경 이벤트
public class EmailChangedEvent {
    private String userId;
    private String newEmail;
		
		// 생성자
    public EmailChangedEvent(String userId, String newEmail) {
        this.userId = userId;
        this.newEmail = newEmail;
    }

    ...
}

  1. Command 정의
// 사용자 계정 생성 커맨드
public class CreateAccountCommand {
    private String userId;
    private String username;
    private String email;

    public CreateAccountCommand(String userId, String username, String email) {
        this.userId = userId;
        this.username = username;
        this.email = email;
    }

    ...
}

// 사용자 계정 이메일 변경 커맨드
public class ChangeEmailCommand {
    private String userId;
    private String newEmail;

    public ChangeEmailCommand(String userId, String newEmail) {
        this.userId = userId;
        this.newEmail = newEmail;
    }

    ...
}

  1. Event Store
public class EventStore {
    private List<Object> events = new ArrayList<>();

    // 이벤트 저장
    public void saveEvent(Object event) {
        events.add(event);
    }

    // 이벤트 조회 메서드
    public List<Object> getEvents() {
        return events;
    }
}

  1. Command Handler
public class AccountCommandHandler {

    private EventStore eventStore;

    public AccountCommandHandler(EventStore eventStore) {
        this.eventStore = eventStore;
    }

    public void handleCreateAccount(CreateAccountCommand command) {
        AccountCreatedEvent event = new AccountCreatedEvent(command.getUserId(), command.getUsername(), command.getEmail());
        eventStore.saveEvent(event);
    }

    // 이메일 변경 처리
    public void handleChangeEmail(ChangeEmailCommand command) {
        EmailChangedEvent event = new EmailChangedEvent(command.getUserId(), command.getNewEmail());
        eventStore.saveEvent(event);
    }
}

  1. Query Side : 읽기
public class AccountQueryHandler {

    private EventStore eventStore;

    public AccountQueryHandler(EventStore eventStore) {
        this.eventStore = eventStore;
    }

		// 읽기 기능
    public Account getAccount(String userId) {
        List<Object> events = eventStore.getEvents();
        Account account = null;

        for (Object event : events) {
            if (event instanceof AccountCreatedEvent) {
                AccountCreatedEvent accountEvent = (AccountCreatedEvent) event;
                if (accountEvent.getUserId().equals(userId)) {
                    account = new Account(accountEvent.getUserId(), accountEvent.getUsername(), accountEvent.getEmail());
                }
            } else if (event instanceof EmailChangedEvent) {
                EmailChangedEvent emailEvent = (EmailChangedEvent) event;
                if (account != null && emailEvent.getUserId().equals(userId)) {
                    account.setEmail(emailEvent.getNewEmail());
                }
            }
        }

        return account;
    }
}

  1. Account (계정 클래스)
public class Account {
    private String userId;
    private String username;
    private String email;

    public Account(String userId, String username, String email) {
        this.userId = userId;
        this.username = username;
        this.email = email;
    }

    ...
}

  • Command Side:
    • 사용자가 계정을 생성 →  CreateAccountCommand, 이를 AccountCommandHandler가 처리 → AccountCreatedEvent가 이벤트 스토어에 저장
    • 사용자가 이메일을 변경 → ChangeEmailCommand , 이를  AccountCommandHandler가 처리 → EmailChangedEvent를 이벤트 스토어에 저장
  • Query Side:
    • 사용자가 계정 정보를 요청 → AccountQueryHandler가 EventStore에 저장된 이벤트 읽기 → 이를 기반으로 최신 상태를 구성하여 데이터를 반환
      • 그냥 최신순으로 수정 내용을 본다는 얘기
  • 전체적인 이벤트 저장 → 재생(최신 상태를 계산) → 반환(처리 된 데이터를 반환) 의 일련의 과정은 결국 Controller - Service - Repository 의 3 Layered Architecture 와 유사하게 작동을 함