본문 바로가기

Design/DDD

[DDD 첫걸음] 2-3. 전술적 설계 - 시간 차원의 모델링.

도메인 모델 패턴과 동일한 전제를 기반으로 한다.

* 복잡한 비즈니스 로직을 갖는 핵심 하위 도메인에 적용.

* 밸류 오브젝트, 애그리게이트, 도메인 이벤트 (도메인 모델과 동일한 전술적 패턴)를 사용.

 

이벤트 소싱 도메인 모델과 도메인 모델과의 차이점

  • 애그리게이트의 상태를 저장하는 방식이 다름.
  • 이벤트 소싱 패턴을 사용하여 애그리게이트 상태를 관리
  • 애그리게이트 상태를 유지하는 대신 모델은 각 변경사항을 설명하는 도메인 이벤트를 생성하고 애그리게이트 데이터에 대한 원천 데이터로 사용.
  • 이번 편에서는 이벤트 소싱을 도메인 모델 패턴과 결합하여 이벤트 소싱 도메인 모델로 만드는 방법을 알아본다.

이벤트 소싱

  • 이벤트 소싱 패턴은 데이터 모델에 시간 차원을 도입한다.
  • 애그리게이트의 현재 상태를 반영하는 스키마 대신 이벤트 소싱 기반 시스템은 애그리게이트의 수명주기의 모든 변경사항을 문서화하는 이벤트를 유지.
{
   "lead-id": 12,
   "event-id": 0,
   "event-type": "lead-initialized",
   "first-name": "Casey",
   "last-name": "David",
   "phone-number": "555-2951",
   "timestamp": "2020-05-20T09:52:55.95Z"
},
{
   "lead-id": 12,
   "event-id": 1,
   "event-type": "contacted",
   "timestamp": "2020-05-20T12:32:08.24Z"
},
{
   "lead-id": 12,
   "event-id": 2,
   "event-type": "followup-set",
   "followup-on": "2020-05-27T12:00:00.00Z",
   "timestamp": "2020-05-20T12:32:08.24Z"
},
{
   "lead-id": 12,
   "event-id": 3,
   "event-type": "contact-details-updated",
   "first-name": "Casey",
   "last-name": "David",
   "phone-number": "555-8101",
   "timestamp": "2020-05-20T12:32:08.24Z"
},
{
   "lead-id": 12,
   "event-id": 4,
   "event-type": "contacted",
   "timestamp": "2020-05-27T12:02:12.51Z"
},
{
   "lead-id": 12,
   "event-id": 5,
   "event-type": "order-submitted",
   "payment-deadline": "2020-05-30T12:02:12.51Z",
   "timestamp": "2020-05-27T12:02:12.51Z"
},
{
   "lead-id": 12,
   "event-id": 6,
   "event-type": "payment-confirmed",
   "status": "converted",
   "timestamp": "2020-05-27T12:38:44.12Z"
}
  • 위 이벤트는 고객의 이야기 흐름을 읽을 수 있다.
  • 고객의 상태는 이러한 도메인 이벤트로부터 쉽게 프로젝션(이력 형태의 데이터를 원하는 시점의 데이터로 추출하는 기법)할 수 있음.
    (간단한 변환 로직을 각 이벤트에 순차적으로 적용하면됨.)
public class LeadSearchModelProjection
{
    public long LeadId {get; private set;}
    public HashSet<string> FirstNames {get; private set;}
    public HashSet<string> LastNames {get; private set;}
    public HashSet<PhoneNumber> PhoneNumbers {get; private set;}
    public int Version {get; private set;}
    
    public void Apply(LeadInitialized @event)
    {
    	LeadId = @event.LeadId;
        FirstNames = new HashSet<string>();
        LastNames = new HashSet<string>();
        PhoneNumbers = new HashSet<PhoneNumber>();
        FirstNames.Add(@event.FirstName);
        LastNames.Add(@event.LastName);
        PhoneNumbers.Add(@event.PhoneNumber);
        Version = 0;
    }
    
    public void Apply(ContactDetailsChanged @event)
    {
    	FirstNames.Add(@event.FirstName);
        LastNames.Add(@event.LastName);
        PhoneNumbers.Add(@event.PhoneNumber);
        Version += 1;
    }
    
    public void Apply(Contacted @event)
    {
    	Version += 1;
    }
	
    public void Apply(FollowupSet @event)
    {
    	Version += 1;
    }
    
    public void Apply(OrderSubmitted @event)
    {
    	Version += 1;
    }
    
    public void Apply(PaymentConfirmed @event)
    {
    	Version += 1;
    }
}
  • 각 이벤트 적용한 후 버전 필드가 증가함을 알 수 있음. 이는 모든 변경의 횟수를 나타냄.
  • 버전5 지점의 엔티티 상태가 필요한 경우, 처음 5개 이벤트만 적용하면 됨.

검색

  • 검색 기능을 구현해야 한다고 가정.
  • 이벤트 소싱을 사용하면 과거정보를 쉽게 프로젝션 할 수 있음.
  • 위 프로젝션 로직을 사용하여 Casey Davis 이벤트 적용하면 아래와 같은 상태가 됨.
LeadId: 12
FirstNames: ['Casey']
LastNames: ['David', 'Davis']
PhoneNumbers: ['555-2951', '555-8101']
Version: 6

 

분석 

  • 분석이 필요한 데이터를 프로젝션 로직에 포함시킨다.
  • 이때 분석 최적화 모델을 데이터베이스에 유지(materialize)해야하는데 이는 CQRS(명령과 조회의 책임 분리)패턴을 통해 구현할 수 있다.

원천 데이터

  • 이벤트 소싱 패턴이 작동하려면 객체 상태에 대한 모든 변경사항이 이벤트로 표현되고 저장되어야함.
    (이벤트는 시스템의 원천데이터가 됨)
  • 이벤트 스토어에 이벤트를 저장.

이벤트 스토어

  • 이벤트 스토어는 추가만 가능한 저장소이므로 이벤트를 수정하거나 삭제할 수 없음.
  • 이벤트 소싱 패턴을 구현하려면 이벤트 스토어가 엔티티에 속한 모든 이벤트를 가져오고, 이벤트를 추가하는 기능을 지원해야함.
interface IEventStore
{
    IEnumerable<Event> Fetch(Guid instanceId);
    void Append(Guid instanceId, Event[] newEvents, int expectedVersion);
}
/**
* - Append 메서드의 expectedVersion은 낙관적 동시성 제어를 구현하는데 필요.
**/
  • 본질적으로 이벤트 소싱 패턴은 새로운 것이 아니고, 원장 관리에 사용되는 로직과 유사하다.
    (비트코인에서 잔액 프로젝션을 생각해보자!)

이벤트 소싱 도메인 모델

  • 이벤트 소싱 도메인 모델은 애그리게이트의 수명주기를 모델링하기 위해 독점적으로 도메인 이벤트를 사용함.
  • 애그리게이트 상태에 대한 모든 변경사항은 도메인 이벤트로 표현되어야 한다.
  • 이벤트 소싱 애그리게이트에 대한 각 작업은 다음 단계를 따름.(책에서 소스코드도 참고해보기.)
    • 애그리게이트의 도메인 이벤트를 로드.
    • 이벤트를 비즈니스 의사결정을 내리는데 사용할 수 있는 상태로 프로젝션해서 상태 표현을 재구성.
    • 애그리게이트의 명령을 실행하여 비즈니스 로직을 실행하고 결과적으로 새로운 도메인 이벤트를 생성.
    • 새 도메인 이벤트를 이벤트 스토어에 커밋.

왜 '이벤트 소싱 도메인 모델'일까?

  • 이벤트를 사용하여 상태 전환(이벤트 소싱 패턴)을 나타내는 것은 도메인 모델의 구성요소가 있든 없든 가능.
  • 도메인 모델 애그리게이트의 수명주기 변경을 나타내기 위해 이벤트 소싱을 사용하고 있음을 명시적으로 보여주는 방법이 이벤트 소싱 도메인 모델임.

장점

  • 기존 모델은 애그리게이트의 현재상태만 DB에 유지하지만 이벤트 소싱 도메인 모델은 모델링에 더 많은 노력이 필요.
  • 시간여행
    • 도메인 이벤트를 사용하여 애그리게이트의 현재 상태를 재구성할 수 있는 것 처럼, 모든 과거 상태를 복원하는 데도 사용할 수 있음.(과거 상태를 필요할 때, 재구성가능.)
    • 시간여행은 시스템의 동작을 분석하고, 시스템의 의사결정을 검사하고 비즈니스 로직을 최적화할 때 종종 필요.
    • 소급 디버깅을 통해 애그리게이트를 버그가 관찰되었을 때의 상태로 되돌릴 수 있음.
  • 심오한 통찰력
    • 시스템의 상태와 동작에 대한 깊은 통찰력을 제공.
    • 이벤트 소싱은 이벤트를 다른 상태 표현 방식으로 변환할 수 있는 유연한 모델을 제공.
      기존 이벤트의 데이터를 활용하여 추가 통찰력을 제공할 새로운 프로젝션 방법을 언제든지 추가할 수 있음.
  • 감사 로그
    • 영속적인 도메인 이벤트는 애그리게이트 상태에 발생한 모든 것에 대한 강력하게 일관된 감사로그(audit log)를 나타낸다.
    • 이 모델은 화폐 또는 금전 거래를 관리하는 시스템에 잘 이용됨. (의사결정과 계정간의 자금 흐름을 쉽게 추적가능)
  • 고급 낙관적 동시성 제어
    • 고급 낙관적 동시성 모델은 읽기 데이터가 기록되는 동안 다른 프로세스에 의해 덮어 쓰여지는 경우 예외를 발생시킴.
    • 이벤트 스토어에 동시에 추가된 정확한 이벤트를 추출하고 새로운 이벤트가 시도된 작업과 충돌하는지, 또는 추가된 이벤트가 관련이 없고 계속 진행하는 것이 안전한지에 대해 비즈니스 도메인 주도 의사결정을 내릴 수 있음.

단점

  • 학습 곡선
    • 팀이 지금까지 이벤트 소싱 시스템을 구현한 경험이 없다면 팀 교육과 새로운 사고 방식에 익숙해지는 시간이 필요.
  • 모델의 진화
    • 이벤트 소싱의 정의를 엄밀하게 따지면 이벤트는 변경할 수 없음.
    • 만약 이벤트의 스키마를 조정해야 하는 경우는 테이블의 스키마를 변경하는 것만큼 간단하지 않음.
      (관련 서적 - Versioning in an Event Sourced System - Greg Young)
  • 아키텍처 복잡성
    • 이벤트 소싱 구현에는 수많은 아키텍처의 '유동적인 부분'이 도입되어 전체 설계가 더 복잡해짐. (CQRS 아키텍처 참고)

자주 묻는 질문?

성능

  1. 이벤트에서 애그리게이트 상태를 재구성하면 시스템 성능에 부정적인 영향을 준다. 이벤트가 추가되면서 성능이 저하된다. 어떻게 해결할 수 있을까?
  • 실제 이벤트를 상태 표현 방식으로 프로젝션하는데에 컴퓨팅 성능이 필요하며, 대부분 시스템에서 10,000개 이상의 이벤트가 있을 경우 성능 저하가 눈에 띄게 나타난다.
  • 하지만 대다수의 시스템에서 애그리게이트의 평균 수명은 100개 이벤트를 초과하지 않는다.
  • 따라서 성능에 문제가 되는 경우는 드물지만, 스냅숏 패턴 같은 다른 패턴을 적용할 수도 있음.

[도메인 주도 설계 첫걸음] 스냅숏 패턴

  • 프로세스는 이벤트 스토어에서 새 이벤트를 지속적으로 순회하고 해당 프로젝션을 생성하고 캐시에 저장.
  • 애그리게이트에 대한 작업을 실행하려면 메모리 내 프로젝션이 필요. 이때 
    • 프로세스는 캐시에서 현재 상태의 프로젝션을 가져온다.
    • 프로세스는 이벤트 스토어에서 스냅숏 버전 이후에 발생한 이벤트를 가져온다.
    • 추가 이벤트는 메모리 내 스냅숏에 적용된다.
  • 참고로 스냅숏 패턴은 적용에 대한 당위성 증명이 필요한 최적화 과정이라는 점.
  • 10,000개 이상의 이벤트를 저장하지 않는 경우 스냅숏 패턴을 구현하는 것은 시스템을 복잡하게 만들 뿐.

데이터 삭제

  • 이벤트 스토어는 추가 전용 데이터베이스지만 물리적으로 데이터를 삭제해야 하는 경우에는 어떻게 할까?
    (예 - GDPR 준수를 위해 물리적 데이터 삭제가 필요한 경우)
    • 이와 같은 경우는 잊어버릴 수 있는 페이로드 패턴으로 해결가능 (Forgettable payload pattern).
    • 모든 민감 정보는 암호화된 형식으로 이벤트에 포함하고, 암호화 키는 외부 키-값 저장소에 저장.
    • 여기서 키는 특정 애그리게이트의 ID이고 값은 암호화 키다.
    • 민감 데이터를 삭제해야 하는 경우 키 저장소에서 암호화 키를 삭제한다. (이벤트에 포함된 민감 정보 접근 불가)

그외...

  • 텍스트 파일에 로그를 작성하여 감사로그로 사용할 수 없는 이유는?
    • 실시간 데이터 처리 DB와 로그파일 모두에 데이터를 쓰는 경우, 결국 두 가지 저장 장치에 대한 트랜잭션이 됨.
    • 첫 번째 작업이 실패하면, 두 번째 작업을 롤백해야하는데 만약 DB 트랜잭션이 실패하면 아무도  이전 로그메시지를 삭제하지 않는다. 결국 로그는 일관성 없어짐.
  • 상태 기반 모델을 계속 사용할 수 없지만 동일하 데이터베이스 트랜잭션에서  로그를 로그 테이블에 추가할 수 없는 이유는?
    • 인프라 관점에서 이 접근 방식은 상태와 로그 레코드 간의 일관된 동기화를 제공
    • 그러나 미래에 코드베이스에서 작업할 엔지니어가 적절한 로그 레코드를 추가하는 것을 잊어버린다면?
    • 또한 상태 기반 표현 방식을 원천 데이터로 사용할 때 추가 로그 테이블의 스키마는 일반적으로 빠르게 혼돈에 빠짐
      (모든 필수 정보가 올바른 형식으로 작성되도록 강제할 방법은 없으므로)
  • 상태 기반 모델을 계속 사용할 수 없지만 레코드의 스냅숏을 만들어 전용 '이력' 테이블에 복사하는 데이터베이스 트리거를 추가할 수 없는 이유는?
    • 이 방식은 로그 테이블에 레코드를 추가하기 위해 명시적으로 수동 호출이 필요하지 않으므로 이전 방식의 단점을 극복함.
      (어떤 필드가 변경되었는지에 대한 사실만 포함)
    • 하지만 어떤 필드가 왜 변경되었는지와 같은 비즈니스 컨텍스트를 잃게 됨. 
      (이 같은 변경에 대한 이력 정보가 없으면 부가적인 모델을 프로젝션하는 역량이 상당히 제한됨.)