본문 바로가기

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

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

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


0. 들어가며

이번 글에서는 도메인 지식을 어떻게 표현할 것인지에 대한 방법을 배운다.
크게 3가지를 다룬다.

  • 값 객체 (Value Object)
  • 엔티티 (Entity)
  • 도메인 서비스 (Domain Service)

1. 값 객체 (Value Object)

1.1. 값 객체 예시

보통 프로그래밍 언어에는 원시 타입(Primitive) 자료형이 있다. 그리고 이를 이용해 여러 값들을 표현할 수 있다.
예를 들면, "이름"은 다음처럼 string 으로 표현할 수 있다.

full_name = "jeon heumsi"

그러나, DDD에서는 이처럼 원시 타입을 사용하지 않고, 도메인에 맞는 객체로 정의해 사용한다.

class FullName:
    def __init__(self, first_name: str, last_name: str) -> None:
        self._first_name = first_name
        self._last_name = last_name

full_name = FullName("heumsi", "jeon")

요지는 이렇다. 이름이라는 것을 string 이 아니라, 좀 더 도메인에 명확히 정의하자는 것.
string 으로만 봤을 때, 이름이 first namelast name 으로 구성된다는 것을 전혀 알 수 없다.
그러나 FullName 이라는 객체를 봤을 때, 이 두 값으로 구성되는 것을 명확히 알 수 있다.

여기서 이 객체를 "값 객체"라고 부르는 이유는, 이 객체를 일종의 "값"을 다루는 객체로 사용하고 있기 때문이다.
이러한 값 객체들은 시스템의 특유의 값에 대한 표현이며, string, int 와 같은 값의 한 종류다.
다만 도메인에 특화된 타입이라고 생각하면 쉽다!

 

1.2. 값 객체의 성질

값 객체의 대표적인 성질로는 다음 3가지가 있다.

  • 변하지 않는다.
  • 주고받을 수 있다.
  • 등가성을 비교할 수 있다.

1) 변하지 않는다.

다음 예제를 보면 좀 더 쉽게 설명될 거 같다.

# 우리는 일반적으로 다음처럼 값을 대입하는게 자연스럽다.
name = "heumsi jeon"
name = "siheum jeon"

# 다음처럼 값을 수정하는건 뭔가 부자연스럽다.
name.change_value("heumsi jeon")  # 물론 이런 메서드를 string 이 제공하지도 않는다.
"heumsi jeon".change_value("siheum jeon")

이렇게 놓고 생각했을 때, 값 객체도 마찬가지다.

# 이게 자연스럽지
full_name = FullName("heumsi", "jeon")
full_name = FullName("siheum", "jeon")

# 이건 좀 부자연스럽다
full_name.change_name("siheum", "jeon")

따라서 값 객체에 이렇게 값을 수정하는 메서드를 넣으면 안 된다.
값 객체 인스턴스가 하나의 불변 값으로 자리 잡아야 하고, 수정이 필요할 시, 새로운 인스턴스를 주는 게 맞다.

2) 주고받을 수 있다.

이것도 위랑 겹치는 내용인데, 값은 대입문을 통해 교환의 형식으로 표현된다는 것이다.
즉 위의 두 번째 예시가 곧 이 내용이다.

3) 등가성 비교 가능

원시 타입을 생각해보면, 기본적으로 값들은 다음처럼 비교가 가능하다.

>>> a = 1
>>> b = 2
>>> a == b
False

값 객체도 동일하게 생각하면 된다. 값 객체 간 비교 연산이 가능하도록 내부에 메서드를 구현해줘야 한다.

class FullName:
    def __init__(self, first_name: str, last_name: str) -> None:
        self._first_name = first_name
        self._last_name = last_name

    # __eq__ 이라는 매직메서드를 사용하면 된다
    def __eq__(self, other: "FullName") -> bool:
        return self._first_name == other._first_name and self._last_name == other._last_name

사실 파이썬 3.7부터 등장한 dataclass 데코레이터와 더불어 order=True 파라미터를 사용하면 __eq__ 과 더불어 __ge__, __le__ 등 객체 간 비교 연산 메서드를 알아서 넣어준다. (dataclass 자체가 데이터 값 그 자체를 다루는 객체를 위해 만들어진 용도다.)
따라서 위 코드는 다음과 같이 더 간단하게 코딩할 수 있다.

from dataclasses import dataclass

@dataclass
class FullName:
    first_name: str
    last_name: str

# dataclass의 order=True 사용 예를 잠깐 보여주기 위해..
@dataclass(order=True)
class Money:
    amount: int
>>> a = FullName("heumsi", "jeon")
>>> b = FullName("heumsi", "jeon")
>>> a == b
True

>>> Money(300) < Money(500)
True

 

1.3. 도메인 규칙 표현하기

예를 들어, FullNamelast_name 은 최대 10자가 넘으면 안 되고, first_name 은 최소 1자 이상이어야 하는 도메인 규칙이 있다고 해보자. 이는 다음과 같이 표현할 수 있다.

class FullName:
    def __init__(self, first_name: str, last_name: str) -> None:
        if len(first_name) > 10:
            raise ValueError("first_name은 10자를 넘으면 안됩니다.")
        if len(last_name) < 0:
            raise ValueError("last_name은 최소 1자 이상이어야 합니다.")

        self._first_name = first_name
        self._last_name = last_name

FullName 이라는 값 객체가 아닌 string 을 사용했다면 위와 같은 도메인 규칙을 코드에 담아내지 못했을 것이다. (하더라도 if 문으로 여기저기 중복 코드가 발생했을 것이다.) 이렇게 도메인 객체에 도메인 규칙을 명시적으로 표현할 수 있는 것이 바로 값 객체의 장점이자, DDD의 철학이다.

[first_name 과 last_name 도 별도의 값 객체로 생각할 수 있지 않나?]

그렇다. 쉽게 말해 다음 코드처럼 짤 수도 있다.

class Fullname:
    first_name: FirstName
    last_name: LastName

class FirstName:
    def __init__(self, value: str) -> None:
        if len(first_name) > 10:
            raise ValueError("first_name은 10자를 넘으면 안됩니다.")
        self.value = value

class LastName:
    def __init__(self, value: str) -> None:
        if len(first_name) < 0:
            raise ValueError("last_name은 최소 1자 이상이어야 합니다.")
        self.value = value

단순히 str 인자 하나 받는 건데, 클래스로 감쌌다. 이게 과하다고 느껴질 수도 있다.
이처럼 값 객체를 어느 수준으로 분리하고 만들 것인지는, 프로그래머가 능동적으로 생각하고 결정할 문제다.

저자는 "규칙이 존재하는가"와 "낱개로 다루어야 하는가"를 값 객체 선정기준으로 본다고 한다.
이 기준에 따르면 적어도 FullName은 성과 이름으로 구성된다는 규칙, 그리고 낱개로 다뤄줘야 한다는 점에서 값 객체로 다루어져야 한다.

 

1.4. 메서드 추가하기

값 객체에서 중요한 또 다른 점은 독자적인 행위를 할 수 있다는 것이다.
다음 예제를 보자.

@dataclass
class Money:
    amount: int
    unit: str

    def __add__(self, other) -> "Money":
        if self.unit != other.unit:
            raise ValueError("더하고자 하는 두 Money의 단위가 같아야 합니다.")
        return Money(self.amount + other.amount, self.unit)

    def display(self) -> None:
        print(f"{self.amount}{self.unit}")
>>> total_money = Money(300, "원") + Money(500, "원")
>>> total_money.display()
800원

이처럼 값 객체는 단지 데이터만 담는 것이 목적이 아니라, 그와 관련된 행동까지도 스스로 가지고 있어야 한다.
(개인적으로 이는 DDD 철학이라기보다는 객체 지향 철학에 좀 더 가까운 거라고 생각한다.)

 

1.5. 정리

위 예제들만으로도, 원시 타입(Primitive) 이 아닌 값 객체(Value object)를 사용함으로써 얻는 장점을 충분히 느꼈을 거라 생각한다.

  • 값의 구성요소와 도메인 규칙을 좀 더 명확하게 표현할 수 있다.
  • 중복 코드를 막고, 중요 로직을 값 객체에 모을 수 있다.

값 객체는 도메인 지식을 코드로 녹여내는 DDD의 기본 패턴이다. 도메인의 개념을 객체로 정의할 때는 우선 값 객체에 적합한 개념인지 검토해볼 필요가 있다.


2. 엔티티 (Entity)

2.1. 엔티티 예시

엔티티는 값 객체와 마찬가지로 도메인 모델을 표현하는 객체다. 다만, 값 객체와 달리 식별자와 일종의 생애주기를 가진다.
다음 예를 보자.

from dataclasses import dataclass, field
import uuid

# 값 객체
@dataclass
class UserId:
    # UserId의 값은 string 타입이어야 한다는 요구사항이 있다고 하자.
    value: str

    def __init__(self, value: str = None) -> None:
        if value is None:
            self.value = uuid.uuid4().hex
        else:
            self.value = value

# 엔티티
@dataclass
class User:
    id: UserId = field(init=False, default_factory = UserId)
    name: str = field(compare=False)
>>> User("heumsi")
User(id=UserId(value='a5fda7fa79534c558cb44b54ba14c185'), name='heumsi')

위 코드를 간단히 설명하면, 먼저 UserId 는 값 객체로, User 객체의 식별자 역할을 담당하는 객체다. __init__ 메서드를 보면 구체적으로 어떻게 식별자를 만드는지 알 수 있다.
다음으로 User 는 엔티티로, 위에서 만든 UserId 를 식별자로 갖는다. 값 객체는 값만 같으면 같은 인스턴스로 취급하지만, 엔티티는 식별자가 같아야 같은 인스턴스로 취급된다. 위의 경우 식별자를 알아서 만드므로, 같은 User 인스턴스를 두 번 만들 수가 없다.

>>> User("heumsi") == User("heumsi")
False

반면, 식별자만 같으면, 두 엔티티는 다른 속성 값과 상관없이 동일한 엔티티다.

>>> a = User("heumsi")
>>> a
User(id=UserId(value='d94977ffc7ee455a8285cb5e34972999'), name='heumsi')

>>> b = User("siheum")
>>> b.id = UserId(value='d94977ffc7ee455a8285cb5e34972999') # 강제로 a의 id를 넣어주었다.
>>> a == b
True  # 식별자(여기서는 id 속성)만 같으면 두 엔티티는 같다.

 

2.2. 엔티티의 성질

엔티티의 대표적인 성질로는 다음 3가지가 있다.

  • 가변이다.
  • 속성이 같아도 구분할 수 있다.
  • 동일성을 통해 구별된다.

1) 가변이다

값 객체는 불변이라고 했다. 즉 다음과 같이 값을 변경하는 메서드는 허용해주면 안 됐다.

>>> full_name = FullName("heumsi", "jeon")
>>> full_name.change_name("siheum", "jeon")

반면 엔티티는 가변이다. 즉 위와 같이 인스턴스의 값을 변경하는 메서드가 허용이 된다.

>>> user = User("heumsi")
>>> user.changeName("siheum")

2) 속성이 같아도 구분할 수 있다.

엔티티 첫 번째 예시에서 알 수 있던 내용이다. 속성과 상관없이 엔티티는 식별자로만 구분한다.

3) 동일성을 통해 구별된다.

위와 같은 말이다. 객체가 동일한지 구분하는 것은 식별자로 해야 한다는 것이다.
좀 더 구체적으로는 엔티티에는 == 연산이 가능하도록 메서드를 작성해야 하고, 이때 메서드 내부적으로 비교는 식별자 속성으로 한다. 파이썬에서는 __eq__ 메서드가 이에 해당하는데, 파이썬에서는 dataclass 어노테이션만 활용하면 __eq__ 을 자동으로 만들어주기 때문에, 따로 작성할 필요가 없다.

위 코드를 다시 가져와 살펴보면, id 를 제외한 나머지 속성에는 compare=False 속성을 주어 __eq__ 의 비교에 들어가지 않게 하였다.

@dataclass
class User:
    id: UserId = field(init=False, default_factory = UserId)
    name: str = field(compare=False)  # compare=False 속성을 주면 __eq__ 의 비교 대상에 들어가지 않는다.

[엔티티와 값 객체의 판단 기준]

값 객체와 엔티티는 둘 다 도메인 개념을 나타낸다는 점에서 유사하다. 어떤 기준으로 도메인 모델을 값 객체로 분류하고 엔티티로 분류해야 할까?

저자는 "생애주기"를 갖는 경우 엔티티로 분류된다고 한다.
예를 들어, 시스템 사용자(User)는 처음 이용하는 사람에 의해 생성되어 값이 바뀌기도 하고, 더 이상 이용되지 않을 때는 삭제된다. 이처럼 엔티티에는 탄생과 죽음을 가지는 생애주기가 있다.

반면 값 객체는 생애주기를 가지지 않거나 생애주기를 나타내는 것이 무의미한 경우가 많다. 즉 생애주기를 가지지 않는 도메인 모델들은 값 객체로 다루는 게 좋다.

 

2.3. 정리

엔티티를 사용하면 얻는 장점도 값 객체의 장점과 거의 같다.

  • 코드만으로도 도메인 규칙을 알 수 있다.
  • 코드가 한 군데 모아져 있어, 도메인에 변경 사항이 있을 시 수정해야 할 곳이 적다.

값 객체와 더불어, 엔티티도 DDD의 근본이 되는 패턴이기 때문에 어떤 도메인 모델을 엔티티로 선정할 것인지 잘 고려해봐야 한다.


3. 도메인 서비스 (Domain Service)

3.1. 도메인 서비스 예시

도메인 서비스는 도메인 모델(엔티티, 값 객체)이 스스로 해결할 수 없는 기능들을 제공해주는 객체다.
예를 들어, 새로운 유저를 등록하려고 할 때 이 유저의 이름이 이미 등록되어 있다면 새로 등록할 수 없는 도메인 규칙이 있다고 하자. 이때 등록된 유저 리스트에서 같은 이름의 유저가 이미 있는지 조회를 해봐야 할 것이다.

이렇게 유저 리스트에서 유저를 조회하는 기능을 유저라는 엔티티가 스스로 수행한다면 뭔가 부자연스럽다.
유저 자기 자신이 유저 리스트에 자기 자신이 있는지 확인하는 꼴이니 말이다.

값 객체가 하는 것도 이상하다. 값 객체는 그냥 값을 담는 객체일 뿐이다.

"유저의 이름이 이미 등록되어 있다면 새로 등록할 수 없다"는 분명 도메인 규칙이다. 그러나 도메인 모델에는 담기에 부자연스럽다.
따라서 이러한 도메인 규칙을 담아내며 도메인 모델들을 다루는 새로운 요소가 하나 등장하는데, 이게 바로 도메인 서비스다.
예를 들면 다음과 같이 작성해볼 수 있다.

class UserDomainService:
    def exists(user: User) -> bool:
        pass

구체적인 구현 코드는 생략했다. 다만 도메인 서비스가 어떤 역할을 하는지 이것만 봐도 알 수 있다.

 

3.2. 도메인 서비스가 사용되는 양상

도메인 서비스가 프로그램에서 어떻게 쓰이는지 위 예를 그대로 가져와서 작성해보겠다.

def create_user(user_name: str) -> None:
    """ 새로운 유저를 생성하는 유즈케이스
    도메인 규칙 : 이미 존재하는 이름의 유저를 생성하려고 할 시, 에러를 뱉음.
    """

    # 유저 생성
    user = User(user_name)

    # 유저 도메인 서비스로 중복 조회
    user_domain_service = UserDomainService()
    if user_domain_service.exists(user):
        raise Exception("이미 같은 이름의 유저가 존재합니다.")

    # 이후 유저 등록하는 코드 (생략)

음 아주 직관적이고 쉽다!

 

3.3. 조심해야 할 점

도메인 서비스를 설계할 때 주의해야 할 점이 있는데, 바로 도메인 서비스의 기능을 "도메인 모델이 하기에 부자연스러운 처리"로만 한정해야 한다는 것이다. 그렇지 않으면 도메인 서비스에 모든 도메인 처리가 담기게 될 수 있다.

예를 들면 다음은 도메인 서비스의 기능을 잘못 설계한 경우다.

class UserDomainService:
    def changeUserName(user: User, user_name: str) -> None:
        if len(user_name) <= 0:
            raise ValueError("이름은 최소 1자 이상이어야 합니다.")
        user.name = user_name

User.name 을 조작하는 일은 도메인 서비스의 책임이 아니라 엔티티의 책임이다.
또한, 엔티티와 관련된 도메인의 규칙이 서비스에 노출되어 있다. 이러한 코드 모두 엔티티 내에 들어가야 한다.

필요치 않다면, 도메인 서비스에 기능을 두는 것은 되도록 피해야 한다. 도메인 모델에 담기 어려운 것들만 도메인 서비스의 책임으로 두어야 한다.

 

3.4. 정리

도메인에는 도메인 객체에 구현하기 자연스럽지 못한 행위가 있다. 이런 행위는 여러 개의 도메인 객체를 가로질러 이뤄지는 처리인 경우가 많다. 도메인 서비스는 이럴 때 활용하는 객체다.