[도메인 주도 설계 핵심] 6장 요약

도메인 이벤트는 바운디드 컨텍스트 내의 비즈니스 관점에서 중요한 사항들에 대한 기록들이다. 

즉, 도메인 이벤트가 전략적 설계를 위해 매우 중요한 도구이다.

 

비즈니스 도메인은 인과관계가 있는 오퍼레이션(한 오퍼레이션이 다른 것의 원인이 되는)이 분산된 시스템의 동일한 요청 내에 존재하는 모든 의존적인 노드들에게 보여지는 경우, 의존 관계 일관성을 제공한다. 인과 관계에 있는 오퍼레이션은 반드시 특정한 요청으로 인해 발생하기 때문에 그 특정한 요청이 발생되지 않으면 인과관계에 있는 오퍼레이션은 발생할 수 없다. 

즉, 특정한 오퍼레이션이 다른 애그리게잇에서 명확하게 발생하기 전에는 한 애그리게잇이 생성되거나 수정될 수 없다는 뜻이다.

 

전술적 설계 노력을 통해 도메인 이벤트가 도메인 모델에 구체화되고, 도메인 이벤트가 만들어지면 바운디드 컨텍스트와 다른 자원들은 이벤트를 받아 활용한다. 이는 중요한 이벤트에 관심이 있는 이벤트 리스너들에게 관련 상황의 발생을 알리는 방법이다. 


도메인 이벤트를 설계, 구현, 사용하기

 

 

이 코드는 모든 도메인 이벤트가 지원해야 하는 최소한의 인터페이스만을 고려한 것이다. 일반적으로 도메인 이벤트가 발생할 때, 그 날짜와 시각을 전달하기를 원하는데 이를 위해 `OccuredOn` 프로퍼티를 제공한다.

이러한 세부사항이 꼭 필요한 것은 아니지만 유용하게 쓸 수 있는 상황이 자주 있으며 일반적인 도메인 이벤트 형태는 이 인터페이스를 구현하게 될 가능성이 높다.

 

 

 

 

 

 

 

 

도메인 이벤트명을 지을 때, 단어들은 도메인 모델의 보편 언어를 반영해야 한다. 이 단어들은 도메인 모델 안에서 발생하는 사건과 모델 밖을 이어주는 다리를 형성하게 된다. 

 

도메인 이벤트 타입을 나타내는 이름은 과거에 발생한 것을 서술하는데, 이는 과거형 동사로 표현할 수 있다. 

`ProductCreated`는 어떤 과거 시점에 스크럼 제품이 생성되었음을 나타내며, `ReleaseScheduled`, `SprintScheduled`, `BacklogItemPlanned`, `BacklogItemCommitted`등의 도메인 이벤트들은 핵심 도메인에서 발생한 사건들을 명확하고 간결하게 서술하고 있다.

 

도메인 모델에서 발생한 사건의 기록을 온전히 전달하려면 도메인 이벤트 이름과 프로퍼티가 모두 필요하다. 그렇다면 도메인 이벤트는 어떤 프로퍼티를 담고 있어야 할까?

 

이 질문에 답하기 이전에, 애플리케이션에서 어떤 것들이 도메인 이벤트를 발생시키는지를 알아야 한다.

`ProductCreated`는 명령을 통해 이벤트를 발생시키며, 명령은 메서드나 액션을 요청하는 객체를 의미한다.

여기서의 명령은 `CreateProduct`이며, 결국 `ProductCreated`는 `CreateProduct` 명령의 결과라고 할 수 있다

 

`CreateProduct` 명령은 여러 개의 프로퍼티를 가지고 있다. 

(1) 구독 테넌트를 식별하는 tenantId (2) 생성되는 고유한 Product를 식별하는 productId (3) Product name (4) Product description 이며, 각 프로퍼티는 Product 생성에 있어 필수적인 속성들이다.

 

 

`ProductCreated` 도메인 이벤트는 이벤트가 만들어진 시점에 명령이 제공하는 모든 프로퍼티들을 담고 있어야 한다.

이렇게 함으로써 모든 이벤트 구독자들에게 모델 안에서 발생한 일(Product가 생성되었다)을 정확히 알릴 수 있다.

테넌트는 tenantId, Product는 productId로 고유하게 식별하고, Product는 그에 할당된 name과 description을 갖는다.

 

이 다섯 가지 예시는 애자일 프로젝트 관리 컨텍스트가 발행하는 다양한 도메인 이벤트가 포함해야 하는 프로퍼티에 대한 예시이다.

예를 들어, `BacklogItem`이 `Sprint`에 할당되면 `BacklogItemCommitted` 도메인 이벤트가 만들어지고, 전달된다. 이 도메인 이벤트는 tenantId, 이벤트에 할당된 `BacklogItem`의 backlogItem, 이벤트에 할당된 `Sprint`의 sprintId를 포함한다.

 

도메인 이벤트에는 그 의미를 잃을 정도로 너무 많은 데이터를 가득 채우는 일이 없도록 해야 한다. 이는 도메인 이벤트에 대한 이해를 감소시킬 수 있기 때문이다. 

예를 들어, `BacklogItemCommitted` 도메인 이벤트에 `BacklogItem` 전체 상태를 담게 된다면, 추가적인 데이터로 인해 이 도메인 이벤트의 소비자가 이를 정확히 이해할 수 없게 된다. 

또는 ,`BacklogItemCommitted` 대신 `BacklogItem`의 전체 상태를 갖는 `BacklogItemUpdated` 도메인 이벤트를 사용한다고 생각해보자. 소비자가 `BacklogItem`에 실제로 어떤 일이 발생했는지 이해하기 위해서는 최신의 `BacklogItemUpdated`와 이전의 `BacklogItemUpdated`를 직접 비교해야 한다.


하나의 시나리오를 통해 도메인 이벤트 사용을 좀 더 깊게 알아보자

제품 책임자는 `Sprint`에 `BacklogItem`을 할당한다. 이 명령은 `BacklogItem`과 Sprint를 메모리에 생성시키며, 그 다음 명령은 `BacklogItem` 애그리게잇에서 실행된다. 그 결과로 `BacklogItemCommitted` 도메인 이벤트가 발생한다.

수정된 애그리게잇과 도메인 이벤트가 같은 트랜잭션에서 함께 저장될 필요가 있다. 

만일, ORM 도구를 사용한다면, 애그리게잇을 하나의 테이블에, 도메인 이벤트를 이벤트 레포지토리 테이블에 저장하고 난 후 트랜잭션을 설정할 수 있다. (만약 이벤트 소싱을 사용한다면 애그리게잇의 상태는 도메인 이벤트 자체로 온전히 표현할 수 있다.)

어느 쪽이든, 도메인 이벤트를 이벤트 레포지토리에 유지하는 것은 도메인 모델 간에 발생한 것에 대한 인과관계의 순서를 지속시켜준다.

 

도메인 이벤트가 이벤트 레포지토리에 한 번 저장되면, 이벤트에 관심 있는 어떤 대상에게든지 전달될 수 있다. 이는 바운디드 컨텍스트 내부일 수도 있고, 외부일 수도 있다.

 

다만, 도메인 이벤트를 인과관계의 순서에 따라 저장하는 것이 같은 요청 내 분산되어 있는 다른 노드들에 도달할 것을 보장하는 것은 아니다. 이에 따른 적절한 인과관계를 파악하는 것은 소비하는 바운디드 컨텍스트가 가져야 할 책임이다. 

분산된 시스템에서의 문제: 분산 시스템에서는 여러 노드(서버나 프로세스 등)에 걸쳐 이벤트가 전달되고 처리될 수 있다. 그러나 이벤트가 레포지토리에 저장되었다고 해서, 모든 노드가 그 이벤트를 발생한 순서대로 정확하게 전달받고 처리할 것이라고 보장할 수는 없다. 네트워크 지연, 장애, 동시성 문제 등으로 인해 이벤트가 순서대로 전달되지 않거나 빠진 이벤트가 생길 수 있기 때문이다.

적절한 인과관계 파악의 책임: 위와 같은 문제로 인해 "적절한 인과관계를 파악하는 것은 소비하는 바운디드 컨텍스트가 가져야 할 책임이다"라고 언급하는 것이다. 즉, 이벤트를 처리하는 바운디드 컨텍스트(이벤트의 소비자)는 해당 이벤트가 올바른 순서대로 처리되었는지, 그리고 필요한 이벤트가 모두 도착했는지를 스스로 확인하고 관리할 책임이 있다는 뜻이다. 예를 들어, 이벤트의 타임스탬프를 확인하거나 이벤트의 시퀀스 번호를 활용해 순서를 보장할 수 있다.

즉, 도메인 이벤트는 한 번 저장되면 다양한 대상에게 전달될 수 있지만, 분산된 시스템에서는 이벤트가 인과관계에 맞게 전달되고 처리되는 것이 보장되지 않기 때문에, 이를 적절하게 파악하고 처리하는 것은 이벤트 소비자의 몫이라는 의미다.

 

도메인 이벤트 자체가 인과관계를 나타내거나 시퀀스와 인과관계 식별자처럼 도메인 이벤트와 관계된 메타데이터 형태가 인과관계를 나타낼 수도 있다. 시퀀스나 인과관계 식별자는 도메인 이벤트가 발생했음을 나타내줄 것이다.

만약, 인과관계가 확인되지 않은 경우 이벤트 소비자들은 그 인과관계가 도달하기 전까지 새롭게 도달할 이벤트를 기다려야 한다. (어떤 경우에는 이후 메시지와 관계된 수행에 의해 특정 도메인이 필요 없어진 경우도 존재하며 이러한 경우 인과관계는 무시된다.)


누가 이벤트를 발생시키는지에 대해서도 확인할 필요가 있다.

보통은 이벤트를 발생시키는 사용자 인터페이스에 의해 발현되는 사용자 기반 명령인 경우가 많지만 때로는 다른 원인으로 도메인 이벤트가 발생될 수도 있다.

예를 들면, 시간 만료에 의해서 이벤트가 발생된 경우이다. 이 경우, 이벤트를 발생시키는 명령을 보내지는 않지만 시간의 마지막이라는 것이 명확한 사실이기 때문이다. 명령이 아닌 도메인 이벤트로 시간 만료를 모델링해야 한다.

 

시간의 만료는 일반적으로 부르는 별칭을 갖고 있으며, 이러한 별칭은 보편언어의 일부가 된다.

예를 들어 월 스트리트의 오후 4시는 단순히 오후 4시가 아닌 "장 종료"라는 의미를 갖게 된다. 특정한 시간대에 기반을 둔 도메인 이벤트는 그 나름대로의 이름을 갖고 있다.

 

명령은 공급과 자원(제품, 자금 등)의 가용성에 대한 사유나 비즈니스 수준의 기준 등이 부적합한 경우에는 거부할 수 있다는 점에서 도메인 이벤트와는 다르다. 즉, 명령은 거부할 수 있지만 도메인 이벤트는 실제 발생하는 것이고 논리적으로 부정할 수 없다.

하지만 시간 기반의 도메인 이벤트에 대한 응답에 대해 애플리케이션이 일련의 액션을 수행하도록 요청한다면 이를 수행하기 위해 1개 이상의 명령을 생성해야 할 수도 있다.

명령(Command)과 도메인 이벤트(Domain Event)의 차이점
명령
은 비즈니스 액션을 요청하는 것이다. 예를 들어, "주문을 생성해라"는 명령은 시스템이나 도메인에서 특정 작업을 수행하도록 요청하는 것이다. 그러나 이 요청은 거부될 수 있다. 가용 자원이 부족하거나 비즈니스 규칙에 맞지 않는 경우, 명령은 실행되지 않을 수 있다. 명령의 결과로 도메인 이벤트가 생성될 수 있다.
도메인 이벤트는 이미 발생한 사실을 나타낸다. 예를 들어, "주문이 생성되었다"라는 도메인 이벤트는 이미 일어난 일을 기록하는 것이다. 이것은 과거의 사실을 반영하기 때문에 논리적으로 부정할 수 없다. 한 번 발생한 이벤트는 되돌릴 수 없고, 거부할 수 없다.
시간 기반 도메인 이벤트와 명령 생성
"시간 기반의 도메인 이벤트에 대한 응답"은, 예를 들어 특정 시간이 지나면 일어나는 이벤트(예: "7일 동안 결제가 이루어지지 않았다"는 이벤트)가 발생할 때를 의미한다. 이런 이벤트가 발생하면 그에 대응하여 시스템이 어떤 액션을 취해야 할 수 있다. 이때, 해당 액션을 수행하기 위해 명령을 생성해야 한다는 내용이다.
예를 들어, "7일 동안 결제가 이루어지지 않았다"는 도메인 이벤트가 발생했을 때, 시스템은 "이 주문을 취소해라"라는 명령을 생성할 수 있다. 이 명령은 시스템이나 서비스에게 특정 작업을 수행하도록 요청하는 것이므로, 실제로 액션을 취하는 것은 명령을 통해 이루어지는 것이다.

 

명령이 실패하는 경우는 시스템에서 자연스럽게 발생할 수 있는 상황이다.
명령이 실패했을 때의 처리 방식은 다양한 전략에 따라 달라질 수 있으며, 주로 다음과 같은 접근 방식이 있다.

1. 명령이 실패하면 도메인 이벤트는 발생하지 않게 하는 경우
일반적으로 명령(Command)이 실패하면 그에 따른 도메인 이벤트(Domain Event)는 발생하지 않는다.
예를 들어, "주문을 생성해라"라는 명령이 실패했다면 "주문이 생성되었다"라는 도메인 이벤트는 당연히 발생하지 않는다.
실패한 명령에 대해서는 그 결과를 기록하거나, 클라이언트에게 에러를 반환할 수 있다. 이 경우, 명령이 실패한 원인을 분석하고, 적절한 오류 처리(예: 예외 처리, 재시도, 사용자에게 알림)를 진행한다.

명령이 실패했을 때 시스템이 어떻게 반응할지는 여러 가지 방법으로 설계할 수 있다.

즉시 오류 반환: 명령을 요청한 클라이언트에게 즉시 오류를 반환하고, 추가적인 작업은 하지 않습니다. 이 경우 클라이언트는 에러 메시지를 받고 다시 시도하거나 문제를 수정해야 합니다.예시: "주문을 생성해라" 명령이 재고 부족으로 실패한 경우, 클라이언트는 "재고가 부족합니다"라는 메시지를 받고 다시 시도해야 합니다.재시도 로직: 일시적인 오류(예: 네트워크 문제, 외부 시스템의 일시적 장애 등)인 경우, 자동으로 명령을 재시도하는 전략을 사용할 수 있습니다. 이를 위해 재시도 횟수를 제한하거나 일정 시간 후에 재시도하는 등의 로직이 필요합니다.예시: 결제 서버가 일시적으로 다운된 경우, 몇 분 후에 다시 명령을 재시도할 수 있습니다.보상 트랜잭션(Compensating Transaction): 일부 도메인에서는 명령이 실패했을 때, 시스템의 상태를 복원하거나 이전 상태로 되돌리는 작업을 해야 할 수 있습니다. 이러한 복구 작업을 보상 트랜잭션이라고 부릅니다.예시: 결제가 성공했지만 이후 주문 생성에서 오류가 발생한 경우, 결제를 취소하고 원래 상태로 되돌리는 보상 트랜잭션을 실행할 수 있습니다.
이벤트 저장소(Event Sourcing) 및 비관적 잠금(Pessimistic Locking): 시스템이 이벤트 소싱(Event Sourcing) 패턴을 사용할 경우, 명령이 실패해도 이벤트는 이미 저장되어 있을 수 있습니다. 하지만 그에 따른 상태 변경이 발생하지 않기 때문에 시스템에서 그 실패를 처리하는 방식이 중요합니다. 또한 비관적 잠금 기법을 통해 명령을 처리하는 동안 충돌을 방지할 수도 있다.

2. 명령 실패 시 도메인 이벤트 발생하게 하는 경우
일반적으로 명령이 실패하면 도메인 이벤트가 발생하지 않지만, 명령 실패 자체를 나타내는 이벤트를 발행할 수도 있다. 이런 경우는 시스템 모니터링이나 알림을 목적으로 사용할 수 있다. 예를 들어, "주문 생성 실패"라는 도메인 이벤트를 발행해 시스템 내에서 다른 서비스가 그 실패를 인식하고 추가적인 대응을 하게 할 수 있다.
CQRS 및 이벤트 소싱 환경에서의 처리
CQRS(Command Query Responsibility Segregation) 및 이벤트 소싱을 사용할 경우, 명령이 실패하더라도 이벤트 로그에는 기록이 남을 수 있다. 하지만 그 실패한 명령에 따라 실제 상태 변화는 발생하지 않다. 이 경우, 이벤트 스트림에 실패한 기록이 남기 때문에 이를 활용해 시스템 모니터링이나 장애 분석을 할 수 있다.

 


이벤트 소싱

이벤트 소싱은 애그리게잇 인스턴스에 대해 변경된 것에 대한 기록으로, 발생했던 모든 도메인 이벤트를 저장하는 것을 말한다. 

즉, 데이터베이스에 상태를 저장하는 대신 발생한 도메인 이벤트를 순차적으로 저장하여 나중에 이 이벤트들을 재적용하는 방식으로 상태를 복구하는 패턴이다.

 

하나의 애그리게잇 인스턴스에 발생했던 모든 도메인 이벤트를 발생한 순서대로 이벤트 스트림에 구성한다. 이벤트 스트림은 애그리게잇에 가장 처음 발생했던 도메인 이벤트로 시작해서 마지막 도메인 이벤트까지 계속된다. 애그리게잇 인스턴스에 새로운 도메인 이벤트가 발생하면, 이벤트 스트림의 마지막에 추가한다. 이 과정과 반대로 애그리게잇에 이벤트 스트림을 재적용하면 저장된 정보가 메모리로 환원된다.

➡️ 이벤트 소싱을 사용하면 어떤 사유로 인해 메모리에서 삭제되었던 애그리게잇들을 이벤트 스트림을 통해 온전히 환원시킬 수 있다.

 

1. 애그리게잇 인스턴스에 발생한 모든 이벤트를 기록
애그리게잇(도메인 모델의 주요 구성 요소)의 상태 변화를 그때그때 저장하지 않고 그 변화의 원인인 도메인 이벤트들을 시간 순서대로 기록한다. 
예를 들어 애그리게잇이 생성, 수정, 삭제되는 모든 이벤트가 기록된다.

2. 이벤트 스트림
하나의 애그리게잇 인스턴스에서 발생한 이벤트들은 시간 순서대로 차곡차곡 쌓인다. 이 기록된 이벤트들의 순서를 이벤트 스트림이라고 부르며, 애그리게잇에 첫 번째로 발생한 이벤트부터 마지막 이벤트까지 저장된 기록이다.

3. 새로운 도메인 이벤트 추가
애그리게잇에 새로운 변화가 생기면 해당 변화에 대한 새로운 이벤트가 생성되고, 이 이벤트가 기존 이벤트 스트림의 마지막에 추가된다.

4. 메모리로 환원
만약 애그리게잇의 인스턴스가 메모리에서 삭제되거나 사라지면, 기록된 이벤트 스트림을 통해 해당 애그리게잇의 상태를 다시 복원할 수 있다. 즉, 처음부터 저장된 이벤트를 차례대로 다시 적용함으로써 애그리게잇의 현재 상태를 재구성할 수 있다는 뜻이다.

이를 통해 데이터의 과거 기록을 모두 보존하게 되어 언제든지 과거의 상태를 다시 살펴볼 수 있다. 
만약 DB나 메모리에서 애그리게잇 인스턴스가 삭제되더라도 이벤트 스트림만 있으면 원래의 상태로 복구할 수 있다. 이로써 데이터 복구가 가능해지고 메모리 절약, 스냅샷 같은 기술로 성능 최적화가 가능해진다.

 

이벤트 레포지토리는 모든 도메인 이벤트를 추가하는 순차적인 레포지토리 컬렉션 또는 테이블을 말한다. 

이벤트 스토어는 추가만 가능하며, 이런 특성으로 인해 레포지토리 메커니즘은 매우 빠르게 동작한다. 그래서 매우 높은 처리량, 낮은 대기 시간, 높은 확장성을 위해 이벤트 소싱을 사용하는 핵심 도메인을 만들 수 있다.

 

이벤트 레포지토리
이벤트 레포지토리는 모든 도메인 이벤트를 기록하는 저장소이다. 이 저장소는 도메인 모델에서 발생한 모든 이벤트를 시간 순서대로 저장하는 컬렉션(혹은 테이블) 역할을 한다.
모든 도메인 이벤트는 순차적으로 추가되며, 이벤트는 수정되거나 삭제되지 않으므로, 발생한 이벤트는 영구히 저장되며, 추가만 가능하다는 특성을 가진다

이벤트 스토어
이벤트 스토어는 실제 이벤트를 저장하는 메커니즘을 의미한다.
이벤트 레포지토리에서 이벤트를 관리하는 데 사용되며, 이 스토어는 이벤트를 기록할 때 추가만 가능(append-only)하므로, 데이터를 수정하거나 삭제할 필요가 없어 매우 단순한 동작 방식으로 효율적으로 운영될 수 있다.

추가만 가능한 구조의 이점
추가만 가능한 구조는 일반적인 데이터베이스와 달리 데이터의 수정이나 삭제 작업이 일어나지 않기 때문에, 데이터 저장 메커니즘이 매우 효율적으로 동작한다.
이 구조는 높은 처리량(Throughput)낮은 대기 시간(Latency)을 제공한다. 왜냐하면 데이터를 단순히 추가하는 작업만 처리하면 되기 때문에 데이터 저장 과정이 빠르게 이루어질 수 있기 때문이다.
또한, 이러한 구조는 높은 확장성(Scalability)을 가진다. 수평적으로 시스템을 확장하는 데 적합하며, 많은 양의 데이터를 빠르게 저장하고 조회할 수 있다.

도메인 이벤트와 높은 성능
이벤트 소싱을 사용하는 도메인은 도메인 모델의 상태를 저장할 필요 없이, 단지 그 상태 변화의 이벤트만을 기록하기 때문에,
상태 자체를 관리하는 오버헤드가 줄어든다.

 

이벤트 소싱을 통해 얻을 수 있는 가장 큰 이점은 핵심 도메인에서 계속 발생하는 모든 기록을 개별적인 발생 수준으로 저장한다는 점이다.