본문 바로가기

더 나은 엔지니어가 되기 위해/읽고 쓰며 정리하기

도메인 주도 설계 철저 입문 4부 - 지식 표현을 위한 고급 패턴

이 글은 도메인 주도 설계 철저 입문 (위키북스) 를 읽고 개인적으로 정리한 글입니다.


0. 들어가며

이번 글에서는 책에서는 고급 패턴이라고 표현되어 있는, 도메인을 더 잘 표현할 수 있는 방법들을 배운다.

 


1. 애그리게이트 (Aggregate)

1.1. 애그리게이트 예시

먼저 다음 예시를 보자.

@dataclass
class User:
    id: UserId = field(init=False, default_factory = UserId)
    name: UserName = field(compare=False)

User 라는 엔티티 객체(Entity)는 UserIdUserName 이라는 값 객체(Value Object) 로 구성되어 있다. 여기서 User, UserId, UserName 이라는 3개의 객체가 등장했는데 이 3개는 User 라는 객체를 중심으로 모두 연관이 있다.
이처럼 연관된 객체 묶음을 애그리게이트(Aggregate)라고 하며, 여기서 중심이 되는 객체를 애그리게이트 루트(Aggregate Root)라고 한다. 여기서는 User 가 애그리게이트 루트가 되겠다.

또 다른 예시 하나를 보자.

@dataclass
class UserGroup:
    id: UserGroupId = field(init=False, default_factory = UserGroupId)
    owner: User
    users: List[User] = field(default_factory = list)

    def __post_init__(self):
        self.users.append(self.owner)

    def join(self, user: User) -> None:
        self.users.append(user)

이 역시 UserGroup, UserGroupId 로 이루어진 애그리게이트고, UserGroup 이 애그리게이트 루트가 된다. 이때 User 는 이미 다른 애그리게이트의 루트로 소속되어 있으므로, UserGroup 의 애그리게이트에 소속되지는 않는다.

애그리게이트를 다룰 때 중요한 규칙이 있다. 애그리게이트에 포함되는 객체를 조작할 때는 항상 애그리게이트 루트를 통해서 해야 한다는 것이다. 예를 들면 다음은 이 규칙을 어기는 코드다.

# Bad case
user = User(...)
user_group.users.append(user)

위 코드는 UserGroup 애그리게이트에 포함되는 users 의 조작을 "직접" 하고 있다.
위 규칙대로라면 이 코드는 다음처럼 수정해야 한다.

# Good case
user_group.join(user)

물론 이를 위해 UserGroup 에는 join 이라는 메서드를 추가해야 한다.
여하튼 요지는 애그리게이트 객체의 조작과 관리는 모두 애그리게이트 루트를 통해 이뤄져야 한다는 사실이다.

[데메테르의 법칙]

위 규칙은 일명 데메테르의 법칙을 지키는 것으로, 데메테르의 법칙은 객체 간의 메서드 호출에 질서를 부여하기 위한 가이드라인이다. 데메테르의 법칙은 어떤 컨텍스트에서 다음 객체의 메서드만을 호출할 수 있게 제한한다.

  • 객체 자신

  • 인자로 전달받은 객체

  • 인스턴스 변수

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

    구체적인 예로 보면 다음과 같다.

class UserApplicationService:
    def __init__(self, 
                 user_repository: UserRepository,
                 user_domain_service: UserDomainService) -> None:
        self._user_repository = user_repository
        self._user_domain_service = user_domain_service

    def register(self, user) -> None:
        self._do_something()  # 객체 자기 자신의 메서드를 호출했으므로 good!
        user._do_something()  # 인자로 전달받은 객체의 메서드를 호출했으므로 good!

        if self._user_domain_service.exists(user):  # 인스턴스 변수의 메서드를 호출했으므로 good!
            raise Exception(f"{user_name}을 가진 사용자가 이미 있습니다.")
        self._user_repository.save(user)  # 인스턴스 변수의 메서드를 호출했으므로 good!
        self._user_repository.users.append(user)  # 인스턴스 변수의 메서드를 호출한 경우가 아니므로 bad!

    def do_something(self) -> None:
        pass

이 법칙은 객체를 다루고 호출하는데 기본이 된다.

 

1.2. 애그리게이트 경계

애그리게이트가 하나의 연관된 객체 묶음이라는 것과, 애그리게이트로 데이터를 조작하기 위해서는 그 중심이 되는 엔티티, 즉 애그리게이트 루트를 통해야 함을 알았다. 그렇다면 정확히 애그리게이트의 경계는 어떻게 규정할 수 있을까?

기본적으로 경계는 도메인 규칙과 요구사항에 따라 설계된다. 그러나 이 경계를 명확히 설계하는 것은 어려운데, 이를 위해 가장 흔히 쓰이는 기준 중 하나는 "변경의 단위"라고 한다.

위의 예시인 UserUserGroup 의 경우를 생각해보자.
누가 보아도, UserUser 와 관련된 객체들인 UserIdUserName 과 같은 애그리게이트에 묶여야 하고, UserGroup 역시 자명하다.
그렇다면 User 의 변경이 UserGroup 에 영향을 주지는 않는가? 다음 코드를 보자.

# 유저 생성 후 저장
user_1 = User(name=UserName("heumsi"))
user_2 = User(name=UserName("evanss"))

user_repository = UserRepository(...)
user_repository.save(user_1)
user_repository.save(user_2)

# 유저 그룹 생성 후 저장
user_group = UserGroup(owner=user_1)
user_group.join(user_2)

user_group_repository = UserGroupRepository(...)
user_group_repository.save(user_group)

여기까지는 문제가 없다. 그러나 다음 코드로 인해 문제가 발생한다.

# user_2 의 이름 변경 후 다시 저장
user_2.name = UserName("f_s_t_k")
user_repository.save(user_2)

user_2 의 이름을 변경한 것 뿐이다. 근데 왜 문제가 될까?
user_group.users 에는 user_2 인스턴스를 리스트의 요소로 담고있다. 이 인스턴스는 위 코드로 인해 변경되었으나, UserGroupRepositorysave 하지 않았기 때문에 데이터 저장소에는 이 내용이 업데이트되지 않았다. 따라서 후에 이 user_group 을 복원하면 업데이트 이전의 인스턴스를 가져올 것이다.

이를 위해 바로 떠오르는 해결책은 다음 코드를 위 코드에 추가하는 것이다.

user_2.name = UserName("f_s_t_k")
user_repository.save(user_2)

# 아래 코드 추가
user_group_repository.save(user_group)

그러나, user 에 대한 변경을 할 때마다 이렇게 user_group 까지 신경 써가며 매번 챙길 수는 없는 노릇이다. 그리고 코드가 커지면 user 변경의 여파가 user_group 외에 더 있을지 쉽게 알기도 어려울 것이다.

이런 변경의 여파, 즉 경계를 좀 더 확실히 하기 위해 user_group.users 는 변경 가능성이 있는 User 인스턴스를 들고있으면 안 된다. 대신 User 엔티티의 식별자인 id 를 들고 있으면 된다. 엔티티의 식별자는 한번 생성된 이후 변경될 일이 없기 때문이다. 즉 UserGroup 은 다음과 같이 수정되어야 한다.

@dataclass
class UserGroup:
    id: UserGroupId = field(init=False, default_factory = UserGroupId)
    owner_id: UserId
    user_ids: List[UserId] = field(default_factory = list)  # User 인스턴스가 아닌 식별자인 UserId 를 저장한다.

    def __post_init__(self):
        self.user_ids.append(self.owner_id)  

    def join(self, user_id: UserId) -> None:  
        self.user_ids.append(user)  

수정된 코드로 다시 위의 문제 상황을 보면 문제가 해결된다는 것을 알 수 있다.
이제 User 인스턴스를 수정해도 이와 엮여있는 다른 엔티티들을 신경쓰지 않아도 된다. 애그리게이트의 경계가 더욱 명확해졌다.

 

1.3. 정리

애그리게이트는 관련 객체의 묶음이다. 애그리게이트는 애그리게이트간에 경계를 갖도록 설계해야 한다.
애그리게이트와 관련된 변경은 애그리게이트 루트를 통해야 한다.
한 애그리게이트에서 경계를 넘어 다른 애그리게이트와 관계할 때, 변하지 않는 에그리게이트 루트의 식별자와 관계해야 한다.

위에서 설명하지 않았지만, 책에 등장하는 추가적인 조언들을 적으면

  • 애그리게이트 하나 당 리포지토리 하나를 만든다.
  • 애그리게이트 크기는 가능한 작게 유지하는 게 좋다.
    • 너무 크면 해당 애그리게이트 리포지토리에서 트랜잭션의 사이즈가 너무 커지기 때문이다.

[애그리게이트 정리에 대한 개인적인 생각]

정리하며 책을 보고보고 다시 봐도, 솔직히 말해 애그리게이트 개념에 대해 아직 확 와 닿진 않는다.
예시로 들어준 UserUserGroup 도 너무 작은 사이즈 + 단순한 관계를 가지고 있어서인지.. 실제 현실에서는 어떻게 구성될지 감이 안 오는 게 사실이다. 그래서 이번 정리는 매우 부끄럽고 틀린게 많을 수 있다.

애그리게이트는... 다른 DDD 책이나 슬라이드를 통해 계속해서 이해하려는 시도를 해봐야겠다.


2. 명세 (Specification)

2.1. 명세 예시

1) 예시 첫 번째

객체를 평가하는 절차가 단순하다면 해당 객체의 메서드로 정의하면 되겠지만, 복잡한 평가 절차가 필요할 수도 있다.
예를 들어 다음과 같은 도메인 규칙이 있다고 하자.

  • 유저 중에는 프리미엄 유저라는 유형이 존재한다.
  • 유저 그룹의 최대 인원은 30명이다.
  • 유저 그룹에 프리미엄 유저가 10명 이상 포함되면, 유저 그룹의 최대 인원은 50명으로 늘어난다.

이를 애플리케이션 서비스 객체의 메서드로 단순히 정의하면 코드는 다음처럼 된다.

class UserGroupApplicationService:
    def __init__(self, 
                 user_group_repository: UserGroupRepository,
                 user_repository: UserRepository) -> None:
        self._user_group_repository = user_group_repository
        self._user_repository = user_repository

    def join(self, 
             user_id: UserId, 
             user_group_id: UserGroupId) -> None:
        # 먼저 유저 그룹을 복원한다.
        user_group = self._user_group_repository.find_by_id(user_group_id)

        # 유저 그룹에 속한 유저들을 복원한다.
        users = self._user_repository.find_by_ids(user_group.users)

        # 유저 그룹에 속한 프리미엄 유저에 따라 최대 인원이 결정된다.
        premium_user_count = sum(map(lambda user: user.is_premium(), users))
        user_group_upper_limit = 30 if premium_user_count < 10 else 50

        # 현재 인원을 파악한 뒤, 유저가 유저 그룹에 들어갈 수 있는지 판단한 뒤 저장한다.
        if len(user_group.users) > user_group_upper_limit:
            raise Exception(f"유저 그룹에 유저가 꽉 차서 들어갈 수 없습니다.")
           user_group.join(user_id)
        self._user_group_repository.save(user_group)

얼핏 보기에 문제 될 게 없어 보인다. 하지만 DDD 관점에서 이 코드는 문제가 있다.
바로, 도메인 규칙이 도메인 객체가 아닌 애플리케이션 서비스 객체에 노출되어 있다는 것이다.
도메인 규칙은 도메인에 정의돼야 한다.

[정리하다 곰곰이 생각해보니....]

정리하다 느낀건데, 위 로직을 애플리케이션 서비스가 아닌 도메인 서비스에 구현해도 되겠지 싶다.
도메인 서비스도 도메인 객체 중 하나므로, 도메인 규칙을 도메인에 정의하는 셈이다.

이렇게 복잡한 조건을 서비스 객체에 노출시키지 않기 위해 별도의 객체 개념이 등장하는데 바로 "명세"라는 객체다. 이 역시 도메인 규칙을 담는 도메인 객체로, 객체가 특정 조건을 만족하는지 판단한다.
아래는 위 코드를 명세 객체로 다시 표현한 코드다.

class UserGroupFullSpecification:
    def __init__(self, user_repository: UserRepository) -> None:
        self._user_repository = user_repository

    def is_satisfied(self, user_group: UserGroup) -> bool:
        # 유저 그룹에 속한 유저들을 복원한다.
        users = self._user_repository.find_by_ids(user_group.users)

        # 유저 그룹에 속한 프리미엄 유저에 따라 최대 인원이 결정된다.
        premium_user_count = sum(map(lambda user: user.is_premium(), users))
        user_group_upper_limit = 30 if premium_user_count < 10 else 50

        # 유저 그룹에 들어갈 자리가 있는지 여부를 반환한다.
        return len(user_group.users) <= user_group_upper_limit

이전 코드에서 조건을 만족하는지 여부의 코드만 가져와 객체로 만들었다. 이것이 명세 객체다.
이제 애플리케이션 코드는 다음과 같이 명세를 활용하여 수정할 수 있다.

class UserGroupApplicationService:
    def join(self, 
             user_id: UserId, 
             user_group_id: UserGroupId) -> None:
        user_group = self._user_group_repository.find_by_id(user_group_id)

        # 명세 인스턴스를 만든다.
        user_group_full_specification = UserGroupFullSpecification(self._user_repository)

        # 명세를 활용하여 조건을 만족하는지 확인한다.
        if not user_group_full_specification.is_satisfied(user_group):
            raise Exception(f"유저 그룹에 유저가 꽉 차서 들어갈 수 없습니다.")
           user_group.join(user_id)
        self._user_group_repository.save(user_group)

 

2) 예시 두 번째

명세의 필요성이 필요한 또 다른 예시를 하나 더 보자.
이번엔 유저에게 추천 유저 그룹 목록을 제공해주려고 한다.
추천의 기준은 다음과 같다.

  • 최근 1개월 이내에 결성된 유저 그룹
  • 소속된 유저 수가 10명 이상

이를 위한 명세 객체로 활용한 코드는 다음과 같다.

class UserGroupRecommendSpecification:
    def __init__(self, executed_datetime: datetime) -> None:
        self._executed_datetime = executed_datetime
        self._recommended_recent_month = timedelta(month=1)
        self._recommended_user_count = 10

    def is_satisfied(self, user_group: UserGroup) -> bool:
        if self._executed_datetime - user_group.created_datetime > self._recommended_recent_month:
            return False
        if len(user_group.users) < self._recommended_user_count:
            return False
        return True
class UserGroupApplicationService:
    def get_recommended(self) -> GetRecommendedDto:
        user_groups = self._user_group_repository.find_all()
        user_group_recommend_specification = UserGroupRecommendSpecification(datetime.now())
        recommended_user_groups = list(map(
            lambda user_group: user_group_recommend_specification.is_satisfied(user_group), 
            user_groups
        ))
        return recommended_user_groups[:min(len(recommended_user_groups), self._recommended_user_count)]

이렇게 명세를 활용할 수 있다!

[명세를 필터로 사용할 때 주의해야 할 점]

위 두 예제 모두 객체를 모두 복원 후, 명세를 일종의 필터로 사용하고 있다.
이렇게 할 경우 코드는 간결해지긴 하지만 아무튼 필터에서 걸러지는 객체들은 사용하지 않는다.
즉, 사용하지 않는 객체들까지 포함하여 모두 복원하는 것이 성능 저하를 일으킬 수 있다.
따라서, 이처럼 명세를 필터로 사용할 때는 쿼리 성능을 고려해야 한다.

라고 책에는 나오는데... 글쎄? 위에 작성한 예시는 사실 현실적으로 적용하기 어려워 보인다.
find_all() 쿼리를 매번 날리는 것도 그렇고, 그 결과를 받아 메모리에 매번 다 올리는 것도 매우 헤비 하기 때문이다.
그럼 실제로는 어떻게 구현해야 할까??

지금 드는 생각으로는 크게 두 가지 방법이 떠오른다.

  • get_recommended() 내부에 캐싱 로직을 적용하여, 매번 쿼리를 날리지 못하게 한다.
    • 예를 들면, UserGroupRecommendSpecification 을 싱글톤 객체로 만든다.
    • 그리고 마지막 함수 실행시간을 기록해둔 이 객체 내부에 저장해둔다.
    • 마지막 함수 실행시간으로부터 1시간이 안 지났으면 이전 결과를 그대로 반환하고, 지난 경우 지금 로직을 따른다.
  • UserGroupRepositoryget_recommend(...) 메서드를 만든 뒤, 이 메서드에서 명세를 활용한다.
    • 이때 명세는 WHERE 절에 들어갈 쿼리 형식으로 작성한다.
    • 예를 들면, 명세가 created_datetime < datetime.now() - self._recommended_recent_month 와 같은 문자열을 반환하는 것이다. 리포지토리는 이 결과를 WHERE 절에 넣어 쿼리를 넣는다.

참고로... 말은 쉽다 했다. 이렇게 생각만 해본 것이지, 실제로 구체적인 어떤 문제가 있는지는 나도 모르겠다.
혹시 이 글을 보고 있는 분은 해답을 알고 있다면 댓글로 알려주시면 감사하겠습니다.

 

2.2. 정리

명세 역시 도메인 지식과 규칙을 담으므로, 도메인 객체다.
객체가 특정 도메인 조건을 만족하는지에 대한 평가를 해당 객체 자신에게 맡기는 방법도 있지만, 이렇게 별도로 명세라는 객체를 만드는 방법도 있음을 알아두자.