////
Search
Duplicate
1️⃣

도메인 모델 시작하기

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) 담고 있으며 구매 가격또한 제공한다.
다음 요구사항은 OrderOrderLine 간의 관계를 설명해준다.
⇒ 최소한 한 종류 이상의 상품을 주문해야 한다.
⇒ 총 주문 금액은 각 상품의 구매 가격 합을 모두 더한 금액이다.
한 종류 이상의 상품을 주문할 수 있으므로 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() 메서드는 ShippingInfonull이면 예외가 발생하는데, 이를 통해 배송지 정보 필수라는 도메인 규칙을 구현한다.
도메인을 구현하다보면 특정 조건이나 상태에 따라 제약이나 규칙이 달리 적용되는 경우가 많음, 주문 요구사항에서는 다음 내용이 제약과 규칙에 해당된다.
출고를 하면 배송지 정보를 변경할 수 없다.
출고 전에 주문을 취소할 수 있다.
이 요구사항들은 출고 상태가 되기 전과 후의 제약사항을 기술하고 있는데, 출고 상태에 따라 정보 변경 기능과 주문 취소 기능은 다른 제약을 갖는다.
이런 요구사항을 반영 하기 위해 주문은 출고 상태에 대한 필드가 존재해야 함, 또한 다음 요구사항도 상태와 관련이 있다.
고객이 결제를 완료하기 전에는 상품을 준비하지 않는다.
이런 요구사항들을 통해 결제 완료 전을 의미하는 상태와 결제 완료 내지 상품 준비 중이라는 상태가 필요함을 알려줌, 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.
엔티티의 식별자 생성
엔티티의 식별자를 생성하는 시점은 도메인의 특징과 사용하는 기술에 따라 달라짐, 흔히 식별자는 다음 중 한 가지 방식으로 생성한다.
특정 규칙에 따라 생성
UUIDNanoID와 같은 고유 식별자 생성기 사용
값을 직접 입력
일련번호 사용(시퀀스나 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는 받는 사람이라는 도메인 개념을 표현한다.
앞서 ShippingInforeceiverName 필드와 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개 이상의 데이터로만 이뤄지는 것은 아니다. 의미를 명확하게 하기 위해 밸류 타입을 사용하는 경우도 있는데, 이는 OrderLinepriceamounts에서 확인할 수 있다.
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() 메소드는 중요한 도메인 규칙을 포함하지 않고 있다.
그저 STEP1인지 2인지만을 확인할뿐 개발자가 그 외의 도메인 로직을 확인할 수는 없다.
실제 이 코드의 STEP1, STEP2가 각각 결제 대기 중, 상품 준비 중 상태로 연결된다는 것을 개발자가 알고 있어야만 가능하다.
⇒ 만약 다음과 같이 코드를 작성하였다면?
export enum OrderState { PAYMENT_WAITING, PREPARING, SHIPPED, DELIVERING, DELIVERY_COMPLETED, CANCELD }
TypeScript
복사
이 도메인에서 사용하는 용어를 최대한 코드에 반영하면 코드를 도메인 용어로 해석하거나 도메인 용어를 코드로 해석하는 과정이 줄어든다.
이는 코드의 가독성을 높여서 코드를 분석하고 이해하는 시간을 줄여줌, 최대한 도메인 용어를 사용해서 도메인 규칙을 코드로 작성하게 되므로 버그도 줄어든다.
에릭 에반스는 도메인 주도 설계에서 언어의 중요함을 강조하기 위해 유비쿼터스 언어라는 용어를 사용한다.
전문가, 관계자, 개발자가 도메인과 관련된 공통의 언어를 만들고 이를 대화, 문서, 도메인 모델, 코드, 테스트 등 모든 곳에서 같은 용어를 사용해야 한다.
이렇게하면 소통 과정에서 발생하는 용어의 모호함을 줄일 수 있고 개발자는 도메인과 코드 사이에서 불필요한 해석 과정을 생략할 수 있다.