본문 바로가기

Design/DDD

[DDD 첫걸음] 2-2. 전술적 설계 - 복잡한 비즈니스 로직 다루기.

 

배경

  • 에릭 에반스는 자신의 책<도메인 주도 설계>에서  비즈니스 도메인의 하위 모델과 코드를 긴밀하게 연결 짓는 데 쓰이는 애그리게이트(aggregate), 밸류 오브젝트(value object), 레포지토리(repository) 등과 같은 패턴을 제시.
  • 이러한 패턴은 종종 전술적 도메인 주도 설계로 불림.
  • 이 패턴이 도메인 모델이고 애그리게이트밸류 오브젝트가 그 구성요소이다.

도메인 모델

  • 도메인 모델 패턴은 복잡한 비즈니스 로직을 다루기 위한 것.
  • 복잡한 상태 전환, 항상 보호해야 하는 규칙인 비즈니스 규칙불변성을 다룸.

구현

  • 도메인 모델은 행동(behavior)과 데이터(data) 모두를 포함하는 도메인의 객체 모델.
  • DDD의 전술 패턴인 애그리게이트, 밸류 오브젝트, 도메인 이벤트, 도메인 서비스는 모두 객체모델의 구성요소이다.
  • 위의 모든 패턴은 비즈니스 로직을 최우선으로 둔다는 공통 관심사가 있다.

복잡성

  • 도메인 비즈니스 로직은 이미 본질적으로 복잡하므로 모델링에 사용되는 객체가 모델에 조금이라도 우발적 복잡성을 추가하면 안 됨.
  • 모델에는 데이터베이스 또는 외부 시스템 구성요소의 호출 구현 같은 인프라 또는 기술적인 관심사를 피해야 함.
  • 이러한 제약을 따르면 모델의 객체는 Plain Old Object가 된다.

유비쿼터스 언어

  • 도메인 모델의 객체가 기술적 관심사가 아닌 비즈니스 로직에 집중하게 하면 코드에서 유비쿼터스 언어를 사용하게 하고 도메인 전문가의 멘탈 모델을 따르게 한다.

구성요소

밸류 오브젝트, 애그리게이트, 도메인 서비스와 같은 DDD에서 제공하는 도메인 모델의 구성요소와 전술적 패턴을 살펴본다.

밸류 오브젝트

  • 밸류 오브젝트는 Color(색)처럼 복합적인(composition) 값에 의해 식별되는 객체다.
  • 필드 중 하나의 값이 바뀌면 새로운 색이 탄생.
  • 색을 식별하기 위한 명시적인 식별 필드(ID)가 필요 없다.
    => 같은 값을 갖는 두 개 이상의 색은 존재하지 않음

유비쿼터스 언어

  • primitive type (String, Integer, Dictionary 등)을 이용하여 필드를 구성하면
    • 클래스가 모든 입력 필드를 검사해야함.
    • 유효성 검사 로직이 중복되기 쉬움.
    • 값이 사용되기 전에 유효성 검사 로직을 호출하기 어려움.
    • 유지보수가 어렵다.
  • 밸류 오브젝트를 사용한다면
    • 명료성이 향상됨. (짧은 변수 이름을 사용하더라도 의도를 명확하게 전달 가능.)
    • 유효성 검사 로직이 밸류 오브젝트 자체에 들어 있기 때문에 값을 할당하기 전에 유효성 검사를 할 필요가 없음.
    • 밸류 오브젝트는 값을 조작하는 비즈니스 로직을 한곳에 모을 수 있으므로 한곳에서 구현되고 쉽게 테스트할 수 있음.
    • 밸류 오브젝트를 사용하면 코드에서 유비쿼터스 언어를 사용하게 하므로 코드에서 비즈니스 도메인의 개념을 표현하게 된다.
    • (책의 Person 예제를 통해 Primitive 방식과 밸류 오브젝트 방식간의 차이점을 확인해보자.)
/**
*
* Primitive Type Fields
*
*/
class Person
{
    private int _id;
    private string _firstName;
    private string _lastName;
    private string _landlinePhone;
    private string _mobilePhone;
    private string _email;
    private int _heightMetric;
    private string _countryCode;

    public Person(...){}

}


static void Main(String[] args){
    var dave = new Person(
        id: 30217,
        firstName: "Dave",
        lastName: "Ancelovici",
        landlinePhone: "021231234",
        mobilePhone: "0800112456",
        email: "jw@learn-ddd.com",
        heightMetric: 180,
        countryCode:"KR"
    );
}
/**
*
* Value Object Type Fields
*
*/
class Person
{
    private PersonId _id;
    private Name _name;
    private PhoneNumber _landline;
    private PhoneNumber _mobile;
    private EmailAddress _email;
    private Height _height;
    private CountryCode _country;
    
    public Person(...){}
}

static void Main(String[] args){
    var dave = new Person(
        id: new PersonId(30217),
        name: new Name("Dave","Ancelovici"),
        landline: PhoneNumber.Parse("021231234"),
        mobilePhone: PhoneNumber.Parse("0800112456"),
        email: Email.Parse("jw@learn-ddd.com"),
        height: Height.FromMetric(180),
        country: CountryCode.Parse("KR")
    );
}

 

구현

  • 밸류 오브젝트는 불변(immutable)의 객체로 구현.
  • 필드가 하나라도 바뀌면 다른 값이 생성됨.(즉 오브젝트 필드중 하나라도 바뀌면 다른 인스턴스가 생성됨)

밸류 오브젝트를 사용하는 경우

  • 밸류 오브젝트는 가능한 모든 경우에 사용하는게 좋다.
  • 밸류 오브젝트는 코드의 표현력을 높여준다.
  • 분산되기 쉬운 비즈니스 로직을 한데 묶어주며 코드를 더욱 안전하게 해준다.
  • 밸류 오브젝트는 불변이기 때문에 내포된 동작은 부작용과 동시성 문제가 없다.
  • 다른 객체의 속성을 표현하는 도메인의 요소에 밸류 오브젝트를 사용하는 것이 비즈니스 도메인 관점에서 유용함.
  • 원시 타입으로 돈을 표현하면 돈과 관련된 비즈니스 로직을 한곳에 모아 두는 것이 제한적.

엔티티

  • 엔티티는 밸류 오브젝트와 정반대.
  • 엔티티는 다른 인스턴스와 구별하기 위해 명시적인 식별 필드(id)가 필요.
    (예를들어 이름이 같다고 같은 사람이 될 수 없기 때문)
    **식별 필드는 각 엔티티의 인스턴스마다 고유해야 한다.**
  • 엔티티의 식별 필드의 값은 엔티티의 생애주기 내내 불변해야함.
  • 밸류 오브젝트와는 반대로 엔티티는 변할 것으로 예상된다.
  • 밸류 오브젝트는 엔티티의 속성을 설명해준다.
  • 엔티티는 모든 비즈니스 도메인의 필수 구성요소이다.
  • 엔티티가 도메인 모델의 구성요소가 아닌 이유 : 엔티티를 단독으로 구현하지 않고, 애그리게이트 패턴의 컨텍스트에서만 엔티티를 구현하기 때문.

애그리게이트

  • 애그리게이트는 엔티티다.
  • 즉 명시적인 필드가 필요, 생애주기 동안 상태가 변할 수 있다.
  • 애그리게이트 패턴의 목적은 일관성을 보호하는데 있음.
  • 애그리게이트의 데이터는 변할 수 있기 때문에 이 패턴에는 데이터의 일관성을 유지하기 위해 해결해야 할 과제가 있다는 의미도 포함.

일관성 강화

  • 데이터의 일관성을 강화하려면 애그리게이트 패턴에서는 애그리게이트 주변에 명확한 경계를 설정해야함.
    즉, 애그리게이트는 일관성을 강화하는 경계다.
  • 구현 관점에서 애그리게이트의 비즈니스 로직을 통해서만 상태를 변경하게 해야 데이터의 일관성이 강화된다.
  • 외부의 모든 프로세스와 객체는 애그리게이트의 상태를 읽을 수만 있고, 퍼블릭 인터페이스에 포함된 관련 메서드를 실행해야만 상태 변경가능.
    이때 상태 변경 메서드를 커맨드라고 부름.
    • 커맨드 구현 방식 1 : 애그리게이트 객체에 평범한 퍼블릭 매서드로 구현.
    • 커맨드 구현 방식 2.  커맨드의 실행에 필요한 모든 입력값을 포함하는 파라미터 객체로 표현하는 것.

public class Ticket
{
	...
    
    /**
    * 평범한 퍼블릭 매서드로 구현
    **/
    public void AddMessage(UserId from, string body)
    {
    	var message = new Message(from, body);
        _messages.Append(message);
    }
    
    
    /**
    * 커맨드 실행에 필요한 입력값을 포함하는
    * 파라미터 객체로 표현
    **/
    public void Execute(AddMessage cmd)
    {
    	var message = new Message(cmd.from, cmd.body);
        _messages.Append(message);
    }
    
	...

}
  • 이 책의 필자는 명시적으로 커맨드 구조를 정의해서 다형적으로 관련 Execute 메서드에 전달하는 것을 선호.
  • 애그리게이트의 퍼블릭 인터페이스는 입력값의 유효성을 검사하고 관련된 모든 비즈니스 규칙과 불변성을 강화하는 것을 담당.
  • 이와 같은 엄격한 경계는 애그리게이트와 관련된 모든 비즈니스 로직이 한 곳, 즉 애그리게이트 자체에 구현되게 한다.
  • 이를 통해 애플리케이션 계층(서비스 계층)의 조율 동작을 좀 더 간단하게 만들 수 있음.
    즉, 조율 동작에서 해야 할 모든 일은 결국 애그리게이트의 현재 상태를 적재(Load) => 필요한 동작을 수행 => 수정된 상태를 저장 => 오퍼레이션의 결과를 호출자에게 반환(Return) 하는 일이다.
public ExecutionResult Escalate(TicketId id, EscalationReason reason)
{
	try
    {
    	var ticket = _ticketRepository.Load(id);//적재
        var cmd = new Escalate(reason);
        ticket.Execute(cmd);//동작수행
        _ticketRepository.Save(ticket);//저장
        return ExecutionResult.Success();//Return
    }
    catch(ConcurrencyException ex)
    {
    	return ExecutionResult.Error(ex);
    }
}
  • 앞 코드의 ConcurrencyException 을 보게되면, 애그리게이트 상태의 일관성을 유지하는 것이 중요함을 알 수 있다.
  • 따라서 여러 프로세스가 동시에 동일한 애그리게이트를 갱신하려 할때, 첫 번째 트랜잭션이 커밋한 변경을 나중의 트랜잭션이 은연중에 덮어쓰지 않게 해야한다. 이때 프로세스는 의사결졍에 사용된 상태가 만료되었다는 것을 통지(Exception event)받고 오퍼레이션을 재시도 해야한다.
    • 해결책 : 매번 갱신할 때마다 증가하는 버전 필드를 애그리게이트에서 관리.
      - DB에서 변경을 커밋할 때, 덮어쓰려는 버전이 처음 읽었던 원본의 버전과 동일한지 확인.
      - SQL문은 상태가 변경되기 전에 읽었던 버전과 현재 버전과 같을 경우에만 변경을 반영하고 버전을 증가시킨다.
      (아래 SQL참고)
UPDATE tickets
SET ticket_status = @new_status,
agg_version = agg_version + 1
WHERE ticket_id=@id and agg_version=@expected_version;

 

트랜잭션 경계

  • 애그리게이트의 상태는 자신의 비즈니스 로직을 통해서만 수정될 수 있기 때문에 애그리게이트는 트랜잭션의 경계 역할을 한다.
    (애그리게이트의 상태가 수정되면 모든 변경이 커밋되거나 모두 원래 상태로 돌아가야됨.)
  • 애그리게이트의 상태 변경은 데이터베이스 트랜잭션 하나당 한 개의 애그리게이트로, 개별적으로만 커밋 될 수 있다.
  • 트랜잭션별로 하나의 애그리게이트 인스턴스만 갖게 제한하면 애그리게이트의 경계가 비즈니스 도메인의 불변성과 규칙을 따르도록 신중이 설계하게 된다.
  • 동일한 트랜잭션에서 여러 객체를 수정해야하는 케이스는 어떤 패턴으로 다루는지 확인해볼 것(뒷 쪽에서 계속)

엔티티 계층

엔티티가 왜 에그리게이트의 구성요소가 되는지 알아보자.

  • DDD에서는 비즈니스 도메인이 시스템의 설계를 주도해야 한다고 규정한다. 애그리게이트도 동일하다.
  • 여러 객체의 변경을 원자적인 단일 트랜잭션으로 지원하기 위해 애그리게이트 패턴은 엔티티 계층 구조와 유사하게 모든 트랜잭션을 공유해서 일관성을 유지.

[도메인 주도 설계 첫걸음]엔티티 계층과 유사한 애그리게이트

  • 이 계층은 엔티티와 밸류 오브젝트를 모두 담고 있다.
    이 요소들이 도메인의 비즈니스 로직 경계 내에 있으면 동일한 애그리게이트에 속한다.
  • 이 패턴은 동일한 트랜잭션 경계에 속한 비즈니스 엔티티와 밸류 오브젝트를 한데 묶기 때문에 '애그리게이트'로 명명되었다.
  • 애그리게이트는 일관된 데이터에 대해 모든 조건을 엄격하게 검사하도록 확인.
  • 애그리게이트 데이터의 모든 변경이 원자적인 단일 트랜잭션으로 수행되도록 보장하여 점검이 완료된 후 수정되지 못하게 한다.

다른 애그리게이트 참조하기

  • 애그리게이트 내의 모든 객체는 같은 트랜잭션 경계를 공유하기 때문에 애그리게이트가 너무 커지면 성능과 확장 문제가 생길 수 있음.
  • 애그리게이트의 비즈니스 로직에 따라 강력한 일관성이 필요한 정보만 애그리게이트에 포함돼야함.
  • 그 밖에 궁극적으로 일관돼도 좋은 모든 정보는 애그리게이트 경계 밖에 다른 애그리게이트의 일부로 둔다.

[도메인 주도 설계 첫걸음] 다른 애그리게이트 참조하기

  • 책 필자의 경험상 애그리게이트를 가능한 한 작게 유지하고 애그리게이트의 비즈니스 로직에 따라 강력하게 일관적으로 상태를 유지할 필요가 있는 객체만 포함.
/**
* 일관적으로 상태를 유지할 필요가 있는 객체만 포함.
*/
public class Ticket
{
	private UserId          _customer;
	private List<ProductId> _products;
	private UserId          _assignedAgent;
	private List<Message>	_messages;
    
    ...
}
  • 티켓 애그리게이트는 경계 내에 속한 메시지의 모음을 참조한다.
  • 반면 티켓과 관련된 고객과 제품의 모음, 그리고 할당된 에이전트는 애그리게이트에 속하지 않아 ID로 참조된다.
  • 외부 애그리게이트를 참조할 때 ID를 이용하는 이유는 이 같은 객체가 애그리게이트 경계에 속하지 않음을 명확히 하고 각 애그리게이트가 자신의 트랜잭션 경계를 갖게 보장하기 위함이다.
  • 엔티티가 애그리게이트에 속하는지 판단하는 방법
    • 비즈니스 로직 내에 궁극적으로 일관된 데이터(Eventually Consistency)를 다루는 상황이 되면 시스템의 상태를 손상시킬 수 있는지 여부를 판단한 후, 그 비즈니스 로직이 애그리게이트에 있는지 여부를 조사.

 

애그리게이트 루트

  • 애그리게이트의 상태는 커맨드 중 하나를 실행해서만 수정가능.
    • 애그리게이트가 엔티티의 계층 구조를 대표하기 때문에 그중 하나만 애그리게이트의 퍼블릭 인터페이스, 즉 애그리게이트 루트로 지정되어야함.
  • 외부에서 애그리게이트와 커뮤니케이션할 수 있는 다른 메커니즘에는 도메인 이벤트도 있음.

도메인 이벤트

  • 도메인 이벤트는 비즈니스 도메인에서 일어나는 중요한 이벤트를 설명하는 메시지.
    ex) 티켓이 할당됨, 티켓이 상부에 보고됨, 메시지가 수신됨.
  • 도메인 이벤트는 이미 발생된 것이기 때문에 과거형으로 명명.
  • 도메인 이벤트의 목적은 비즈니스 도메인에서 일어난 일을 설명하고 관련된 모든 필요한 데이터를 제공하는 것.
  • 도메인 이벤트의 이름이 비즈니스 도메인에서 일어난 일을 간결하고 정확하게 반영하게 해야 함.

[도메인 주도 설계 첫걸음] 도메인 이벤트의 게시 흐름

public class Ticket
{
    ...
    private List<DomainEvent> _domainEvents;
    ...
    
    public void Execute(RequestEscalation cmd)
    {
    	if(!this.IsEscalated && this.RemainingTimePercentage <= 0)
        {
            this.IsEscalated = true;
            //새로운 도메인 이벤트 인스턴스 생성.
            var escalatedEvent = new TicketEscalated(_id, cmd.Reason);
            // 도메인 이벤트 추가.
             _domainEvents.Append(escalatedEvent);
        }
    }
	
    ...
}

유비쿼터스 언어

  • 애그리게이트는 유비쿼터스 언어를 사용해야함.
  • 애그리게이트의 이름, 데이터 멤버, 동작 그리고 도메인 이벤트에 사용된 모든 용어는 모두 바운디드 컨텍스트의 유비쿼터스 언어로 명명되어야 함.

도메인 서비스

  • 애그리게이트에도 복수의 애그리게이트에 관련된 비즈니스 로직을 다루게 될텐데, 이때 도메인 서비스로 로직을 구현할 것을 제안.
  • 도메인 서비스는 비즈니스 로직을 구현한 상태가 없는 객체(Statelaess object)다.
  • 대부분의 경우 이러한 로직은 어떤 계산이나 분석을 수행하기 위한 다양한 시스템 구성요소의 호출을 조율함.
  • 도메인 서비스는 여러 애그리게이트의 데이터를 읽는 것이 필요한 계산 로직을 구현하는 것을 도와줌.
  • 도메인 서비스는 마이크로서비스, 서비스 지향 아키텍처 또는 소프트웨어 엔지니어링에서 '서비스'용어를 사용하는 대부분의 것과 무관. 단순히 비즈니스 로직에서 사용되는 상태가 없는 객체일 뿐.

복잡성 관리

  • 시스템의 복잡성을 논의할 때 우리는 제어와 동작 예측의 어려움을 평가하는데 관심이 있음.
  • 시스템의 자유도 : 시스템의 상태를 설명하는 데 필요한 데이터 요소의 개수.
public class ClassA
{
   //자유도 : 5
   public int A {get; set;}
   public int B {get; set;}
   public int C {get; set;}
   public int D {get; set;}
   public int E {get; set;}
}

public class ClassB
{
    //자유도 : 2
    //상태를 설명하기 위해 2개의 변수만 알면 되므로.
    private int _a, _d;
    
    public int A
    {
    	get => _a;
        set{
        	_a = value;
            B = value / 2;
            C = value / 3;
        }
    }
    
    public int B {get; private set;}
    
    public int C {get; private set;}
    
    public int D
    {
    	get => _d;
        set {
        	_d = value;
            E = value * 2
        }
    }
    
    public int E {get; private set;}
}
  • 위 소스코드에서 ClassB가 ClassA보다 복잡해 보이지만, 자유도 관점에서는 ClassB의 경우 상태 설명을 위해 변수 2개만 필요하므로 예측이 더 용이하다.
  • ClassB의 경우 불변성이 복잡성을 낮춘 것이다. 이것이 애그리게이트와 밸류 오브젝트 패턴이 하는 것.
  • 비즈니스 로직은 비즈니스 불변성을 감싸고 보호해서 자유도를 줄임.

 

 

도도메인 모델 패턴은 복잡한 비즈니스 로직을 갖는 하위 도메인에만 적용되므로
이를 핵심 하위 도메인으로 가정해도 좋다.