: 도출한 모델은 크게 엔티티와 밸류로 구분할 수 있음, 앞서 요구사항 분석 과정에서 만든 모델은 다음과 같음
: 엔티티와 밸류를 제대로 구분해야 도메인을 올바르게 설계하고 구현할 수 있기 때문에 이 둘의 차이를 명확하게 이해하는 것은 아주 중요하다.
1. 엔티티
: 엔티티의 가장 큰 특징은 식별자를 가진다는 것, 식별자는 엔티티 객체마다 공유해서 각 엔티티는 서로 다른 식별자를 갖는다.
: 예를 들어, 주문 도메인에서 각 주문은 주문번호를 가지고 있는데 이 주문번호는 각 주문마다 다르다. 따라서 주문번호가 주문의 식별자가 된다.
: 주문에서 배송지 주소가 바뀌거나 상태가 바뀌더라도 주문번호는 바뀌지 않는 것처럼 엔티티의 식별자는 변경되지 않는다.
: 엔티티의 식별자는 바뀌지 않고 고유하기 때문에 두 엔티티 객체의 식별자가 같다면 두 엔티티는 동일한 객체라고 판단할 수 있다.
2. 엔티티의 식별자 생성
: 엔티티의 식별자를 생성하는 시점은 도메인의 특징과 사용하는 기술에 따라 달라짐, 흔히 식별자는 다음 중 한 가지 방식으로 생성
•
특정 규칙에 따라 생성
•
UUID나 Nano ID와 같은 고유 식별자 생성기 사용
•
값을 직접 입력
•
일련번호 사용(시퀀스나 DB의 자동 증가 칼럼 사용)
: 주문번호, 운송장번호, 카드번호와 같은 식별자는 특정 규칙에 따라 생성, 이 규칙은 도메인에 따라 다르고 같은 주문번호라도 회사마다 다름
: 자동 증가 칼럼은 DB 테이블에 데이터를 삽입해야 비로소 해당 식별자를 확인할 수 잇기 때문에 엔티티 객체를 생성 시 식별자를 전달할 수 없다는 단점이 존재
: 리포지터리는 도메인 객체를 데이터베이스에 저장할 때 사용하는 구성요소로 자동 증가 칼럼을 사용할 경우 리포지터리는 DB가 생성한 식별자를 구해서 엔티티 객체에 반영
3. 밸류 타입
: ShippingInfo 클래스는 받는 사람과 주소에 대한 데이터를 가지고 있음
: ShippingInfo 클래스의 receiverName 필드와 receiverPhoneNumber 필드는 서로 다른 두 데이터를 담고 있지만 두 필드는 논리적으로 받는 사람을 의미, 즉 두 필드는 실제로 하나의 개념을 표현하고 있음, 비슷하게 shippingAddress1 필드, shippingAddress2 필드, shippingZipcode 필드는 주소라는 하나의 개념을 표현
: 즉 밸류 타입은 개념적으로 완전한 하나를 표현할 때 사용, 예로 받는 사람을 위한 밸류 타입인 Receiver를 클래스로 작성할 수 있음
export class Receiver {
private name:string;
private phoneNumber: string;
constructor(name: string, phoneNumber: string) {
this.name = name;
this.phoneNumber = phoneNumber;
}
}
TypeScript
복사
: Receiver는 받는 사람이라는 도메인 개념을 표현, 앞서 ShippingInfo의 receiverName 필드와 receiverPhoneNumber 필드가 이름을 갖고 받는 사람과 관련된 데이터라는 것을 유추한다면 Receiver는 그 자체로 받는 사람을 의미, 밸류 타입을 사용하므로써 개념적으로 완전한 하나를 잘 표현할 수 있게 됨
: ShippingInfo의 주소 관련 데이터도 다음의 Address 밸류 타입을 사용해서 보다 명확하게 표현이 가능함
export class Address {
private address1: string;
private address2: string;
private zipcode: string;
constructor(address1: string, address2: string, zipcode: string) {
this.address1 = address1;
this.address2 = address2;
this.zipcode = zipcode;
}
}
TypeScript
복사
: 밸류 타입이 꼭 2개 이상의 데이터로만 이뤄지는 것은 아님, 의미를 명확하게 하기 위해 밸류 타입을 사용하는 경우도 있는데, 이는 OrderLine의 price와 amounts에서 확인할 수 있음
export class Money {
private readonly value: number;
constructor(value: number) {
this.value = value;
}
public getValue(): number {
return this.value;
}
public add(money: Money): Money {
return new Money(this.value + money.getValue());
}
public subtract(money: Money): Money {
return new Money(this.value - money.getValue());
}
public multiply(value: number): Money {
return new Money(this.value * value);
}
public divide(value: number): Money {
return new Money(this.value / value);
}
}
TypeScript
복사
: 밸류 타입의 객체의 데이터를 변경할 때는 기존 데이터를 변경하기보다 변경한 데이터를 갖는 새로운 객체를 만드는 것을 선호, 예를 들어 Money 타입의 연산은 모두 새로운 객체를 반환하는 것을 확인 가능
: Money처럼 데이터 변경 기능을 제공하지 않는 타입을 불변 객체라고 하는데, 밸류 타입을 불변으로 구현하는 것에는 안전한 코드를 작성할 수 있다는 장점이 있음
4. 엔티티 식별자와 밸류 타입
: 엔티티 식별자의 실제 데이터는 String과 같은 문자열로 구성된 경우가 많음, 신용카드 번호도 16개의 숫자로 구성된 문자열이며 많은 온라인 서비스에서 회원을 구분할 때 사용하는 이메일 데이터도 문자열
: 단순히 Money가 숫자가 아닌 우리가 구현하고자 하는 도메인에서 동작하는 돈을 의미하는 것처럼, 이런 식별자는 단순한 문자열이 아니라 도메인에서 특별한 의미를 지니는 경우가 많기 때문에 식별자를 위한 밸류 타입을 사용해서 의미가 잘 드러날수록 할 수 있다. 예를 들어 주문번호를 표현하기 위해 Order의 식별자 타입으로 String 대신 OrderNo를 사용하면 타입을 통해 해당 필드가 주문번호라는 것을 즉각적으로, 직관적으로 확인할 수 있다.
: 만약 OrderNo 대신 String 타입을 사용한다면 ‘id’라는 이름만으로는 해당 필드가 주문번호인지를 알 수 없다. 필드의 의미가 잘 드러나도록 하려면 ‘id’라는 필드 이름 대신 ‘orderNo’라는 필드 이름을
사용해야 한다. 반면에 식별자를 위해 OrderNo 타입을 만들면 타입 자체로 주문번호라는 것을 알 수 있으므로 실제 의미를 찾는 것이 어렵지 않다.
5. 도메인 모델에 set 메서드 넣지 않기
: get/set 메서드를 습관적으로 추가할 때가 있는데, 사용자 정보를 담는 UserInfo 클래스를 작성할 때 다음과 같이 데이터 필드에 대한 get/set 메서드를 습관적으로 작성할 수 있음
export class UserInfo {
private id: string;
constructor() {}
public getId(): string {
return id;
}
public setId(id: string): void {
this.id = id;
}
}
TypeScript
복사
: get/set 메서드를 습관적으로 만드는 이유로는 여러가지가 있겠지만! 물론 나는 아니다.. setter 주입의 폐해에 대해 알고 있음..!!!!
: 가장 큰 이유라면 프로그래밍 입문 시, 예제 코드들 때문이라고 생각하심, 필자는, 처음 프로그래밍을 익힐 때, 예제 코드를 그대로 사용하다보니 구현하게 되는 것 같다고 하심
: 만약 다음과 같이 Order의 메서드를 재정의해본다면 어떻게 될까
export class Order {
public setShippingInfo(shippingInfo ShippingInfo) { ... }
public setOrderState(state OrderState) { ... }
}
TypeScript
복사
: 앞서 changeShippingInfo()가 배송지 정보를 새로 변경한다는 의미를 가졌다면 setShippingInfo() 메서드는 단순히 배송지 값을 설정한다는 것을 의미, completePayment()는 결제를 완료했다는 의미를 갖는 반면에 setOrderState()는 단순히 주문 상태값을 설정한다는 것을 의미, 즉 논리적인 의미가 달라지고 메소드가 책임지는 범위가 달라짐
: set 메서드의 또 다른 문제는 도메인 객체 생성 시 온전하지 않은 상태가 될 수 있다는 점이다.
const order: Order = new Order();
order.setOrderLine(...);
order.setShippingInfo(...);
order.setState(OrderState.PREPARING);
TypeScript
복사
: 위 코드는 주문자에 대한 데이터가 없음에도 주문의 상태가 준비중으로 변경되었다. 이를 방지하고자 주문자 정보를 체크하는 내용을 setState에 추가하는 것도 바람직하지 않다. 따라서 도메인 객체가 불완전한 상태로 사용되는 것을 막으려면 생성 시점에 필요한 것을 모두 전달해 주어야 한다. 즉 생성자를 통해 필요한 데이터를 모두 받아야 한다는 것
export class Order {
constructor(orderer: Orderer, orderLines: OrderLine[]) {
setOrderer(orderer);
serOrderLines(orderLines);
}
private setOrderer(...) { ... };
private setOrderLines(...) { ... };
}
TypeScript
복사
: 이 코드의 set 메서드는 앞서 언급한 set 메서드와 중요한 차이점이 있는데, 그것은 바로 접근 범위가 private이라는 것, 즉 클래스 내부에서 데이터를 변경할 목적으로 사용되어 그 범위가 제한된다는 점에서 다르다, 외부에서는 이 메서드를 사용해 데이터를 변경할 수 없으므로 안전하다는 것
DTO의 get/set 메서드
DTO는 Data Transfer Object의 약자로 프레젠테이션 계층과 도메인 계층이 데이터를 서로 주고받을 때 사용하는 일종의 구조체, 오래전에 사용했던 프레임워크는 요청 파라미터나 DB 칼럼의 값을 설정할 때, set 메서드를 필요로 했기 때문에 구현 기술을 적용하려면 어쩔 수 없이 DTO에 get/set 메서드를 구현해야 했다 하지만 DTO가 도메인 로직을 담고 있지는 않기 때문에 get/set 메서드를 제공해도 데이터 일관성에 영향을 줄 가능성이 높지 않다.
요즘 개발 프레임워크나 도구는 set 메서드가 아닌 private 필드에 직접 값을 할당할 수 있는 기능을 제공하고 있어 set 메서드가 없어도 프레임워크의 기능을 이용해서 데이터를 전달받을 수 있다. 프레임워크가 필드에 직접 값을 할당하는 기능을 제공하고 있다면 set 메서드를 만드는 대신 해당 기능을 사용하는 것을 고려해보자, 이렇게하면 DTO도 불변 객체가 되어 불변의 장점을 DTO까지 확장가능!