[Domain Driven Design] 복잡한 비즈니스 로직 다루기

이홍준·2023년 7월 4일

DDD

목록 보기
6/10

앞장에서는 비교적 간단한 비즈니스 로직을 다루는 두 가지 패턴에 대해 논의했다. 이번 장에서는 계속해서 비즈니스 로직의 구현에 대해 다루는데, 복잡한 비즈니스 로직에 사용되는 도메인 모델 패턴이 있다.

1. 도메인 모델 패턴

  • CRUD 인터페이스 대신 복잡한 상태 전환, 항상 보호해야 하는 규칙은 비즈니스 규칙과 불변성을 다룬다.
  1. 구현
    • 행동(behavior)과 데이터(data) 모두를 포함하는 도메인의 객체 모델이다.
    • DDD의 전술 패턴인 Aggregate, Value Object, Domain Event, Domain Service는 모두 객체 모델의 구성요소이다.
      → 이 모든 패턴은 비즈니스 로직을 최우선으로 둔다는 공통 관심사가 있다.
  2. 복잡성
    • 이미 본질적으로 복잡하므로 모델링에 사용되는 객체가 모델에 조금이라도 우발성 복잡성을 추가하면 안 된다.
    • 모델에는 DB 또는 외부 시스템 구성 요소의 호출 구현 같은 인프라 또는 기술적인 관심사를 피해야 한다.
      → 이 제약들을 따르면 “Plain old object”가 된다.
      → plain old object: 인프라 구성요소 또는 프레임워크에 의지하지 않고 직접 협업하지 않으면서 비즈니스 로직을 구현하는 객체
  3. 유비쿼터스 언어
    • 도메인 모델의 객체가 기술적 관심사가 아닌 비즈니스 로직에 집중하게 하면 바운디드 컨텍스트에서 사용하는 언어의 용어를 따르기 쉬워진다.
      → 이 패턴은 코드에서 유비쿼터스 언어를 사용하게 하고 도메인 전문가의 멘탈 모델을 따르게 한다.

2. Value Obejct

  • value object는 예를 들어 색(color)처럼 복합적인(composition) 값에 의해 식별되는 객체다.
    → 같은 값을 갖는 두 개의 행을 만들수 있게하는 불팔요한 ColorId 필드가 생긴다.
    class Color{
    	int _red;
    	int _green;
    	int _blue;
    }
  1. 유비쿼터스 언어

    • 언어의 표준 라이브러리에 포함된 string, int, dictionary 같은 원시 데이터 타입에 전적으로 의존해서 비즈니스 도메인의 개념을 표현하는 것은 원시 집착 코드 징후(primitive obsession code smell)로 알려져 있다.
    • 초기
      • 아래 코드는 유효성 검사 로직이 중복되기 쉽다. 그리고 값이 사용되기 전에 유효성 검사 로직을 호출하기 어렵다.

      • 다른 엔지니어가 미래를 대비한 유지보수가 더 어렵다.

        
        static void main(String[] args){
        	Person hong = new Person(3321, "Hong", "zxc123@gmail.com", "LA");
        }
    • VO 적용 결과
      • value object로 타입을 정해서 변수명을 짧게해도 의도를 명확하게 전달 가능하다.

      • 유효성 검사 로직이 이미 value object 자체에 들어 있기 때문 값을 할당하기 전에 유효성 검사를 할 필요가 없다.

      • 값을 조작하는 비즈니스 로직을 한곳에 모을 때 더욱 진가를 발휘한다.

        → 테스트 하기 쉽다.

      • 코드에서 유비쿼터스 언어를 사용하게 하므로 코드에서 비즈니스 도메인의 개념을 표현하게 된다.

        class Person{
        	private PersonId _id;
        	private Name _name;
        	private EmailAddress _email;
        	private CountryCode _country;
        // 생성자 생략
        }
        
        static void main(String[] args){
        	Person hong= new Person(
        		new PersonId(23232),
        		new Name("Hong", "GILDONG"),
        		Email.Parse("zxc123@gmail.com"),
        		CountryCode.Parse("LA")
        	);
        }
  2. 구현

    • 불변의 객체로 구현되므로 하나라도 값이 바뀌면 다른 인스턴스가 생성된다.
    • 새로운 값이 반환하게 하듯이 원래 인스턴스를 수정하지 않고 새로운 인스턴스를 생성해서 반환하게 한다.
      public class Color {
          public final byte Red;
          public final byte Green;
          public final byte Blue;
      
          public Color(byte red, byte green, byte blue) {
              Red = red;
              Green = green;
              Blue = blue;
          }
      
          public Color MixWith(Color other) {
              return new Color((byte) Math.min(this.Red + other.Red, 255),
                      (byte) Math.min(this.Green + other.Green, 255),
                      (byte) Math.min(this.Blue + other.Blue, 255));
          }
      }
  3. 사용하는 경우

    • 가능한 모든 경우에 사용하는 게 좋다.
      → 코드의 표현력을 높여주고 분산 되기 쉬운 비즈니스 로직을 한데 묶어줄 뿐만 아니라 코드를 더욱 안전하게 준다.
      → 불변이기 때문에 내포된 동작은 부작용과 동시성 문제가 없다.
    • 예시
      • ID
      • 이름
      • 전화번호
      • 이메일

3. Entity

  • vs VO
    → 다른 엔티티 인스턴스와 구별하기 위해 명시적인 식별 필드가 필요하다.
    → 식별 값은 엔티티의 생애주기 내내 불변이어야 한다.
    → 속성 값이 바뀔 수도 있다.
    • 아래 클래스는 같은 이름을 가진 사람이 있을 수 있기 때문에 식별 필드가 필요하다
      class Person{
      	public Name Name // getter & setter
      	public Person(Name name){
      		this.Name = name;
      	}
      }
    • 아래 클래스에는 식별필드(id)를 추가한다. (VO와 개념적 차이)
      class Person{
      		public final PersonId id; // 식별 필드
      		public Name Name // getter & setter
      	
      }

4. Aggregate

  • Entity Aggregate
    → id가 필요하고 인스턴스 생애주기 동안 상태가 변할 것이라고 예상된다.
    → 데이터의 일관성을 보호하는 것이 목적이다.
  1. 일관성 강화
    • Aggregate의 상태를 변형될 수 있기 때문에 데이터가 손상될 수 있는 여러 경로가 존재한다.
    • 데이터 일관성을 강화하기 위해 Aggregate 패턴에서 주변에 명확한 경계가 있어야 한다.
      → Aggregate는 일관성을 강화하는 경계이다.
    • 로직은 모든 들어오는 변경 요청을 검사해서 그 변경이 Aggregate의 비즈니스 규칙에 위배되지 않게 해야 한다.
      → 데이터의 일관성은 Aggregate의 비즈니스 로직을 통해서만 Aggregate의 상태를 변경하게 해야 강화된다.
    • Aggregate 외부의 모든 프로세스와 객체는 Aggregate의 상태를 읽을 수만 있고 Aggregate의 public interface에 포함된 관련 메서드를 실행해야만 바꿀 수 있다.
      → “Command”라고 부른다.
      public class Message {
          private PersonId from;
          private String body;
      
          public Message(PersonId from, String body) {
              this.from = from;
              this.body = body;
          }
      
          public void Append(Message message) {
              this.body += message.getMessage();
          }
      }
      // Aggregate 객체에 평범한 public method로 구현
      public class Ticket {
          public void AddMessage(PersonId from, String body){
              Message message = new Message(from, body);
              message.Append(message);
          }
      }
      
      // command 실행에 필요한 모든 입력값을 포함하는 파라미터 객체로 표현하는 것 -> 다형적
      public class Ticket {
          public void Execute(AddMessage cmd){
              Message message = new Message(cmd.from, body);
              message.Append(message);
          }
      }
      
    • Aggregate 상태의 일관성을 유지하는 것이 중요하다.
      → 첫 번째 트랜잭션이 커밋한 변경을 나중에 트랜잭션이 은연중에 덮어쓰지 않게 해야 한다.
      → 의사 결정에 사용된 상태가 만료되었다는 것을 통지 받고 오퍼레이션을 재시도 해야 한다.
      public ExecutionResult Escalate(TickeId id, EscalationReason reason){
      	try{
      		Ticket ticket = _ticketRepository.Load(id);
      		Command cmd = new Escalate(reason);
      		ticket.Execute(cmd);
      		_ticketRepository.Save(ticket);
      		return ExecutionResult.Success();
      	}
      	catch(ConcurrencyException ex){
      		return ExecutionResult.Error(ex);
      	}
      }
    • Aggregate를 저장하는 DB에서 동시성 관리를 지원해야 한다.
      → 가장 간단한 형태는 매번 갱신할 때마다 증가하는 버전 필드를 Aggregate에서 관리하는 것
      class Ticket{
      	TicketId _id;
      	int _version;
      	//...
      }
      UPDATE tickets
      SET ticket_status = @new_status,
      agg_version = agg_version + 1
      WHERE ticket_id=@id and agg_version=@expected_version; # 버전이 같을때만 변경
  2. 트랜잭션 경계
    • Aggregate의 상태는 자신의 비즈니스 로직으로만 변경이 가능하기 때문에, Aggregate가 트랜잭션 경계의 역할을 한다.
    • 모든 Aggregate 상태 변경은 원자적인 단일 오퍼레이션으로 트랜잭션 처리돼야 한다.
    • Aggregate 상태가 수정되면 모든 변경이 커밋되거나 모두 롤백되어야 한다.
    • 다중 Aggregate 트랜잭션을 지원하는 시스템 오퍼레이션은 없다고 가정한다.
      → Aggregate의 상태 변경은 DB 트랜잭션 하나당 1개의 Aggregate로, 개별적으로만 커밋될 수 있다.
  3. Entity 계층
    • 엔티티는 독립적 패턴이 아닌 Aggregate의 일부로서만 사용된다.
    • 시나리오
      • 둘 다 동시에 변경되거나 객체 하나가 다른 객체의 상태에 의존하는 비즈니스 규칙인 것 처럼 여러 객체가 하나의 트랜잭션 경계를 공유하는 비즈니스 시나리오가 있다.

      • DDD에서는 비즈니스 도메인이 시스템의 설계를 주도해야 한다고 규정한다.

        → Aggregate도 마찬가지로 엔티티 계층 구조와 비슷하게 모든 트랜잭션을 공유해서 일관성을 유지한다.

      • 이 계층은 entity와 VO를 모두 담고있다.

        → 이 요소들이 도메인의 비즈니스 로직 경계내에 엤으면 동일한 Aggregate에 속한다.
        public class Ticket{
        	//...
        	List<Message> _messages;
        	//...
        	public void Execute(EvaluateAutomaticActions cmd){
        		// 1. 남은 처리 시간이 정의된 50% 임계치 아래인지 확인하기 위해 티켓의 값을 검사
        		// 2. 현재 에이전트가 메시지를 아직 읽기 전인지 검사한다.
        		// 3. 현재 에이전트가 메시지를 아직 읽기 전인지 검사한다.
        		if(this.IsEscalated && this.RemainingTimePercentage < 0.5 
        		&& GetUnreadMessagesCount(for: AssignedAgent) > 0)
        		{
        			_agent = AssignNewAgent();
        		}
        	}
        	public int GetUnreadMessagesCount(User id){
        		return _messages.Where(x -> x.To == id && !x.WasRead).Count();
        	}
        ~~}~~
      • 위 메서드는 일관된 데이터에 대해 모든 조건을 엄격하게 검사하도록 확인한다.

        → Aggregate 데이터의 모든 변경이 원자적인 단일 트랜잭션으로 수행되도록 보장하여 점검이 완료된 후 수정되지 못하게 한다.

  4. 다른 Aggregate 참조하기
    • Aggregate 내의 모든 객체는 같은 트랜잭션 경계를 공유하기 때문에 너무 커지면 성능과 확장의 문제가 생김
    • 데이터의 일관성은 Aggregate의 경계를 설계하는 데 편리한 가이드 원칙이다. → 강력한 일관성이 필요한 정보만 Aggregate에 포함돼야 한다.
      → 그 밖에 궁극적을 일관돼도 좋은 모든 정보는 Aggregate 경계 밖에 다른 Aggregate의 일부로 둔다.
      public class Ticket{
      	private UserId _customer;
      	private List<ProductId> _products;
      	private UserId _assignedAgent;
      	private List<Message> _messages;
      // ...
      }
    • 해당 소스 코드에서 티켓 Aggregate는 경계 내에 속한 메시지의 모음을 참조한다.
      → 티켓과 관련된 고객과 제품의 모음, 그리고 할당된 에이전트는 Aggregate에 속하지 않아 ID로 참조된다.
      → 외부 Aggregate를 참조할 때 ID를 사용함으로써 경계에 속하지 않음을 명확히 하고 각 Aggregate가 자산의 트랜잭션 경계를 갖게 보장하기 위함이다.

    • Entity가 Aggregate에 속하는지 판단하는 방법은 우선 비즈니스 로직 내에 궁극적으로 일관된 데이터를 다루는 상황이 되면 시스템의 상태를 손상시킬 수 있는지 여부를 판단한 후, 그 비즈니스 로직이 Aggregate에 있는지 여부를 조사하는 것이다.
      → 어느 정도 지연된 후 읽었다는 알림을 받는다면 상당수의 티켓이 불필요하게 재할당될 거라는 합리적 예상을 할 수있다. 그러므로 메시지 데이터는 경계 안에 속해야 한다.
  5. Aggregate 루트
    • Aggregate의 상태는 커맨드중 하나를 실행해서만 수정할 수 있다.
      → Entity의 계층 구조를 대표하기 때문에 그중 하나만 Aggregate public interface, 즉 Aggregate 루트로 지정돼야 한다.
      public class Ticket{
      	...
      	List<Message> _message;
      	...
      	public void Execute(AcknowledgeMessage cmd){
      		Message message = _message.Where(x-> x.id == cmd.id).First();
      		message.WasRead = true;
      	}
      	...
      
      }
    • Aggregate는 특정 메시지의 읽음 상태를 수정할 수 있는 커맨드를 노출한다.
      → Agrgegate 루트인 Ticket을 통해서만 접근 가능하다.
      Aggregate
  6. 도메인 이벤트
    • 비즈니스 도메인에서 일어나는 중요한 이벤트를 설명하는 메시지다.
      → 티켓이 할당됨
      → 티켓이 상부에 보고됨
      → 메시지가 수신 됨
    • 목적은 비즈니스 도메인에서 일어난 일을 설명하고 이벤트와 관련된 모든 필요한 데이터를 제공하는 것이다.
      //언제, 무슨 이유로 특정 티켓이 상부에 보고됬는지 설명함.
      {
      	"ticket-id": "asdasddssaew-dasdsa-dasdas-dsadsadqe23e2",
      	"event-id": 133,
      	"event-type": "ticket-escalated",
      	"escalation-reason": "missed-sla",
      	"escalation-time" 16289714577 
      }
    • 도메인 이벤트는 Aggregate public interface의 일부다.
      → Aggregate는 자신의 도메인 이벤트를 발행한다.
      public class Ticket{
      	...
      	private List<DomainEvent> _domainEvents;
      	public void Execute(RequestEscalation cmd){
      		if(!this.IsEscalated && this.RemainingTimePercentage <= 0){
      			this.IsEscalated = true;
      			// 새로운 도메인 이벤트가 인스턴스화 된다.
      			Event escalatedEvent = new TicketEscalated(_id, cmd.Reason); 
      
      			// 티켓의 도메인 이벤트 모음에 추가된다.
      			_domainEvents.Append(esacalatedEvent);
      		}
      	}
      }
  7. 유비쿼터스 언어
    • Aggregate는 유비쿼터스 언어를 사용해야한다.
      → 이름, 데이터 멤버, 동작 그리고 도메인 이벤트에 사용된 모든 용어는 모두 Bounded context의 유비쿼터스 언어로 명명되어야 한다.

5.도메인 서비스

  • 향후 Aggregate에도 VO에도 속하지 않거나 복수의 Aggregate에 관련된 비즈니스 로직을 다루게 될 것이다.

    → 이 경우 DDD에서는 도메인 서비스로 로직을 구현할 것을 제안한다.

  • 도메인 서비스는 비즈니스 로직을 구현한 상태가 없는 객체(stateless Object)다.

    → MSA, SOA(서비스 지향 아키텍처)에서 '서비스'와 다른 의미이다.

  • 어떤 계산이나 분석을 수행하기 위한 다양한 시스템 구성 요소의 호출을 조율한다.

          public class ResponseTimeFrameCalculationService{
              ...
              public ResponseTimeframe CalculateAgentResponseDealline(UserId agentId,
                  var priority, boolean escalated, DateTime startTime){
                  var policy = _departmentRepository.GetDepartmentPolicy(agentId);
                  var maxProcTime = policy.GetMaxResponseTimeFor(priority);
                  if(escalated){
                      maxProcTime = maxProcTime * policy.EscalationFactor;
                  }
                  var shifts = _departmentRepository.GetUpcomingShifts(agentId, 
                  startTime, startTime.Add(policy.MaxAgentResponseTime));
                  return CalculateTargetTime(maxprocTime, shifts);
              }
              ...
          } 

    → 여러 Aggregate 작업들을 쉽게 조율할 수 있다.

    → 1개의 DB 트랜잭션으로 Aggregate 인스턴스의 수정할 수 있다는 패턴의 한계를 명심해야 함

    → 여러 개의 Aggregate 데이터를 읽는 것이 필요한 계산 로직을 구현하는 것을 도와준다.

결론

  • VO(Value Object)
    → 값만으로만 식별되는 비즈니스 도메인의 개념이기 때문에 명시적인 ID 필드가 필요없다.
    → 필드 중 하나가 변경되려면 새로운 값을 생성해야 하므로 불변이다.
    → 데이터 뿐만아니라 행동도 모델링 한다. 메서드는 값을 조작하고 새로운 VO를 초기화한다.
  • Aggregate
    → 트랜잭션 경계를 공유하는 Entity의 계층
    → 경계에 속하는 모든 데이터는 비즈니스 로직의 구현을 통해 강력한 일관성을 유지해야만한다.
    → Aggregate의 상태와 내부 객체는 Aggregate 커맨드를 실행하여 public interface를 통해서만 수정될 수 있다.
    → 내부 객체를 포함한 모든 데이터는 원자적인 단일 트랜잭션으로 DB에 커밋되어야 한다.
    → 도메인 이벤트를 게시하여 외부 Entity와 커뮤니케이션 할 수 있다.
    → 도메인 이벤트는 Aggregate의 수명 주기에서 중요한 비즈니스 이벤트를 설명하는 메시지이며, 다른 컴포넌트는 이벤트를 구독하고 비즈니스 로직의 실행을 촉발하는 데 사용할 수 있다.
profile
I'm not only a web developer.

0개의 댓글