최근 프로젝트에서 audit을 생성하는 코드를 작성하면서 이벤트 소싱 패턴을 찾아보게 되었다. 여러 포스트를 통해 접해본 내용이지만 실제로 구현해보지 않아서 크게 와닿지 않았었다. 특히 용어가 익숙하지 않았는데 읽으며 궁금해서 찾아봤던 순서대로 정리했다.


전통적으로 사용하는 CRUD 모델을 생각해보자. 데이터를 갱신하기 위해서는 데이터 저장소에서 해당 데이터를 가져오는 작업이 필요하다. 동시성 문제가 나타날 수도 있고 확장성을 낮추는 지점이 될 수도 있다. 이벤트소싱 패턴은 일련의 이벤트를 통해 데이터를 조작하는 접근 방식이다. 데이터에 영향을 주는 모든 동작을 이벤트라는 저수준의 데이터로 관리하는 것으로 여러 문제를 해결할 수 있다. 물론 단점도 여러가지 존재하므로 필요에 따라 적용해야 한다.

이벤트소싱 패턴도 성숙한 아키텍처이기 때문에 다양한 주제로 세분화되어 있고 각각의 키워드를 알아야 쉽게 찾아볼 수 있다. 예를 들면 이벤트를 저장하고 불러오는 방식 하나만으로도 큰 주제다. 특히 이 패턴을 제대로 쓰기 위해서는 CQRS를 빼놓을 수 없기 때문에 두 이야기가 뒤섞이기도 한다.

이벤트 소싱

앞서 적은 것처럼 이벤트소싱 패턴은 일련의 이벤트를 통해 데이터를 조작한다. 현재의 상태는 변화의 총합으로 표현할 수 있다. 이 패턴을 설명할 때는 쇼핑몰을 많이 예로 든다.

다음과 같이 일련의 이벤트가 있다고 생각해보자.

id root_id event
1 1 카트 생성함
2 1 상품1 추가함
3 1 상품2 추가함
4 1 상품2 제거함
5 1 배송정보 입력함

이 이벤트를 개체에 하나씩 적용한다면 최종적으로 생성된 카트에 상품2가 추가되어 있고 배송정보까지 입력된 개체를 얻을 수 있게 된다. 기존 CRUD 모델과 비교한다면 이미 정형화된 모델에 상태를 저장하는 과정에서 나타나는 문제를 해결할 수 있다. CRUD에서는 카트에 추가되었다가 제거된 상품 목록을 뽑고 싶다고 했을 때 별도로 그 특정한 상태를 저장하지 않는다면 어려운 작업이 될 것이다. 게다가 작업을 한다 하더라도 그 작업 이후의 데이터에 대해서만 볼 수 있는 한계점이 있다. 이처럼 정형화되지 않은 데이터인 이벤트를 저장하고 있다는 점에서 더욱 유연한 변화가 가능하다.

여기서 사용되는 개체는 도메인 모델이고 흔히 집합체(aggregate)로 불리며 root_idaggregateRoot로 삼아서 각 개체로 전환한다.

var cart = events.reduce((aggregate, event) => aggregate.apply(event), new CartAggregate);
console.log(cart.getItems()) // ['상품1']
console.log(cart.shippingInfoExists()) // true

내 경우에는 다음과 같은 궁금점이 생겼다.

  • 이벤트는 어떻게 데이터로 저장하고 복원하지?
  • 이벤트가 많이 쌓이면 느려지지 않나?
  • 데이터가 필요할 때마다 매번 전부 이벤트를 돌려봐야 한다면 번거롭지 않을까?
  • 이벤트는 어떻게 다시 재생하지?

이벤트 저장(Eventstore)

이벤트 저장에 사용할 수 있는 저장소는 크게 세 가지로 분류된다.

  • 이벤트 저장에 특화된 데이터 저장소 사용 (e.g. eventstore.org)
  • NoSQL을 사용
  • 관계형 데이터베이스 사용

이벤트는 데이터베이스에 직렬화(serialize)해서 저장하고 역직렬화(deserialize)해서 사용한다. 각 저장하는 방식은 전략에 따라 다른데 관계형 테이터베이스를 사용한다면 이벤트명(주로 이벤트 타입명)과 데이터(주로 payload) 등으로 분리해서 저장한다. 이벤트의 정규 구조가 단순하기 때문에 단순히 위에서 언급한 데이터베이스가 아니더라도 용도에 맞게 선택할 수 있다.

스냅샷

스냅샷은 이벤트 저장에서 사용할 수 있는 전략이다. 기준에 따라서 이벤트가 많이 쌓이면 중간에 스냅샷을 만들고 그 스냅샷 이후의 이벤트만 가져와서 사용하는 방식이다. 특화된 저장소라면 시스템이 알아서 처리해주지만 그 외에는 이 문제를 고민해서 저장 방식을 설계해야 한다.

명령과 조회 책임 분리

필요한 데이터를 얻기 위해 모든 이벤트를 반복해서 재생하는 일은 많은 자원을 필요로 한다. 일련의 이벤트를 여러 장의 필름이라고 생각한다면 현재의 상태란 이 여러 필름을 한 위치에 투영(projecting)해서 나타난 그림이라고 볼 수 있겠다. 매번 모든 필름을 겹쳐 투영하는 대신 현재의 상태를 어딘가 저장하고 있다면 좀 더 쉽게 데이터를 사용할 수 있을 것이다.

현재 상품1의 재고량을 파악하기 위해 모든 이벤트를 투영하는 대신 재고 테이블에서 상품1의 재고량을 바로 찾아보는 것이 훨씬 쉽다. 다시 말하면 저장은 이벤트로 하지만 조회는 투영된 데이터, 구체화(materialised)된 데이터를 대상으로 수행하고 싶은 것이다. 이런 맥락에서 자연스럽게 명령과 조회의 책임을 분리하는 패턴(Command and Query Responsibility Segregation, CQRS)을 적용하게 된다. 명령으로 이벤트를 쌓고 리드 모델에서 조회하는 것이다. 명령과 조회에서의 책임 분리는 이 아키텍처의 장점을 끌어올릴 수 있다.

위에서 예시로 든 테이블의 경우는 id를 increment id와 같이 지정했지만 CQRS에서는 무작정 생성해도 충돌을 피할 수 있는 만큼 큰 id(예로 128bit GUID)를 생성해서 사용한다.

리드 모델

그렇다면 리드 모델은 어떻게 최신 데이터를 계속 유지할 수 있을까? 새 이벤트는 이벤트 버스(event bus)를 통해 전파되는데 리드 모델에 변화를 투영하는 경우에도 이벤트 리스너로서 발행되는 이벤트를 관찰하고 있다가 리드 모델을 갱신해야 하는 이벤트가 발생하는 순간에 갱신할 수 있다.

이벤트에 의해 갱신되는 리드 모델은 자료를 모두 유실하더라도 이벤트를 모두 저장하고 있다면 모든 이벤트를 이벤트 버스에 보내는 것으로 다시 복구할 수 있게 된다. 구체화된 데이터가 자료의 원형이 아니라 이벤트가 자료의 원형이기 때문에 필요에 따라서 언제든 비정규화된 조회를 새로 생성하고 삭제하는 것이 가능하다.

하지만 이 방식을 적용하면 리드 모델을 비동기적으로 처리하기 때문에 최종 일관성(eventual consistency)을 유지하게 된다. 리드 모델 갱신이 빠르다면 다시 조회를 수행했을 때 최신의 자료를 볼 수 있겠지만 연산이 많거나 부하가 커서 갱신이 느려진다면 이벤트는 생성되었지만 리드 모델은 갱신되지 않아 일시적으로 이전 데이터를 조회하게 될 수 있다. 분산 환경에서는 흔하게 나타나는 문제로 요구사항과 충돌한다면 이런 부분에 대한 대책을 세워야 한다.


간략하게 JavaScript로 이벤트소싱 패턴을 구현해 글로 작성했다.

더 읽기

색상을 바꿔요

눈에 편한 색상을 골라보세요 :)

Darkreader 플러그인으로 선택한 색상이 제대로 표시되지 않을 수 있습니다.