본문 바로가기

책/도메인 주도 개발 시작하기

[DDD] 1. 도메인 모델 시작하기

1.1 도메인이란?

  • 도메인은 소프트웨어로 해결하고자 하는 문제 영역에 해당한다.
  • 한 도메인은 다시 하위 도메인으로 나눌 수 있다.
  • 한 하위 도메인은 다른 하위 도메인과 연동하여 완전한 기능을 제공한다
    • 예를들어 고객이 물건을 구매하면 주문, 결제 배송, 혜택 하위 도메인의 기능이 엮이게 된다.
  • 도메인마다 고정 된 하위 도메인이 존재하는 것은 아니다.
  • 하위 도메인을 어떻게 구성할지 여부는 상황에 따라 달라진다.

1.2 도메인 전문가와 개발자 간 지식 공유

  • 개발자는 요구사항을 분석하고 설계하고 코드를 작성하며 테스트하고 배포한다.
  • 요구사항을 올바르게 이해하려면 개발자와 전문가가 직접 대화해야 한다.
    • 개발자와 전문가 사이에 내용을 전파하는 전달자가 많으면 많을수록 정보가 왜곡되고 손실이 발생하게 되며, 개발자는 최초에 전문가가 요구한 것과는 다른 무언가를 만들게 된다.
  • 이해관계자와 개발자도 도메인 지식을 갖춰야 한다.
    • 제품 개발과 관련된 도메인 전문가, 관계자, 개발자가 같으 지식을 공유하고 직접 소통할수록 도메인 전문가가 원하는 제품을 만들 가능성이 높아진다.

1.3 도메인 모델

  • 도메인 모델은 특정 도메인을 개념적으로 표현한 것이다.
    • 주문 도메인을 생각해 보자.
    • 온라인 쇼핑몰에서 주문을 하려면 상품을 몇 개 살지 선택하고 배송지를 입력한다. 선택한 상품 가격을 이용해서 총 지불 금액을 계산하고, 금액 지불을 위한 결제 수단을 선택한다. 주문한 뒤에도 배송 전이면 배송지 주소를 변경하거나 주문을 취소할 수 있다.
    • 이를 위한 주문 모델을 객체 모델로 구성하면 [그림 1.3]과 같이 만들 수 있다.
  • 도메인 모델을 사용하면 여러 관계자들이 동일한 모습으로 도메인을 이해하고 도메인 지식을 공유하는데 도움이 된다.
    • [그림 1.3]을 보면 주문은 주문번호(orderNumber)와 지불할 총금액(totalAmounts)이 있고, 배송정보(shippingInfo)를 변경(changeShipping)할 수 있음을 알 수 있다. 또한 주문을 취소(cancel)할 수 있는 것을 알 수 있다.
  • 도메인을 이해하려면 도메인이 제공하는 기능과 도메인의 주요 데이터 구성을 파악해야 하는데, 이런 면에서 기능과 데이터를 함께 보여주는 객체 모델은 도메인을 모델링하기에 적합하다.

 

 

  • 도메인 모델은 객체로 모델링을 하는 것 외에도 [그림 1.4]처럼 상태 다이어그램을 이용해서 주문의 상태 전이를 모델링할 수 있다.
    • 이 다이어그램을 보면 상품 준비 중 상태에서 주문을 취소하면 결제 취소가 함께 이루어진다는 것을 알 수 있다.
  • 도메인 모델을 표현할 때 클래스 다이어그램이나 상태 다이어그램과 같은 UML 표기법만 사용해야 하는 것은 아니다.
    • 관계가 중요한 도메인이라면 그래프를 이용해서 도메인을 모델링 할 수 있다.
    • 규칙이 중요하다면 수학 공식을 활용해서 도메인 모델을 만들 수 있다.
    • 도메인을 이해하는데 도움이 된다면 표현 방식이 무엇인지는 중요하지 않다.
  • 도메인 모델은 기본적으로 도메인 자체를 이해하기 위한 개념 모델이다.
    • 개념 모델을 이용해서 바로 코드를 작성할 수 있는 것은 아니기에 구현 기술에 맞는 구현 모델이 따로 필요하다.
    • 개념모델과 구현 모델은 서로 다른 것이지만 구현 모델이 개념 모델을 최대한 따르도록 할 수는 있다.
      • 예를들어 객체 기반 모델을 기반으로 도메인을 표현했다면 객체 지향언어를 이용한다.

💡 하위 도메인과 모델

  • 각 하위 도메인이 다루는 영역은 서로 다르기 때문에 같은 용어라도 하위 도메인마다 의미가 달라질 수 있다.
    • 예를들어 카탈로그 도메인의 상품이 상품 가격, 상세 내용을 담고 있는 정보를 의미한다면 배송 도메인의 상품은 고객에게 실제로 배송되는 물리적인 상품을 의미한다.
  • 도메인에 따라 용어 의미가 결정되므로 여러 하위 도메인을 하나의 다이어그램에 모델링하면 안 된다.
    • 카탈로그와 배송 도메인 모델을 구분하지 않고 하나의 다이어그램에 함께 표시한다면 상품을 제대로 이해하는데 방해가 된다.

1.4 도메인 모델 패턴

 

  • 위의 아키텍처구성은 네 개의 영역으로 구성 된다. 이는 아래 표를 참고하자.

  • 앞서 살펴본 도메인 모델이 도메인 자체를 이해하는데 필요한 개념 모델을 의미한다면, 지금 살펴볼 도메인 모델은 아키텍처 상의 도메인 계층을 객체 지향 방법으로 구현하는 패턴을 말한다.
  • 도메인 계층은 도메인의 핵심 규칙을 구현한다.
    • 주문 도메인의 경우 ‘출고 전에 배송지를 변경 할 수 있다’ 라는 규칙과 ‘주문 취소는 배송 전에만 할 수 있다’ 라는 규칙을 구현한 코드가 도메인 계층에 위치하게 된다.
    • 이런 도메인 규칙을 객체 지향 기법으로 구현하는 패턴이 도메인 모델 패턴이다.

배송지 정보 변경 가능 여부를 OrderState에서 판단

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는 주문 대기 중이거나 상품 준비중에는 배송지를 변경할 수 있다는 도메인 규칙을 구현하고 있다.
    • 큰 틀에서 보면 OrderState는 Order에 속한 데이터이므로 배송지 정보 변경 가능 여부를 판단하는 코드를 Order로 이동할 수 있다.

배송지 정보 변경 가능 여부를 Order에서 판단

public class Order {
    private OrderState state;
    private ShippingInfo shippinginfo;

    public void changeShippingInfo(ShippingInfo newShippingInfo) {
        if (!isShippingChangeable()) {
            throw new IllegalStateException("can't change shipping in " + state);
        }
        this.shippinginfo = newShippingInfo;
    }

    private boolean isShippingChangeable() {
        return state == OrderState.PAYMENT_WAITING || state == OrderState.PREPARING;
    }
    // ...
}

public enum OrderState {
    PAYMENT_WAITING, PREPARING, SHIPPED, DELIVERING, DELIVERY_COMPLETED;
}
  • 배송지 변경이 가능한지를 판단할 규칙이 주문 상태와 다른 정보를 함께 사용한다면 OrderState만으로는 배송지 변경 가능 여부를 판단할 수 없으므로 Order에서 로직을 구현해야 한다.
  • 주문과 관련된 중요 업무 규칙을 주문 도메인 모델인 Order나 OrderState에서 구현한다는 점이 중요하다.
'도메인 모델'이란 용어는 도메인 자체를 표현하는 개념적인 모델을 의미하지만, 
도메인 계층을 구현할 때 사용하는 객체 모델을 언급할 때에도 '도메인 모델'이란 용어를 사용한다.
이 책에서도 도메인 계층의 객체 모델을 표현할 때 도메인 모델이라고 표현하고 있다.

💡 개념 모델과 구현 모델

  • 개념 모델은 데이터베이스, 트랜잭션 처리, 성능, 구현 기술과 같은 것을 고려하고 있지 않기 때문에 실제 코드를 작성할 때 개념 모델을 있는 그대로 사용할 수 없다.
    • 그래서 개념 모델을 구현 가능한 형태의 모델로 전환하는 과정을 거치게 된다.
  • 처음부터 완벽한 개념 모델을 만들기보다는 전반적인 개요를 알 수 있는 수준으로 개념 모델을 작성해야 한다.
  • 프로젝트 초기에는 개요 수준의 개념 모델로 도메인에 대한 전체 윤곽을 이해하는 데 집중하고, 구현하는 과정에서 개념 모델을 구현 모델로 점진적으로 발전시켜 나가야 한다.

1.5 도메인 모델 도출

  • 구현을 시작하기 위해서는 도메인에 대한 초기 모델이 필요하다.
  • 도메인을 모델링할 때 기보이 되는 작업은 모델을 구성하는 핵심 구성요소, 규칙, 기능을 찾는 것이다.

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

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

위 요구사항에서 주문은 '출고 상태로 변경하기', '배송지 정보 변경하기', '주문 취소하기', '결제 완료하기' 기능을 제공한다는 사실을 도출할 수 있고, Order에 관련 기능을 메서드로 추가할 수 있다.

public class Order {
	// 출고 상태로 변경하기
    public void changeShipped() { ... }    
	// 배송지 정보 변경하기
    public void changeShippingInfo(ShippingInfo newShippingInfo) { ... }
	// 주문 취소하기
    public void cancel() { ... }
	// 결제 완료하기
    public void completePayment() { ... }
}

다음 요구사항은 주문 항목이 어떤 데이터로 구성되어있는지 알려준다.

  • 한 상품을 한 개 이상 주문할 수 있다.
  • 각 상품의 구매 가격 합은 상품 가격에 구매 개수를 곱한 값이다.
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.amounts = calculateMounts();
    }

	// 각 상품의 구매 가격 합은 상품 가격에 구매 개수를 곱한 값이다.
    private int calculateMounts() {
        return price * quantity;
    }

    public int getAmounts() { ... }
    
    // ...
}

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

  • 최소 한 종류 이상의 상품을 주문해야 한다.
  • 총 주문 금액은 각 상품의 구매 가격 합을 모두 더한 금액이다.
public class Order {
    private List<OrderLine> orderLines;
    private Monty 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() {
        int sum = orderLines.stream()
                .mapToInt(x -> x.getAmounts())
                .sum();
        this.totalAmounts = new Money(sum);
    }
    
    // .. 다른 메서드 
    
}

배송지 정보는 다음과 같이 정의한다.

public class ShippingInfo {
    private String receiverName;    // 이름
    private String receiverPhoneNumber; // 전화번호
    private String shippingAddress1;    // 주소 데이터
    private String shippingAddress2;
    private String shippingZipcode;
    // ... 생성자, getter
}

기존 요구사항

  • 주문할 때 배송지 정보를 반드시 지정해야 한다.
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;
    }
    
    // ...
}

주문 요구사항에서는 다음 내용이 제약과 규칙에 해당된다.

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

이 요구 사항을 충족하려면 주문은 최소한 출고 상태를 표현할 수 있어야 한다.

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

 

출고 상태에 따라 배송지 정보 변경 기능과 주문 취소 기능은 다른 제약을 갖는다.

이 요구사항을 충족하려면 주문은 최소한 출고 상태를 표현할 수 있어야 한다.

 

다음 요구사항도 상태와 관련이 있다.

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

이 요구사항은 결제 완료 전을 의미하는 상태와 결제 완료 내지 상품 준비 중이라는 상태가 필요하다.

public enum OrderState {
    PAYMENT_WATING, PREPARING, SHIPPED, DELIVERING, DELIVERY_COMPLETED, CANCELED;
}

배송지 변경이나 주문 취소 기능은 출고 전에만 가능하다는 제약 규칙이 있다.

이 규칙을 적용하기 위해 changeShippingInfo()와 cancel()은 verifyNotYetShipped() 메서드를 먼저 실행한다.

public class Order {
    private OrderState state;
    
    public void changeShippingInfo(ShippingInfo shippingInfo) {
        verifyNotYetShipped();
        setShippingInfo(newShippingInfo);
    }
    
    public void cancel() {
        verifyNotYetShipped();
        this.state = OrderState.CANCELED;
    }
    
    private void verifyNotYetShipped() {
        if (state != OrderState.PAYMENT_WATING && state != OrderState.PREPARING)
            throw new IllegalArgumentException("already shipped");
    }
}

✔️ isShippingChangeable() → verifyNotYetShipped() 로 바뀐 이유

isShippingChangeable() : 최초에는 배송지 정보 변경에 대한 제약 조건만 파악 → 배송지 정보 변경 가능 여부 확인으로 설정

verifyNotYetShipped() : 배송지 정보 변경과 주문취소가 둘 다 ‘출고 전에 가능’ 하다는 제약이 있음을 알게 됨 → ‘출고 전’이라는 의미 반영

 

💡 문서화

  • 문서화를 하는 주된 이유는 지식을 공유하기 위함이다.
  • 전반적인 기능 목록이나 모듈 구조, 빌드 과정은 코드를 보고 직접 이해하는 것보다 상위 수준에서 정리한 문서를 참조하는 것이 소프트웨어 전반을 빠르게 이해하는데 도움이 된다.
  • 도메인 관점에서 코드가 도메인을 잘 표현해야 비로소 코드의 가독성이 높아지고 문서로서 코드가 의미를 갖는다.

1.6 엔티티와 벨류

  • 도출한 모델은 크게 엔티티와 벨류로 구분할 수 있다.
    • 이 둘의 차이를 명확하게 이해하는 것은 도메인을 구현하는데 있어서 중요하다.

1.6.1 엔티티

  • 엔티티의 가장 큰 특징은 식별자를 가진다는 것이다.
    • 식별자는 엔티티 객체마다 고유해서 각 엔티티는 서로 다른 식별자를 갖는다.
    • 예를 들어 주문 도메인에서 각 주문은 주문번호를 가지고 있는데 이 주문번호는 각 주문마다 서로 다르다. 이 주문번호는 식별자가 된다.
  • 주문 도메인 모델에서 주문에 해당하는 클래스가 Order이므로 Order가 엔티티가 되며 주문번호를 속성으로 갖게 된다.
  • 엔티티를 생성하고 속성을 바꾸고 삭제할 떄까지 식별자는 유지된다.
    • 엔티티를 구현한 클래스는 다음과 같이 식별자를 이용해서 equals() 메서드와 hashCode() 메서드를 구현할 수 있다.
    public class Order {
        private String orderNumber;
    
        @Override
        public boolean equals(Object obj) {
            if (this == obj) return true;
            if (obj == null) return false;
            if (obj.getClass() != Order.class) return false;
            Order other = (Order)obj;
            if (this.orderNumber == null) return false;
            return this.orderNumber.equals(other.orderNumber);
        }
    
       @Override
        public int hashCode() {
            final int prime = 31;
            int result = 1;
            result = prime * result + ((orderNumber == null) ? 0 : orderNumber.hashCode());
            return result;
        }
    }

1.6.2 엔티티의 식별자 생성

  • 엔티티의 식별자를 생성하는 시점은 도메인의 특징과 사용하는 기술에 따라 달라진다.
  • 식별자 생성 방법 (보통 다음 중 한 가지 방식으로 생성된다.)
    • 특정 규칙에 따라 생성
    • UUID나 Nano ID와 같은 고유 식별자 생성기 사용
      • 자바는 java.util.UUID 사용
      • UUID uuid = UUID.randomUUID();  
        * 최근에는 고유 식별자 생성을 위해 Nano ID를 사용하는 곳도 증가하고 있다.
    • 값을 직접 입력
    • 일련번호 사용(시퀀스나 DB의 자동 증가 칼럼 사용)
✔️ 자동 증가 칼럼을 제외한 다른 방식은 다음과 같이 식별자를 먼저 만들고 엔티티 객체를 생성할 때 식별자를 전달한다.

// 엔티티를 생성하기 전에 식별자 생성
String orderNumber = orderRepository.generateOrdreNumber(); 
Order order = new Order(orderNumber, ...); 
orderRepository.save(order);

✔️자동 증가 칼럼은 DB 테이블에 데이터를 삽입해야 비로소 값을 알 수 있으므로 테이블에 데이터를 추가하기 전까지는 식별자를 알 수 없다. 
따라서 엔티티 객체를 생성할 때 식별자를 전달 할 수 없다.

Article article = new Article(author, title, ...);
articleRepository.save(article); // DB에 저장한 뒤 구한 식별자를 엔티티에 반영 
Long savedArticleId = article.getId(); // DB에 저장한 후 식별자 참조 가능

1.6.3 밸류 타입

  • 밸류 타입의 장점
    • 가독성 향상
    • 의미 파악에 도움
    • 밸류 타입을 위한 기능 추가 가능
  • 밸류 타입은 개념적으로 완전한 하나를 표현할 때 사용한다.
    • ShippingInfo 클래스의 receiverName필드와 receiverPhoneNumber 필드는 서로 다른 두 데이터를 담고 있지만, 두 필드는 개념적으로 ‘받는 사람’을 의미한다.
    • shippingAddress1 필드, shippingAddress2 필드, shippingZipcode 필드는 ‘주소’라는 하나의 개념을 표현한다.

Receiver, Address 를 밸류 타입을 사용하여 표현

public class Receiver {
    private String name;
    private String phoneNumber;

    public Receiver(String name, String phoneNumber) {
        this.name = name;
        this.phoneNumber = phoneNumber;
    }

    public String getName() {
        return name;
    }

    public String getPhoneNumber() {
        return phoneNumber;
    }
}

public class Address {
    private String address1;
    private String address2;
    private String zipcode;

    public Address(String address1, String address2, String zipcode) {
        this.address1 = address1;
        this.address2 = address2;
        this.zipcode = zipcode;
    }
}

ShippingInfo 클래스를 밸류를 사용해서 다시 구현

public class ShippingInfo {
	private Receiver receiver;
	private Address address;

	// ... 생성자, get method
}
  • 이전 [그림 1.8] 보다 가독성이 향상된 것을 확인할 수 있다.

의미를 명확하게 표현하기 위해 벨류 타입을 사용한 예시

- 수정 전

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

- 수정 후

public class OrderLine {
	private Product product;
	private Money price;
	private int quantity;
	private Money amount;
	// ...
}

public class Money {
	private int value;
	// ...
}
  • Money 타입 덕에 price, amounts가 금액을 의미한다는 것을 쉽게 알 수 있다.

밸류 타입을 위한 기능을 추가

public class Money {
	private int value;

	// 생성자, getter

	public Money add(Money money) {
		return new Money(this.value + money.value);
	} 

	public Money multiply(int multiplier) {
		return new Money(this.value * multiplier);
	} 

}
  • Money를 사용하는 코드는 이제 ‘정수 타입 연산’이 아니라 ‘돈 계산’ 이라는 의미로 코드를 작성할 수 있다.
  • 밸류 객체의 데이터를 변경할 때는 기존 데이터를 변경하기보다는 변경한 데이터를 갖는 새로운 밸류 객체를 생성하는 방식을 선호한다.
  • 데이터 변경 기능을 제공하지 않는 타입을 불변이라고 표현한다.

참조 투명성 관련된 문제 (setter 제공하는 것으로 인한 문제)

Money price = new Money(1000);
OrderLine line = new OrderLine(product, price, 2);
price.setValue(2000);

[price=1000, quantity=2, amounts=2000]에서
[price=2000, quantity=2, amounts=2000]로 변한다.
  • setter 관련 문제를 해결하기 위해서 아래와 같이 구현해야 한다.
public class OrderLine {
	// ... 다양한 필드
    private Money price;
    
    public OrderLine(Product, product, Money price, int quantity) {
        this.product = product; 
        // Money 가 불변 객체가 아니라면, price 파라미터가 변경될 떄 발생하는 문제를 방지하기 위해 
        // 데이터를 복사한 새로운 객체를 생성해야 한다. 
        this.price = new Money(price.getValue()); 
        // ... 
    } 
}

1.6.4 엔티티 식별자와 밸류 타입

  • 엔티티 식별자의 실제 데이터는 String과 같은 문자열로 구성된 경우가 많다.
  • 식별자는 단순한 문자열이 아니라 도메인에서 특별한 의미를 지니는 경우가 많기 때문에 식별자를 위한 밸류 타입을 사용해서 의미가 잘 드러나도록 할 수 있다.
public class Order {
	// OrderNo 타입 자체로 id가 주문번호임을 알 수 있다.
	private OrderNo id;
	
	// ...
	public OrderNo getId() {
		return id;
	}
}

1.6.5 도메인 모델에 setter 넣지 않기

  • 도메인 모델에 get/set 메서드를 무조건 추가하는 것은 좋지 않은 버릇이다.
  • setter는 도메인의 핵심 개념이나 의도를 코드에서 사라지게 한다.
    • 예를 들어 changeShippingInfo()는 배송지 정보를 새로 변경한다는 의미를 가졌다면 setShippingInfo()는 단순히 배송지 값을 설정한다는 것을 의미한다.
    • completePayment() 는 결제를 완료했다는 의미를 갖는 반면에 setOrderState()는 단순히 주문 상태 값을 설정한다는 것을 의미한다.
    • 이렇게 setter를 쓰면 단순히 상태 값만 변경할 것인지 혹은 다른 처리를 위한 코드를 함께 구현할 것인지 애매해진다.
  • setter는 도메인 객체를 생성할 때 온전하지 않은 상태가 될 수 있다.
    • 객체를 생성하여 setter로 데이터를 넣어주면 누락되어 이후 문제가 발생할 수 있다.
  • 도메인 객체가 불완전한 상태로 사용되는 것을 막으려면 생성자를 통해 필요한 데이터를 모두 받아야 한다.
    • 생성자로 필요한 것을 모두 받으므로 다음처럼 생성자를 호출하는 시점에 필요한 데이터가 올바른지 검사할 수 있다.

setter 대신 생성자로 객체를 생성

public class Order {
	public Order(Orderer orderer, List<OrderLine> orderLines, 
						ShippingInfo shppingInfo, OrderState state) {
		setOrderer(orderer);
		setOrderines(orderLines);
		// ... 다른 값 설정
	}

	private void setOrdere(Orderer orderer) {
		if (orderer == null) throw new IllegalArgumentException("no orderer");
		this.orderer = orderer;
	}

	private void setOrderLines(List<OrderLine> orderLines) {
		verifyAtLeastOneOrMoreOrderLines(orderLines);
		this.orderLines = orderLines;
		calculateTotalAmount();
	}

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

	private void calculateTotalAmount() {
		this.totalAmounts = orderLines.stream().mapToInt(x -> x.getAmounts()).sum();
	}
}
  • 해당 코드 중 생성자 쪽에 있는 setter는 앞서 말한 setter와는 다르다.
    • 접근 범위가 private 라 외부에서 데이터를 변경할 목적으로 사용할 수 없다.

💡 DTO의 getter/setter

  • DTO는 Data Transfer Object의 약자로 프레젠테이션 계층과 도메인 계층이 데이터를 서로 주고받을 떄 사용하는 일종의 구조체이다.
  • DTO는 도메인 로직을 담고 있지는 않기에 getter/setter 메서드를 제공해도 도메인 객체의 데이터 일관성에 영향을 줄 가능성이 높지 않다.
  • DTO도 밸류 타입과 마찬가지로 setter를 열어주는 대신 생성 시점에 값이 주입되게 만듦으로써 불벼 객체를 만드는 것이 좋다.

1.7 도메인 용어와 유비쿼터스 언어

도메인에서 사용하는 용어를 코드에 반영하지 않았을 경우

public enum OrderState {
        STEP1, STEP2, STEP3, STEP4, STEP5, STEP6;
}

public class Order {
    public void changeShippingInfo(ShippingInfo newShippingInfo) {
        verifyStep1OrStep2();
        setShippingInfo(newShippingInfo);
    }
    
    public void verifyStep1OrStep2() {
        if (state != OrderState.STEP1 && state != OrderState.STEP2)
            throw new IllegalArgumentException("already shipped");
    }
}

enum 코드 변경

public enum OrderState {
        PAYMENT_WAITING, PREPARING, SHIPPED, DELIVERING, DELIVERY_COMPLETED; 
}
  • 도메인에서 사용하는 용어를 코드에 반영하지 않으면 그 코드는 개발자에게 코드의 의미를 해석해야 하는 부담을 준다.
  • 코드를 작성할 때 최대한 도메인 용어를 사용해서 도메인 규칙을 코드로 작성하게 되면 가독성을 높여 코드를 분석하고 이해하는 시간을 줄일 수 있고, 의미를 변환하는 과정에서 발생하는 버그도 줄일 수 있다.
  • 에릭 어반스는 도메인 주도 설계에서 언어의 중요함을 강조하기 위해 ‘유비쿼터스 언어’라는 용어를 사용했다.
    • 전문가, 관계자, 개발자가 도메인과 관련된 공통의 언어를 만들고 이를 대화, 문서, 도메인 모델, 코드, 테스트 등 모든 곳에서 같은 용어를 사용한다.
    • 이 언어를 사용하면 용어의 모호함을 줄일 수 있고 개발자는 도메인과 코드 사이에서 불필요한 해석 과정을 줄일 수 있다.
  • 영어로 코드를 작성해야 하는 환경 때문에 도메인 용어를 영어로 해석하는 노력이 필요하다.
    • 알맞은 영단어를 찾는 것을 쉽지 않은 일이지만 시간을 들여 찾는 노력을 해야 한다.
    • 도메인에 어울리지 않는 단어를 사용하면 코드는 도메인과 점점 멀어지게 된다.

 

 

 

 

References

http://www.yes24.com/Product/Goods/108431347

https://cotak.tistory.com/283

반응형

' > 도메인 주도 개발 시작하기' 카테고리의 다른 글

[DDD] 2. 아키텍처 개요  (0) 2023.01.08