[Event Sourcing & CQRS ] 이론
Event-Driven MSA를 공부하기 위해 핵심이라 할 수 있는 Event Sourcing 및 CQRS 이론 부분을 https://www.youtube.com/watch?v=Yd7TXUdcaUQ&t=5807s 의 Youtube 동영상 보고 정리
Event Sourcing 개념
RDS를 사용하여 상태를 저장하는 시스템에서, 사용자가 장바구니에 특정 상품을 넣을 수 있는 애플리케이션을 생각해봅시다.
이런 상태에서 장바구니에서 사용자가 아이템을 빼면 상태를 기반으로 하는 시스템에서는 상태의 일부가 삭제됩니다. (장바구니 항목 삭제) 이런 방법은 사용자나 개발자한테 딱히 나쁘지 않음. 그런데 비즈니스 담당자 입장에서는 C라는 아이템을 사용자가 장바구니를에서 뺐다는 사실을 알고 싶은 경우가 있을 수 있음. (반복적으로 했을 시 → 추천이라 든지). 최종상태만을 기록하는 애플리케이션에서는 장바구니에서 뺀 사실을 알 수 없음.
보통 그런 시스템에서는 로그로 이런 내용들을 담습니다. 그런데 보통 상태 저장, 로그 저장은 별도의 트랜잭션에서 구현합니다. 그래서 로그의 내용과 상태가 달라질 수 도있는 가능성이 있습니다.
이벤트 소싱은 데이터를 이벤트로 다루는데요. 사용자가 어떤 행위를 하면 그 행위 자체를 이벤트로 보고 이벤트 저장소에 기록합니다. 이렇게 기록된 데이터를 Event Handler를 통해 상태를 제어합니다. 그러면 위에서 발생했던 기록과 상태의 불일치가 일어나지 않습니다. 왜냐하면 상태는 항상 기록된 이벤트로 부터 추론 되기 때문이죠.
그래서 아까 보았던 장바구니를 이벤트 소싱로 구현한다면 이런 모습입니다. 핵심 데이터는 이벤트입니다. 사용자가 행위 별로 이벤트가 존재합니다. 과거 이벤트 + 현재 이벤트를 통해 오른쪽에 있는 상태를 만들어 냅니다.
이렇게 사용자가 아이템을 삭제한다면, 상태는 마찬가지로 삭제가 되지만, 상태는 이벤트로 부터 유추 되는겁니다. 이벤트는 수정/삭제가 일어나지 않고 오직 추가만 일어납니다.
데이터 영속 - Event Sourcing의 데이터 저장 / 복원
이벤트가 저장되는 방식입니다. 이벤트 스토어에서 하나의 거대한 스트림이 아니라 도메인 객체마다 각자 이벤트 스트림을 갖게 됩니다. 이 때 도메인은 DDD의 Aggregate도 될 수 있습니다.
도메인 객체에서 명령이 전달 되면 객체는 이 명령을 수행 할 수 있는지 확인합니다. (장바구니에 아이템 C삭제 명령을 받으면 현재 장바구니에 Item C가 있는지 확인). 명령을 수행할 수 없으면 바로 오류를 반환합니다. 명령을 수행 가능하다면 이벤트를 발생 시키고 그 이벤트는 이벤트 저장소에 저장됩니다.
이런 방식으로 이벤트가 저장이 된다면, 상태를 복원하기 위한 방법 공식입니다. 특정 버전 상태의 상태는 객체의 초기값 + (첫번째 event부터 해당 버전의 이벤트까지 순차적으로 집계한 결과물)입니다.
도메인 객체에 명령이 도달하면, 이 명령을 확인합니다. 가능하다면 이벤트를 만들고 이벤트 처리기, 이벤트 저장소로 이벤트를 보냅니다. 이벤트 처리기에서는 이벤트를 분석 후 어떻게 상태를 바꿀지 결정합니다.
객체가 복원 될 때는, 이벤트 스토어에서 이 객체에 대한 이벤트를 가져와서 순차적으로 이벤트 핸들러에서 상태를 복원합니다.
이벤트 저장소에 저장되는 각 이벤트들은 key-value로 구성됩니다. key는 객체 id와 이벤트 버전이고, value는 이벤트 유형과 이벤트를 직렬화한 데이터입니다.
이를 사용함으로 이점은 동시성 문제를 해결해줍니다. 한 객체에 여러 프로세스가 동시에 명령을 수행할 때 key로 객체, version을 잡기 때문에 이미 처리된 key값에 대해서 명령은 실패합니다.
이벤트 저장을 하는 방식은 많습니다. RDS에서는 이런식으로 Compound Primary key로 둘 수 있겠습니다.
백만개의 이벤트를 가지는 도메인 개체
백만개의 이벤트를 가지고 있는 도메인을 복원 시 성능 이슈가 일어날 수 도 있습니다.
이를 해결하기 위해 예를 들어 1 ≤ m ≤ n , 1과 n사이 존재하는 m버전의 상태를 가지고 있다고 가정해보겠습니다. 그러면 이런식으로 좀 더 이벤트를 가져오고 처리하는 수를 줄 일 수 있습니다.
항상 쓰이지 않지만 성능 부담이 될 말한 도메인 객체에서는 Rolling Snapshot을 사용합니다. 어떤 버전 주기 마다 스냅샷을 만들게 할 수 있습니다. 그래서 객체 복원 시 일단 스냅샷을 보고 있다면 스냅샷 이후로 이벤트를 불러와 복원하면 되겠죠.
스냅샷은 이런식으로 저장하면 되겠죠.
CQRS - 이벤트 소싱과 CQRS 패턴 조합
이벤트 소싱 기반 애플리케이션에서 이런 요구사항이 들어왔다고 가정하겠습니다. 이벤트 스토어에 저장된 이벤트를 사용해서 이 요구를 어떻게 해결할까요?
CQS에서는 상태를 변경하는 함수는 아무것도 반환하지 않고 (Command) 무엇을 반환하는 함수는 상태를 변경하지 않는다. (Query)
- 한 함수에 두 가지 기능을 넣지 말라
역할을 분리한다는 개념인데요, CQS랑 기본적인 개념은 같습니다. CQS에서는 함수 단위에서 분리한다면, CQRS에서는 OOP이상에서 Command와 Query를 분리하는 것입니다.
이렇게 시스템을 command side와 query side로 분리하는 것이죠.
이벤트 소싱에 CQRS를 적용하면 이런 모습입니다. 아까 질문이었던 재고가 10개미만 상품목록 조회 요구사항을 생각해봅시다.
이벤트 스토어에서는 답이 안나옵니다. (모든 이벤트를 로딩하는것 : 메모리 상으로 로딩해와서 full scan → 미친 짓)
만약에, 이벤트 스토어에 저장된 모든 이벤트들을 이 Query Side의 Materialized (Denormalized)를 지속적으로 만들고 있으면 문제가 해결할 수 있어보입니다.
Client에서 Command Side에 Command를 날리면은 command side에서는 이벤트를 저장후 Message Broker같은 것을 통해 Query Side에 이벤트를 전달합니다. Query Side에서는 이 이벤트들을 차곡차곡 쌓아 데이터 상태를 저장하는 거죠.
그래서 Event Sourcing은 반드시 CQRS랑 반드시 사용되어야 하는거죠.
이렇게 Event Store ↔ Read DB 는 무조건 1대1 관계가 아닌 목적에 맞게 다양하게 구성될수 있습니다.
Message-Driven Operation Procedure
Command Side에서 Query Side로 데이터를 어떻게 보낼까요?
초기에는 Command API로 통해 입력을 받고, 메시지 브로커를 통해 명령을 처리하는 쪽에 명렁을 전달합니다(동기). 메시지 브로커에서 메시지 처리쪽으로 전달할 때는 비동기 식으로 전달합니다.
가끔 동기적일때가 좋을 때가 있습니다. 예를 들어 회원가입 같은 경우인데요, 이럴 때는 API에서 메시지 핸들러로 바로 동기적인 메시지를 날리는 방식을 선택할 수 있습니다.
이벤트 소싱에서도 이러한 문제가 발생하는데요.
이러한 문제를 해결하기 위해 여러가지를 고려해볼 수 있습니다. 하나는 멱등성인데요. 어떤 x에 f를 몇번 적용해도 결과는 같은 성질인데요.
메시지를 멱등하게 만들어 버리는 것은 어떨까요?
그래서 이벤트 소싱에서는 이벤트를 멱등성 있게 설계하는 것이 중요합니다
재고를 증가 시켜라는 이벤트에서
Quantity Increase와 Quantity Changed가 어떤 것이 멱등할 까요?
전자는 여러번이 증가가 될 수 있습니다. 후자는 값을 가지고 있기 때문에 값이 멱등하죠.
메시지 순서 보장에 대해서 이야기 해봅시다. 사실 이벤트 소싱에서는 모든 이벤트가 순서가 보장되어야 하는 것은 아닙니다. 이벤트 스트림 내에서만 보장이 되면 되는데요. 사용자 2명에 대한 이벤트가 발생하고 있다면, 한 사용자 내에서만 이벤트 순서가 보장되면 됩니다. Kafka가 이를 제공하죠. (MS Azure : Partition).
이벤트 스트림은 하나의 도메인 객체에 대해서 존재하는데요. 이 때, 도메인 식별자를 파티션 키로 사용하면 하나의 도메인 객체에서 발생한 모든 이벤트는 하나의 파티션으로 가게 될겁니다. 파티셔닝을 지원하는 메시지 브로커는 한 파티션 → 한 컨슈머만 허용을 합니다. 그래서 하나의 컨슈머에 대해서는 순서가 보장이 되는거죠.
명령 메시지를 직렬화 할 때 type을 넣어주는 것이 중요합니다. 왜냐하면 이 type이 동사를 나타내기 때문이죠.
메시지 Payload로만은 이벤트 구별이 되지 않습니다.