본문 바로가기

Design/DDD

[DDD 첫걸음] 2-5. 전술적 설계 - 커뮤니케이션 패턴

  • 이번 장에서는 단일 컴포넌트의 경계를 넘어 시스템 요소 전반의 커뮤니케이션 흐름을 구성하는 패턴을 알아본다.
  • 바운디드 컨텍스트 간 커뮤니케이션을 용이하게 하고,
  • 애그리게이트 설계 원칙에 의해 부과된 제한 사항을 해결하며,
  • 여러 시스템 컴포넌트에 걸쳐 비즈니스 프로세스를 조율하는 방법을 알아본다.

모델 변환

  • 바운디드 컨텍스트는 유비쿼터스 언어 모델의 경계이다.
  • 서로 다른 바운디드 컨텍스트 사이에 커뮤니케이션 하기 위한 다양한 설계 패턴이 있음.
    • 각기 다른 바운디드 컨텍스트를 구현하는 두 팀이 효과적으로 의사소통하고 협력할 의향이 있을 경우
      • 파트너십을 통해 통합할 수 있음. 프로토콜은 임시방편식으로 조정될 수 있고 모든 통합 문제는 사실상 팀 간의 커뮤니케이션을 통해 해결할 수 있다.
      • 공유 커널은 다른 협력 기반 통합 방식. 팀은 모델의 제한된 부분을 분리해서 공동으로 함께 발전시킴.
        - 예) 바운디드 컨텍스트의 연동 컨트랙트를 공동 소유의 저장소로 분리할 수 있음.
    • 다운스트림 바운디드 컨택스트가 업스트림 바운디드 컨텍스트 모델을 따를 수 없는 경우. (모델 변환 로직 적용)
      • 다운스트림이 맞춰줄 때 : ACL(충돌 방지 계층)을 사용하여 업스트림 바운디드 컨텍스트의 모델을 필요에 맞게 조정할 수 있음.
      • 업스트림이 맞춰줄 때 : OHS(오픈 호스트 서비스) 역할을 하고 연동 관련 공표된 언어(public)를 사용하여 구현 모델에 대한 변경으로부터 사용자를 보호할 수 있음.
    • 모델 변환 로직은 스테이트리스 변환(Stateless translation) 혹은 스테이트풀 변환(Stateful translation)이 있음.
      • 스테이트 리스 변환 : 수신(OHS) 혹은 발신(ACL) 요청이 발행할 때 즉석에서 발생.
      • 스테이트 풀 변환 : 상태 보존을 위해 데이터베이스를 사용하여 좀 더 복잡한 변환 로직을 다룰 수 있음.

스테이트리스 모델 변환

  • 스테이트리스 모델 변환을 소유하는 바운디드 컨텍스트(ACL, OHS)는 프락시 디자인 패턴을 구현하여 수신과 발신 요청을 삽입하고 소스 모델을 바운디드 컨텍스트의 목표 모델에 매핑함.

[도메인 주도 설계 첫걸음] 프락시에 의한 모델 변환

  • 프락시는 동기식으로 통신하는지 또는 비동기식으로 통신하는지에 따라 다름.

동기

  • 동기식 모델 변환의 일반적인 방법은 바운디드 컨텍스트의 코드베이스에 변환 로직을 포함하는 것.
  • OHS에서는 유입되는 요청을 처리할 때, ACL에서는 업스트림 바운디드 컨텍스트를 호출할 때 발생.

[도메인 주도 설계 첫걸음] 동기 통신

  • 경우에 따라 변환 로직을 API 게이트웨이 패턴과 같은 외부 컴포넌트로 넘기는 것이 더 비용 효과적이고 편할 수 있음.
  • OHS 패턴을 구현하는 바운디드 컨텍스트의 경우 API 게이트웨이는 내부 모델을 통합에 최적화된 공표된 언어로 변환하는 역할을 한다.
  • 명시적 API 게이트웨이를 사용하면 아래 그림과 같이 바운디드 컨텍스트에 API의 여러 버전을 관리하고 제공하는 프로세스를 도울 수 있음.

[도메인 주도 설계 첫걸음] 공표된 언어의 다른 버전 노출

  • API 게이트웨이를 사용하여 구현된 ACL은 여러 다운스트림 바운디드 컨텍스트에서 사용할 수 있음.
  • 이 경우 ACL은 아래와 같이 통합 관련 바운디드 컨텍스트 역할을 한다.

[도메인 주도 설계 첫걸음] 공유 충돌 방지 계층

  • 이러한 바운디드 컨텍스트는 주로 다른 컴포넌트에서 좀 더 편리하게 사용할 수 있게 모델을 변환하는 역할을 하며, 종종 교환 컨텍스트(interchange context)라고도 부름.

비동기

  • 비동기 통신에 사용하는 모델을 변환하기 위해 메시지 프락시(message proxy)를 구현할 수 있음.
  • 메시지 프락시는 소스 바운디드 컨텍스트에서 오는 메시지를 구독하는 중개 컴포넌트이다.
  • 프락시는 필요한 모델 변환을 적용하고 결과 메시지를 대상 구독자에게 전달함.

[도메인 주도 설계 첫걸음] 비동기 통신에서 모델 변환

  • 메시지 모델을 변환하는 것 외에도 중개 컴포넌트는 관련 없는 메시지를 필터링하여 목표 바운디드 컨텍스트의 노이즈를 줄일 수 있다.
  • OHS를 구현할 때 비동기식 모델 변환은 반드시 필요.
    • 도메인 이벤트를 가로채서 공표된 언어로 변환할 수 있으므로 바운디드 컨텍스트의 구현상세를 더 잘 캡슐화 할 수 있음.
    • 메시지를 공표된 언어로 변환하면 바운디드 컨텍스트의 내부 요구사항을 위한 프라이빗 이벤트와 다른 바운디드 컨텍스트와 연동하기 위해 설계된 퍼블릭 이벤트를 구분할 수 있음.

[도메인 주도 설계 첫걸음] 공표된 언어로 된 도메인 이벤트

스테이트풀 모델 변환

  • 원천 데이터를 집계하거나 여러 개의 요청에서 들어오는 데이터를 단일 모델로 통합해야 하는 변환 메커니즘의 경우 사용.

들어오는 데이터 집계하기

  • 바운디드 컨텍스트가 들어오는 요청을 집계하고 성능 최적화를 위한 일괄처리가 필요할 경우
  • 이 경우, 아래와 같이 비동기 요청 모두에 대해 집계가 필요할 수 있음.

[도메인 주도 설계 첫걸음] 요청 일괄 처리

  • 아래는 여러 개의 세분화된 메시지를 단일 메시지로 결합하는 케이스.

[도메인 주도 설계 첫걸음] 들어오는 이벤트 통합

  • 유입되는 데이터를 집계하는 모델 변환의 경우, 들어오는 데이터를 추적하고 그에 따라 처리하려면 변환 로직에 자체 영구 저장소가 필요.
  • 일부는 스테이트풀 변환을 위한 상용제품을 사용함, 예로 스트림 처리 플랫폼(Kafka, AWS Kinesis), 또는 일괄 처리 솔루션 (Apache NiFi, AWS Glue, Spark 등)

[도메인 주도 설계 첫걸음] 스테이트풀 모델 변환

여러 요청 통합

  • 다른 바운디드 컨텍스트를 포함하여, 여러 요청에서 집계된 데이터를 처리해야할 경우가 있음.
  • 예) 사용자 인터페이스가 여러 서비스에서 발생하는 데이터를 결합해야 하는 프런트 엔드를 위한 백엔드 패턴(backend-for-frontend pattern)

[도메인 주도 설계 첫걸음] ACL패턴을 사용하여 통합 모델을 단순화

애그리게이트 연동

  • 이벤트 발행 프로세스에서 일어날 법한 몇 가지 일반적인 실수와 각 접근 방식의 결과를 먼저 살펴보자.
public class Campaign
{
   ...
   List<DomainEvent> _events;
   IMessageBus _messageBus;
   ...
   
   public void Deactivate(string reason)
   {
      for (l in _locations.Values())
      {
         l.Deactivate();
      }
      
      IsActive = false;
      
      //새 이벤트가 인스턴스화 됨.
      var newEvent = new CampaignDeactivated(_id, reason);
      
      //내부 목록에 새 이벤트가 추가됨.
      _events.Append(newEvent);
      
      //메시지 버스로 이벤트 발행.
      _messageBus.Publish(newEvent);
   }
}
  • 애그리게이트에서 바로 도메인 이벤트를 발행하는 위의 구현은 잘못되었다.
    • 애그리게이트의 새 상태가 DB에 커밋되기 전에 이벤트가 전달된다.
    • 만약 DB의 기술적인 문제로 트랜잭션이 커밋되지 않으면 DB트랜잭션은 롤백되었지만 이벤트는 발행되어버리고, 이벤트를 철회할 수 있는 방법이 없을 것.
public class ManagementAPI
{
   ...
   private readonly IMessageBus _messageBus;
   private readonly ICampaignRepository _repository;
   ...
   public ExecutionResult DeactivateCampaign(CampaignId id, string reason)
   {
      try
      {
         //1.campaign 애그리게이트를 로드
         //2.비활성화 command실행.
         //3.갱신상태 커밋.
         var campaign = repository.Load(id);
         campaign.Deactivate(reason);
         _repository.CommitChanges(campaign);
         
         //4.새 도메인 이벤트를 발행하기 시작 
         var events = campaign.GetUnpublishedEvents();
         for(IDomainEvent e in events)
         {
            _messageBus.publish(e);
         }
         campaign.ClearUnpublishedEvents();
      }
      catch(Exception ex)
      {
         ...
      }
   }
}
  • 위 예제에서는 새 도메인 이벤트를 발행할 책임을 애플리케이션 계층으로 이전함.
  • 위코드는 애그리게이트 상태를 업데이트 한 후, 도메인 이벤트를 발행.
  • 이 예제 또한 신뢰할 수 없다.
    • 서버가 DB 트랜잭션을 커밋한 직후 이벤트 발행에 실패하면 시스템은 일관성 없는 상태로 종료
    • 즉 DB 트랜잭션은 커밋되지만 도메인 이벤트는 발행되지 않는다.
  • 이러한 문제들은 아웃박스 패턴을 사용하여 해결할 수 있음.

아웃박스

[도메인 주도 설계 첫걸음] 아웃박스 패턴

아웃박스 패턴은 다음 알고리즘을 사용하여 도메인 이벤트의 안정적인 발행을 보장한다.

  • 업데이트된 애그리게이트의 상태와 새 도메인 이벤트는 모두 동일한 원자성 트랜잭션으로 커밋된다.
  • 메시지 릴레이는 데이터베이스에서 새로 커밋된 도메인 이벤트를 가져온다.
  • 릴레이는 도메인 이벤트를 메시지 버스에 발행.
  • 성공적으로 발행되면 릴레이는 이벤트를 데이터베이스에 발행한 것으로 표시하거나 완전히 삭제.
  • 관계형DB를 사용할 때는 애그리게이트 상태를 저장하는 테이블과 메시지 저장을 위한 전용 테이블을 각각 두는 것이 좋다.

[도메인 주도 설계 첫걸음] 아웃박스 테이블

발행되지 않은 이벤트 가져오기

발행 릴레이는 풀(Pull) 기반 또는 푸시(Push) 기반 방식으로 새 도메인 이벤트를 가져올 수 있다.

  • : 발행자 폴링.
    • 릴레이는 발행되지 않은 이벤트에 대해 데이터베이스를 지속해서 질의.
    • 지속적인 폴링으로 인한 DB부하를 줄이기 위해서는 적절한 인덱스 필요.
  • 푸시: 트랜잭션 로그 추적
    • DB의 기능을 활용하여 새 이벤트가 추가될 때마다 발행 릴레이를 호출.
    • AWS DynamoDB(NoSQL) Streams를 활용하면, DB의 커밋된 변경사항을 이벤트 스트림으로 노출할 수 있음.

아웃박스 패턴은 적어도 한 번은 메시지 배달을 보장한다는 점에 유의.

- 데이터베이스에 발행한 것으로 표시하기 전에 릴레이가 실패할 경우, 다음 이터레이션에서 같은 메시지가 발행됨.

 

사가

  • 핵심 애그리게이트 설계 원칙 중 하나는 각 트랜잭션을 애그리게이트의 단일 인스턴스로 제한하는 것.
  • 그러나 여러 애그리게이트에 걸쳐 있는 비즈니스 프로세스를 구현해야하는 경우가 있음.
  • 이러한 케이스는 사가(saga)를 통해 구현될 수 있음.
  • 사가는 관련 컴포넌트에서 발생하는 이벤트를 수신하고 다른 컴포넌트에 후속 커맨드를 실행한다.
  • 실행 단계 중 하나가 실패하면 사가는 그 시스템 상태를 일관되게 유지하도록 적절한 보상 조치를 내리는 일을 담당.

 

[도메인 주도 설계 첫걸음] 사가

  • 사가는 Campaign 애그리게이트로부터 CampaignActivated 이벤트를, AdPublishing 바운디드 컨텍스트로부터 PublishingConfirmed와 PublishingRejected 이벤트를 기다린다.
  • 사가는 AdPublishing에서 SubmitAdvertisement 커맨드를 실행하고 Campaign 애그리게이트에서 TrackPublishingConfirmation과 TrackPublishingRejection 커맨드를 실행해야 한다.
    • TrackPublishingRejection 커맨드는 광고 캠페인이 활성 상태가 되지 않도록 하는 보상조치를 실행하는 역할을 함.
  • 사가는 이벤트 소싱 애그리게이트로 구현되어 수신된 이벤트와 실행된 커맨드의 전체 기록을 유지할 수 있음.
  • 그러나 커맨드 로직은 도메인 이벤트가 아웃박스 패턴으로 전달하는 방식과 유사하게 사가 패턴 자체에서 벗어나 비동기적으로 실행.

일관성

  • 사가 패턴이 다중 컴포넌트의 트랜잭션을 조율하지만 관련된 컴포넌트의 상태는 궁극적 일관성을 갖는다.
    (즉, 원자적 트랜잭션이 아님)

 

애그리게이트 경계 내의 데이터만 강한 일관성을 가진다.
외부의 모든 것은 궁극적으로 일관성을 갖는다.

 

프로세스 관리자

  • 사가 패턴은 단순하고 선형적인 흐름을 관리.
  • 사가는 이벤트를 해당 커맨드와 일치시킨다.
    • CampaignActivated 이벤트와 PublishingService.SubmitAdvertisement 커맨드
    • PublishingConfirmed 이벤트와 Campaign.TrackConfirmation 커맨드
    • PublishingRejected 이벤트와 Campaign.TrackRejection 커맨드
  • 프로세스 관리자 패턴은 비즈니스 로직 기반 '프로세스'를 구현하기 위한 것이다.
  • 프로세스 관리자는 시퀀스의 상태를 유지하고 다음 처리 단계를 결정하는 중앙 처리 장치로 정의한다.

[도메인 주도 설계 첫걸음] 프로세스 관리자

  • 경험상 올바른 동작과정을 선택하는 if-else문이 포함되어있다면 그것은 프로세스 관리자 일 것.
  • 프로세스 관리자는 사가와는 달리 '여러 단계'로 구성된 응집된 비즈니스 프로세스이다.
  • 예시를 통해 알아본다.
    • 출장 예약 시작 > 효과적인 비행 경로 선택 > 직원에게 승인 요청 (> 지원이 다른 경로를 선호하는 경우, 직속관리자가 승인.) > 항공편을 예약 > 사전 승인된 호텔 중 하나를 적절한 날짜에 예약 (> 이용 가능한 호텔이 없다면 항공권 취소)

[도메인 주도 설계 첫걸음] 출장 예약 프로세스 관리자

public class BookingProcessManager
{
   private readonly IList<IDomainEvent> _events;
   private BookingId _id;
   private Destination _destination;
   private TripDefinition _parameters;
   private EmployeeId _traveler;
   private Route _route;
   private IList<Route> _rejectedRoutes;
   private IRoutingService _routing; ...
   
   public void Initialize(Destination destination, 
   						TripDefinition parameters, 
						EmployeeId travler)
   {
      _destination = destination;
      _parameters = parameters;
      _traveler = traveler;
      _route = _routing.Calculate(destination, parameters);
      
      var routeGenerated = new RouteGeneratedEvent(
         BookingId: _id, Route: _route);
      
      //선택된 비행경로로 직원에게 승인 요청.
      var commandIssuedEvent = new CommandIssuedEvent(
         command: new RequestEmployeeApproval(_traveler, _route)
      );
      _events.Append(routeGenerated);
      _events.Append(commandIssuedEvent);
   }
   
   public void Process(RouteConfirmed confirmed)
   {
      var commandIssuedEvent = new CommandIssuedEvent(
      		command: new BookFlights(_route, _parameters)
      );
      _events.Append(confirmed);
      _events.Append(commandIssuedEvent);
   }
   
   public void Process(RouteRejected rejected)
   {
      //직원이 다른 경로를 선호하는 경우.
      var commandIssuedEvent = new CommandIssuedEvent(
         command: new RequestRerouting(_traveler, _route)
      );
      _events.Append(rejected);
      _events.Append(commandIssuedEvent);
   }
   
   public void Process(ReroutingConfirmed confirmed)
   {
      //다른 루트를 생성하여 재 승인 요청.
      _rejectedRoutes.Append(route);
      _route = _routing.CalculateAltRoute(destination,
      		parameters, rejectedRoutes);
      var routedGenerated = new RouteGeneratedEvent(
      	BookingId: _id,
        Route: _route);
      var commandIssuedEvent = new CommandIssuedEvent(
         command: new RequestEmployeeApproval(_traveler, _route)
      );
      
      _events.Append(confirmed);
      _events.Append(routeGenerated);
      _events.Append(commandIssuedEvent);
   }
   
   public void Process(FlightBooked booked)
   {
      //항공편 예약이 된 후, 호텔을 예약하도록 이벤트 발행.
      var commandIssuedEvent = new CommandIssuedEvent(
         command: new BookHotel(_destination, _parameters)
      );
      _events.Append(booked);
      _events.Append(commandIssuedEvent);
   }
   
   ...
}