[Domain Driven Design] 2. 도메인 모델

반응형
728x90
반응형

도메인 모델

도메인 모델은 특정 도메인을 개념적으로 표현한 것이다. 

예시로 '주문 도메인'을 보자.

 

주문을 하기위해 상품을 몇 개 살지 선택하고 배송지를 입력한다. 선택한 상품 가격을 이용해서 총 지불 금액을 계산하고, 금액 지불을 위한 결제 수단을 선택한다. 주문한 뒤에도 배송 전이면 배송지 주소를 변경하거나 주문을 취소할 수 있다. 

https://www.msaschool.io/operation/design/design-two/

위 그림은 객체를 이용한 도메인 모델이다. 도메인을 이해하려면 도메인이 제공하는 기능과 도메인의 주요 데이터 구성을 파악해야 하는데, 이런 면에서 기능과 데이터를 함께 보여주는 객체 모델은 도메인을 모델링하기에 적합하다. 

 

도메인 모델을 객체로만 모델링할 수 있는 것은 아니다. 상태 다이어그램을 이용해서 주문의 상태 전이를 모델링하고 있다. 이 다이어그램을 보면 상품 준비 중 상태에서 주문을 취소하면 결제 취소가 함께 이루어진다는 것을 알 수 있다.

https://snowdeer.github.io/sw-architecture/2021/06/01/sw-domain-model/

클래스 다이어그램이나 상태 다이어그램과 같은 UML 표기법을 사용하는 방법 이외에, 관계가 중요한 도메인이라면 그래프를 이용해서 도메인을 모델링할 수 있다. 

 

도메인 모델은 기본적으로 도메인 자체를 이해하기 위한 개념 모델이다. 개념 모델을 이용해서 바로 코드를 작성할 수 있는 것은 아니기에 구현 기술에 맞는 구현 모델이 따로 필요하다. 개념 모델과 구현 모델은 서로 다른 것이지만 구현 모델이 개념 모델을 최대한 따르도록 할 수는 있다. 예를 들어 객체 기반 모델을 기반으로 도메인을 표현했다면, 객체 지향 언어를 이용해 개념 모델에 가깝게 구현할 수 있다. 

 

 

도메인 모델 패턴

일반적인 애플리케이션의 아키텍처는 4개의 영역으로 구성된다.

https://incheol-jung.gitbook.io/docs/study/ddd-start/1

영역 설명
사용자 인터페이스(UI) 또는 표현(Presentation) 사용자의 요청을 처리하고 사용자에게 정보를 보여준다.
여기서 사용자는 소프트웨어를 사용하는 사람 뿐만 아니라 외부 시스템일 수도 있다.
응용 (Application) 사용자가 요청한 기능을 실행한다.
업무 로직을 직접 구현하지 않으며 도메인 계층을 조합해서 기능을 실행한다.
도메인 시스템이 제공할 도메인 규칙을 구현한다.
인프라스트럭처 (Infrastructure) 데이터베이스나 메시징 시스템과 같은 외부 시스템과의 연동을 처리한다.

도메인 모델은 아키텍처 상의 도메인 계층을 객체 지향 기법으로 구현하는 패턴을 말한다.

 

도메인 계층은 도메인의 핵심 규칙을 구현한다.

주문 도메인의 경우 '출고 전에 배송지를 변경할 수 있다."라는 규칙과 "주문 취소는 배송 전에만 할 수 있다."라는 규칙을 구현한 코드가 도메인 계층에 위치하게 된다. 이런 도메인 규칙을 객체 지향 기법으로 구현하는 패턴이 도메인 모델 패턴이다.

 

 

주문 도메인의 일부 기능을 도메인 모델 패턴으로 구현한 예제

Order.java
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;
	}
	...
}

 

OrderState.java
public enum OrderState {
	PAYMENT_WAITING {
		public boolean isShippingChangeable() {
			return true;
		}
	},
	PREPARING {
		public boolean isShippingChangeable() {
			return true;
		}
	},
	SHIPPED, DELIVERING, DELIVERY_COMPLETED;

	public boolean isShippingChangeable() {
		return false;
	}
}

 

주문 상태를 표현하는 OrderState는 배송지를 변경할 수 있는지를 검사할 수 있는 isShippingChangeable() 메서드를 제공하고 있다. 코드를 보면 주문 대기 중(PAYMENT_WAITING) 상태와 상품 준비중(PREPARING) 상태의 isShippingChangeable() 메서드는 true를 리턴한다. 즉 OrderState는 주문 대기 중이거나 상품 준비 중에는 배송지를 변경할 수 있다는 도메인 규칙을 구현하고 있다.

 

실제 배송지를 변경하는 Order 클래스의 changeShoppingInfo() 메서드는 OrderState의 isShppingChangeable() 메서드를 이용해서 변경 가능한 경우에만 배송지를 변경한다.

큰 틀에서 보면, OrderState는 Order에 속한 데이터이므로 배송지 정보 변경 가능 여부를 판단하는 코드를 Order로 이동할 수도 있다.

 

Order.java
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;
	}
    
    private boolean isShippingChangeable() {
    	return state == OrderState.PAYMENT_WAITING || state == OrderState.PREPARING;
    }
	...
}

 

 

도메인 모델 도출

도메인을 모델링할 때 기본이 되는 작업은 모델을 구성하는 핵심 구성요소, 규칙, 기능을 찾는 것이다. 이 과정은 요구사항에서 출발한다. 

주문 도메인과 관련된 몇가시 요구사항을 보자.

  • 최소 한 종류 이상의 상품을 주문해야 한다.
  • 한 상품을 한개 이상 주문할 수 있다.
  • 총 주문 금액은 각 상품의 구매 가격 합을 모두 더한 금액이다.
  • 각 상품의 구매 가격 합은 상품 가격에 구매 개수를 곱한 값이다.
  • 주문할때 배송지 정보를 반드시 지정해야 한다.
  • 배송지 정보는 받는 사람 이름, 전화번호, 주소로 구성된다.
  • 출고를 하면 배송지를 변경할 수 없다.
  • 출고 전에 주문을 취소할 수 있다.
  • 고객에 결제를 완료하기 전에는 상품을 준비하지 않는다.

이 요구사항에서 알 수 있는 것은 주문은 '출고 상태로 변경하기', '배송지 정보 변경하기', '주문 취소하기', '결제 완료하기' 기능을 제공한다는 것이다. 

 

Order.java
public class Order {
	public void changeShipped() { ... }
	public void changeShippingInfo(ShippingInfo newShipping) { ... }
	public void cancel() { ... }
	public void completePayment() { ... }
}

 

위 코드의 요구사항

  • 한 상품을 한 개 이상 주문할 수 있다.
  • 각 상품의 구매 가격 합은 상품 가격에 구매 개수를 곱한 값이다.

두 요구사항에 따르면 주문 항목을 표현하는 OrderLine은 적어도 주문할 상품, 상품의 가격, 구매 개수도 포함해야한다.

추가로 각 구매 항목의 구매 가격도 제공해야한다.

 

OrderLine.java
public class OrderLine {
	private Product product;
	private int price;
	private int quantity;
	private int amount;

	public OrderLine(ProductId productId, Money price, int quantity) {
        this.productId = productId;
        this.price = price;
        this.quantity = quantity;
        this.amounts = calculateAmounts();
    }

    private Money calculateAmounts() {
        return price.multiply(quantity);
    }
    
    ...
}

한 상품 (product 필드)을 얼마에 (price 필드) 몇개 살지 (quantity 필드)를 담고 있고, calculateAmounts() 메서드로 구매 가격을 구하는 로직을 구현하고 있다.

 

다음 요구사항은 Order와 OrderLine과의 관계를 알려준다.

  • 최소 한 종류 이상의 상품을 주문해야한다.
  • 총 주문 금액은 각 상품의 구매 가격 합을 모두 더한 금액이다.

 

한 종류 이상의 상품을 주문할 수 있으므로 Order는 최소 한 개 이상의 OrderLine을 포함해야 한다. 또한 총 주문 금액은 OrderLine에서 구할 수 있다. 두 요구사항은 Order에 다음과 같이 반영할 수 있다.

 

Order.java
public class Order {
	private List<OrderLine> orderLines;
    private Money totalAmounts;
    
    public Order(List<OrderLine> orderLines) {
        setOrderLines(orderLines);
    }
    
    private void setOrderLines(List<OrderLine> orderLines) {
        verifyAtLeastOneOrMoreOrderLines(orderLines);
        this.orderLines = orderLines;
        calculateTotalAmounts();
    }

    private void verifyAtLeastOneOrMoreOrderLines(List<OrderLine> orderLines) {
        if (orderLines == null || orderLines.isEmpty()) {
            throw new IllegalArgumentException("no OrderLine");
        }
    }

    private void calculateTotalAmounts() {
        this.totalAmounts = new Money(orderLines.stream()
                .mapToInt(x -> x.getAmounts().getValue()).sum());
    }
}

Order는 한 개 이상의 OrderLine을 가질 수 있으므로 Order를 생성할때 OrderLine 목록을 List로 전달한다.

메서드 설명
생성자에서 호출하는 setOrderLines() 메서드 요구사항에 정의한 제약 조건을 검사한다.
verifyAtLeastOneOrMoreOrderLines() 메서드 요구사항에 따르면 최소 한 종류 이상의 상품을 주문해야 하므로 verifyAtLeastOneOrMoreOrderLines() 메서드를 이용해서 OrderLine이 한 개 이상 존재하는지 검사한다.
calculateTotalAmounts() 메서드 총 주문 금액을 계산한다.

 

배송지 정보를 추가하자.

배송지 정보는 이름, 전화번호, 주소 데이터를 가지므로 ShoppingInfo 클래스를 다음과 같이 정의할 수 있다.

 

ShoppingInfo.java
public class ShipingInfo {
	private String receiverName;
	private String receiverPhoneNumber;
	private String shipingAddress1;
	private String shipingAddress2;
	private String shipingZipcode;

	...
}

"주문할 때 배송지 정보를 반드시 지정해야한다."

이 요구사항은 Order를 생성할때 OrderLine의 목록뿐만 아니라 ShoppingInfo도 함께 전달해야 함을 의미한다. 이를 생성자에 반영한다.

 

Order.java
public class Order {
	private List<OrderLine> orderLines;
	private int totalAmounts;
	private ShipingInfo shippingInfo;

	public Order(List<OrderLine> orderLines, ShippingInfo shippingInfo) {
        setOrderLines(orderLines);
        setShippingInfo(shippingInfo);
    }
    
    private void setShippingInfo(ShippingInfo shippingInfo) {
        if (shippingInfo == null) throw new IllegalArgumentException("no shipping info");
        this.shippingInfo = shippingInfo;
    }
    ...
}

생성자에서 호출하는 setShippingInfo() 메서드는 ShoppingInfo가 null이면 익셉션이 발생하는데, 이렇게 함으로써 "배송지 정보 필수"라는 도메인 규칙을 구현한다.

 

아래의 요구 사항은 출고 상태가 되기 전과 후의 제약사항을 기술하고 있다.

  • 출고를 하면 배송지 정보를 변경할 수 없다.
  • 출고 전에 주문을 취소할 수 있다.

이 요구사항은 출고 상태가 되기 전과 후의 제약사항을 기술하고 있다.

출고 상태에 따라 배송지 정보 변경 기능과 주문 취소 기능은 다른 제약을 갖는다. 이 요구사항을 충족하려면 주문은 최소한 출고 상태를 표현할 수 있어야한다.

 

  • 고객이 결제를 완료하기 전에는 상품을 준비하지 않는다.

이 요구사항 또한 상태와 관련이 있다. 결제 완료 전을 의미하는 상태와 결제 완료 내지 상품 준비 중이라는 상태가 필요함을 알려준다.

 

OrderState.java
public enum OrderState {
    PAYMENT_WAITING, PREPARING, SHIPPED, DELIVERING, DELIVERY_COMPLETED, CANCELED
}

 

배송지 변경이나 주문 취소 기능은 출고 전에만 가능하다는 제약 규칙이 있으므로 이 규칙을 적용하기 위해, verifyNotYetShipped() 메서드를 먼저 실행해야한다.

 

Order.java
public class Order {
	private List<OrderLine> orderLines;
	private int totalAmounts;
	private ShipingInfo shippingInfo;

	public void changeShippingInfo(ShippingInfo newShippingInfo) {
        verifyNotYetShipped();
        setShippingInfo(newShippingInfo);
        Events.raise(new ShippingInfoChangedEvent(number, newShippingInfo));
    }

    public void cancel() {
        verifyNotYetShipped();
        this.state = OrderState.CANCELED;
        Events.raise(new OrderCanceledEvent(number.getNumber()));
    }

    private void verifyNotYetShipped() {
        if (!isNotYetShipped())
            throw new AlreadyShippedException();
    }

    public boolean isNotYetShipped() {
        return state == OrderState.PAYMENT_WAITING || state == OrderState.PREPARING;
    }
	...
}

 

이렇게 주문과 관련된 요구사항에서 도메인 모델을 점진적으로 만들어 나갔다. 이렇게 만든 모델은 도메인 전문가나 다른 개발자와 논의하는 과정에서 공유되기도 한다. 모델을 공유할때는 누구나 쉽게 접근할 수 있도록 해야한다.

 

 

 

반응형

Designed by JB FACTORY