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