이 글은 도메인 주도 설계에 대해 공부하기 위해 다음 책을 읽고 공부한 내용을 정리한 글입니다.
도메인 주도 설계 철저 입문 (나루세 마사노부 저/심효섭 역)
이번 시간에는 도메인 지식을 표현하기 위한 패턴에 대해서 다뤄보려고 한다.
프로그래밍 언어에는 원시 데이터 타입이 있다. 이 원시 데이터 타입만 사용해 시스템을 개발할 수도 있지만, 때로는 시스템 특유의 값을 정의해야 할 때가 있다. 이러한 시스템 특유의 값을 표현하기 위해 정의하는 객체를 값 객체라고 한다.
예를 들어, "성명"을 다음과 같이 string으로 표현할 수 있다.
const fullName = "naruse mananobu";
그런데, 시스템에서 성과 이름을 각각 따로 필요로 한다면 어떻게 될까? 위의 fullName만 봐서는 구분할 수 없다.
class FullName {
private readonly firstName;
private readonly lastName;
constructor(firstName: string, lastName: string) {
this.firstName = firstName;
this.lastName = lastName;
}
getFirstName() {
return this.firstName;
}
getLastName() {
return this.lastName;
}
}
let fullName = new FullName("masanobu", "naruse");
console.log(fullName.getLastName()); // naruse
문자열이던 fullName을 객체로 정의했다. 이제 필요에 따라 FullName 객체에서 성과 이름을 각각 받아올 수 있게 되었다. 원시 타입을 사용하지 않고, 도메인에 맞는 객체로 정의해 사용한 것이다.
이처럼 값 객체는 시스템 특유의 값에 대한 표현이다. string, number와 같은 원시 타입처럼 일종의 "값"을 다루고 있는 객체로 사용하기 때문에 "값 객체"라고 부른다.
값 객체는 다음과 같은 성질이 있다.
let greet = "안녕하세요";
console.log(greet); // 안녕하세요
greet = "Hello";
console.log(greet); // Hello
위와 같이 값을 수정할 때는 새로운 값을 대입한다. 대입을 통해 수정되는 것은 변수의 내용이지, 값 자체가 수정되는 것은 아니다.
let greet = "안녕하세요";
greet.changeTo("Hello"); // 실제로 이런 메서드는 없다
"안녕하세요".ghangeTo("Hello"); // 실제로 이런 메서드는 없다
만약 위와 같이 값을 수정할 수 있다고 한다면 뭔가 자연스럽지 않고 혼란스럽다.
let fullName = new FullName("masanobu", "naruse");
fullName = new FullName("minsu", "kim"); // o
fullName.changeToLastName("kim") // x
값 객체도 마찬가지이다. 값 객체에 값을 수정하는 메서드를 넣어서는 안 된다. 값 객체의 인스턴스에 수정이 필요하다면 새 인스턴스를 생성하여 할당해야 한다.
값은 불변한 성질을 가지고 있다. 하지만 값을 수정하지 않고 목적을 달성하는 소프트웨어를 만들기란 어렵다. 값이 불변일지라도 값을 수정할 필요는 있다. 아래와 같이 값은 대입문을 통해 교환의 형식으로 표현된다.
let fullName = new FullName("masanobu", "naruse");
fullName = new FullName("minsu", "kim");
원시 타입에서 서로 같은 종류의 값은 서로 비교할 수 있다.
console.log(123 === 123); // true
console.log('a' === 'b')' // false
값 객체도 마찬가지이다. 아래와 같이 값 객체끼리 비교 연산이 가능하도록 해주어야 한다.
class FullName {
private readonly firstName;
private readonly lastName;
constructor(firstName: string, lastName: string) {
this.firstName = firstName;
this.lastName = lastName;
}
equals(other) {
// 구현 생략
}
}
console.log(nameA.equals(nameB));
위 예제에서 FullName 클래스를 구성하는 firstName, lastName 과 같은 속성은 원시 타입인 문자열로 정의되어 있다. 이들 또한 값 객체로 만들 필요는 없을까?
어느 한 쪽이 맞고 틀리고의 문제가 아니다. 어디까지 값 객체로 만들어야 하는가는 어려운 문제이다. 단순히 도메인 모델로 정의되는 개념은 값 객체로 정의할 수 있지만, 그렇지 않은 경우 혼란을 낳는다.
'성명은 성과 이름으로 구성된다는 규칙이 있기에 값 객체로 정의하고, 성/이름은 낱개로 다뤄져야 하기 때문에 값 객체로 정의하지 않는다' 와 같이 나름의 기준이 있어야 한다.
예를 들어, firstName과 lastName이 최소 1글자 이상이여야 한다는 도메인 규칙이 있다고 가정해보자. 이를 값 객체에서 다음과 같이 표현할 수 있다.
class FullName {
private readonly firstName;
private readonly lastName;
constructor(firstName: string, lastName: string) {
if (firstName.length < 1) {
throw new Error('이름은 최소 1글자 이상이어야 함');
}
if (lastName.length < 1) {
throw new Error('성은 최소 1글자 이상이어야 함');
}
this.firstName = firstName;
this.lastName = lastName;
}
getFirstName() {
return this.firstName;
}
getLastName() {
return this.lastName;
}
}
만약, FullName을 값 객체가 아닌 원시 타입인 string을 사용했다면 위와 같은 도메인 규칙을 담아내기 어려웠을 것이다. 이렇듯, 도메인 객체에 도메인 규칙을 명시적으로 표현할 수 있는 것이 값 객체의 장점이다.
값 객체에서 중요한 점 중 하나는 독자적인 행위를 정의할 수 있다는 것이다.
아래 예제는 돈을 나타내는 값 객체이다. 여기에는 액수와 화폐 단위를 속성으로 갖는다.
돈은 덧셈이 가능하다. 이 행동을 메서드로 구현한 것이 아래와 같다.
class Money {
private readonly amount: number;
private readonly currency: string;
...
add(money: Money): Money {
... (화폐 단위가 다를 경우 에러 처리)
return new Money(amount + money.amount, currency);
}
}
이렇듯, 값 객체는 데이터를 담는 것만인 목적인 구조체가 아니다. 값 객체는 데이터와 더불어 그 데이터에 대한 행동을 한 곳에 모아둠으로써 자신만의 규칙을 갖는 도메인 객체가 된다.
원시 타입이 아닌 값 객체를 사용하면서 얻을 수 있는 이점으로는 다음과 같은 것들이 있다.
DDD에서 말하는 엔티티란 도메인 모델을 구현한 도메인 객체를 의미한다. 값 객체와 달리 식별자와 생애 주기를 가지고 있다.
class User {
private readonly userId: UserId;
private name: string;
constructor(name: string) {
...
}
}
위에서 User 객체는 엔티티이다. UserId는 값 객체로 User 엔티티의 식별자 역할을 담당한다. 값 객체는 값만 같으면 같은 인스턴스이지만, 엔티티는 식별자가 같아야 같은 인스턴스로 취급된다. 반대로, 식별자만 같다면 두 엔티티는 속성 값이 달라도 동일한 엔티티이다.
엔티티의 성질에는 다음과 같은 것들이 있다.
불변인 값 객체와 달리 엔티티는 가변이다. 그러므로 아래와 같이 인스턴스의 값을 변경하는 메서드가 허용된다.
const user = new User("minsu");
user.changeName("sumin");
엔티티는 속성이 아닌 동일성으로 식별되는 객체이다. 속성이 같아도 식별자가 다르다면 서로 다른 객체이다.
엔티티는 값 객체와 달리 생애주기를 갖는다.
서비스의 사용자는 해당 서비스를 이용하고자 하는 사람은 회원 가입을 하고 사용자가 생성된다. 서비스를 이용하면서 닉네임, 나이 등 회원 정보가 수정되기도 한다. 더 이상 서비스가 필요 없다면 회원 탈퇴 하고 삭제된다. 이와 같이 엔티티는 생성되고 삭제되는 생애주기가 있다.
반면에 값 객체는 생애주기를 가지지 않거나 생애주기를 포현하는 것이 무의미한 경우이다. 그러므로 생애주기를 가지고 있지 않은 도메인 모델은 값 객체로 표현하는 것이 좋다.
이 장점들은 최초 개발 시점보다 개발 완료 후 유지보수 단계에서 빛을 발한다.
소프트웨어 개발에서 말하는 서비스란 클라이언트를 위해 무언가를 해주는 객체를 말한다.
반면에, 도메인 주도 설계에서 말하는 서비스는 크게 두 가지로 나뉜다.
이 중에서 살펴볼 서비스는 도메인을 위한 서비스이다.
앞서 살펴보았듯이 값 객체나 엔티티 같은 도메인 객체에는 객체의 행동을 정의할 수 있었다. 그러나 시스템에는 값 객체나 엔티티로 구현하기 어색한 행동도 있다. 도메에인 서비스는 이런 어색함을 해결해주는 객체이다.
예를 들어, 사용자 닉네임을 중복으로 사용할 수 없게 하는 도메인 규칙이 있다고 해보자. 새로운 유저를 등록하려고 할 때 이미 등록된 사용자 리스트에서 같은 닉네임이 있는지 조회해봐야 한다.
class User {
private readonly userId: UserId;
private nickname: string;
...
exist(user: User): boolean {
// 닉네임 중복을 확인하는 코드
}
}
const user = new User("minsu");
const duplicatedCheckResult = user.exist(user);
그런데 사용자 리스트에서 사용자를 조회하는 기능을 유저 엔티티가 스스로 수행하게 하는 것이 뭔가 부자연스럽다. (자기 자신에게 중복 여부를 묻는 상황..)
'사용자 닉네임이 중복되었다면 새로 등록할 수 없다'라는 것은 분명한 도메인 규칙이다. 하지만, 도메인 모델에 담기에는 어색하다.
위에서 봤던 것처럼 값 객체와 엔티티 같은 도메인 객체에는 담기 어색한 도메인 규칙을 표현하는 새로운 요소가 등장하는데 이것이 도메인 서비스이다.
class UserService {
exists(user: User) {
// 닉네임 중복을 확인하는 코드
}
}
위와 같이 닉네임 중복을 확인하는 메서드를 User 도메인 서비스에 정의하고,
...
const user = new User("minsu");
const duplicatedCheckResult = userService.exists(user);
...
사용자를 생성하는 로직을 다음과 같이 변경했다.
자기 자신에게 중복 여부를 확인하는 것보다 더 직관적으로 보인다.
도메인 서비스를 정의할 때 주의할 점은 도메인 서비스의 기능을 도메인 모델이 하기에 부자연스러운 경우로만 한정해야 한다는 것이다. 그렇지 않으면 도메인 서비스에 모든 도메인 처리가 담기게 될 수 있다.
class UserService {
changeNickname(user: User): void {
...
user.nickname = nickname;
}
}
위에서는 User 도메인 서비스에서 User의 nickname을 변경하고 있다. 하지만, 닉네임을 수정하는 일은 도메인 서비스가 아닌 엔티티의 책임이다.
생각 없이 모든 처리 코드를 도메인 서비스로 옮기면 다른 도메인 객체는 그저 데이터를 저장할 뿐, 별다른 정보를 제공할 수 없는 객체가 되고 만다. 도메인 서비스를 남용하면 데이터와 행위가 단절되어 로직이 흩어지기 쉽다. 그렇게 되면, 소프트웨어가 변화에 대응하는 유연성이 저해되어 심각하게 정체된다.
어떤 행위를 값 객체나 엔티티에 구현할지 도메인 서비스에 구현할지 망설여진다면 우선 엔티티나 값 객체에 정의하는 것이 좋으며, 도메인 서비스에 행위를 구현하는 것은 가능한 한 피애야 한다.
Reference
도메인 주도 설계 철저 입문 (나루세 마사노부 저/심효섭 역)