[책]DDD START! #1. 도메인 모델 시작

bien·2024년 7월 21일
0

DDD_START!

목록 보기
1/3

1. 도메인 모델 시작

도메인

  • 온라인 서점 프로젝트
    • 온라인 서점: 소프트웨어로 해결해하고자하는 문제 영역, 도메인(domain)

도메인 모델

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

도메인 모델 패턴

  • 사용자인터베이스(UI) 또는 표현
    • 사용자의 요청을 처리하고 사용자에게 정보를 보여준다.
    • 여기서 사용자는 소프트웨어를 사용하는 사람 뿐만 아니라 외부시스템도 사용자가될 수 있다.
  • 응용(Application)
    • 사용자가 요청한 기능을 실행한다. 업무 로직을 직접 구현하지 않으며 도메인 계층을 조합해서 기능을 실행한다.
  • 도메인
    • 시스템이 제공할 도메인의 규칙을 구현한다.
  • 인프라 스트럭처(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 void changeShippped() {
        // 로직 검사
        this.state = OrderState.SHIPPED;
    }

}

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

배송지 정보 변경 가능 여부를 판단하는 코드를 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;
    }

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

}

enum OrderState {
    PAYMENT_WAITING, PREPARING, SHIPPED, DELIVERING, DELIVERY_COMPLETED;
}
  • 중요한 점은 주문과 관련된 중요 업무 규칙을 주문 도메인 모델인 Order나 OrderState에서 구현한다는 점이다.
  • 핵심 규칙을 구현한 코드는 도메인 모델에만 위치하기 때문에 규칙이 바뀌거나 규칙을 확장해야 할 때 다른 코드에 영향을 덜 주고 변경 내역을 모델에 반영할 수 있게 한다.

개념 모델과 구현 모델

개념 모델은 순수하게 문제를 분석한 결과물이다. 개념모델은 데이터베이스, 트랜잭션 처리, 성능, 구현 기술과 같은 것들을 고려하고 있지 않기 때문에 작성할 때 개념 모델을 있는 그대로 사용할 수 없다. 그래서 개념 모델을 구현 가능한 형태의 모델로 전환하는 과정을 거치게 된다.
개념 모델을 만들 때 처음부터 완벽하게 도메인을 표현하는 모델을 만드는 시도를 할 수 있지만 실제로 이는 불가능에 가깝다. 소프트웨어를 개발하는 동안 개발자와 관계자들은 해당 도메인을 더 잘 이해하게 된다. 프로젝트 초기에 완벽한 도메인을 만들더라도 결국 도메인에 대한 새로운 지식이 쌓이면서 모델을 보완하거나 수정하는 일이 발생한다.
따라서, 처음부터 완벽한 개념 모델을 만들기보다는 전반적인 개요를 알 수 있는 수준으로 개념 모델을 작성해야 한다. 프로젝트 초기에는 개요 수준의 개념 모델로 도메인에 대한 전체 윤곽을 이해하는데 집중하고, 구현하는 과정에서 개념 모델로 점진적으로 발전시켜 나가야 한다.

도메인 모델 도출

요구사항

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

요구사항으로 부터 주문이 '출고 상태로 변경하기' 배송지 정보 변경하기' '주문 취소하기' '결제 완료로 변경하기'의 4가지 기능을 제공함을 알 수 있다.
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 amount;
    
    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;
    }
    
    public int getAmounts() {...}
}

Order

package com.example.dddstudy.domain;

import java.util.List;

public class Order {
    private List<OrderLine> orderLines;
    private int totalAmounts;
    
    public Order(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));
    }
    
}
  • 주문할 때 배송지 정보를 반드시 지정해야 한다
public class Order {
    private List<OrderLine> orderLines;
    private int totalAmounts;
    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;
        }
    }
    ...
}


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

문서화

문서화를 하는 주된 이유는 지식을 공유하기 위함이다. 실제 구현은 코드에 있으므로 모든 것은 코드를 보면 알 수 있지만 코드는 상세한 모든 내용을 다루고 있기 때문에 코드를 이용해서 전체 소프트웨어를 분석하려면 많은 시간을 투자해야한다. 전반적인 기능 목록이나 모듈 구조, 빌드 과정은 코드를 보고 직접 이해하는 것보다 상위 수준에서 정리한 문서를 참조하는 것이 소프트웨어 전반을 빠르게 이해하는 데 도움이 된다. 전체 구조를 이해하고 더 깊게 이해할 필요가 있는 부분을 코드로 분석해 나가면 된다.
코드를 보면서 도메인을 깊게 이해하게 되므로 코드 자체도 문서화의 대상이 된다. 도메인 지식이 잘 묻어나도록 코드를 작성하지 않으면 코드의 동작 과정은 해석할 수 있어도 도메인 관점에서 왜 코드를 그렇게 작성했는지 이해하는 데는 도움이 되지 않는다. 단순히 코드를 보기 좋게 작성하는 것뿐만 아니라 도메인 관점에서 코드가 도메인을 잘 표현해야 비로소 코드의 가독성이 높아지며 문서로서 코드가 의미를 갖는다.

엔티티와 벨류

도출한 모델은 크게 엔티티(Entity)벨류(Value)로 분류할 수 있다.

1. 엔티티 (Entity)

  • 엔티티의 가장 큰 특징은 식별자를 갖는다는 것이다.
  • 식별자는 엔티티 객체별로 고유해서 각 엔티티는 서로 다른 식별자를 갖는다.
    • 앞서 예시에서, 주문 도메인 모델에서 주문에 해당하는 클래스인 Order는 엔티티가 되며 주문 번호를 식별자로 갖게 된다.

Order.java

  • 엔티티의 식별자는 고유하고 변경되지 않으므로, 두 엔티티 객체의 식별자가 동일하면 두 엔티티는 동일하다고 판단할 수 있다.
package com.example.dddstudy.domain;

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 order = (Order) obj;
        if (this.orderNumber == null) return false;
        return this.orderNumber.equals(order.orderNumber);
    }

    @Override
    public int hashCode() {
        final int prime = 31;
        int result = 1;
        result = prime * result + ((orderNumber == null) ? 0 : orderNumber.hashCode());
        return result;
    }
}

2. 벨류 (Value)

  • 벨류 타입은 개념적으로 완전한 하나를 표현할 때 사용된다.
    • 예시에서 ShipppingInfo에서 특정 필드들이 묶여 ReceiverAddress라는 벨류로 다뤄질 수 있다.

ShippingInfo.java

public class ShippingInfo {
    
    // 받는사람
    private String receiverName;
    private String receiverPhoneNumber;
    
    // 주소
    private String shippingAddress1;
    private String shippingAddress2;
    private String shippingZipcode;
        
    // ...
}
  • receiverName, receiverPhoneNumber는 개념적으로 받는 사람을 의미한다.
    • 두 필드가 실제로 한 개념을 표현하고 있다.
  • 비슷하게 shippingAddress1, shippingAddress2 , shippingZipcode 필드도 주소라는 하나의 개념을 표현한다.
  • 두 객체의 동일 여부를 판별할 때 식별자를 사용하는 엔티티외 달리, 두 벨류 객체를 비교할 때는 모든 속성이 같은지 비교해야 한다.

Receiver.java

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 boolean equals(Object obj) {
        if (this == obj) return true;
        if (obj == null) return false;
        if (! (obj instanceof Receiver)) return false;
        Receiver order = (Receiver) obj;
        return this.name.equals(that.name) &&
                this.phoneNumber.equals(that.phoneNumber);
    }
}

Address.java

class Address {
    private String shippingAddress1;
    private String shippingAddress2;
    private String shippingZipcode;

    public Address(String shippingAddress1, String shippingAddress2, String shippingZipcode) {
        this.shippingAddress1 = shippingAddress1;
        this.shippingAddress2 = shippingAddress2;
        this.shippingZipcode = shippingZipcode;
    }
    
    // getter 메서드...
}

Money.java

  • 의미를 명확히 하기 위해 밸류 타입을 사용할 수 있다.
    • OrderLine에서 price와 amounts는 int 타입의 숫자를 사용하고 있지만, 이들은 '돈'을 의미한다.
  • 이 경우, 밸류타입을 위한 기능을 추가할 수 있다는 장점이 생긴다.
  • 밸류 객체를 변경할 때는 기존 데이터를 변경하기보다는 변경한 데이터를 갖는 새로운 밸류 객체를 생성하는 방식을 선호한다.
    • Money처럼 데이터 변경 기능을 제공하지 않는 타입을 불변(immutable)이라고 표현한다.
    • 밸류 타입을 불변으로 구현하면 안전한 코드를 작성할 수 있다.
public class Money {
    private int value;

    public Money(int value) {
        this.value = value;
    }

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

OrderLine.java

  • Money를 사용한 OrderLine은 다음과 같이 변경된다.
    • Money타입 덕에 price와 amounts가 금액을 의미한다는 것을 쉽게 알 수 있다.
public class OrderLine {
    private Product product;
    private Money price;
    private int quantity;
    private Money amount;
}

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

  • 도메인 모델에 get/set 메서드를 습관적으로 추가하는 것은 좋지 않은 버릇이다.
    • 특히 set메서드는 도메인의 핵심 개념이나 의도를 코드에서 사라지게 한다.
    • 앞선 예시에서 changeShippingInfo() 메서드는 배송지 정보를 새로 변경한다는 의미를 가졌는데, setOrderState(OrderState state) 메서드는 단순히 배송지 값을 변경한다는 것을 뜻한다.
  • 도메인 객체를 생성할 때 완전한 상태가 아닐 수 있다.
    • 따라서 도메인 객체가 불완전한 상태로 사용될 위험이 있다.
  • 도메인 객체가 불완전한 상태로 사용되는 것을 막으려면 생성 시점에 필요한 것을 전달해주어야 한다.
    • 즉, 생성자를 통해 필요한 데이터를 모두 받아야 한다.
      Order order = new Order(orderer, lines, shippingIinfo, OrderState.PREPARING);
  • 생성자로 필요한 것을 모두 받으므로 생성자 호출 시점에 필요한 데이터가 올바른지 검사할 수 있다.

Order.java


public class Order {
    Orderer orderer;
    List<OrderLine> orderLines;
    ShippingInfo shippingInfo;
    OrderState state;

    public Order(Orderer orderer, List<OrderLine> orderLines,
    			ShippingInfo shippingInfo, OrderState state) {
        this.orderer = orderer;
        this.orderLines = orderLines;
        this.shippingInfo = shippingInfo;
        this.state = state;
    }
    
    private void setOrderer(Orderer orderer) {
        if (orderer == null) throw new IllegalArgumentException("no Orderer");
        this.orderer = orderer;
    }
    
    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 = orderLines.stream().mapToInt(x -> x.getAmounts()).sum();
    }
    
}
  • set메서드의 접근 범위가 private이다.
    • 이 코드에서 set메서드는 클래스 내부에서 데이터를 변경할 목적으로 사용된다.
    • private이기 때문에 외부에서 데이터를 변경할 목적으로 set메서드를 사용할 수 없다.

도메인 용어

  • 코드 작성 시 도메인에서 사용하는 용어는 매우 중요하다.
    • 도메인에서 사용하는 용어를 코드에 반영하지 않으면 그 코드는 개발자에게 코드의 의미를 해석해야 하는 부담을 준다.
    • 아래의 예시에서 각 단계는 '결제 대기중', '상품 준비중', '출고 완료됨', '배송 중', '배송 완료됨', '주문 취소됨'이다.
public OrderState {
	STEP1, STEP2, STEP3, STEP4, STEP5, STEP6
}
  • 도메인 용어를 사용해 OrderState를 수정하면 아래와 같다.
public OrderState {
	PAYMENT_WAITING, PREPARING, SHIPPED, DELIVERING, DELIVERY_COMPLETED;
}
  • 코드를 도메인 용어로 해석하거나 도메인 용어를 코드로 해석하는 과정이 줄어든다.
    • 이는 코드의 가독성을 높여서 코드를 분석하고 이해하는 시간을 절약한다.
    • 도메인 용어를 사용해서 최대한 도메인 규칙을 코드로 작성하게 되므로 (의미를 변환하는 과정에서 발생하는) 버그도 줄어들게 된다.

Reference

  • [책] DDD START!: 도메인 주도 설계 구현과 핵심 개념 익히기 - 최범균
profile
Good Luck!

0개의 댓글