1장 : 도메인 모델 시작하기

도메인이란 ?

개발자 입장에서 바라보면 온라인 서점은 구현해야 할 소프트웨어의 대상이 된다. 온라인 서점 소프트웨어는 온라인으로 책을 판매하는 데 필요한 기능을 제공해야 하는데, 이때 온라인 서점은 소프트웨어로 해결하고자 하는 문제 영역, 즉 도메인(domain)에 해당한다. 도메인은 다수의 하위 도메인으로 구성된다.

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

요구사항을 제대로 이해하지 않으면 쓸모없거나 유용함이 떨어지는 시스템을 만들기 때문에 코딩에 앞서 요구사항을 올바르게 이해하는 것이 중요하다.

도메인 모델

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

객체를 이용한 도메인 모델이다.

상태 다이어그램을 이용해서 주문의 상태 전이를 모델링 할 수도 있다.

UML

하위 도메인과 모델

각 하위 도메인이 다루는 영역은 서로 다르기 때문에 같은 용어라도 하위 도메인마다 의미가 달라질 수 있다. 예를 들어 카탈로그 도메인의 상품이 상품 가격, 상세 내용을 담고 있는 정보를 의미한다면, 배송 도메인의 상품은 고객에게 실제 배송되는 물리적인 상품을 의미한다.

도메인에 따라 용어 의미가 결정되므로 여러 하위 도메인을 하나의 다이어그램에 모델링하면 안 된다.

모델의 각 구성요소는 특정 도메인으로 한정할 때 비로소 의미가 완전해지기 때문에 각 하위 도메인마다 별도로 모델을 만들어야 한다.

도메인 모델 패턴

public class Order {
    private OrderState state;
    private ShippingInfo shippingIfo;
    
    public void changeShippingInfo(ShippingInfo newShippingInfo) {
        if (!state.isSippingChangeable()) {
            throw new IllegalStateException("can't change shipping in " + state);
        }
        this.shippingIfo = new ShippingInfo;
    }
    
    ...
    
    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는 Order에 속한 데이터이므로 배송지 정보 변경 가능 여부를 판단하는 코드를 Order로 이동할 수도 있다.

public class Order {
    private OrderState state;
    private ShippingInfo shippingIfo;
    
    public void changeShippingInfo(ShippingInfo newShippingInfo) {
        if (!state.isSippingChangeable()) {
            throw new IllegalStateException("can't change shipping in " + state);
        }
        this.shippingIfo = new ShippingInfo;
    }
    
    public boolean isShippingChangeable() {
        return state == OrderState.PAYMENT_WAITING || 
            state == OrderState.PREPARING;
    }
    
    ...
    
    public enum OrderState {
        PAYMENT_WAITING, PREPARING, SHIPPED, DELIVERING, DELIVERY_COMPLETED;
    }
}

배송지 변경이 가능한지를 판단할 규칙이 주문 상태와 다른 정보를 함께 사용한다면 OrderState만으로 배송지 변경 가능 여부를 판단할 수 없으므로 Order에서 로직을 구현해야 한다.

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

개념 모델과 구현 모델

도메인 모델 도출

도메인을 모델링할 때 기본이 되는 작업은 모델을 구성하는 핵심 구성요소, 규칙, 기능을 찾는 것이다.

✔️ 요구사항

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

✔️ 주문이 제공하는 기능

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

✔️ 주문 항목을 구성하는 데이터 (2), (4)

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();
    }
    
    private int calculateAmounts() {
        return price * quantity;
    }
    
    private int getAmounts() { ... }
    
    ...
}

✔️ Order와 OrderLine과의 관계

public class Order {
    private List<Orderline> orderLines;
    private Monty toalAmounts;
    
    public Order(List<OrderLine> oderLines) {
        setOrderLines(orderLines);
    }
    
    public void setOrderLine(List<OrderLine> orderLines) {
        verifyAtLeastOneOrMoreOrderLines(orderLies);
        this.orderLines = orderLines;
        calculateTotoalAmous();
    }
    
    private void verifyAtLeastOneOrMoreOrderLine(List<OrderLine> orderLines) {
        if (orderLines == null || orderLines.isEmpty()) {
            thorw new IllegalArgumentException("no OrderLine");
        }
    }
    
    private void cla culateTotalAmounts() {
        int sum = orderLines.stream()
                            .mapToInt(x -> x.getAmounts())
                            .sum();
        this.totalAmounts = new Money(sum);
    }
    
    ... // 다른 메서드
}

✔️ 배송지 정보를 담은 ShippingInfo 클래스 (6)

public class ShippingInfo {
    private String receiverName;
    private String receiverPhoneNumber;
    private String shippingAddress1;
    private String shippingAddress2;
    private String shipoingZipcode;
    
    ... 생성자, getter
}

✔️ 요구사항 (5)번 추가

public class Order {
    private List<Orderline> orderLines;
    private ShippingInfo shippingIfo;
    ...
    
    public Order(List<OrderLine> oderLines, ShippingInfo shippingIfo) {
        setOrderLines(orderLines);
        setShippingInfo(shippingInfo);
    }
    
    private void setShippingInfo(ShippingInfo shippingInfo) {
        if (shippingInfo == null)
            thorw new IllegalAtgumentException("no ShippingInfo");
        this.shippingInfo = shippingInfo;
    }
    ...
}

✔️ 도메인을 구현하다 보면 특정 조건이나 상태에 따라 제약이나 규칙이 달리 적용되는 경우가 많다. (7), (8), (9)

public enum OrderState {
    PAYMENT_WAITING, PREPARING, SHIPPED, DELIVERING, DELIVERY_COMPLETED,
    CANCELED;
}
public class Order {
    private OrderState state;

    public void changeShippingInfo(ShippingInfo newShippingInfo) {
        verifyNotYetShipped();
        setShippingInfo(new ShippingInfo);
    }
    
    public void cancel() {
        verifyNoYetShipped();
        this.state = OrderState.CANCELED;
    }
    
    private void verifyNotYetShipped() {
        if (state != OrderState.PAYMENT_WAITING && state != OrderState.PREPARING)
            throw new IllegalStateExcpetion("already shipped");
    }
    
    ...

}

🖇 제약 조건 검사하는 이름이 isShippingChangeable에서 verifyNotyetShipped으로 변경되었는데, 그 사이에 도메인을 더 잘 알게 되었기 때문이다.

엔티티와 밸류

엔티티

엔티티의 가장 큰 특징은 식별자를 가진다는 것이다. 엔티티의 식별자는 바뀌지 않고 고유하기 때문에 두 엔티티 객체의 식별자가 같으면 두 엔티티는 같다고 판단할 수 있다.

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.oderNumber);
    }
    
    @Override
    public int hashCode() {
        final int prime = 31;
        int result = 1;
        result = prime*result + ((orderNumber == null) ? 0 : orderNumber.hashCode());
        return result;
    }
}

엔티티의 식별자 생성

식별자 생성 방식

  • 특정 규칙에 따라 생성

  • UUID 나 Nano ID와 같은 고유 식별자 생성기 사용

  • 값을 직접 입력

  • 일련번호 사용 (시퀀스나 DB의 자동 증가 칼럼 사용)

밸류 타입

✔️ 밸류 타입은 개념적으로 완전한 하나를 표현할 때 사용한다.

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;
    }
    
    // get 메서드
}

✔️ 밸류 타입을 이용해서 ShippingInfo 클래스를 다시 구현해보자.

public class ShippingInfo {
    private Receiver receiver;
    private Address address;
    
    ... 생성자, get 메서드
}

✔️ 밸류 타입이 꼭 두 개 이상의 데이터를 가져야하는 것은 아니고, 의미를 명확하게 표현하기 위해 사용하는 경우도 있다.

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

✔️ OrderLine의 price와 amounts는 int 타입의 숫자를 사용하고 있지만 이들은 '돈'을 의미하는 값이므로 Money 타입을 만들어 사용하면 코드를 이해하는 데 도움이 된다.

public class Money {
    private int value;
    
    public Money(int value) {
        this.money = money;
    }
    
    public int getValue() {
        return this.value;
    }
}
public class OrderLine {
    private Product product;
    private Money price;
    private int quantity;
    private Money amounts;
    ...
}

✔️ 밸류 타입은 밸류 타입을 위한 기능을 추가할 수 있다. 또한 코드의 가독성도 향상된다.

public class Money {
    private int value;
    
    ... 생성자, getValue()
    
    public Money add(Money money) {
        return new Money(this.value + money.value);
    }
    
    public Money multiply(int multiplier) {
        return new Money(value*multiplier);
    }
    
    // value를 변경할 수 있는 메서드 없음
}

밸류 객체의 데이터를 변경할 때는 기존 데이터를 변경하기 보다는 변경한 데이터를 갖는 새로운 밸류 객체를 생성하는 방식을 선호한다. Money 처럼 데이터 변경 기능을 제공하지 않는 타입을 불변(immutable)이라고 표현한다.

밸류 타입을 불변으로 구현하는 가장 중요한 이유는 안전한 코드를 작성할 수 있는 데 있다. -> ????

불변 객체는 참조 투명성과 스레드에 안전한 특징을 갖고 있다.

엔티티 식별자와 밸류 타입

타입을 사용한다면 'id'라는 이름만으로는 해당 필드가 주문번호인지를 알 수 없다. 식별자를 위해 OrderNo 타입을 만들면 타입 자체로 주문번호라는 것을 알 수 있으므로 필드의 의미가 드러나므로 필드 이름이 'id'여도 실제 의미를 찾는 것은 어렵지 않다.

public class Order {
    // OrderNo 타입 자체로 id가 주문번호임을 알 수 있다.
    private OrderNo id;
    
    ...
    public OrderNo getId() {
        return id;
    }
}

도메인 모델에 set 메서드 넣지 않기

도메인 모델에 get/set 메서드를 무조건 추가하는 것은 좋지 않은 버릇이다.

  • set 메서드는 도메인의 핵심 개념이나 의도를 코드에서 사라지게 한다.

  • 도메인 객체를 생성할 때 온전하지 않은 상태가 될 수 있다.

public class Order {
    ...
    public void setShippingInfo(ShippingInfo newShipping) { ... }
    public void setOrderState(OrderState state) { ... }
}

public class Order {
    public Order(Orderer orderer, List<OrderLine> orderLines,
            ShippingInfo shippingInfo, OrderState state) {
        setOrderer(orderer);
        setOrderLines(orderLines);
         ... // 다른 값 설
    }
    
    private void setOrderer(Orderer orderer) {
         if (orderer == null) throw new IllegalArgumentException("no order");
         this.orderer = orderer;
    }
    
    private void setOrderLine(List<OrderLine> orderLines) {
         verifyAtLeatOneOrMoreOrderLines(orderLines);
         this.orderLines = orderLines;
         calculateTotalAmounts();
    }
    
    private void verifyAtLeastOneOrMoreOrderLines(List<OrderLine> orderLines) {
         if (orderLines == null || orderLines.isEmpty()) {
              thorw new IllegalArgumentException("no OrderLine");
         }
    }
    
    private void calculateTotalAmounts() {
         this.totalAmounts = orderLines.stream().mapToInt(x -> x.getAmounts()).sum();
    }
}

이 코드의 set 메서드는 접근 범위가 pricate이다. 이 코드에서 set 메서드는 클래스 내부에서 데이터를 변경할 목적으로 사용된다.

DTO의 get/set 메서드

?????

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

에릭 에반스는 도메인 주도 설계에서 언어의 중요함을 강조하기 위해 유비쿼터스 언어라는 용어를 사용했다. 전문가, 관계자, 개발자가 도메인과 관련된 공통의 언어를 만들고 이를 대화, 문서, 도메인 모델, 코드, 테스트 등 모든 속에서 같은 용어를 사용한다.

Last updated