본문으로 건너뛰기

도메인 주도 설계 철저 입문

도메인 주도 설계 철저 입문​

정보

도메인 주도 설계 철저 입문 을 보고 간단하게 정리한 내용이다.
이 책을 읽어보는것을 추천한다.

  • 지식 표현을 위한 패턴
    • 값 객체
    • 엔티티
    • 도메인 서비스
  • 애플리케이션을 구성하는 패턴
    • 리포지터리
    • 애플리케이션 서비스
    • 팩토리
  • 지식 표현을 위한 고급 패턴
    • 애그리게이트
    • 명세

값 객체 (Value Object)​

  • 프로그래밍 언어에는 원시 데이터 타입(Primitive Type) 이 있고, 이 타입만 사용해서 개발이 가능하지만, 필요에 따라서 직접 타입을 정의해야 할 때가 있다

  • DDD 에서는 원시 타입만을 사용하지 않고, 도메인에 맞는 객체로 정의해서 사용한다

  • 객체 자체를 하나의 값으로써 취급하기 때문에 값 객체 라고 부른다.

  • 예시

    class FullName {
    private firstName: string;
    private lastName: string

    constructor(firstName: string, lastName: string) {
    this.firstName = firstName;
    this.lastName = lastName;
    }
    }

성질​

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

장점​

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

정리​

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

엔티티 (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;
    }
    }

성질​

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

정리​

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

도메인 서비스 (Domain Service)​

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

예시​

  • 중복된 이메일은 등록할 수 없다는 도메인 규칙이 있을 경우 이 행위 자체를 도메인 모델에 담기에는 부자연스럽다

    class User {
    exists(user: User): boolean {
    // ...
    }
    }

    const user = new User()
    user.exists(user)
  • 이런 경우 도메인 서비스를 이용하면 자연스럽게 해결할 수 있다

    class UserDomainService {
    exists(user: User): boolean {
    // ...
    }
    }

    const userDomainService = new UserDomainService()
    const user = new User()
    userDomainService.exists(user);

주의사항​

  • 도메인 서비스로 대다수의 로직들을 작성할 수 있지만, 도메인 서비스는 도메인 모델에서 처리하기 부자연스러운 처리로만 제한해서 작성해야 한다

  • 도메인 서비스를 남용하면 데이터와 행위가 여러군데로 흩어지기 때문에 도메인 모델의 유연성에 문제가 생길 수 있다

    class UserDomainService {
    changeEmail(user: User, email: string): void {
    if (email.length <= 0) {
    throw new Error('이메일은 최소 1글자 이상이어야 한다.');
    }

    user.email = email;
    }
    }
  • 가능한 도메인 모델에 행위를 정의하고 도메인 서비스는 피하는것이 좋다


리포지터리 (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)
// ...
}
}
interface UserRepository {
find(name: UserName): User | null;
save(user: User): void;
}

정리​

  • 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)
}
}

정리​

  • 도메인 객체를 직접 공개하는 경우 외부에서 데이터를 조작할 수 있기 때문에 직접 노출하지 않고 DTO (Data Transfer Object) 를 만들어서 전달하는것을 권장한다

  • 데이터를 변경하는 경우 파라미터로 직접 다 받는것보다는 커맨드 객체를 만들어서 받는것이 좋다

    class UpdateUserCommand {
    constructor(
    public readonly email: string
    )
    }

    class UserApplicationService {
    update(command: UpdateUserCommand): void {
    // ...
    }
    }
  • 애플리케이션 서비스는 도메인 객체가 수행하는 작업들을 조율하기만 해야 하며, 도메인 규칙이 노출되지 않게 해야한다

  • 애플리케이션 서비스를 작성할 때는 응집도에 신경을 쓰는것이 바람직하다

    • 사용자와 관련된 유스케이스라고 해서 꼭 한 클래스에 모아둘 필요는 없다
    • 유스케이스마다 클래스를 반드시 분리할 필요도 없지만, 클래스를 구성하는 변수와 메소드가 적절히 대응되어 있는지 검토해보면 좋다
    class UserRegisterService {
    // ...
    }

    class UserDeleteService {
    // ...
    }
  • 서비스는 어떠한 영역에도 존재할 수 있으며 상태를 가지고 있으면 안된다

    • 상태를 가지고 있을 경우 복잡성이 증가하고 혼란스럽게 만들 수 있기 때문에 무상태를 유지할 수 있는 방법을 찾아보는것이 좋다

팩토리 패턴 (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);
    }
    }
  • 객체의 생성 과정이 복잡하거나, 데이터베이스의 식별키 (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);
    }
    }
  • 객체의 생성 절차가 간단하다면 생성자를 통해서 만드는것이 더 낫다

  • 팩토리를 이용해 객체 생성 절차를 캡슐화하는 것도 로직의 의도를 더 명확히 드러내면서 유연성을 확보할 수 있는 좋은 방법이다


데이터 무결성 보장​

  • 데이터의 무결성을 보장하기 위해서는 여러가지 방법들이 있다
  • 데이터베이스 유니크 인덱스 제약조건(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;
}
  • 위의 예시에서 User 가 애그리거트 루트 (Aggregate Root) 가 된다

규칙​

  • 애그리거트에 포함된 객체를 조작할 때는 항상 애그리거트 루트를 통해서 수정해야 한다

    const userName = new UserName("Claude")

    user.Name = userName; // 이렇게 사용하면 안된다

    user.changeName(userName); // 이렇게 사용해야 한다
  • 데메테르 법칙

    • 객체 간의 메소드 호출에 질서를 부여하기 위한 가이드라인이다.
    • 어떤 컨텍스트에서 다음 객체의 메서드만을 호출할 수 있게 제한한다
      • 객체 자신
      • 인자로 전달받은 객체
      • 인스턴스 변수
      • 해당 컨텍스트에서 직접 생성한 객체
  • 객체 내부의 데이터는 함부로 외부에 공개해서는 안된다

    • 일반적으로는 리포리저티 객체 외에는 애그리거트 내부 데이터에 접근하지 않는 코드를 규칙으로 만들어 협의해서 한다
    • 강제성을 두기 위해서는 노티피케이션 객체 (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);
  • 위 코드로 인해 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
}
}

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

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