이 글은 도메인 주도 설계 철저 입문 (위키북스) 를 읽고 개인적으로 정리한 글입니다.
0. 들어가며
이전 글까지는 도메인 모델을 표현하는 방법과 이를 도메인 모델을 다루는 방법을 배웠다.
이번 글에서는 본격적으로 애플리케이션을 만들기 위한 패턴을 배운다.
즉 이제 만들어 놓은 "값 객체", "엔티티" 그리고 "서비스"를 실제 유저의 관점에서 사용할 수 있도록 유스케이스를 하나씩 만들어나가는 것이다.
1. 리포지토리 (Repository)
1.1. 리포지토리의 역할
리포지토리의 일반적인 의미는 "보관창고"다.
이런 의미에 맞게, 리포지토리 객체는 데이터를 저장하고 필요시 다시 복원하는 역할을 한다.
쉽게 말해 DB와 연결하여 데이터를 DB에 저장하고, 불러오는 일을 맡는다고 생각하면 된다.
다만, 꼭 DB가 아니여도 된다. 메모리에 저장할 수도 있고 파일에 저장할 수도 있다.
여하튼 핵심은, "데이터를 저장하고 복원하는 일"을 한다는 것이다.
1.2. 리포지토리 예시
간단한 리포지토리 예를 하나 보면 이해하기 쉽다.
from abc import ABCMeta, abstractmethod
class UserRepository(metaclass=ABCMeta):
@abstractmethod
def save(user: User) -> None:
pass
@abstractmethod
def find_by_name(user_name: UserName) -> User:
pass
UserRepository
객체는 User 객체를 저장하고 복원하는 기능을 한다.
구체적으로 어떻게 저장하고 복원하는지는 나타내지 않았다. (따라서 인터페이스 형태로 작성하였다.)
사실 이렇게 구체적인 구현부는 설계에서 항상 뒤로 미루어야 할 일이다. 리포지토리에 대한 개념 자체는 이 인터페이스 하나로 충분하다고 본다.
1.3. 리포지토리 구현
그럼에도 실제로 구현부를 보지 않고서는 처음에는 개념을 확실히 눈에 안 들어올 수 있다.
이를 위해 인메모리 형태, ORM을 이용한 DB형태 이렇게 두가지 구현 방법을 간단히 적어보겠다.
1) 인메모리 형태
class InMemoryUserRepository(UserRepository):
def __init__(self) -> None:
self.users = [] # 유저 객체를 이 리스트에 저장하게 된다.
def save(user: User) -> None:
self.users.append(user)
def find_by_name(user_name: UserName) -> User:
for user in self.users:
if user.name == user_name:
return user
raise Exception(f"{user_name}의 이름을 가진 유저는 없습니다.")
인메모리 형태는 구현도, 코드도 아주 간단하다.
일반적으로 실제로 운영에 들어가기 전, 테스트 용도로 만들어 종종 쓴다.
2) ORM을 이용한 DB 형태
ORM을 쓰기 위해서는 먼저 DB 테이블과 매핑되는 객체를 만들어줘야 한다.
여기서는 파이썬에서 주로 쓰이는 sqlalchemy를 사용하겠다.
# repository/orm_object.py
from sqlalchemy.ext.declarative import declarative_base
from sqlalchemy import Column, Integer, String
Base = declarative_base()
class User(Base):
__tablename__ = "user"
id = Column(Integer, primary_key=True) # id는 예약어라 뒤에 추가
name = Column(String)
이제 위에서 정의한 ORM 객체를 다루는 Repository를 만들자.
from sqlalchemy import create_engine
from domain.models import User
from domain.value_objects import UserName
from repository import orm_object
class UserRepository(UserRepository):
def __init__(self, db_config: dict) -> None:
# db_config 에는 db 연결 관련된 데이터들이 담겨있다.
engine = create_engine(
f"mysql+pymysql://{db_config.user}:{db_config.password}@{db_config.host}/{db_config.database}",
pool_size=db_config.DB_POOL_SIZE,
max_overflow=db_config.DB_POOL_MAX,
echo=db_config.echo,
pool_recycle=db_config.pool_recycle,
pool_pre_ping=db_config.pool_pre_ping,
)
orm_object.Base.metadata.create_all(engine)
self.Session = scoped_session(sessionmaker(bind=engine, expire_on_commit=False))
def save(user: User) -> None:
orm_user = orm_object.User(id=user.id, name=str(user.name))
with self._get_session() as session:
session.add(orm_user)
def find_by_name(user_name: UserName) -> User:
with self._get_session() as session:
user = session.query(orm_object.User).filter_by(name=str(user_name)).first()
if user:
return User(id=user.id, name=UserName(user.name))
else:
raise Exception(f"{user_name}의 이름을 가진 유저는 없습니다.")
ORM을 사용할 때 주의해야 할 점은 우리가 도메인 모델에서 정의한 엔티티와 ORM 객체는 같은 객체가 아니라는 것이다. 위 코드를 보면 도메인 모델 관련 객체들은 domain
패키지에 들어가있고, ORM 관련 객체들은 repository.orm_object
에 들어가 있는 것을 볼 수 있다. 이 둘의 모습은 같아 보이는데, 사실 엄연히 다른 책임을 가진 객체들이며 이 둘을 분리하지 않고 결합하여 사용하면 (도메인 엔티티 자체를 ORM 객체화 시켜버리면), 이후 DB와 코드가 완전히 결합되어 수정하기가 힘들 수가 있다.
(실제로 이런 케이스를 종종 보는 것 같다...)
1.4. 리포지토리에 정의되는 행동
리포지토리에는 기본적으로 저장과 복원에 기반한 행동들이 정의되어야 한다.
- 객체의 저장 (
save(...)
) - 객체의 복원 (
find(...)
)- 어떤 것을 기준으로 객체를 찾아 복원할 것인가 역시 때에 정해주면 된다.
- 예를 들어 id 가 기준이면
find_by_id(...)
, 이름이 기준이면find_by_name(...)
가 된다. - 파이썬은 메서드 오버로딩 기능이 없기 때문에 이처럼 메서드 이름
by
를 붙여 다르게 해야 한다.
1.5. 정리
리포지토리는 객체를 저장, 복원하는 역할을 하는 객체다.
따라서 이를 지원하는 메서드를 정의해야 하며, 구체적인 구현이 아닌 인터페이스 형태로 설계해야 한다.
2. 애플리케이션 서비스
2.1. 애플리케이션 서비스의 역할
애플리케이션 서비스는 유스케이스를 구현한다. 도메인 서비스의 대상이 도메인 모델이었다면, 애플리케이션 서비스는 그 대상이 실제 유저의 가능한 행동이 된다. 즉 애플리케이션 서비스에서는 우리가 일반적으로 생각하는 "애플리케이션"의 기능들을 구현하며, 이 기능을 구현하기 위해 도메인 서비스, 리포지토리 등 지금까지 배운 것들을 활용하게 된다.
2.2. 애플리케이션 서비스 예시
간단한 CRUD 시스템의 일부를 만들어보자. 사용자는 다음의 행동(유스 케이스)들을 할 수 있다.
- 유저 생성하기
- 유저 조회하기
이 행동들은 곧 애플리케이션 서비스 객체의 메서드가 된다.
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(user_name: UserName) -> None:
user = User(name=user_name)
if self._user_domain_service.exists(user):
raise Exception(f"{user_name}을 가진 사용자가 이미 있습니다.")
self._user_repository.save(user)
def get(user_name: UserName) -> GetUserDto:
user = self._user_repository.find_by_name(user_name)
return GetUserDto(user.id, user.name)
마지막에 get
메서드의 반환 값으로 GetUserDto
가 등장하는데, 이는 다음처럼 생겼다.
@dataclass
class GetUserDto:
user_id: str
user_name: str
이 GetUserDto
는 User
랑 별반 차이가 없어 보인다. 그러나 둘의 용도는 명확히 다르다.
애플리케이션 서비스의 기능에서 유저 조회하기는 말 그대로 유저 정보를 조회하는 것이다. 따라서 우리는 "엔티티" 그 자체가 아니라 정보"만 주면 된다. 엔티티 그 자체를 넘겨주는 것은 위험하다. 만약 엔티티를 반환 값으로 넘겨준다면, 이 값을 받아 클라이언트 코드에서 이 엔티티에 어떤 조작을 할지 알 수 없기 때문이다. (물론 그런 경우는 드물겠지만, 그래도 이를 확실하게 하는 것이 좋다.)
이처럼 엔티티의 외부 유출 및 필요한 정보만 다시 담아 전송 용도로 사용하는 객체를 DTO (Data Transfer Object) 라고 한다.
지금으로선 엔티티와 DTO가 별 차이가 없는 구성을 가지고 있지만, 둘은 분명 다른 목적을 가지고 있고 추후 다르게 발전할 수 있음을 알아야 한다.
UserApplicationService
를 다시 살펴보자. 사용자의 유스케이스를 구현하기 위해, 리포지토리, 도메인 서비스 모두 활용하고 있다. 즉, 클라이언트 코드는 애플리케이션 서비스 객체를 사용하게 되고, 애플리케이션 서비스는 리포지토리, 도메인 서비스를 사용하게 된다. 클라이언트 코드가 직접적으로 리포지토리, 도메인 서비스 코드를 사용하는 경우는 없다. 모든 유스케이스는 바로 이 애플리케이션 서비스 객체에 표현되며, 이 객체를 거쳐가게 된다.
2.3. 도메인 규칙의 유출
애플리케이션 서비스는 도메인 객체가 수행하는 테스크를 조율하는 데만 전념해야 한다. 따라서 애플리케이션 서비스에 도메인 규칙을 기술해서는 안된다.
예를 들어, "같은 이름을 가진 유저는 없어야 한다." 는 아주 명확한 도메인 규칙이다. 이 규칙은 애플리케이션 서비스가 아니라 도메인 서비스에 기술되어야 한다. 아래 코드는 이를 애플리케이션 서비스에 기술한 잘못된 예다.
class UserApplicationService:
def register(user_name: UserName) -> None:
# 이미 같은 이름의 유저가 존재하는지 확인
if self._user_repository.find_by_name(user_name):
raise Exception(f"{user_name}을 가진 사용자가 이미 있습니다.")
user = User(name=user_name)
self._user_repository.save(user)
이와 같은 코드가 문제인 가장 큰 이유는, 도메인 규칙이 바뀌었을 때 수정해야 할 부분이 도메인 객체들이 아닌 애플리케이션 객체가 되기 때문이다. 예를 들어, "같은 이름과 같은 이메일을 가진 유저는 없어야 한다." 라는 규칙으로 수정되었을 때, 위 코드는 수정이 필요하다. 분명 도메인 규칙이 바뀐 것인데, 유스 케이스 처리를 담당하기로 한 애플리케이션 서비스 객체의 코드가 바뀌는 것은 뭔가 이상하다.
따라서 다음처럼 애플리케이션 서비스에는 유스케이스 처리 로직만 넣어두는 게 맞고, 도메인 규칙과 관련된 자세한 사항들은 도메인 객체에게 위임하는 것이 맞다. (아래 코드는 위 2.2에서 보인 코드와 같다.)
class UserApplicationService:
def register(user_name: UserName) -> None:
user = User(name=user_name)
if self._user_domain_service.exists(user): # 도메인 서비스가 도메인 규칙을 담는다.
raise Exception(f"{str(user_name)}을 가진 사용자가 이미 있습니다.")
self._user_repository.save(user)
2.4. 서비스는 무상태다.
서비스는 자신의 행동을 변화시키는 것을 목적으로 하는 상태를 갖지 않는다.
상태를 그 자체를 가지지 않는 것은 아니다. 위 코드만 보아도 self._user_repository
, self._user_domain_service
라는 두 개의 인스턴스를 상태로 가지고 있다. 그러나 자신의 행동을 변화시키기 위한 목적으로 가지지 않는다. 애플리케이션 서비스를 구현할 때 이를 꼭 염두에 두어야 한다.
2.5. 정리
애플리케이션 서비스는 사용자의 유스케이스 로직을 담는 객체다. 각 유스케이스들은 메서드로 구현된다. (사실 객체 그 자체로 구현할 수도 있다. 예를 들면 User.register
메서드는 UserRegisterService
로 분리할 수 있다. 책에서도 응집도와 관련하여 이 내용이 나오긴 하는데, 굳이 따로 설명하진 않겠다.)
애플리케이션 서비스는 유스케이스 로직을 처리하는 데 있어 도메인 서비스, 리포지토리를 활용한다. 이때 애플리케이션 서비스 로직 자체에 도메인 규칙을 담지 않도록 주의해야 한다. 애플리케이션 서비스 객체는 일반적으로 자신의 행동을 변화시키는 목적의 상태를 갖지 않는다.
[팩토리 패턴]
책에는 팩토리 패턴에 대한 설명이 나온다. 따로 정리할만큼 매우 가치있는 부분은 아닌거 같아 이 글에서는 생략했으나, 간단히 언급만 한다.
팩토리 패턴은 말 그대로 객체를 생성하는 역할을 하는 객체다. 위 예에서 User 객체를 만들고자 할 때 user = User(...) 식으로 직접 인스턴스를 생성한다. 팩토리 객체를 사용하면 이렇게 객체를 직접 만들지 않고 팩토리 객체의 메서드를 사용하여 user = UserFactory.create(...) 과 같이 인스턴스를 생성한다.
팩토리 패턴은 객체의 인스턴스 생성 과정이 복잡하거나, 엔티티 객체의 id 값을 데이터 베이스의 PK 등과 연동해서 부여해야할 때 사용될 수 있다. 팩토리 객체 패턴 자체는 도메인 그 자체에 포함되는 객체라기 보다는, 리포지토리나 애플리케이션 서비스와 같이 애플리케이션을 만들기 위한 패턴에 속한다고 볼 수 있다.
'더 나은 엔지니어가 되기 위해 > 읽고 쓰며 정리하기' 카테고리의 다른 글
도메인 주도 설계 철저 입문 5부 - 아키텍처와 앞으로의 학습 (2) | 2020.12.03 |
---|---|
도메인 주도 설계 철저 입문 4부 - 지식 표현을 위한 고급 패턴 (0) | 2020.11.25 |
도메인 주도 설계 철저 입문 2부 - 지식 표현을 위한 패턴 (2) | 2020.11.17 |
도메인 주도 설계 철저 입문 1부 - 들어가며 & 목차 (0) | 2020.11.15 |
파이썬으로 구현하는 클린 아키텍쳐 - rentomatic (1) | 2020.09.03 |