Blog

도메인 주도 설계 철저 입문

도메인 주도 설계 철저 입문

도메인 주도 설계 철저 입문 을 보고 간단하게 정리한 내용이다. 이 책을 읽어보는것을 추천한다.
지식 표현을 위한 패턴
값 객체
엔티티
도메인 서비스
애플리케이션을 구성하는 패턴
리포지터리
애플리케이션 서비스
팩토리
지식 표현을 위한 고급 패턴
애그리게이트
명세

값 객체 (Value Object)

프로그래밍 언어에는 원시 데이터 타입(Primitive Type) 이 있고, 이 타입만 사용해서 개발이 가능하지만, 필요에 따라서 직접 타입을 정의해야 할 때가 있다
DDD 에서는 원시 타입만을 사용하지 않고, 도메인에 맞는 객체로 정의해서 사용한다
객체 자체를 하나의 값으로써 취급하기 때문에 값 객체 라고 부른다.
예시
class FullName { private firstName: string; private lastName: string constructor(firstName: string, lastName: string) { this.firstName = firstName; this.lastName = lastName; } }
TypeScript

성질

불변이다
교환 가능하다
등가성을 비교할 수 있다

장점

표현력이 증가된다
무결성이 유지된다
잘못된 대입을 방지한다
로직이 흩어지는걸 방지한다

정리

원시 타입만으로 개발을 할 수 있지만, 범용적이기 때문에 도메인에 맞게 표현하기 어렵다
값 객체를 사용하면 좀 더 명확하게 표현할 수 있다
중복 코드를 방지할 수 있고, 중요한 로직을 값 객체에 모아둘 수 있다

엔티티 (Entity)

엔티티는 도메인 모델을 구현한 도메인 객체를 의미한다.
값 객체와 달리 생애주기를 가지고 있고, 식별자를 가지고 있다
생애주기를 갖고 있지 않거나, 생애주기가 무의미하면 값 객체로 다루는것이 좋다
값 객체는 값만 같으면 동일한 데이터로 취급하지만, 엔티티는 식별자가 같아야 같은 데이터로 취급한다
예시
class UserId { public value: string; constructor(value: string) { this.value = value; } } class User { private readonly id: UserId; private name: string; constructor(id: UserId, name: string) { this.id = id; this.name = name; } }
TypeScript

성질

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

정리

도메인에 대한 내용이 한 곳에 모여있기 때문에 도메인을 이해하는데 도움이 된다
도메인 요구 사항이 변경된 경우 반영하기 쉽다

도메인 서비스 (Domain Service)

값 객체나 엔티티는 도메인의 행동을 정의할 수 있지만, 도메인 모델이 스스로 해결하기 부자연스러운 경우 도메인 서비스를 이용해서 해결한다

예시

중복된 이메일은 등록할 수 없다는 도메인 규칙이 있을 경우 이 행위 자체를 도메인 모델에 담기에는 부자연스럽다
class User { exists(user: User): boolean { // ... } } const user = new User() user.exists(user)
TypeScript
이런 경우 도메인 서비스를 이용하면 자연스럽게 해결할 수 있다
class UserDomainService { exists(user: User): boolean { // ... } } const userDomainService = new UserDomainService() const user = new User() userDomainService.exists(user);
TypeScript

주의사항

도메인 서비스로 대다수의 로직들을 작성할 수 있지만, 도메인 서비스는 도메인 모델에서 처리하기 부자연스러운 처리로만 제한해서 작성해야 한다
도메인 서비스를 남용하면 데이터와 행위가 여러군데로 흩어지기 때문에 도메인 모델의 유연성에 문제가 생길 수 있다
class UserDomainService { changeEmail(user: User, email: string): void { if (email.length <= 0) { throw new Error('이메일은 최소 1글자 이상이어야 한다.'); } user.email = email; } }
TypeScript
가능한 도메인 모델에 행위를 정의하고 도메인 서비스는 피하는것이 좋다

리포지터리 (Repository)

리포지터리는 도메인 객체를 저장하고 복원하는 역할을 담당한다
리포지토리의 책임은 객체의 퍼시스턴시까지이다

예시

class UserRepository { findOne(id: UserId): User | null { // ... } findOneByEmail(email: string): User | null { // ... } save(user: User): void { // ... } } class ApplicationService { createUser(email: string): User { const userRepository = new UserRepository() const user = new User(email) if (userService.exists(user)) { // ... } userRepository.svave(user) // ... } }
TypeScript
interface UserRepository { find(name: UserName): User | null; save(user: User): void; }
TypeScript

정리

ORM 에서 사용하는 엔티티와 도메인에서 얘기하는 엔티티는 완전 다른 객체이다
도메인의 엔티티와 ORM 에서 얘기하는 엔티티를 분리하지 않을 경우 특정 기술에 종속되거나 DB 와 코드가 결합되어 수정하기 어려워진다
리포지터리는 객체의 저장, 복원만 담당하기 때문에 그 이상의 역할을 담아서는 안된다
구체적인 구현보다는 인터페이스로 작성해두면 특정 인프라스트럭처에 의존하지 않게 작성할 수 있다

애플리케이션 서비스

애플리케이션 서비스는 유스케이스를 구현한다
애플리케이션은 이용자의 목적을 해결하기 위한 프로그램을 의미한다
애플리케이션 서비스는 사용자가 원하는 기능을 구현하기위해 도메인 서비스, 리포지터리, 도메인 객체 등을 조합해서 수행한다

예시

유스케이스 예시
사용자 등록하기
사용자 정보 수정하기
class GetUserDto { constructor( public readonly id: UserId, public readonly email: string ) } class UserApplicationService { constructor( private readonly userRepository: UserRepository, private readonly userDomainService: UserDomainService, ) register(email: string) { const user = new User(email); if (this.userDomainService.exists(user)) { throw new Error('이미 가입된 사용자입니다'); } this.userRepository.save(user); } get(email: string): User | null { const user = this.userRepository.findOneByEmail(email); if (!user) { throw new Error('유저 정보가 없습니다'); } return GetUserDto(user.id, user.email) } }
TypeScript

정리

도메인 객체를 직접 공개하는 경우 외부에서 데이터를 조작할 수 있기 때문에 직접 노출하지 않고 DTO (Data Transfer Object) 를 만들어서 전달하는것을 권장한다
데이터를 변경하는 경우 파라미터로 직접 다 받는것보다는 커맨드 객체를 만들어서 받는것이 좋다
class UpdateUserCommand { constructor( public readonly email: string ) } class UserApplicationService { update(command: UpdateUserCommand): void { // ... } }
TypeScript
애플리케이션 서비스는 도메인 객체가 수행하는 작업들을 조율하기만 해야 하며, 도메인 규칙이 노출되지 않게 해야한다
애플리케이션 서비스를 작성할 때는 응집도에 신경을 쓰는것이 바람직하다
사용자와 관련된 유스케이스라고 해서 꼭 한 클래스에 모아둘 필요는 없다
유스케이스마다 클래스를 반드시 분리할 필요도 없지만, 클래스를 구성하는 변수와 메소드가 적절히 대응되어 있는지 검토해보면 좋다
class UserRegisterService { // ... } class UserDeleteService { // ... }
TypeScript
서비스는 어떠한 영역에도 존재할 수 있으며 상태를 가지고 있으면 안된다
상태를 가지고 있을 경우 복잡성이 증가하고 혼란스럽게 만들 수 있기 때문에 무상태를 유지할 수 있는 방법을 찾아보는것이 좋다

팩토리 패턴 (Factory Pattern)

팩토리는 객체를 만드는 지식에 특화된 객체이다
class User { private readonly id: UserId; private name: UserName; constructor(id: UserId, name: UserName) { // ... } } class UserFactory { create(name: UserName): User { const id = new UserId(// ...); return new User(id, name); } }
TypeScript
객체의 생성 과정이 복잡하거나, 데이터베이스의 식별키 (Primary key) 를 연동해서 ID 에 부여해야할 때 사용할 수 있다
팩토리는 클래스 자체가 아니라 메서드가 역할을 하는 경우도 있다
class Circle { constructor( public userId: UserId, public name: CircleName ); } class User { private id: UserId; createCircle(circleName: CircleName): Circle { return new Circle(this.id, circleName); } }
TypeScript
객체의 생성 절차가 간단하다면 생성자를 통해서 만드는것이 더 낫다
팩토리를 이용해 객체 생성 절차를 캡슐화하는 것도 로직의 의도를 더 명확히 드러내면서 유연성을 확보할 수 있는 좋은 방법이다

데이터 무결성 보장

데이터의 무결성을 보장하기 위해서는 여러가지 방법들이 있다
데이터베이스 유니크 인덱스 제약조건(Unique Index Constraint)
강력하게 제한할 수 이씾만, 코드에 관련된 내용이 없기 때문에 알 수 없다
유일 키 제약 기능은 특정 데이터베이스의 기술이기 때문에 의존성이 생긴다
데이터베이스 트랜잭션
트랜잭션은 서로 의존적인 조작을 한꺼번에 완료하거나 취소하는 방법으로 무결성을 지킨다
관점 지향 프로그래밍 (AOP, Aspect Oriented Programming)
유닛 오브 워크 (UOW, Unit Of Work)
어떤 객체의 변경 사항을 기록하는 객체이다
퍼시스턴시 대상이 되는 객체의 생성, 변경, 삭제 등의 모든 동작이 모두 유닛오브워크를 통하게 된다

애그리거트 (Aggregate)

여러 개의 객체가 모여 한 가지 의미를 갖는 하나의 객체가 되는데, 이 객체들을 묶어서 표현하는것을 애그리거트(Aggregate) 라고 한다
애그리거트의 중점이 되는 객체를 애그리거트 루트 (Aggregate Root) 라고 한다

예시

class UserId { consturctor (public readonly value: number) {} } class UserName { constructor (public readonly value: string) {} } class User { public id: UserId; public name: UserName; }
TypeScript
위의 예시에서 User 가 애그리거트 루트 (Aggregate Root) 가 된다

규칙

애그리거트에 포함된 객체를 조작할 때는 항상 애그리거트 루트를 통해서 수정해야 한다
const userName = new UserName("Claude") user.Name = userName; // 이렇게 사용하면 안된다 user.changeName(userName); // 이렇게 사용해야 한다
TypeScript
데메테르 법칙
객체 간의 메소드 호출에 질서를 부여하기 위한 가이드라인이다.
어떤 컨텍스트에서 다음 객체의 메서드만을 호출할 수 있게 제한한다
객체 자신
인자로 전달받은 객체
인스턴스 변수
해당 컨텍스트에서 직접 생성한 객체
객체 내부의 데이터는 함부로 외부에 공개해서는 안된다
일반적으로는 리포리저티 객체 외에는 애그리거트 내부 데이터에 접근하지 않는 코드를 규칙으로 만들어 협의해서 한다
강제성을 두기 위해서는 노티피케이션 객체 (Notification Object) 를 이용한다

애그리거트 경계

애그리거트의 경계를 정하는 원칙 중 가장 흔히 쓰이는 방법은 변경의 단위이다.
애그리거트에 대한 변경은 해당 애그리거트에 맡기고, 퍼시스턴시 요청도 애그리거트 단위로 해야 한다
경계를 확실하게 하기 위해서는 객체 인스턴스 대신 식별자(id) 를 들고 있으면 된다
이 방법은 메모리를 절약하는 부수적인 효과가 있다
애그리거트 (Aggregate) 당 리포지터리 (Repository) 를 1개씩 만든다
애그리거트 크기는 가능한 작게 정하는게 좋다

예시

class Circle { users: User[]; } const user1 = new User(); const user2 = new User(); userRepository.save(user1); userRepository.save(user2); const circle = new Circle(); circle.join(user1) circle.join(user2); circleRepository.save(circle); user2.name = new UserName('New Name'); userRepository.save(user2);
TypeScript
위 코드로 인해 circle 안에 있는 user2 의 데이터가 변경되었으나, circle 안에는 user2 가 변경된 상태로 저장되어 있지 않기 때문에 복원할 경우 이전 데이터로 불러온다

명세 (Specification)

명세는 어떤 객체가 그 객체의 평가 기준을 만족하는지 판정하기 위한 객체이다
명세는 엔티티나 값 객체가 리포지터리를 다루지 않으면서도 문제를 해결할 수 있다
도메인 규칙은 도메인 객체 안에 정의되어야 한다
명세는 단독으로 사용되기도 하지만 리포지터리와 조합해 사용할 수 있다
리포지터리에서 명세를 필터로 사용할 경우 항상 성능에 염두를 둬야 한다

예시

class CircleFullSepcification { constructor(private readonly userRepository: UserRepository) {} isSatisfiedBy(circle: Circle): boolean { const users = this.userRepository.find(circle.members); return users.filter(user => user.isPremium).length > 10 } }
TypeScript

복잡한 쿼리는 읽기 모델로 처리

시스템의 애초 존재 의의는 사용자의 문제를 해결하는것이다
그 어떤 조건보다 이 조건이 최우선이다
복잡한 읽기 작업에서 성능 문제가 우려된다면 이러한 부분에 한해 도메인 객체의 제약을 벗어나도 된다
페이지네이션, 복잡한 JOIN 쿼리 등
쓰기 작업 (Command) 에서는 도메인 객체 등을 적극적으로 활용하지만, 읽기 작업 (Query) 에서는 그렇지 않은 경우가 많다
CQS (Command-Query Separation), CQRS (Command-Query Responsibility Segregation) 등 이 있다
지연 실행 (Lazy Execution) 을 통해 리포지터리는 그대로 사용하면서 성능 문제를 해결할 수 있는 방법도 있다
class CircleSummaryService { getSummaries(query: GetCircleSummary): GetCircleSummariesResult {} }
TypeScript