Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

책 : 도메인 주도 설계 철저 입문 - 코드와 패턴으로 밑바닥부터 이해하는 DDD #1

Open
SAgiKPJH opened this issue Jan 28, 2024 · 15 comments
Assignees

Comments

@SAgiKPJH
Copy link
Contributor Author

SAgiKPJH commented Jan 28, 2024

DDD 도메인 주도설계 1장

도메인 주도 설계란?

도메인 : 영역, 프로그램에 쓰이는 대상 분야

모델 : 현실에 일어나는 사건 혹은 개념을 추상화

  • DDD 개념은 2001년에 제시가 되었다.
  • 초반에 그 중요성이 부각되지 않아 사용하지 않았으나,
  • 개발자-사용자 사이에 이해할 수 있는 최소의 생각수단이다.

@SAgiKPJH
Copy link
Contributor Author

SAgiKPJH commented Jan 28, 2024

DDD 도메인 주도설계 2장

시스템 특유의 값을 나타내기 위한 값 객체

값 객체

  • 값 객체 : 시스템 고유의 값을 만드는 단순한 작업
  • 값 객체 : 값 선언 => 불가능 정의
  • 값의 성질
    • 변하지 않는다.
    • 주고받을 수 있다.
    • 등가 비교
class FullName : IEquatable<FullName> // IEquatable : 등가성 비교 기능
{
    private readonly FirstName firstName; // 값의 불변성 (생성자에서만 정의 가능)
    private readonly LastName lastName;

    public FullName(FirstName firstName, LastName lastName)
    {
        if (firstName == null) throw new ArgumentNullException(nameof("firstName"));
        if (lastName == null) throw new ArgumentNullException(nameof("lastName"));
        if (!ValidateName(firstName)) throw new ArgumentException("허가되지 않은 문자가 사용됨", nameof(firstName));
        if (!ValidateName(lastName)) throw new ArgumentException("허가되지 않은 문자가 사용됨", nameof(lastName));

        this.firstName = firstName;
        this.lastName = lastName;
    }

    public bool ValidateName(string name) => Regex.IsMatch(name, @"^[a-zA-Z]+$"); // 값에 규칙 추가

    public bool Equals(FullName other) // 등가성 비교
    {
        if (ReferenceEquals(null, other)) return false;
        if (ReferenceEquals(this, other)) return true;
        return string.Equals(firstName, other.firstName)
          &&  string.Equals(lastName, other.lastName); // 속성 쉽게 추가 가능
    }

    public override bool Equals(object obj) // 등가성 비교
    {
        if (ReferenceEquals(null, obj)) return false;
        if (ReferenceEquals(this, obj)) return true;
        if (obj.GetType() != this.GetType()) return false;
        return Equals((FullName)obj);
    }

    // c#에서 Equals를 오버라이드 하려면 GetHashCode를 함께 오버라이드 해야 한다.
    public override int GetHashCode()
    {
        unchecked
        {
            return ((firstName != null ? firstName.GetHashCode() : 0) * 397)
              ^ (lastName != null ? lastName.GetHashCode() : 0);
        }
    }

}

// -- change

class Name
{
    private readonly string value;
    public Name(string value)
    {
        if (value == null) throw new ArgumentNullException(nameof(value));
        if (!Regex.IsMatch(value, @"^[a-zA-Z]+$")) throw new ArgumentException("허가되지 않은 문자가 사용됨", nameof(value));
        if(value.Length <= 2) thorw new Exception("유효하지 않는 값"); // 무결성
        
        this.value = value;
    }
}

class FullName
{
    private readonly Name firstName;
    private readonly Name lastName;

    public FullName(Name firstName, Name lastName)
    {
        if (firstName == null) throw new ArgumentNullException(nameof("firstName"));
        if (lastName == null) throw new ArgumentNullException(nameof("lastName"));

        this.firstName = firstName;
        this.lastName = lastName;
    }
}

객체 표현

  • 행동을 정의하여 표현
    public Money Add(Money arg)
    {
       // ...
       if (currency != arg.currency) throw new ArgumentException($"화폐 단위가 다름" ); /* 일부 생략*/
       // ...
    }
  • 정의하지 않았기에 알 수 있는 것
    public Money Multuple(Rate rate); // 정의하지 않음 -> 곱셈 불가능 함을 암시
  • 객체에 대한 표현 (무엇에 대한 정보인지 쉽게 파악)
    public ModelNumber(/*...*/)
    {
      private readonly string productCode;
      private readonly string branch;
      private readonly string lot;
    
      // ...
    
      public override string  ToString() => productCode + "-" + branch + "-" + lot;
    }

C# DDD

  • C#에서는 다음을 통해 쉽게 구현할 수 있다.
    • ValueObject
    • record

???

  • DDD 값 객체에서 변할 수 있고,
  • 엔티티에서 식별자가 없는 것을 무엇이라 하나?

@SAgiKPJH
Copy link
Contributor Author

SAgiKPJH commented Jan 28, 2024

DDD 도메인 주도설계 3장

생애주기를 갖는 객체 - 엔티티

  • 생애주기를 위해 식별자가 존재
  • 엔티티 : 도메인 모델을 구현한 도메인 객체
  • 값 객체도 도메인 모델을 구현한 도메인 객체
  • 엔티티 - 값 객체 차이 : 동일성을 통해 식별 가능한가?
    • 생일 전 후는 다른 사람인가?
  • 사람은 속성이 아닌, 동일성(identity)으로 식별

엔티티 성질

  • 가변
    private readonly string value;
    // to be
    private string value;
    public Name(string name)
    {
      ChangeName(name);
    }
    public void ChangeName(string name)
    {
      // 생성자 내용
    }
  • 속성이 같아도 구분 가능
    • 이름이 같아도 다른 사람
    public UserId(string value)
    {
      // ...
    }
  • 동일성을 통해 구별
    • 이름을 변경해도 같은 사람
    • 이를 구분 하는 방법은?
    private readonly UserId id; // 식별자
    public bool Equals(User other)
    {
      // ...
      return Equals(id, other.id); // 실제 id로 비교
    }
    public override int GetHashCode() => (id != null ? id.GetHashCode() : 0 );

생애주기, 연속성

  • 생애주기의 연속성
    • 시간이 흘러 시스템을 더 이상 사용할 필요가 없어지면서 사용자를 삭제.

도메인 객체를 정의할 때의 장점

  • 자기 서술적인 코드가 된다.
  • 도메인에 변경사항이 있을 시 코드에 반영 쉽다.

@SAgiKPJH
Copy link
Contributor Author

SAgiKPJH commented Jan 28, 2024

DDD 도메인 주도설계 4장

소프트웨어에서 말하는 서비스

  1. 도메인을 위한 서비스
  2. 애플리케이션을 위한 서비스

부자연스러움을 해결하는 도메인 서비스

  • 시스템에는 값 객체나 엔티티로 구현하기 어색한 행동이 있다. (빈혈 도메인 객체)
    // 값이 이미 존재하는지 확인해야 할 경우
    var user = new UserName("Park");
    user.Exsist("Park"); // user에게 물어야 하는가?
    var checkResult = checkObject.Exsist(user); // checkObject는 사용자를 나타내는 객체이면서 사용자가 아니다. => 부자연스러움
  • 도메인 서비스는 이러한 어색함을 해결해주는 객체
    class UserService
    {
      public bool Exists(User user) { /*...*/ }
    }

도메인 서비스를 남용한 결과

  • 도메인 서비스는 가능한 피할 것
    • User 내부에 정의된 내용이 사실 UserService에 들어가도 이상하지 않는데
    • UserService에 전부 정의하면, User의 도메인 객체로써의 역할이 모호해진다.
    class UserService
    {
      public void ChangeName(User user, UserName name) { /* ... */ }
      // ... (기타 추가 작업 기입)
    }
    class User 
    {
      private readonly UserId id;
      public User(UserId id, UserName name)
      {
        this.id = id;
        Name = name;
      }
      public UserName Name { get; set; }
      // 더는 정의할 내용이 없다.
      // User라는 값 객체에 대한 단서가 없다.
    }

@SAgiKPJH
Copy link
Contributor Author

SAgiKPJH commented Jan 28, 2024

1장 ~ 4장 내용 정리

  • 값 객체 : 하나하나 정의한 나만의 객체
  • 엔티티 : 이름은 달라져도 같은 사람 (보통 id로 구분)
  • 도메인 서비스 : UserService, Exsist 확인은 User가 하지 않음

@SAgiKPJH
Copy link
Contributor Author

DDD 도메인 주도설계 5장

데이터와 관계된 처리를 분리하자 - 리포지토리

  • 엔티티는 생애주기를 갖기에, 시스템이 꺼져도 사라지면 안된다.
  • 리포지토리 : 데이터를 저장하고 복원하는 처리를 추상화하는 객체
    • 리포지토리를 거쳐 간접적으로 데이터를 저장 및 복원하는 방식을 취하면 소프트웨어의 유연성이 크게 향상
  • 값 객체 및 엔티티 <-> 리포지토리 <-> 데이터 스토어
    • 즉 실질적인 저장 이전에 리포지토리라는 중간단계를 구성

리포지토리 책임

  • 리포지토리의 책임
    • 도메인 객체를 저장하고 복원하는 퍼시스턴스
    class UserService
    {
      // As-Is
      public bool Exsist(User user)
      {
        var connectingString = // ...
        // ...
        command.CommandTest = "SELECT * FROM users WHERE name = @name";
        // ...      
      }
      
      // To-Be
      private IUserRepository userRepository;
      public bool Exists(User user)
      {
        var found = userRepository.Find(user.Name);
        return found != null;
      }
    }

리포지토리 인터페이스

  • 리포지토리를 인터페이스로 지정하여, 동작 지정.
  • 데이터스토어에 따라 구현이 다르므로 필요

테스로 구현 검증

  • 리포지토리 테스트는 Dictonary를 데이터 스토어로 구성해도 해도 좋다
  • 또는 Stub

객체-관계 매핑

  • ORM (O-R Mapper)
    • 어플리케이션과 데이터베이스 연결 시 SQL언어가 아닌 어플리케이션 개발언어로 데이터베이스를 접근할 수 있게 해주는 툴
    // 예시 (EntityFramework를 이용한 리포지토리)
    private readonly MyDbContext context;
    
    // ...
    
    var target = context.Users.FirstOrDefault(userData=>  userData.Name == name.Value);

리포지토리 정의 지침

  • 저장
    // As-Is
    void UpdateName(UserId id, UserName name);
    void UpdateName(UserId id, UserName name, Email email);
    void UpdateName(UserId id, Email email);
    
    // To-Be
    // 9장에서 다룰 예정
    // 객체 자신에게 맡기는 것이 좋다.

@SAgiKPJH
Copy link
Contributor Author

1장 ~ 5장 내용 정리

  • 값 객체 : 하나하나 정의한 나만의 객체
  • 엔티티 : 이름은 달라져도 같은 사람 (보통 id로 구분)
  • 도메인 서비스 : UserService, Exsist 확인은 User가 하지 않음
  • 리포지토리 : 실질적인 저장 사이의 중간단계, 여러 종류의 데이터 스토어를 위해 Interface로 구성

@SAgiKPJH
Copy link
Contributor Author

DDD 도메인 주도설계 6장

유스케이스를 구현하기 위한 '애플리케이션 서비스'

  • 애플리케이션 서비스 : 유스케이스를 구현하는 객체
  • 사용자 기능에 대한 애플리케이션 서비스는 유스케이스에 따라
    • 사용자 등록 행위를 정의
    • 사용자 정보 수정 행위를 정의|
  • 이들 행위는 도메인 객체를 실제로 조합해 실행되는 스크립트
  • 정리하자면, 지금까지 구현한 1~5장 개념을 하나의 애플리케이션으로써 묶어서, 도메인으로써 관리.
  • 도메인 구현 영역을 확정 관리합니다.

유스케이스 수립 (예시 제공)

  • 등록, 정보 수집, 정보 수정, 탈퇴
  • 등록 예시
      public class UserApplicationService
      {
          private readonly IUserRepository userRepository;
          private readonly UserService userService;
    
          public UserApplicationService(IUserRepository userRepository, UserService userService)
          {
              this.userRepository = userRepository;
              this.userService = userService;
          }
    
          public void Register(string name)
          {
              var user = new User(
                  new UserName(name)
              );
    
              if (userService.Exsist(user))
                  throw new CanNotRegisterUserException(user, "이미 등록된 사용자임");
              
              userRepository.Create(user);
          }
      }

도메인 규칙의 유출

  • 애플리케이션 서비스는 도메인 객체가 수행하는 테스크를 조율하는 데만 전념해야 한다.
  • 애플리케이션 서비스에 도메인 규칙을 기술해서는 안된다.
    • 중복 현상이 나타난다.
  • 만일 중복 검사에 이메일 추가 검사라는 추가적인 조건이 붙는다고 하자.
    • 애플리케이션에서 이를 구현하면,, 구멍이 생긴다.
    • 이럴 땐 도메인 서비스를 변경한다.
    • 만일 애플리케이션에서 정의하면
    • 서로 다른 두 군데에서 서로 다르게 중복검사를 한다.
    • 이는 유지보수가 어려워지고 및 기타 버그를 유발한다.

애플리케이션 서비스와 프로그램의 응집도

  • 응집도 : 모듈의 책임 범위가 얼마나 집중되어 있는지 나타내는 척도
  • 측정 방법 : LCOM (Lack of Cohesion in Methods)
    • 모든 인스턴스 변수가 모든 메서드에서 사용 되어야 한다는 관점에서 인스턴스 변수의 개수와 메서드의 수를 통해 응집도를 계산
  • 응집도 낮은 예
    pubilc class LowCohesion
    {
      private int value1, value2;
      private int value3, value4;
      public int MethodA() => value1 + value2;
      public int MethodB() => value3 + value4;
    }
  • 응집도 높은 예
    pubilc class HeightCohesionA
    {
      private int value1, value2;
      public int MethodA() => value1 + value2;
    }
    pubilc class HeightCohesionB
    {
      private int value3, value4;
      public int MethodB() => value3 + value4;
    }
  • 응집도를 높이기 위해서 클래스로 분리하자.
    • 예) 탈퇴 처리에는 UserService가 필요없다. IUserRepository만 사용해도 충분,
    • 따라서 UserDeleteService라고 따로 구성한다.

애플리케이션 서비스의 인터페이스

  • 애플리케이션 서비스를 인터페이스로 구성할 수 있다.
  • 이 경우 클라이언트 측에서의 편의성이 높아진다.
    • 클라이언트와 애플리케이션이 분업 형태로 개발 중일 경우 좋다.
    • (개인생각) 서비스 버전에 상관없이 테스트 가능

@SAgiKPJH
Copy link
Contributor Author

1장 ~ 6장 내용 정리

  • 값 객체 : 하나하나 정의한 나만의 객체
  • 엔티티 : 이름은 달라져도 같은 사람 (보통 id로 구분)
  • 도메인 서비스 : UserService, Exsist 확인은 User가 하지 않음
  • 리포지토리 : 실질적인 저장 사이의 중간단계, 여러 종류의 데이터 스토어를 위해 Interface로 구성
  • 위 4가지를 묶어서 활용하는 애플리케이션 서비스 : 도메인 객체가 수행하는 테스크를 조율하는 데만 전념

@SAgiKPJH
Copy link
Contributor Author

DDD 도메인 주도설계 7장

소프트웨어의 유연성을 위한 의존 관계 제어

  • DI 의존관계 역전 원칙 (Dependency Inversion Principle)

Service Locator 패턴

private readonly IUserRepository userRepository;

public UserApplicationService()
{
  // ServiceLocator를 통해 필요한 인스턴스를 받음
  this.userRepository = ServiceLocator.Resolve<IUserRepository>();
}
  • ServiceLocator에 의존 해소를 위한 정보 미리 등록하기
    ServiceLocator.Register<IUserRepository, InMemoryUserRepository>();
  • 교체
    ServiceLocator.Register<IUserRepository, UserRepository>();
  • 이를 통해 Test여부를 쉽게 선택할 수 있다.
  • 단점
    • 의존 관계를 외부에서 보기 어렵다.
    • 테스트 유지가 어렵다.

IoC Container 패턴

  • IoC Container(DI Container)
// IFootRepository 가 추가되었을 때 생성자에 추가
public UserApplicationService(IUserRepository userRepository, IFootRepository footRepository) { /*...*/ }
// 하지만 UserApplicationService를 생성하는 모든 코드를 교체해야 한다.
  • 이런한 의존 관계를 제거한 IoC Container 패턴
var serviceCollection = new ServiceCollection();
serviceCollection.AddTransient<IUserRepository, InMemoryUserRepository>();
serviceCollection.AddTransient<UserApplicationService>();

var provider = serviceCollection.BuildServiceProvider();
var userApplicationService = provider.GetService<UserApplicationService>();

@SAgiKPJH
Copy link
Contributor Author

SAgiKPJH commented Mar 20, 2024

DDD 도메인 주도설계 8장

복잡한 객체 생성을 맡길 수 있는 '팩토리 패턴'

팩토리

  • 기존 식별자 클래스의 생성자 영역 (예시로 Class User)에 생성부분을 팩토리로 분리
  • 즉, 객체 생성과정을 객체로 정의
    • 객체 생성을 책임지는 공장(Factory)
    • 객체 생성 과정과 관련된 지식이 정리된 객체
  • 이를 분리하여 기존 식별자 클래스에 복잡한 로직을 제외

예시

  • As-Is
public class User 
{
  private readonly UserId id;
  private UserName name;
  
  // 사용자를 최초 생성할 때 실행되는 생성자 메서드
  public User(UserName name)
  {
    if (name = null)
      throw new ArgumentNullException(nameof(name));

    // 식별자로 GUID를 사용한다.
    id = new UserId(Guid.NewGuid().ToString());
    this.name = name;
  }

  // 사용자 객체를 복원할 때 실행되는 생성자 메서드
  public User(UserId id. UserName name)
  {
    if (id = null)
      throw new ArgumentNullException(nameof(id));
    if (name = null)
      throw new ArgumentNullException(nameof(name));

    this.id = id;
    this.name = name;
    // (...생략...)

  }
}
  • To-Be
public class User
{
  private readonly UserId id;
  private UserName name;
 
  // 사용자 객체를 복원할 때 실행되는 생성자 메서드
  public User(UserId id. UserName name)
  {
    if (id = null)
      throw new ArgumentNullException(nameof(id));
    if (name = null)
      throw new ArgumentNullException(nameof(name));

    this.id = id;
    this.name = name;
    // (...생략...)

  }
}
public interface IUserFactory
{
  User Create(UserName name);
}
public class UserApplicationService
{
  private readonly IUserFactory userFactory;
  private readonly IUserRepository userRepository;
  private readonly UserService userService;
  // (...생략...)

  public void Register(UserRegisterCommand command)
  {
    var userName = new UserName(command.Name);

    // 팩토리를 이용해 인스턴스를 생성 
    var user = userFactory.Create(userName);

    if (userService.Exists(user))
      throw new CanNotRegisterUserException(user);

    userRepository.Save(user);
  }
}
class InMemoryUserFactory : IUserFactory
{
  // 마지막으로 발행된 식별자
  private int currentId;
  public User Create(UserName name)
  {
    // 사용자를 생성할 때마다 1씩 증가
    currentId++;

    return new User(new UserId(currentId.ToString()),name);
  }
}

팩토리 존재감

  • 팩토리는 객체 생성에 활용합니다.
  • 객체와 아주 밀접한 관련이 있으므로, 같은 폴더에 위치해 중요성을 알립니다.
    • SnsDomain.Model.Users.User
    • SnsDomain.Model.Users.IUserFactory

팩토리와 레파지토리

  • 레파지토리에 팩토리를 구성하면 가능은 하다.
  • 하지만 레파지토리 저장과 팩토리 생성에 기술이 어긋나면 위험하다.
  • 따라서 어떻게 활용할 지는 개발자의 몫이다.

@SAgiKPJH
Copy link
Contributor Author

SAgiKPJH commented Mar 21, 2024

DDD 도메인 주도설계 10장

데이터 무결성 유지하기

  • 무결성 문제에 대비해, 데이터 무결성을 유지하는 방법

무결성?

  • 무결성 : 서로 모순이 없고 일관적

무결성 문제?

  • {A : 0}, {B : 1}, {C : 2}와 같이 `{O : O}' 일관적인 형태로 데이터가 저장된다.
  • 이때 데이터 저장시 {A:?}또는 {?:12}와 같이 누락되면 문제가 발생한다.
  • 이러한 일관성 문제를 대비해 무결성 유지방법을 배운다.

무결성 문제 예시

public class UserApplicationService
{
  private readonly IUserFactory userFactory;
  private readonly IUserRepository userRepository;
  private readonly UserService userService;
  // (...생략...)

  public void Register(UserRegisterCommand command)
  {
    var userName = new UserName(command.Name);
    var user = userFactory.Create(userName);

    if (userService.Exists(user))
      throw new CanNotRegisterUserException(user);

    userRepository.Save(user);
  }
}
  • A와 B가 동시에 "test"를 만들었다고 하자.
  • A가 먼저 만들어서 "test"가 있는지 확인할 때, B가 "test를 만들었다.
    • 이때, B도 "test"가 있는지 확인하는데, 아직 A에서 Repository를 Save하지 않았으므로
    • A와 B 모두 통과된다.
  • 결과적으로 "test"가 2개 만들어진다. (버그)

해결방법 1 유일키

  • 장점
    • 소프트웨어의 위험성 제거
  • 단점
    • 잘 못 사용하면 코드 표현력이 저하
  • 어떻게 사용하느냐가 중요
    • 유일키를 활용하면, 위 예시의 경우에는 중복 확인이 불필요하게 된다.
    • 하지만 그렇게 되면 중복 확인 한다는 비즈니스를 알기 어렵다.
    • 따라서 중복확인에 유일키를 넣어서 사용중이면 예외처리하도록 구성한다.

해결방법 2 트랜잭션

  • 트랜잭션 : 데이터 베이스의 무결성 유지를 위한 수단으로, 서로 의존적인 조작을 한꺼번에 완료하거나 취소하는 방법으로, 무결성을 지킨다.
  • 데이터가 바로 반영이 안되고, 나중에 한꺼번에 커밋 해야 한다.

해결방법 3 트랜잭션 - C# TransactionScope()

  • C#에서는 무결성을 필요로 하는 내용임을 알리기 위해 트랜잭션 범위 기능 제공한다.
public class UserApplicationService
{
  private readonly IUserFactory userFactory;
  private readonly IUserRepository userRepository;
  private readonly UserService userService;
  // (...생략...)

  public void Register(UserRegisterCommand command)
  {
   using(var transaction = new TransactionScope())
   {
      var userName = new UserName(command.Name);
      var user = userFactory.Create(userName);

      if (userService.Exists(user))
        throw new CanNotRegisterUserException(user);

      userRepository.Save(user);

      transaction.Complete(); // 커밋
   }
  }
}

해결방법 4 트랜잭션 - AOP

  • 관점 지향 프로그래밍(AOP, aspect oriented programming)
  • Java의 경우에 메서드에 @transactional을 붙여서 구현한다.

해결방법 5 트랜잭션 - 유닛오브워크

  • 유닛오브워크(UnitOfWork)

@SAgiKPJH SAgiKPJH self-assigned this Mar 22, 2024
@SAgiKPJH
Copy link
Contributor Author

SAgiKPJH commented Apr 4, 2024

11장

@SAgiKPJH
Copy link
Contributor Author

SAgiKPJH commented Apr 4, 2024

DDD 도메인 주도설계 12장

도메인의 규칙을 지키는 ‘애그리게이트’

  1. 애그리게이트란?

애그리게이트는 불변 조건을 유지하는 단위로 꾸려지며 객체 조작의 질서를 유지한다.

애그리게이트는 경계와 루트 를 갖는다.

경계는 말 그대로 애그리게이트에 포함되는 대상을 결정하는 경계

루트는 애그리게이트에 포함되는 특정한 객체

외부에서 애그리게이트를 다루는 조작은 모두 루트를 거쳐야만 한다.

애그리게이트에 포함되는 객체를 외부에 노출하지 않음으로써 불변의 조건을 유지 할 수 있다.

1.1 애그리게이트의 기본 구조

애그리게이트는 서로 연관된 객체를 감싸는 경계를 통해 정의 된다.

외부에서는 애그리게이트 내부에 있는 객체를 조작할 수 없다.

오직, 애그리게이트 루트(aggregate root)로만 조작이 가능하다.

var userName = new UserName("NewName");

//NG
user.Name = userName;

// OK
user.ChangeName(userName);
ChangeName 매서드를 만들면 메서드를 통해 전달받은 값을 확인(null 체크 등) 할 수 있다.

circle.Members.Add(member);
위 코드는 애그리게이트의 규칙을 위반하는 코드이다.

서클 애그리게이트에 포함되는 Members에 대한 조작은 애그리게이트의 루트인 Circle 객체를 통해야 한다.

public class Circle
{
private readonly CircleId id;
private User owner;
// 소속된 사용자 목록을 비공개로 돌린다.
private List members;

(... 생략 ...)

public void Join(User member)
{
if(member == null)
throw new ArgumentNullException(nameof(member));

if(members.Count >= 29)
throw new CircleFullException(id);

members.Add(member);
}
}
members 속성이 외부에 공개되지 않으므로 서클에 새로운 사용자를 추가하려면 Join 메서드를 호출하는 방법이다.

circle.Join(user);
객체 지향 프로그래밍에서는 외부에서 내부 객체를 직접 다루는 대신, 내부 객체를 감싸는 객체에 요청하는 형태를 취한다.

이런 방법으로 불변 조건을 유지하면서도 직관과 좀 더 일치하는 코드를 만들 수 있다.

데메테르의 법칙으로 알려진 규칙이다.

1.2 객체를 다루는 조작의 기본 원칙

데메테르의 법칙은 객체 간의 메서드 호출에 질서를 부여하기 위한 가이드라인이다.

데메테르의 법칙은 어떤 컨텍스트에서 다음 객체의 메서드만을 호출할 수 있게 제한한다.

객체 자신

인자로 전달받은 객체

인스턴스 변수

해당 컨텍스트에서 직접 생성한 객체

if(circle.Members.Count >= 29)
{
throw new CircleFullException(id);
}
위 코드는 서클에 소속된 사용자의 수가 규칙에 정해진 최대 인원을 초과하는지 확인하는 코드로, Circle 객체의 속성인 Mmebers에 직접 접근해 Count 메서드를 호출한다.

메서드를 사용할 수 있는 객체의 범위 벗어나기 때문에 데메테르의 법칙을 위반한 코드이다.

public class Circle
{
private readonly CircleId id;
// 멤버 목록을 비공개로 함
private List members;

(... 생략 ...)

public bool IsFull()
{
return members.Count >= 29;
}

public void Join(User user)
{
if(user == null)
throw new ArgumentNullException(nameof(user));

if(IsFull())
throw new CircleFullException(id);

members.Add(user);
}
}
최대 인원에 도달했는지 여부는 isFull 메서드를 통해 확인한다.

if(circle.IsFull())
{
throw new CircleFullException(circleId);
}
서클의 최대 인원수와 관련된 지식은 모두 isFull 메서드에 집중돼 있다.

public class Circle
{
(... 생략 ...)

public bool IsFull()
{
// return member.Count >= 29;
return members.Count >= 49;
}
}
게터를 통해 필드를 공개하면 객체에 구현돼야 할 규칙이 다른 곳에서 중복 구현되는 일을 완전히 막을 수 없다.

데메테르의 법칙은 소프트웨어의 유지 보수성을 향상시키고 코드를 더욱더 유연하게 한다.

1.3 내부 데이터를 숨기기 위해

객체 내부의 데이터를 함부로 외부에 공개돼서는 안 된다.

그러나 데이터를 외부에 전혀 공개하지 않으면 리포지토리가 객체를 데이터스토에에 저장 할 수가 없다.

public class EFUserRepository : IUserRepository
{
public void Save(User user)
{
// 게터를 이용해 데이터를 옮겨담는다.
var userDataModel = new UserDataModel
{
Id = user.Id.Value,
Name = user.Name.Value
}
context.Users.Add(userDataModel);
context.SaveChanges();
}
(... 생략 ...)
}
EFUserRepository는 User 클래스의 객체를 저장할 때 프레임워크에서 사용되는 데이터 모델인 UserDataModel로 데이터를 옮겨 담는다.

UserDataModel 객체를 생성하려면 User 클래스의 Id와 Name에 접근해야 하므로 User 클래스가 Id와 Name을 완전히 비공개로 하면 이 코드는 컴파일 애러를 일으킨다.

가장 단순하고 일반적인 해결체는 팀 내에 규칙을 정해 리포티토리 객체외에는 애그리게이트의 내부 데이터에 접근하는 코드를 작성하지 말자고 할 수 있다.

또 다른 방법은 노티피케이션 객체를 이용하는 것이다.

public interface IUserNotification
{
void Id(UserId id);
void Name(UserName name);
}
public class UserDataModelBuilder : IUserNotification
{
// 전달 된 데이터는 인스턴스 변수로 저장된다.
private UserId id;
private UserName name;

public void Id(UserId id)
{
this.id = id;
}

public void Name(UserName name)
{
this.name = name;
}

// 전달 받은 데이터로 데이터 모델을 생성하는 메서드
public UserDataModel Build()
{
return new UserDataModel
{
Id = id.Value,
Name = name.Value
};
}
}
User 클래스는 노티피케이션의 객체의 인터페이스를 통해 내부 정보를 전달 받는다.

public class User
{
// 인스턴스 변수는 모두 비공개
private readonly UserId id;
private UserName name;

(...생략 ...)

public void Notify(IUserNotification note)
{
// 내부 데이터를 전달
note.Id(id);
note.Name(name);
}
}
객체의 내부 데이터는 비공개로 그대로 두면서 외부에 데이터를 전달 할 수 있다.

public class EFUserRepository : IUserRepository
{
public void Save(User user)
{
// 노티피케이션 객체를 전달했다가 다시 회수해 내부 데이터를 입수한다.
var userDataModelBuilder = new UserDataModelBuilder();
user.Notify(userDataModelBuilder);

// 전달 받은 내부 데이터로 데이터 모델을 생성
var userDataModel = userDataModelBuilder.Build();

// 데이터 모델을 ORM에 전달한다.
context.Users.Add(userDataModel);
context.SaveChanges();
}

(... 생략 ...)
}

  1. 애그리게이트의 경계를 어떻게 정할 것인가

애그리게이트의 경계를 정하는 원칙 중 가장 흔히 쓰이는 것은 변경의 단위이다.

서클과 사용자는 별개의 애그리게이트이므로 서클을 변경할 때는 서클 애그리게이트 내부로 변경이 제한돼야 하고, 사용자를 변경할 때도 사용자 애그리게이션 내부의 정보만 변경돼야 한다.

만약, 이러한 규칙을 위반하고 서클 애그리게이트에서 자신의 경계를 넘어 사용자 애그리게이트까지 변경하려고 하면 프로그램에 어떤 일이 일어날까?

public class Circle
{
private List members;

(... 생략 ...)

public void ChangeMemberName(UserId id, UserName name)
{
var target = members.FirstOrDefault(x => x.Id.Equals(id))
if(target != null)
{
target.ChangeName(name);
}
}
}
서클 애그리게이트가 자신의 경계를 넘어 사용자 애그리게이트를 조작하면 그 영향이 리포지토리에 나타난다.

아래는 서클 애그리게이트의 퍼시스턴시 처리 코드이다.

public class CircleRepository : ICircleRepository
{
(... 생략 ...)

public void Save(Circle circle)
{
using(var command = connection.CreateCommand())
{
command.CommandText = @"
MERGE INTO circles
USING(
SELECT @id AS id, @name AS name, @ownerId AS ownerId
) AS data
ON circles.id = data.id
WHEN MATCHED THEN
UPDATE SET name = data.name, ownerId = data.ownerId
WHEN NOT MATCHED THEN
INSERT (id, name, ownerId)
VALUES (data.id, data.name, data.ownerId);
";

command.Parameters.Add(new SqlParameter("@id", circle.Id.Value));
command.Parameters.Add(new SqlParameter("@name", circle.Name.Value));
command.Parameters.Add(new SqlParameter("@ownerId", (object)circle.Owner?.Id.Value ?? DBNull.Value));
command.ExecuteNonQuery();
}

using(var command = connection.CreateCommand())
{
command.CommandText = @"
MERGE INTO userCircles
USING(
SELECT @userid AS userId, @circleid AS circleId
) AS data
ON userCircles.userId = data.userId AND userCircles.circleId = data.circleId
WHEN MATCHED THEN
INSERT (userId, circleId)
VALUES (data.userId, data.circleId);
";

command.Parameters.Add(new SqlParameter("@circleid", circle.Id.Value));
command.Parameters.Add(new SqlParameter("@userid", null));

foreach (var member in circle.Members)
{
command.Parameters["@userid"].Value = member.Id.Value;
command.ExecuteNonQuery();
}

}
}
}
서클 애그리게이트가 자신의 경계를 넘어 사용자 애그리게이트의 데이터를 변경한다.

이 상태로는 경계 너머 사용자 애그리게이트를 변경한 내용이 저장되지 않는다. 그래서 리포지토리를 수정해야 한다.

public class CircleRepository : ICircleRepository
{
(... 생략 ...)

public void Save(Circle circle)
{
// 사용자 애그리게이션에 대한 업데이트 처리
using( var command = connection.CreateCommand())
{
command.CommandText = "UPDATE users SET username = @username WHERE id = @id";
command.Parameters.Add(new SqlParameter("@id", null));
command.Parameters.Add(new SqlParameter("@username", null));

foreach(var user in circle.Members)
{
command.Parameters["@id"].Value = user.Id.Value;
command.Parameters["@username"].Value = user.Name.Value;
command.ExecuteNonQuery();
}
}

// 서클 애그리게이션에 대한 업데이트는 그 다음
(... 생략 ...)
}
}
리포지토리를 수정한 결과, 서클 리포지토리의 로직 대부분이 사용자의 정보를 수정하는 코드로 오염됐다.

더불어 서클 리포지토리에 새로 추가된 코드의 대부분이 사용자 리포지토리의 코드와 중복 된다.

애그리게이트에 대한 변경은 해당 애그리게이트 자신에게만 맡기고, 퍼시스턴시 요청도 애그리게이트 단위로 해야 한다.

해당 이유로 인해 리포지토리는 애그리게이트마다 하나씩 만든다.

2.1 식별자를 이용한 컴포지션

Circle 객체는 User 클래스의 인스턴스를 컬렉션 객체에 저장하고 프로퍼티를 통해 객체에 접근해 메서드를 호출 할 수 있다.

하지만, 이것 자체를 문제로 보는 시각이 많다.

애그리게이트의 경계를 넘지 않는다는 불문율을 만드는 것보다 더 나은 방법이 없을까?

인스턴스를 갖지 않도록 하면 된다.

인스턴스를 실제로 갖지는 않지만 그런 것처럼 보이게끔 하는것, 엔티티의 식별자를 사용하면 된다.

public class Circle
{
public CircleId Id { get; }
public CircleName Name { get; private set;}
//public List Members { get; private set; }
public List Members { get; private set; }

(... 생략 ...)
}
이러한 방법을 사용하게 된다면 User 객체의 메서드를 호출 할 일은 없을 것이다.

이러한 절차를 강제한다면 부주의하게 메서드를 호출해 애그리게이트 너머의 영역을 변경하는 일은 일어나지 않는다.

또한 메모리를 절약하는 효과가 있는데 아래는 서클명을 변경하는 처리 코드이다.

public class CircleApplicationService
{
private readonly ICircleRepository circleRepository;

(... 생략 ...)

public void Update(CircleUpdateCommand command)
{
using(var transaction = new TransactionScope())
{
var id = new CircleId(command.Id);
// 이 지점에서 User 객체가 복원되지만,
var circle = circleRepository.Find(id);
if(circle == null)
throw new CircleNotFoundException(id);

if(command.Name != null)
{
var name = new CircleName(command.Name);
circle.ChangeName(name);

if(circleService.Exists(circle))
 throw new CanNotRegisterCircleException(circle, "이미 등록된 서클임");

circleRepository.Save(circle);

transaction.Complete();

// User 객체를 사용하지 않고 처리가 끝남

}
}
}
}
Circle 객체가 소속 사용자를 User 객체 형태로 포함하는 경우 리포지토리가 사용자 객체를 복원해도 전혀 사용되지 않고 버려진다. (리소스 낭비)

User 객체를 직접 포함하는 대신 UserId를 포함하면 소속 사용자는 모든 User 객체를 복원할 만큼의 처리 능력을 절약할 수 있을 것이고 인스턴스를 저장하기 위한 메모리도 절약될 것이다.

  1. 애그리게이트의 크기와 조작의 단위

애그리게이트의 크기는 가능한 한 작게 유지하는 것이 좋다.

한 트랜잭션에서 여러 애그리게이트를 다루는 것도 가능한 한 피해야 한다.

여러 애그리게이션에 걸친 트랜잭션은 범위가 큰 애그리게이트와 마찬가지로 광범위한 데이터에 로크를 걸 가능성이 높다.

  1. 언어 표현과의 모순 제거

서클의 규칙 중 서클에 소속되는 인원은 서클장을 포함해 최대 30명까지 허용된다.이 있다.

30이라는 구체적인 수치가 규칙에 실려 있지만, 정작 코드에 나오는 수치는 29이다.

public class Circle
{
private User owner;
private List members;

(... 생략 ...)

public bool IsFull()
{
return members.Count >= 29;
}
}
표면적인 수치에 차이가 있어도 정상적인 코드이다.

하지만 코드에 문제가 없다고 해서 언어 표현과의 모순을 그대로 두면 오해를 일으키기 쉽다.

public class Circle
{
private User owner;
private List members;

(... 생략 ...)

public bool IsFull()
{
return CountMembers() >= 30;
}

public int CountMembers()
{
return members.Count + 1;
}
}

@SAgiKPJH
Copy link
Contributor Author

SAgiKPJH commented Apr 10, 2024

DDD 도메인 주도설계 13장

복잡한 조건을 나타내기 위한 ‘명세’

명세?

  • 객체를 평가하기 위한 객체

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
None yet
Projects
None yet
Development

No branches or pull requests

1 participant