Programming/DDD

DDD - 도메인이란 무엇인가? (+ 도메인 설계 예시)

JaeHoney 2022. 5. 24. 07:56
반응형

도메인

개발자 입장에서 온라인 서점은 구현해야 할 소프트웨어의 대상이 된다. 온라인 서점 소프트웨어는 온라인으로 책을 판매하는 데 필요한 상품조회, 구매, 결제, 배송 추적 등의 기능을 제공해야 한다.

 

이때, '온라인 서점'은 소프트웨어로 해결하고자 하는 문제 영역, 즉 도메인(domain)에 해당한다.

한 도메인은 다시 하위 도메인으로 나눌 수 있다. 예를 들어 다음 그림은 온라인 서점 도메인을 몇 개의 하위 도메인으로 나타낸 것이다.

카탈로그 하위 도메인은 고객에게 구매할 수 있는 상품 목록을 제공하고, 주문 하위 도메인은 고객의 주문을 처리한다. 혜택 하위 도메인은 쿠폰이나 특별 할인과 같은 서비스를 제공한다. 한 하위 도메인은 다른 하위 도메인과 연동하여 완전한 기능을 제공하게 된다.

 

특정 도메인을 위한 소프트웨어라고 해서 도메인이 제공해야 할 모든 기능을 구현해야 하는 것은 아니다. 많은 온라인 쇼핑몰이 자체적으로 배송 시스템이나 결제 시스템의 경우에는 외부 업체의 시스템을 사용하고, 정보 제공 기능만 구현한다.

 

그리고 메인마다 고정된 하위 도메인이 존재하는 것은 아니다. 모든 온라인 쇼핑몰이 고객 혜택을 제공하는 것은 아니고, 정산은 엑셀과 같은 도구를 이용해서 수작업으로 처리할 수도 있다. 하위 도메인을 어떻게 구성할지 여부는 상황에 따라 달라진다.

 

도메인 모델

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

 

주문 도메인을 객체 기반 모델로 구성하면 아래 그림처럼 만들 수 있다.

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

 

하지만 도메인 모델을 객체로만 모델링 할 수 있는 것은 아니다. 도메인을 이해하는 데 도움이 된다면 표현 방식이 무엇인지는 중요하지 않다.

 

도메인 모델 패턴

일반적인 애플리케이션의 아키텍처는 다음과 같이 네 개의 계층으로 구성된다.

[사용자 인터페이스(UI) 또는 표현(Presentation)]

  • 사용자의 요청을 처리하고 사용자에게 정보를 보여준다.
  • 사용자는 소프트웨어를 사용하는 사람 뿐만 아니라 외부 시스템일 있다.

[응용(Application)]

  • 사용자가 요청한 기능을 실행한다.
  • 업무 로직을 직접 구현하지 않으며, 도메인 계층을 조합해서 기능을 실행한다.

[도메인(domain)]

  • 시스템이 제공할 도메인 규칙을 구현한다.

[인프라스트럭처(Infrastructure)]

  • 데이터베이스나 메시징 시스템과 같은 외부 시스템과의 연동을 처리한다.

앞서 살펴본 도메인 모델이 도메인 자체를 이해하는 필요한 개념 모델이라면, 지금 살펴볼 도메인 모델은 마틴 파울러가 엔터프라이즈애플리케이션아키텍처패턴 책의 도메인 모델 패턴을 의미한다. 

 

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

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;
    }
      
}

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() 메서드를 제공하고 있다.

 

여기서 만약 배송지 변경 가능 여부를 검사할 때 주문 상태와 다른 정보를 함께 사용한다면 다음과 같이 Order에서 로직을 구현해야 한다.

 public class Order {
    private OrderState state;
    private ShippingInfo shippingInfo;
    
    public void changeShippingInfo(ShippingInfo newShippingInfo){
    	if (!isShippingInfoChangable()){
    	    throw new IllegalStateException("can't change shipping in" + stat	
    	}	
    	this.shippingInfo = newShippingInfo;
    }
    
    private boolean isShippingInfoChangeable(){
    	return state == OrderState.PAYMENT_WAITING || state == OrderState.WAITING;
    }
    

public enum OrderState {
    PAYMENT_WAITING, PREPARING, SHIPPED, DELIVERING, DELIVERY_COMPLETED;
}

이 때 가장 중요한 점은 주문과 관련된 중요 업무 규칙을 주문 도메인 모델인 Order나 OrderState에서 구현한다는 점이다.

 

핵심 규칙을 구현한 코드는 도메인 모델에만 위치하기 때문에 규칙이 바뀌거나 규칙을 확장할 때 다른 코드에 영향을 덜 주고 변경 내역을 모델에만 반영할 수 있게 된다.

 

<참고>

'도메인 모델'이란 용어는 도메인 자체를 표현하는 개념적인 모델을 의미하지만,
도메인 계층을 구현할 때 사용하는 객체 모델을 언급할 때에도 '도메인 모델'이란 용어를 사용한다.

도메인 모델 도출

도메인에 대한 이해 없이 코딩을 시작할 수는 없다. 구현을 시작하려면 도메인에 대한 초기 모델이 필요하다. 도메인을 모델링할 때 기본이 되는 작업은 모델을 구성하는 핵심 구성요소, 규칙, 기능을 찾는 것이다.

 

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

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

이 요구사항을 통해 주문 도메인은 다음의 기능을 제공한다는 사실을 알 수 있다.

  • 출고 상태로 변경하기
  • 배송지 정보 변경하기
  • 주문 취소하기
  • 결제 완료하기

해당 기능들은 Order에 메소드로 추가할 수 있다.

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

추가적으로 아래 요구사항을 보자.

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

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

public class OrderLine {
    private Product product;
    private int price;
    private int quantity;
    private int amounts;
    
    public OrderLine(Prodect product, int price, int quantity) {
        this.product = product;
        this.private = price;
        this.quantity = quantity;
        this.amounts = calculateAounts();
    }
    
    private int calculateAounts() {
        return price * quantity;
    }
    
    public int getAmounts() { ... }
}

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

 

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

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

두 요구사항은 Order에 다음과 같이 반영할 수 있다.

public class Oreder {
    private List<OrderLine> ordrLines;
    private int totalAmounts;
    
    public Order(List<OrederLine> orderLines) {
        setOrderLines(orderLines);
    }
    
    private void setOrderLines(List<OrderLine> orderLines) {
        verifyAtLeastOneOrMoreOrderLines(orderLines);
        this.orderLines = orderLines;
        calculateTotalAmounts();
    }
    
    private void verifyAtLeastOneOrMoreOrderLines(List<OrderLine> orderLine) {
        if(orderLines == null || orderLines.isEmpty()) {
            throw new Exception("no OrderLine");
        }
    }
    
    private void calculateTotalAmounts() {
        this.totalAmounts = new Money(orderLines.stream()
            .mapToInt(x -> x.getAmounts().getValue()).sum();
  
}

Order는 한 개 이상의 OrderLine을 가질 수 있으므로 Order는 List<OrderLine>을 갖는다. 생성자에서 호출하는 setOrderLines() 메서드는 요구사항에 정의한 제약 조건을 검사한다.

 

요구사항에 따르면 최소 한 종류 이상의 상품을 주문해야 하므로 verifyAtLeastOneOrMoreOrderLines() 메서드를 이용해서 검사하고, calculateTotalAmounts() 메서드를 이용해서 총 주문 금액을 계산한다.

 

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

public class ShippingInfo {
    private String receiverName;
    private String receiverPhoneNumber;
    private String shippingAddress1;
    private String shippingAddress2;
    private String shippingZipcode;

    public void ShippingInfo(...) {...}
}

앞서 요구사항 중에 '주문할 때 배송지 정보를 반드시 지정해야 한다'는 내용이 있었다. 이는 주문을 할 때 OrderLine 목록 뿐만 아니라 배송지인 ShippingInfo도 함께 전달해야 함을 의미한다. 이를 생성자에 반영한다.

public class Order {
    private List<OrderLine> orderLines;
    private ShippingInfo shippingInfo;
    ...

    public Order(List<OrderLine> orderLines, ShippingInfo shippingInfo) {
        setOrderLines(orderLines);
        setShippingInfo(shippingInfo);
    }

    private void setShippingInfo(ShippingInfo shippingInfo) {
    	if(shippingInfo == null) {
    	    throw new IllegalArgumentException("no ShippingInfo");
    	}
    	this.shippingInfo = shippingInfo;
    }
    ...
}

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

 

지금까지 주문과 관련된 요구사항에서 도메인 모델을 점진적으로 만들어 나갔다. 모델은 요구사항 정련을 위해 도메인 전문가나 다른 개발자와 논의하는 과정에서 공유하기도 한다. 모델을 공유할 때는 화이트보드나 위키와 같은 도구를 사용해서 누구나 쉽게 접근할 수 있도록 하면 좋다.

 


Reference

 

 

반응형