도메인 모델이란 도메인을 개념적
으로 표현한 것이다.
그렇다면 어떻게 도메인을 표현하면 좋을까?
도메인을 이해하는 데에 도움이 된다면 뭐든 좋다. 일반적으로는 다음과 같은 방식들로 도메인을 개념적으로 표현한다.
객체 모델을 이용한 도메인 모델링
: 기능과 데이터를 함께 보여줄 수 있다.
UML 표기법(상태 다이어그램, 클래스 다이어그램 등)을 이용한 도메인 모델링
그래프를 이용한 도메인 모델링
: 관계가 중요한 도메인이라면 도움이 될 것이다.
수학 공식을 활용한 도메인 모델링
: 계산 규칙이 중요하다면 도움이 될 것이다.
앞서 말했듯이 도메인 모델
이란 개념 모델
이다. 개념 모델은 바로 코드로 옮길 수 없다. 따라서 구현 모델
이 필요하다.
일반적인 애플리케이션의 아키텍처는 다음 4개로 구성된다.
사용자 인터페이스(또는 표현)
: 사용자의 요청 처리, 사용자에게 정보를 보여줌
응용(application)
: 사용자가 요청한 기능을 실행
도메인
: 도메인 규칙 구현
인프라스트럭처
: db나 메시징 시스템 같은 외부 시스템과의 연동 처리
1.3 도메인 모델
파트에서는 개념 모델로서의 도메인 모델에 대해 알아봤다.
이어서 도메인 모델 패턴에 대해 알아보자.
도메인 모델 패턴이란 도메인 규칙을 객체 지향 기법으로 구현하는 패턴을 말한다.
예시를 이용해서 도메인 모델 패턴이 어떻게 적용되는지 알아보자.
요구사항에 다음과 같은 내용이 있다.
- 출고 전에 배송지를 변경할 수 있다.
- 주문 취소는 배송 전에만 가능한다.
public class Order{
private OrderState state;
private ShippingInfo shippingInfo;
public void changeShippingInfo(ShippingInfo newShippingInfo){
if(!state.isShippingChangeable()){ //출고 전에만 배송지를 변경하도록 한다.
throw new IllegalStateException("can't change shipping in "+state);
}
this.shippingInfo = newShippingInfo;
}
//출고(SHIPPED) 전 상태인 PAYMENT_WAITING, PREPARING 상태에만 true를 리턴한다.
private boolean isShippingChangeable(){
return state == OrderState.PAYMENT_WAITING || state == OrderState.PREPARING;
}
public enum OrderState{
PAYMENT_WAITING,
PREPARING,
SHIPPED,
DELIVERING,
DELIVERY_COMPLETED;
}
위의 코드에서 주문과 관련된 중요 업무 규칙을 주문 도메인 모델이 Order나 OrderState 내부에서 구현한다.
핵심 규칙이 도메인 모델 내부에 있으면 규칙을 확장할 때 다른 코드에 주는 영향을 줄이면서 변경 내역을 모델을 반영할 수 있기 때문이다.
도메인을 모델링할 때는 모델을 구성하는 핵심 구성요소, 규칙, 기능을 찾는 것이 기본이다. 이 과정은 요구사항에서 출발한다.
예시를 통해 알아보자.
다음과 같은 요구사항이 주어졌다.
- 최소 한 종류 이상의 상품을 주문해야 한다.
- 한 상품을 한 개 이상 주문할 수 있다.
- 총 주문 금액은 각 상품의 구매 가격 합을 모두 더한 금액이다.
- 각 상품의 구매 가격 합은 상품 가격에 구매 개수를 곱한 값이다.
- 주문할 때 배송지 정보를 반드시 저장해야 한다.
- 배송지 정보는 받는 사람 이름, 전화번호, 주소로 구성된다.
- 출고를 하면 배송지를 변경할 수 없다.
- 출고 전에 주문을 취소할 수 있다.
- 고객이 결제를 완료하기 전에는 상품을 준비하지 않는다.
주문은 Order, 하나 품목에 대한 주문 사항은 OrderLine로 표현해보자.
OrderLine은 주문할 상품, 상품 가격, 구매 개수, 그리고 해당 항목의 구매 총액을 포함한다.
public class OrderLine{
private Product product;
private int price;
private int quantity;
private int amounts;
public OrderLine(Product product, int price, int quantity){
this.product = product;
this.price = price;
this.quantity = quantity;
this.amount = calculateAmounts(); //가격과 개수를 통해 구한 총액을 구하는 메소드를 이용한다.
}
//요구사항) 4. 각 상품의 구매 가격 합은 상품 가격에 구매 개수를 곱한 값이다.
private int calculateAmount(){
return price * quantity;
}
public int getAmounts(){...}
}
위에서 "2. 한 상품은 한 개이상 주문할 수 있다."는 요구사항이 있었다. 이를 고려하여 Order를 작성하면 다음과 같다.
public class Order{
private List<OrderLine> orderLines;
private Money totalAmounts;
public Order(List<OrderLine> orderLines){
setOrderLines(orderLines);
setShippingInfo(shippingInfo);
}
private void setOrderLines(List<OrderLine> orderLines){
verifyAtLeastOneOrMoreOrderLines(orderLines);
this.orderLines = orderLines;
calculateTotalAmounts();
}
// 요구사항) 1. 최소 한 종류 이상의 상품을 주문해야 한다.
private void verifyAtLeastOneOrMoreOrderLines(List<OrderLine> orderLines){
if(orderLines == null || orderLines.isEmpty(){
throw new IllegalArgumentException("no OrderLine");
}
}
// 요구사항) 3. 총 주문 금액은 각 상품의 구매 가격 합을 모두 더한 금액이다.
private void calculateTotalAmounts(){
int sum = orderLines.stream()
.mapToInt(x -> x.getAmounts())
.sum();
this.totalAmounts = new Money(sum);
}
private void setShippingInfo(ShippingInfo shippingInfo){
if(shippingInfo == null){
throw new IllegalArgumentException("no ShippingInfo");
}
this.shippingInfo = shippingInfo;
}
public class ShippingInfo{
private String receiverName;
private Stirng receiverPhoneNumber;
private String shippingAddress1;
private String shippingAddress2;
private String shippingZipcode;
}
위에서 "9. 고객이 결제를 완료하기 전에는 상품을 준비하지 않는다."는 요구사항이 있었다.
이를 고려하여 OrderState를 수정하면 다음과 같다.
public enum OrderState{
PAYMENT_WAITING,
PREPARING,
SHIPPED,
DELIVERING,
DELIVERY_COMPLETED,
CANCELED; //추가
}
Order 클래스에는 verifyNotYetShipped() 메서드를 추가한다.
public class Order{
private List<OrderLine> orderLines;
private Money totalAmounts;
public Order(List<OrderLine> orderLines){
setOrderLines(orderLines);
setShippingInfo(shippingInfo);
}
private void setOrderLines(List<OrderLine> orderLines){
verifyAtLeastOneOrMoreOrderLines(orderLines);
this.orderLines = orderLines;
calculateTotalAmounts();
}
// 요구사항) 1. 최소 한 종류 이상의 상품을 주문해야 한다.
private void verifyAtLeastOneOrMoreOrderLines(List<OrderLine> orderLines){
if(orderLines == null || orderLines.isEmpty(){
throw new IllegalArgumentException("no OrderLine");
}
}
// 요구사항) 3. 총 주문 금액은 각 상품의 구매 가격 합을 모두 더한 금액이다.
private void calculateTotalAmounts(){
int sum = orderLines.stream()
.mapToInt(x -> x.getAmounts())
.sum();
this.totalAmounts = new Money(sum);
}
private void setShippingInfo(ShippingInfo shippingInfo){
if(shippingInfo == null){
throw new IllegalArgumentException("no ShippingInfo");
}
this.shippingInfo = shippingInfo;
}
private void verifyNotYetShipped(){
if(state != OrderState.PAYMENT_WAITING && state != OrderState.PREPARING)
throw new IllegalStateException("already shipped!);
}
도메인 모델은 크게 두가지로 나뉜다.
엔티티의 가장 큰 특징은 식별자를 가진다는 것이다.
이때 식별자는 고유
& 불변
이다.
그렇다면 식별자는 어떻게 생성할까?
주로 사용되는 방식은 다음 중 하나이다.
밸류 타입은 개념적으로 완전한 하나를 표현할 때 사용한다.
예를 들어 알아보자.
아래 코드에서 price와 amounts는 모두 돈을 가리킨다.
public class OrderLine{
private Product product;
private int price;
private int quantity;
private int amounts;
...
그렇다면 이렇게 Money라는 밸류 타입을 만들어 사용할 수도 있다.
public class Money{
private int value;
public Money add(Money money){ //이렇게 밸류 타입을 위한 기능을 추가할 수도 있다.
return new Money(this.value + money.value);
}
pubic Money multiply(int multiplier){
return new Money(value * multiplier);
}
}
public class OrderLine{
private Product product;
private Money price;
private int quantity;
private Money amounts;
...
밸류 객체의 데이터를 변경할 때는 변경한 데이터를 갖는 새로운 밸류 객체를 생성하는 방식이 낫다.
이렇게 데이터 변경 기능을 제공하지 않는 타입을 불변
이라고 한다.
불변 타입을 적용하면 안전한 코드를 작성할 수 있다.
비슷한 관점에서 도메인 모델에 set 메서드를 넣지 않는 것도 안전한 코드를 작성하기 위한 방법 중 하나이다.
코드에는 도메인에서 사용하는 용어를 최대한 반영하자.
전문가, 관계자, 개발자가 모든 곳에서 같은 용어를 사용할 때, 이를 유비쿼터스 언어라고 부른다.