이 글은 도메인 주도 설계에 대해 공부하기 위해 다음 책을 읽고 공부한 내용을 정리한 글입니다.
이번 시간에는 도메인 지식을 표현하기 위한 고급 패턴들에 대해 정리해보았다.
객체 지향 프로그래밍에서는 여러 개의 객체가 모여 하나의 의미를 갖는 객체가 된다. 이 연관된 객체들을 묶어서 표현하는 것을 '애그리게이트'라고 한다.
도메인 모델이 복잡해졌을 때 개별 객체 수준에서 모델을 바라본다면 전반적인 구조나 도메인 간의 관계를 파악하기 어렵다. 그렇게 되면 코드를 변경하고 확장하는 것이 힘들어진다. 이러한 문제점을 해결하기 위해선 상위 수준에서 모델을 바라볼 수 있어야 한다. 애그리게이트를 활용하면 이러한 어려움을 해결할 수 있다.
애그리게이트의 중심이 되는 객체를 '애그리게이트 루트(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 클래스가 애그리게이트 루트가 된다.
애그리게이트에 포함된 객체를 조작할 때에는 애그리게이트 루트를 통해서 수정해야 한다.
const userName = new UserName("minsu");
const user = new User(userName);
user.Name = userName; // x
user.changeName(userName); // o
애그리게이트 경계는 하나의 애그리게이트에 포함되는 대상 객체를 결정하는 경계이다. 이 경계를 정하는 가장 흔하게 쓰이는 기준 중 하나는 '변경의 단위'이다.
위 그림에서 Circle과 User는 별개의 애그리게이트로 구성되어 있다. 만약, 서클에 속한 특정 사용자의 정보를 변경하는 작업을 서클 애그리게이트에 맡긴다면 어떻게 될까?
서클 리포지토리의 로직에 사용자의 정보를 수정하는 코드로 오염될 뿐만 아니라, 사용자 리포지토리의 코드와 중복된다. 어쩔 수 없는 경우가 아니면 코드 중복은 피하는 것이 좋다.
이는 변경의 단위를 넘어서는 변경을 시도했다가 발생한 문제이다. 애그리게이트에 대한 변경은 해당 애그리게이트 자신에게만 맡기고, 퍼시스턴시 요청도 애그리게이트 단위로 해야 한다. 그렇기 때문에 리포지토리는 애그리게이트마다 하나씩 만든다.
class Circle {
users: User[];
}
const user1 = new User("minsu");
const user2 = new User("sumin");
userRepository.save(user1);
userRepository.save(user2);
const circle = new Circle();
circle.join(user1)
circle.join(user2);
circleRepository.save(circle);
user2.name = new UserName('suji');
userRepository.save(user2);
위 예제에서 서클 안에 있는 user2의 이름이 변경되었지만 서클 안에는 user2가 변경된 상태로 저장되어 있지 않다. 그래서 해당 서클을 복원할 경우 이전 user2의 데이터로 불러와진다.
이런 변경의 여파를 줄이고 경계를 더 확실히 하기 위해서 객체 인스턴스 대신 식별자(id)를 가지고 있어야 한다.
class Circle {
userIds: UserId[];
...
이렇게 하면 User의 인스턴스를 수정해도 이와 관련된 다른 엔티티들을 신경쓰지 않아도 된다. 또한 메모리를 절약하는 부수적인 효과도 있다.
객체를 평가하는 절차가 단순하다면 해당 객체의 메서드로 정의하면 되지만, 복잡한 평가 절차가 필요할 수도 있다. 또한 평가 절차를 해당 객체의 메서드로 두는 것이 부자연스러운 경우도 있다.
이러한 객체 평가 절차는 애플리케이션 서비스에 구현되어서는 안 된다. 객체에 대한 평가는 도메인 규칙 중에서도 중요도가 높은 것으로 서비스에 구현하기에 알맞지 않다.
이를 위한 대책으로 사용하는 것이 명세이다. 명세는 어떤 객체가 그 객체의 평가 기준을 만족하는지 판정하기 위한 객체이다.
다음과 같은 도메인 규칙이 있다.
이 규칙을 아래와 같이 애플리케이션 서비스에서 기술하는 것이 아닌 명세를 이용했다.
class CircleFullSepcification {
constructor(private readonly userRepository: UserRepository) {}
isSatisfiedBy(circle: Circle): boolean {
const users = this.userRepository.find(circle.members);
const premiumUserNumber = users.count(user => user.IsPremium);
const circleUpperLimit = premiumUserNumber < 10 ? 30 : 50;
return circle.contMembers() >= circleUpperLimit;
}
}
이 명세를 서비스에 적용한다.
class CircleApplicationService {
...
join(command: CircleJoinCommand) {
const circleId = new CircleId(command.circleId);
const circle = circleRepository.find(circleId);
const circleFullSpecification = new CircleFullSpecification(userRepository);
if (circleFullSpecification.ifSatisfiedBy(circle)) {
throw new Error('~~~');
}
...
}
}
이제 복잡한 객체 평가 코드를 캡슐화해 원래 객체의 의도를 잘 드러낼 수 있게 되었다.
명세는 단독으로 사용되기도 하지만 리포지토리와 조합해 사용할 수도 있다.
다만, 리포지토리에서 명세를 필터로 사용할 경우 항상 성능을 염두해 두어야 한다.
복잡한 읽기 작업에서 성능 문제가 우려된다면 이러한 부분에 한해 도메인 객체의 제약을 벗어나도 된다. (페이지네이션, 복잡한 JOIN문 등)
Reference
도메인 주도 설계 철저 입문 (나루세 마사노부 저/심효섭 역)
https://private-space.tistory.com/94
https://rudaks.tistory.com/entry/%EB%8F%84%EB%A9%94%EC%9D%B8-%EC%A3%BC%EB%8F%84-%EC%84%A4%EA%B3%84-%EC%B2%A0%EC%A0%80-%EC%9E%85%EB%AC%B8-%EC%95%A0%EA%B7%B8%EB%A6%AC%EA%B2%8C%EC%9D%B4%ED%8A%B8?category=1025984