기술서 읽고 정리

도메인구동설계입문을 읽고

Jonchann 2020. 12. 6. 01:31

원제: ドメイン駆動設計入門

저자: 나루세 마사노부

읽게 된 경위

내가 속한 팀에서 프로토타입으로만 구현되었던 알고리즘을 DDD(Domain Driven Design)로 갈아엎었기 때문에 알아야했다.

Domain Driven Design

DDD란 도메인 (영역) 에 대한 지식에 초점을 맞춘 설계법이다.


여기서 도메인은 이제부터 프로그래밍 해야하는 시스템에서 가장 중심이 되는 개념을 가리킨다. 예를 들어, 회계 시스템에서는 금전, 회계 장부 등이 그럴 것이고 물류 시스템에서는 제품, 창고, 운송 수단 등이 그럴 것이다.

 

난 자연언어처리를 다루고 있으니 text vectorizer 를 예로 들어보겠다.
먼저 벡터로 만들기 위한 문장이 있을 것이고 이를 형태소분석 해서 얻은 형태소, 토큰, 문장에서 얻은 토큰 리스트 등도 도메인에 해당한다. 그리고 text vectorizer 의 가장 중요한 역할인 tokenize, 형태소 분석, vectorize 또한 도메인 지식이다.

그렇다면 어떤 것이 도메인이 아닐까.
직접적으로 text vectorizer 의 책무가 아닌 것들이다. 예로, 외부 저장소에서 데이터 가져오기, 외부 저장소 를 들 수 있다.

그림: Robert C. Martin Clean Architecture

값 오브젝트

왜 필요할까

값 오브젝트가 해결하고 싶은 것은 시스템에서 다루는 중요한 지식이 무엇인지 그 개념을 확실히 하는 것이다.
예를 들어, 회원 등록 시스템을 만드는데 아래와 같이 이름을 정의하면 성이고 이름이고 어느 것도 그저 문자열일 뿐이다.

from typing import List

name: List[str] = ['홍', `길동`]

전해 받은 이름 중에 성만 따로 모아 정리하고 싶은데 잘못 ['길동', '홍']이라고 했다가 이름을 성으로 보관할 수도 있다. 이는 딱히 프로그램적으로 에러를 내진 않지만 의도한 바가 아니니 시스템 에러라 할 수 있다.

이 때 값 오브젝트를 정의해 이름을 다시 정의하면 아래와 같이 어느 것이 성이고 어느 것이 이름인지 확실해진다.

from dataclasses import dataclass


@dataclass(frozen=True)
class FirstName:
    value: str


@dataclass(frozen=True)
class LastName:
    value: str

@dataclass(frozen=True)
class Name:
    first_name: FirstName
    last_name: LastName


person = Name(FirstName('길동'), LastName('홍'))
print(person.first_name.value)  # 길동

불변성

위에서 말한 시스템에서 중요하게 다뤄지는 개념은 이렇게 값 오브젝트로 변환해 다뤄야 한다.
값 오브젝트에서 가장 중요한 것은 '불변'하는 값이어야 한다는 점이다.

 

예를 들어, 값 오브젝트의 값이 항상 변할 수 있다고 해보자. 그러면 아래와 같은 에러 아닌 에러가 일어난다.
그리고 이런 것들이 모여 어디서 일어났는지 모르는 버그를 초래하게 될 것이다. 어디서 무슨 수정을 했는지 쫓을 수 없기 때문이다.

bye = Greet("안녕히가세요")
bye.value = "안녕하세요"
print(bye)  # 안녕하세요

그렇다면 대체 값은 어떻게 바꿔야 할까.
값 오브젝트의 값을 바꾸는 것이 아니라 아래와 같이 값 오브젝트로 선언한 변수를 바꾸면 된다. 새로운 값을 정의하는 것이다.

person_name = Name(FirstName('길동'), LastName('홍'))
person_name = Name(FirstName('아무개'), LastName('홍'))

거동을 갖는 값

다른 예로, 회계 관련 시스템을 만들어야 해서 돈 오브젝트를 만들었는데 돈 계산은 어떻게 하면 될까.

class Money:
    amount: int
    currency: str

아래와 같이 메소드를 추가하는 것으로 해결할 수 있다.

class Money:
    amount: int
    currency: str

    def add(self, obj: Money) -> Money:
        return Money(
            self.amount + obj.amount,
            self.currency
        )

값 오브젝트를 쓰면 좋은 점

  • 더 많은 것을 정확하게 표현할 수 있게 됨
  • 규칙 등을 두어 부정값을 미리 피할 수 있음
  • 잘못된 대입을 피할 수 있음
  • 로직이 여기저기 흩어지는 것을 막음

엔티티

라이프 사이클

엔티티도 값 오브젝트와 비슷하지만 보관할 값 오브젝트라는 점이 다르다. 즉, 속성은 바뀌면서도 같은 오브젝트로 다룰 필요가 있을 때 엔티티를 만든다.


예를 들어, 회원 정보는 어떤 한 사람이 서비스에 가입할 때 태어나고 탈퇴할 때 죽는다. 이러한 연속성을 갖지 않는다면 엔티티가 아니라 값 오브젝트로 다뤄야 할 것이다.
그리고 이러한 엔티티를 도메인 오브젝트라 한다.

가변성

회원 정보를 데이터로 보관하려면 그 오브젝트를 DB 등에 보관해야 하는데 비밀번호나 나이, 주소, 결제 수단 등은 수시로 바뀔 수 있는 값이지 않은가.


그런데 만약 같은 이름을 갖는 회원이 나타나면 어떻게 구별할까.
그럴 때를 위해 id를 부여해 구별한다.

@dataclass(frozen=True)
class UserId:
    value: int

@dataclass(frozen=True)
class UserName:
    first_name: FirstName
    last_name: LastName

@dataclass(frozen=True)
class User:
    user_id: UserId
    user_name: UserName

user1 = User(
    UserId(12345),
    UserName(FirstName('철수'), LastName('김')),
)
user2 = User(
    UserId(54321),
    UserName(FirstName('철수'), LastName('김')),
)

print(user1 == user2)  # False

도메인 오브젝트를 정의하는 것의 이점

  • 코드의 다큐멘트성이 높아짐
  • 도메인을 수정하기 쉬워짐

도메인 서비스

무엇이 도메인 서비스가 될까

도메인 오브젝트에 부여하기엔 위화감이 있는 거동을 구현하고 싶을 때 그 거동을 오브젝트에서 떼어 내어 도메인 서비스로 정의한다.


예를 들어, 어떠한 회원이 새로 가입하려 하는데 동일한 아이디를 갖는 회원이 DB에 있다면 에러를 내야 한다. 그런데 DB에 가서 체크하는 거동이 회원 정보라는 데이터가 가져야 할 책무일까?
아닐 것이다. 회원 정보 데이터의 책무는 그 회원 정보 자체를 보유하는 것만에 한정될테니까.

 

아래와 같이 도메인 오브젝트가 외부 서비스를 참조하는 등의 필요 이상의 거동을 가질 때엔 로직을 관리하기도 힘들고 오브젝트 자체가 부자연스러워진다. 다시 한 번 말하지만 회원 정보 오브젝트는 특정 회원만의 정보를 가져야 하니까.

@dataclass(frozen=True)
class User:
    user_id: UserId
    user_name: UserName

    def is_exist(self) -> bool:
        db = DataBaseRepository
        # db에서 중복을 확인하는 처리
        if result:
            return True
        return False

이를 해결하기 위해 아래와 같은 도메인 서비스를 만든다.

class UserService
    def __init__(self):
        self.db = DataBaseRepository()

    def is_exist(self, user: User) -> bool:
        # self.db에서 중복을 확인하는 처리
        if result:
            return True
        return False

user = User(
    UserId(12345),
    UserName(FirstName('철수'), LastName('김')),
)
result = UserService().is_exist(user)

남용

그렇다고 모든 거동을 도메인 서비스로 나누면 안된다.
예를 들어, 회원 이름을 바꾸는 것은 '그' 회원에 대한 정보 변경이기 때문에 도메인 오브젝트 자체가 담당하는 것이 자연스럽기 때문이다.

수정에 유연한 서비스

위에서 적은 예와 같이 회원 정보에 중복이 있는지 체크하기 위해서는 회원 데이터를 격납한 DB에 리퀘스트를 보내야 한다.
하지만 어느 날 전혀 다른 DB로 바꾸자고 하면, RDB가 아니라 NoSQL로 바꿔야 한다면, DB에 리퀘스트를 보내는 모든 서비스를 하나 하나 수정할 것인가?


이를 위해 외부와 접촉해야 하는, 엄밀히 말해서 서비스가 해야 하는 일이 아닌(DB접속) 것은 서비스 클래스에서 제외하는 것이 좋다.

즉, 이런거다. 창고에 물건을 격납해서 보관해주는 대행 서비스가 있는데 각 회원의 물건이 겹치면 안되는 규칙이 있다고 하자.

손님: '삼다수 맡기고 싶어요' -> 서비스 직원: '확인해 볼게요'
서비스 직원: '혹시 이미 삼다수가 있는지 확인 부탁드려요' -> 격납고 직원: '확인해 볼게요'
격납고 직원: '아직 삼다수는 없네요' -> 서비스 직원: '삼다수는 격납 가능하니 절차 진행하겠습니다' -> 손님: '네'

리포지토리

방금 말한 격납고 직원이 바로 리포지토리이다. 즉, 데이터를 영속화해 재구축하는 처리를 추상적으로 표현한 오브젝트이다.


서비스 직원이 바로 격납고를 확인하면 덜 번거롭겠다 싶겠지만 오히려 격납고의 시스템이나 규칙을 변경할 때 서비스는 아무런 영향을 받지 않고 이제까지처럼 처리할 수 있다는 점에서 더 효율적이다 할 수 있다. 그 왜, 스마트폰 떨어뜨렸는데 액정만 나간 줄 알았더니 메인보드까지 나가면 수리비도 엄청나고 일시적으로 사용도 못해서 싫지 않은가.

 

그리고 코드를 읽을 때 서로 해야할 일만 할 수 있도록 독립되어 있으니 이해하기도 쉬워진다.
만약 도메인 서비스 오브젝트에 리포지토리가 하는 내용까지 들어있으면 아래와 같아진다.

class UserService:
    def __init__(self):
        self.connect_info = ...
        self.connection = SQLConnection(**connect_info)


    def create_user(self, user_name: UserName) -> None:
        if self._is_exist(user_name):
            create_query = f"..."
            self.connection.execute()
        print(f'AlreadyExistError: {user_name}')

    def _is_exist(self, user_name: UserName) -> bool:
        query_parameters = ...
        query = f"SELECT * FROM {...} WHERE {...}"
        is_exist = self.connection.execute(query)
        if is_exist:
            return True
        return False

하지만 이 경우, 위에서 말했던 것 처럼 query를 변경해야 한다거나, DB의 종류가 바뀐다거나 하면 그 때마다 서비스 내부를 갈아 엎어야 한다. 게다가 오브젝트 하나가 너무 많은 것을 하고 있다.

아래 코드를 보자.

class UserService:
    def __init__(self, user_repository: UserRepositoryInterface):
        self.user_repository = user_repository

    def create(self, user_name: UserName) -> None:
        if self.user_repository.is_exist(user_name):
            print(f'AlreadyExistError: {user_name}')
        self.user_repository.save(user_name)

훨씬 간결해서 이해하기도 쉽고 격납고 상황이 바뀐다고 서비스가 바뀔 일도 없어졌다.

인터페이스

위 코드에서 인수로 받은 리포지토리의 타입은 사실 UserRepository가 아니라 UserRepositoryInterface이다.
이는 DDD의 규칙에 외부 레이어를 내부에서 호출할 수 없다는 것이 있기 때문이다.


가장 위에 첨부한 그래프를 보면 가장 외부 레이어에 리포지토리가 있고 내부에는 도메인이 있다. UserService는 도메인 서비스이기 때문에 리포지토리를 직접 호출하면 안된다. 의존관계가 생기거나 어디서 무엇이 호출되어 사용했는지 모르게 되는 것을 막기 위함이다.

 

인터페이스는 도메인 레이어에 도메인 리포지토리라는 개념을 두어 관리하면 도메인 서비스는 직접 외부 레이어를 참조하지 않고도 리포지토리를 사용할 수 있다.

 

즉, 실체는 멀리 있지만 속은 없는 껍데기를 넘겨서 실체가 없이도 처리가능하게 하는 것이다.
실제 리포지토리가 어떤 행동을 해야 하는지 메소드만 정의하고 구체적인 처리 내용은 외부에 적는다 이말이다.

from abc import ABCMeta


class UserRepositoryInterface(metaclass=ABCMeta):
    def is_exist(self, user_name: UserName) -> bool:
        raise NotImplementedError

인터페이스를 두면 실체와 테스트코드를 나눠 쓰기도 편해진다.
(내부 레이어는 외부 레이어를 호출할 수 없지만 외부 레이어는 내부 레이어를 호출할 수 있다. 의존 관계를 일방적인 상태로 유지한다)
또한, 외부 레이어를 완성하지 않고도 내부 레이어를 구현할 수 있다. 가장 중요한 부분(도메인)부터 구현하기 편해진다는 것이다.

# project/repository/user_repository.py
from project.domain.service.user_repository_interface import UserRepositoryInterface


class UserReporitory(UserRpositoryInterface):
    def __init__(self):
        self.connect_info = configs...
        self.connection = SQLService(**connect_info)

    def is_exist(self, user_name: UserName) -> bool:
        query = f"..."
        (중략)
        return ...

정확하게 말하자면, 테스트코드를 위한 dummy 오브젝트이다.
실제로 DB에 접속하지 않고도 테스트를 할 수 있도록 하기 위함이다.

# tests/stub/user_repository_for_test.py
from project.domain.service.user_repository_interface import UserRepositoryInterface


class UserRepositoryForTest(UserRepositoryInterface):
    def __init__(self):
        self.dummy_db = {user_name1: user1, ...}

    def is_exist(user_name: UserName) -> bool:
        if user_name in self.dummy_db:
            return True
        return False        

리포지토리가 가져야 하는 거동

  • 보관
  • 갱신
  • 탐색

어플리케이션 서비스

어플리케이션이란, 일반적으로 이용자의 목적에 따라 만든 프로그램을 칭하는 말이다.
서비스란 의뢰자를 위해 무언가를 하는 것이다.
따라서 어플리케이션 서비스란 이용자의 목적에 맞는 거동을 하는 것이다. 도메인 서비스는 그 안에서 이뤄지는 중요한 거동이고.

 

이용자를 위해 만드는 서비스에는 어떠한 거동이 필요한지 생각해야 하고 이것을 유스케이스라 한다.
어플리케이션 서비스는 모든 유스케이스를 충족하는 서비스이다.

 

예를 들어, 유저가 가입해서 무언가를 하는 서비스에는 '가입'과 '탈퇴'라는 유스케이스를 떠올릴 수 있을 것이다.

상태

서비스는 그 자체의 거동을 바꾸기 위해서라는 이유로는 되도록 상태 정보를 갖지않는 것이 좋다. 서비스가 상태 정보를 갖게 되면 서비스가 지금 어떤 상태인지 신경써야 하기 때문이다.

 

여기서 상태란, 오브젝트를 인스턴스화 할 때 갖는 변수인데 아래처럼 변수로 인해 어플리케이션 서비스의 거동이 변할 수도 있고 아닐 수도 있게 구현하는 것은 되도록 삼가는 것이 좋다고 한다.

class UserApplicationService:
    def __init__(self, send_mail: bool):
        self.send_mail = send_mail
        self.usecase = ...

    def regist(self) -> None:
        if self.send_mail:
            self.usecase.send(...)
        print('user not registered.')
        return

하지만 어플리케이션 서비스의 거동을 바꾸기 위한 상태가 아니라면 가져도 좋다고 한다.

class UserApplicationService:
    def __init__(self, user_repository: UserRepositoryInterface):
        self.user_repository = user_repository
        self.usecase = ...

    def execute(self, *args) -> None:
        self.usecase.execute(self.user_repository)

즉, 어플리케이션 서비스가 해야 하는 거동은 그대로 하게 하고 진짜로 조건에 따라 분기하는 것은 도메인에서 하라는 것 같다. 그래야 어플리케이션 서비스가 무엇을 하는지 담보되니까.

커맨드 오브젝트

예를 들어, 회원 정보를 변경 혹은 갱신해야 한다고 하자.
이를 위해 아이디, 이름, 메일 주소 등을 전부 인수로 넘기는 것은 특정 인수가 있고 없고에 따라 거동이 제어되는 것이기 때문에 피하는 것이 좋다.

 

즉, 인수와 상관 없이 예정된 거동은 행해져야 하는 것이다.

이를 해결하기 위한 것이 커맨드 오브젝트이다.


아래 오브젝트에서는 인수가 넘어오지 않을 것에 대비해 keyarg로 지정하고 있다.

class UserUpdateCommand:
    user_id: str
    user_name: str
    mail_address: str

    def __init__(self, user_id: str, user_name: str = '', mail_address = ''):
        self.user_id = user_id
        self.user_name = user_name
        self.mail_address = mail_address

이를 이용하면 어플리케이션 서비스의 갱신 거동은 아래와 같이 된다.

class UserApplicationService:
    def __init__(self, user_repository: UserRepositoryInterface):
        self.user_repository = user_repository

    def update(self, command: UserUpdateCommand) -> None:
        target_id = command.user_id
        user = self.user_repository.find(target_id)
        if not user:
            print('UserNotExistError: ...')
            return

        user_name = command.user_name
        if user_name:
            user_name = UserName(user_name)
            (중략)

        mail_address = command.mail_address
        if mail_address:
            mail_address = MailAddress(mail_address)
            (중략)

사실 커맨드를 인수로 받으나 개개로 받으나 오브젝트 내용은 변하지 않지만, 어플리케이션은 어떤 인수가 전해질지 전혀 신경쓰지 않아도 되기 때문에 좋다고 한다. 그리고 코드 표현력도 풍부해진다.

app = UserApplicationService(UserRepository())

update_name_command = UserUpdateCommand(id, user_name = name)
app.update(update_name_command)

update_mail_address_command = UserUpdateCommand(id, mail_address = mail_address)
app.update(update_mail_address_command)

도메인 규칙의 유출

어플리케이션 서비스는 어디까지나 도메인 오브젝트의 태스크 조정에만 신경써야 한다. 때문에, 도메인 오브젝트가 어떤 규칙을 가져야 하는지는 어플리케이션 서비스는 알바가 아니다 이말이다.

 

예를 들어, 서비스에 가입을 할 때 회원의 이름은 3~20자로만 만들 수 있다는 규칙을 갖고 있다고 하자. 이는 회원 이름을 변경할 때에도 동일하게 적용된다.
그런데 이를 어플리케이션 서비스 클래스에 명시하면 모든 거동마다 확인해야 하는데 여러번이나 같은 거동을 하게 되고 각자의 역할이 확실하지 않기 때문에 무엇이 어디에서 기술되었는지 파악하기 힘들어진다. 특히, 규칙이 바뀌었을 때에는 이 모든 곳을 다 찾아서 수정해주어야 하니 얼마나 비효율적인가.

class UserApplicationService:
    def __init__(self, user_repository: UserRepositoryInterface):
        self.user_repository = user_repository

    def regist(self, name: str) -> None:
        user_name = UserName(name)
        is_duplicated = self.user_repository.is_exist(user_name)
        if is_duplicated:
            print(NameDuplicatedError: {user_name} already exist.)
            raise ValueError
        user_repository.save(user_name)

    def update(self, command: UserUpdateCommand) -> None:
        (중략)
        name = command.user_name
        if name:
            new_user_name = UserName(name)
            is_duplicated = self.user_repository.is_exist(new_user_name)
            if is_duplicated:
                print(NameDuplicatedError: {user_name} already exist.)
                raise ValueError
            user_repository.save(user_name)

        (중략)

이를 해결하기 위해서는 도메인에 관한 규칙은 도메인 오브젝트에게 맡겨야 한다.

의집도

의집도란 모듈의 책임 범위가 얼마나 집중되어있는지를 알아보는 척도이다. 견고성, 신뢰성, 재이용성, 가독성이 그 판단 기준이다. 의집도를 재는 방법으로는 LCOM (Lack of Cohension in Methods) 라는 것이 있다.

 

예를 들어, 어떠한 클래스 인스턴스 변수로 value1, ..., value4가 정의되어있다고 하자.
메소드a는 value1, value2 만 참조하고 있는데 메소드b는 value3, value4 만 참조하고 있다면 의집도가 낮다고 판단한다.

 

의집도를 높이기 위해서는 메소드a와 메소드b를 하나의 클래스로 분리시키는 것이다.
즉, 모든 변수는 모든 메소드에서 사용되어야 의집도가 높다고 판단되는 것이다.

물론, 의집도를 높이는 것만이 좋은 것은 아니기에 그 때마다 알맞게 설계할 필요가 있다.

팩토리

오브젝트 지향 프로그래밍에서 클래스란 하나의 도구와 같다.
메소드의 역할만 알고 있다면 클래스 내부 구조에 대해 상세히 알 필요 없이 사용하는 것이 가능하다.

 

하지만 예를 들어, 우리는 컴퓨터를 일상적으로 사용한다. 그만큼 편리한 도구라는 것이다. 하지만 이 편리함에 비례해 컴퓨터 구조는 꽤 복잡하며 만드는 과정 또한 복잡하다.
생성 과정만으로도 복잡할 경우에는 '생성'을 하나의 지식으로 분리해 생각할 수 있다. 즉, 생성하는 과정만을 오브젝트로 만들어 관리할 수 있다는 것이다. 이를 팩토리라 한다.

 

예를 들어, 회원 정보를 생성하기 위해서는 식별자로 아이디를 부여해야 한다.
이 과정을 팩토리로 만들면 편리하다.

class UserFactory:
    def create(self, user_name: UserName) -> User:
        (번호를 부여하는 처리)


@dateclass(frozen=True)
class User:
    user_id: UserId
    user_name: UserName

    @staticclass
    def get(user_id: UserId, user_name: UserName) -> "User"
        (중략)
        return User(user_id, user_name)


class UserApplicationService:
    def __init__(
            self,
            user_repository: UserRepositoryInterface,
            user_factory: UserFactoryInterface
    ):
        self.user_repository = user_repository
        self.user_factory = user_factory

    def regist(self, command: UserRegisterCommand) -> None:
        user_name = UserName(command.user_name)
        user = self.user_factory.create(user_name)
        (중략)

메소드 팩토리

모든 팩토리를 클래스로 정의해야 하는 것은 아니다. 메소드로도 구현할 수 있다.
create메소드를 만들면 된다.

집약(集約)

오브젝트 지향 프로그래밍에서는 여러 오브젝트가 한 덩어리가 되어 한 가지 지식을 의미하도록 구축된다. 이런 덩어리를 집약이라고 한다.
집약이란 관련된 오브젝트끼리 하나의 범위 안에 포함시키는 것을 말한다.

 

예를 들어, 서클을 만들어 활동하는 서비스가 있다고 하자. 서클에 가입해 활동하기 위해서는 먼저 서비스에 가입을 해 회원이 되어야 한다.
여기서 집약을 만들자면, 회원(회원 아이디, 회원 이름, ...), 서클(서클 아이디, 서클 이름, ...) 과 같이 나눌 수 있을 것이다.

 

따라서 하나의 집약에 포함되는 정보를 변경 혹은 갱신할 때는 집약 전체에 대한 거동을 통해 해야지 집약의 한 요소를 통해서는 안된다.
아래와 같이 서클을 생략하고 서클 멤버 오브젝트에 바로 접근하게끔 구현하면 안된다는 말이다.

circle.members.add(member)

아래와 같이 구현하자.

@dataclass(frozen=True)
class Circle:
    circle_id: CircleId
    owner: User
    members: List[User]

    def join(self, member: User) -> None:
        members.add(member)

오브젝트 조작에 관한 기본 원칙

메소드를 호출하는 오브젝트는 다음에만 한정된다.

  • 오브젝트 자신
  • 인수로 전달 받은 오브젝트
  • 인스턴스 변수
  • 직접 인스턴스화 한 오브젝트

스펙

위에서 나왔던 서클을 예로 들어, 서클이 만원인지 아닌지 확인해야 한다고 하자.


그리고 위에서 말했던 것처럼 서비스에서는 도메인의 규칙에 관한 로직을 기술해서도 안된다는 것을 기억하자.

그렇다면 도메인 오브젝트가 그 로직을 가지면 되는 걸까?

@dataclass(frozen=True)
class Circle:
    circle_id: CircleId
    owner: User
    members: List[User]

    def is_full(user_repository: UserRepositoryInterface) -> bool:
        users = user_repository.find(members)
        circle_limit = 30
        return users > cuircle_limit

그런데 규칙이 많아져서 위와 같이 상태를 체크하기 위한 메소드가 많아진다면 도메인 오브젝트가 무엇을 위한 것이었는지 애매해지지 않을까.

@dataclass(frozen=True)
class Circle:
    circle_id: CircleId
    owner: User
    members: List[User]

    def is_full(self) -> bool:
        return

    def is_popular(self) -> bool:
        return

    def is_anniversary(self) -> bool:
        return

    def is_recruiting(self) -> bool:
        return

    def is_private(self) -> bool:
        return

    def join(self) -> None:
        return

또한, 엔티티나 값 오브젝트가 리포지토리를 직접 참조하는 것은 되도록 피해야 한다. 도메인 모델의 역할 밖이기 때문이다.


위에서 나왔던 예를 가져 오자면, 손님이 삼다수를 격납하고 싶다고 했는데 삼다수가 직접 격납고 직원에게 '거기 삼다수 있어요?' 하는 것과 마찬가지라 할 수 있다.

 

이를 해결하기 위한 것이 사양 오브젝트를 만드는 것이다.

class CircleFullSpecification:
    def __init__(self, user_repository: UserRepositoryInterface):
        self.user_repository = user_repository

    def is_full(circle: Circle) -> bool:
        users = self.user_repository.find(circle.members)
        circle_limit = 30
        return users > cuircle_limit  

class CircleApplicationService:
    def __init__(
            self,
            circle_repository: CircleRepositoryInterface,
            user_repository: UserRepositoryInterface
    ):

    def join(self, command: CircleJoinCommand) -> None:
        circle_id = CircleId(command.circle_id)
        circle = self.circle_repository.find(circle_id)

        circle_full_spec = CircleFullSpecification(self.user_repository)
        if circle_full_spec:
            print('CircleFullError: {circle_id} is full.')
            return

        (중략)

하지만 사양 오브젝트도 엄밀히 말하자면 도메인 오브젝트이기 때문에 리포지토리를 사용하는 것이 부자연스럽기도 하다.


이를 해결하기 위한 것이 퍼스트 클래스 콜렉션이다. 퍼스트 클래스 콜렉션은 List라는 범용적인 집합 오브젝트를 이용하는 것이 아니라 특화된 집합 오브젝트를 이용하는 패턴이다.

 

예를 들어, 서클 멤버 군을 표현하는 퍼스트 클래스 콜렉션은 CircleMembers오브젝트인 것이고 이 오브젝트에서 직접 멤버 수를 세는 것으로 리포지토리 사용을 피할 수 있다.

owner = self.user_repository.find(circle.owner)
members = self.user_repository.find(circle.members)
circle_members = CircleMembers(circle.circle_id, owner, members)
circle_full_spec = CircleMembersFullSpecification()
if circle_full_spec.is_statisfied_by():
    (중략)

혹은 리포지토리 안에 사양을 포함시킬 수 있다. 하지만 리포지토리에 격납되어 있는 수만건의 데이터를 조회해야 하기 때문에 처리 속도가 느려진다는 단점이 있다.

 

어쨌든 가장 중요한 것은 성능보다 유저의 편리성이기 때문에 이를 고려해서 방안을 모색해야 한다.