1. 도메인이란?
•
사용자 입장에서 온라인 서점은 장바구니에 담아두는 기능이다.
◦
저렴하게 구매하기 위한 쿠폰 기능, 결제, 배송 추적 등 다양한 기능을 제공한다.
•
개발자 입장에서 온라인 서점은 구현해야 할 소프트웨어의 대상이다.
◦
온라인 서점 소프트웨어는 상품 조회, 구매, 결제, 배송 추적 등의 기능을 구현해야 한다.
◦
개발자에게 온라인 서점은 소프트웨어로 해결하고자 하는 문제 영역, 즉 도메인에 해당하게 된다.
•
한 도메인은 다시 하위 도메인으로 나눌 수 있다.
◦
하위 도메인을 어떻게 구성할지 여부는 상황에 따라 달라진다.
2. 도메인 전문가와 개발자 간 지식 공유
•
도메인의 각 영역에는 전문가가 존재한다. 이들은 해당 도메인에 대한 지식과 경험을 바탕으로 본인들이 원하는 기능 개발을 요구한다.
•
개발자는 이런 요구사항을 분석하고 설계하여 코드를 작성하며 테스트하고 배포해야 한다.
•
개발에 앞서 요구사항을 올바르게 이해하는 것이 중요하다. 요구사항을 올바르게 이해하지 못하면 생산성이 저하된다.
•
요구사항을 올바르게 이해하려면 전문가와 직접 대화하는 것이 제일 좋다. 이해관계자와 개발자도 어느정도 도메인 지식을 갖춰야 한다.
•
이해관계자들이 같은 지식을 공유하고 직접 소통할수록 원하는 기능이 구현될 확률이 높아진다.
•
Grabage in, Garbage out
◦
잘못된 값이 들어가면 잘못된 결과가 나온다
◦
개발자가 도메인 전문가와 직접 소통할수록 요구사항이 변질될 가능성이 줄지만 도메인 전문가라고 해서 항상 올바른 요구사항을 주는 것은 아니다.
◦
전문가나 이해 관계자가 요구한 내용이 항상 올바른 것은 아니며 때론 본인들이 원하는 것을 정확하게 표현하지 못할 때도 있다.
◦
따라서 개발자는 요구사항을 이해할 때 왜 이런 기능을 요구하는지 또는 실제로 원하는 게 무엇인지 생각하고 대화를 해야한다.
3. 도메인 모델
•
기본적으로 도메인 모델은 특정 도메인을 개념적으로 표현한 것이다.
•
객체 모델과 상태 다이어그램을 예시로 들어보자.
◦
객체 모델
▪
예를 들어 주문 도메인은 온라인 쇼핑몰의 경우 주문 시 상품을 몇 개 살지 선택하고 배송지를 입력한다고 해보자. 다음과 같은 기능들이 존재할 것이다.
•
상품을 몇 개 살지 선택한다.
•
선택한 상품 가격을 이용해서 총 지불 금액을 계산한다.
•
금액 지불을 위한 결제 수단을 선택한다.
•
또한 주문한 뒤에도 배송 전일 경우 배송지 주소를 변경하거나 주문을 취소할 수 있다고 해보자.
▪
이를 위한 주문 모델을 객체모델로 구성하면 다음과 같이 만들 수 있다.
⇒ 이 모델을 보면 주문은 주문번호와 지불할 총 금액이 있고, 배송정보를 변경할 수 있음을 알 수 있다. 또한 주문을 취소할 수 있다는 것도 알 수 있음
◦
상태 다이어그램
▪
이 다이어그램을 보면 상품 준비 중 상태에서 주문을 취소하면 결제 취소가 함께 이루어진다는 것을 알 수 있음
⇒ 도메인 모델은 기본적으로 도메인 자체를 이해하기 위한 개념 모델로 개념 모델을 이용해서 이를 바로 코드에 옮길 수 있는 것은 아니기에 구현 기술에 맞는 구현 모델이 따로 필요하다.
•
각 모델은 서로 다른 것이지만 구현 모델이 최대한 개념 모델을 따르도록 객체 지향 언어를 사용하는 등의 노력을 할 수 있다.
4. 도메인 모델 패턴
•
일반적인 애플리케이션의 아키텍쳐는 표현 | 응용 | 도메인 | 인프라스트럭쳐, 4개의 영역으로 구성된다.
영역 | 설명 |
사용자 인터페이스 또는 표현 | 사용자의 요청을 처리하고 사용자에게 정보를 표시, 사용자는 사람뿐만 아니라 외부 시스템일수도 있다. |
응용 | 사용자가 요청한 기능을 실행, 비즈니스 로직을 직접 구현하지 않으며 도메인 계층을 조합해서 기능을 실행한다. |
도메인 | 시스템이 제공할 도메인 규칙을 구현한다. |
인프라스트럭쳐 | 데이터베이스나 메시징 시스템과 같은 외부 시스템과의 연동을 처리한다. |
◦
도메인 계층은 도메인의 핵심 규칙을 구현한다.
◦
주문 도메인의 경우, 출고 전에 배송지를 변경할 수 있다라는 규칙과 주문 취소는 배송 전에만 할 수 있다라는 규칙을 구현한 코드가 도메인 계층에 위치하게 된다.
◦
비즈니스 로직은 도메인 모델에만 위치하기 때문에 규칙을 변경하거나 확장해야 할 때 다른 코드에 영향을 덜 주게 된다.
5. 도메인 모델 도출
•
기획서, 유스케이스와 같은 요구사항, 관련자와의 대화를 통해 도메인을 이해하고 이를 바탕으로 도메인 모델 초안을 만들어야 비로소 코드를 작성할 수 있다.
•
구현을 시작하려면 도메인에 대한 초기 모델이 필요한 것이다.
•
도메인을 모델링할 때 기본이 되는 작업은 모델을 구성하는 핵심 구성요소, 규칙, 기능을 찾는 것, 이 과정은 요구사항에서 출발한다.
◦
주문 도메인과 관련된 요구사항 예시
▪
최소 한 종류 이상의 상품을 주문해야 한다.
▪
한 상품을 한 개 이상 주문할 수 있다.
▪
총 주문 금액은 각 상품의 구매 가격 합을 모두 더한 금액이다.
▪
각 상품의 구매 가격 합은 상품 가격에 구매 개수를 곱한 값이다.
▪
주문할 때 배송지 정보를 반드시 지정해야 한다.
▪
배송지 정보는 받는 사람 이름, 전화번호, 주소로 구성된다.
▪
출고를 하면 배송지를 변경할 수 없다.
▪
출고 전에 주문을 취소할 수 있다.
▪
고객이 결제를 완료하기 전에는 상품을 준비하지 않는다.
•
위의 예시를 통해 주문은 출고 상태로 변경하기, 배송지 정보 변경하기 등의 기능을 제공한다는 것을 알 수 있다.
•
이런 정보들을 통해 상세 구현까진 불가하지만 추상적으로 주문에 관련된 기능을 메서드로 정의가능하다.
•
다음 요구사항은 주문 항목(OrderLine)이 어떤 데이터로 구성되는 지 알려준다.
⇒ 한 상품을 한 개 이상 주문할 수 있다.
⇒ 각 상품의 구매 가격의 합은 상품 가격에 구매 개수를 곱한 값이다.
◦
두 요구사항에 따르면 주문 항목을 표현하는 OrderLine은 적어도 주문할 상품, 상품의 가격, 구매 개수를 포함해야 하며 추가로 각 구매 항목의 구매 가격도 제공해야 한다.
export class OrderLine {
private product: object;
private quantity: number;
private price: number;
private amount: number;
}
TypeScript
복사
▪
OrderLine은 한 상품(product)를 얼마에(price) 몇 개 살지(quantity) 담고 있으며 구매 가격또한 제공한다.
•
다음 요구사항은 Order와 OrderLine 간의 관계를 설명해준다.
⇒ 최소한 한 종류 이상의 상품을 주문해야 한다.
⇒ 총 주문 금액은 각 상품의 구매 가격 합을 모두 더한 금액이다.
◦
한 종류 이상의 상품을 주문할 수 있으므로 Order는 최소 한 개 이상의 OrderLine을 포함, 또한 총 주문 금액은 OrderLine으로 구할 수 있다.
export class Order {
private orderLines: OrderLine[];
private totalAmount: number;
constructor(orderLines: OrderLine[]) {
this.verifyAtLeastOneOrMoreOrderLines(orderLines);
this.orderLines = orderLines;
this.totalAmount = this.calculateTotalAmount(orderLines);
}
private verifyAtLeastOneOrMoreOrderLines(orderLines: OrderLine[]) {
if (!orderLines || orderLines.length === 0) {
throw new Error('Order must have at least one or more order lines.');
}
}
private calculateTotalAmount(orderLines: OrderLine[]) {
return orderLines.map(orderLine => orderLine.getAmount()).reduce((a, b) => a + b, 0);
}
}
TypeScript
복사
▪
Order는 한 개 이상의 OrderLine을 가질 수 있으므로 Order를 생성할 때 OrderLine 목록을 List로 전달, 생성자에서는 verifyAt…, calculat…를 통해 초기화한다.
•
배송지 정보는 이름, 전화번호, 주소 데이터를 가지므로 ShippingInfo 클래스를 다음과 같이 정의한다.
export class ShippingInfo {
private receiverName: string;
private receiverPhone: string;
private firstShippingAddress: string;
private secondShippingAddress: string;
private shippingZipCode: string;
}
TypeScript
복사
•
앞서 요구사항 중, 주문할 때 배송지 정보를 반드시 지정해야 한다라는 내용을 통해 Order를 생성할 때 OrderLineList 뿐아니라 ShippingInfo도 넘겨주어야함을 알 수 있다.
•
생성자에서 호출하는 setShippingInfo() 메서드는 ShippingInfo가 null이면 예외가 발생하는데, 이를 통해 배송지 정보 필수라는 도메인 규칙을 구현한다.
•
도메인을 구현하다보면 특정 조건이나 상태에 따라 제약이나 규칙이 달리 적용되는 경우가 많음, 주문 요구사항에서는 다음 내용이 제약과 규칙에 해당된다.
◦
출고를 하면 배송지 정보를 변경할 수 없다.
◦
출고 전에 주문을 취소할 수 있다.
•
이 요구사항들은 출고 상태가 되기 전과 후의 제약사항을 기술하고 있는데, 출고 상태에 따라 정보 변경 기능과 주문 취소 기능은 다른 제약을 갖는다.
•
이런 요구사항을 반영 하기 위해 주문은 출고 상태에 대한 필드가 존재해야 함, 또한 다음 요구사항도 상태와 관련이 있다.
◦
고객이 결제를 완료하기 전에는 상품을 준비하지 않는다.
◦
이런 요구사항들을 통해 결제 완료 전을 의미하는 상태와 결제 완료 내지 상품 준비 중이라는 상태가 필요함을 알려줌, enum을 통해 표현할 수 있다.
•
배송지 변경이나 주문 취소 기능은 출고 전에만 가능하다는 제약 규칙이 존재하므로 이 규칙을 적용하기 위해 changeShippingInfo()와 cancel()은 verifyNotYetShipped() 메서드를 먼저 실행 후 진행한다.
export class Order {
private orderLines: OrderLine[];
private shippingInfo: ShippingInfo;
private totalAmount: number;
private state: OrderState;
public changeShipped() {
this.state = OrderState.SHIPPED;
}
public changeShippingInfo(shippingInfo: ShippingInfo) {
this.checkNotYetShipped();
this.setShippingInfo(shippingInfo)
}
public cancel() {
this.checkNotYetShipped();
this.state = OrderState.CANCELED;
}
private checkNotYetShipped() {
if (this.state !== OrderState.PAYMENT_WAITING && this.state !== OrderState.PREPARING) {
throw new Error('Order already shipped.');
}
}
}
TypeScript
복사
•
지금까지 주문과 관련된 요구사항에서 도메인 모델을 점진적으로 만들어 나가는 프로세스를 진행했다.
•
이렇게 만든 모델은 요구사항 정련을 위해 도메인 전문가나 다른 개발자와 논의하는 과정에서 공유하기도 한다.
•
모델을 공유할 때는 화이트보드나 위키와 같은 도구를 사용해서 누구나 쉽게 접근할 수 있도록 하면 더 좋다.
•
문서화
◦
문서화를 하는 주된 이유는 지식을 공유하기 위함이다. 구현은 코드에 존재하므로 보면 알 수 있지만 상세한 모든 내용을 다루고 있기 때문에 전체 소프트웨어를 분석하려면 많은 시간이 필요하다.
◦
즉 전반적인 기능 목록이나 모듈 구조, 빌드 과정은 상위 수준에서 정리한 문서를 참고하는 것이 소프트웨어 전반을 이해하는 데 도움이 된다는 것이다.
6. 엔티티와 밸류
•
도출한 모델은 크게 엔티티와 밸류로 구분할 수 있음, 앞서 요구사항 분석 과정에서 만든 모델은 다음과 같다.
◦
엔티티와 밸류를 제대로 구분해야 도메인을 올바르게 설계하고 구현할 수 있기 때문에 이 둘의 차이를 명확하게 이해하는 것은 아주 중요하다.
1.
엔티티
•
엔티티의 가장 큰 특징은 식별자를 가진다는 것, 식별자는 엔티티 객체마다 공유해서 각 엔티티는 서로 다른 식별자를 갖는다.
•
예를 들어, 주문 도메인에서 각 주문은 주문번호를 가지고 있는데 이 주문번호는 각 주문마다 다르다. 따라서 주문번호가 주문의 식별자가 된다.
•
주문에서 배송지 주소가 바뀌거나 상태가 바뀌더라도 주문번호는 바뀌지 않는 것처럼 엔티티의 식별자는 변경되지 않는다.
•
엔티티의 식별자는 바뀌지 않고 고유하기 때문에 두 엔티티 객체의 식별자가 같다면 두 엔티티는 동일한 객체라고 판단할 수 있다.
2.
엔티티의 식별자 생성
•
엔티티의 식별자를 생성하는 시점은 도메인의 특징과 사용하는 기술에 따라 달라짐, 흔히 식별자는 다음 중 한 가지 방식으로 생성한다.
◦
특정 규칙에 따라 생성
◦
UUID나 NanoID와 같은 고유 식별자 생성기 사용
◦
값을 직접 입력
◦
일련번호 사용(시퀀스나 DB의 자동 증가 칼럼 사용)
•
주문번호, 운송장번호, 카드번호와 같은 식별자는 특정 규칙에 따라 생성되며 이 규칙은 도메인에 따라 다르고 같은 주문번호라도 회사마다 다르다.
•
AutoIncrement 칼럼은 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과 같은 문자열로 구성된 경우가 많다.
•
Money가 숫자가 아닌 도메인에서 동작하는 돈을 의미하는 것처럼 단순한 문자열이 아니라 특별한 의미를 지니는 경우가 많기 때문에 식별자를 위한 밸류 타입을 사용해서 의미가 잘 드러날수록 할 수 있다.
•
예를 들어 주문번호를 표현하기 위해 Order의 식별자 타입으로 String 대신 OrderNo를 사용하면 타입을 통해 해당 필드가 주문번호라는 것을 즉각적으로, 직관적으로 확인할 수 있다.
◦
만약 OrderNo 대신 String 타입을 사용한다면 ‘id’라는 이름만으로는 해당 필드가 주문번호인지를 알 수 없다.
◦
필드의 의미가 잘 드러나도록 하려면 ‘id’라는 필드 이름 대신 ‘orderNo’라는 필드 이름을 사용해야 한다.
◦
반면에 식별자를 위해 OrderNo 타입을 만들면 타입 자체로 주문번호라는 것을 알 수 있으므로 실제 의미를 찾는 것이 어렵지 않다.
5.
도메인 모델에 set 메서드 넣지 않기
•
set 넣지마라
•
private으로 접근 제한하고 사용을 해도 괜찮다. 추상화 수준을 위해서라면 괜찮을 것 같다.
7. 도메인 용어와 유비쿼터스 언어
•
코드를 작성할 때 도메인에서 사용하는 용어는 매우 중요하다.
export enum OrderState {
STEP1, STEP2, STEP3, STEP4, STEP5, STEP6
}
TypeScript
복사
⇒ 이와 같은 구현이 있다고할 때
•
실제 주문 도메인의 상태값은 ‘결제 대기중’, ‘상품 준비 중’, ‘출고 완료’, ‘배송 중’, ‘배송 완료’, ‘주문 취소’인데 이 코드는 개발자가 전체 상태를 6단계로 보고 코드로 표현한 것이다.
•
이 개발자는 Order의 상태 변경 관련 메소드를 다음과 같이 작성할 확률이 높다.
export class Order {
public changeShippingInfo(shippingInfo: ShippingInfo) {
verifyStep1OrStep2();
setShippinInfo(shippingInfo);
}
private verifyStep1OrStep2() {
if (state !== OrderState.STEP1 && state !== OrderState.STEP2) {
throw new Error();
}
}
}
TypeScript
복사
⇒ 배송지 변경은 ‘출고 전’에 가능한데, 이 코드의 verifyStep1OrStep2() 메소드는 중요한 도메인 규칙을 포함하지 않고 있다.
•
그저 STEP이 1인지 2인지만을 확인할뿐 개발자가 그 외의 도메인 로직을 확인할 수는 없다.
•
실제 이 코드의 STEP1, STEP2가 각각 결제 대기 중, 상품 준비 중 상태로 연결된다는 것을 개발자가 알고 있어야만 가능하다.
⇒ 만약 다음과 같이 코드를 작성하였다면?
export enum OrderState {
PAYMENT_WAITING, PREPARING, SHIPPED, DELIVERING, DELIVERY_COMPLETED, CANCELD
}
TypeScript
복사
•
이 도메인에서 사용하는 용어를 최대한 코드에 반영하면 코드를 도메인 용어로 해석하거나 도메인 용어를 코드로 해석하는 과정이 줄어든다.
•
이는 코드의 가독성을 높여서 코드를 분석하고 이해하는 시간을 줄여줌, 최대한 도메인 용어를 사용해서 도메인 규칙을 코드로 작성하게 되므로 버그도 줄어든다.
•
에릭 에반스는 도메인 주도 설계에서 언어의 중요함을 강조하기 위해 유비쿼터스 언어라는 용어를 사용한다.
•
전문가, 관계자, 개발자가 도메인과 관련된 공통의 언어를 만들고 이를 대화, 문서, 도메인 모델, 코드, 테스트 등 모든 곳에서 같은 용어를 사용해야 한다.
•
이렇게하면 소통 과정에서 발생하는 용어의 모호함을 줄일 수 있고 개발자는 도메인과 코드 사이에서 불필요한 해석 과정을 생략할 수 있다.