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

·2022년 12월 12일
0
post-thumbnail

최범균님의 '도메인 주도 개발 시작하기'를 읽고 정리한 글입니다.

1.1 도메인이란?

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

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

  • 각 도메인(ex. 온라인 홍보, 정산, 배송)에는 전문가가 있다.
  • 이들 전문가는 해당 도메인에 대한 지식과 경험을 바탕으로 본인들이 원하는 기능 개발을 요구한다.
  • 개발자는 이런 요구사항을 분석하고 설계하여 코드를 작성하며 테스트하고 배포한다.
  • 여기서 요구사항 분석은 첫 단추와 같아서, 이를 올바르게 이해하지 못하면 엉뚱한 기능을 만들게 된다.
  • 따라서 코딩에 앞서 요구사항을 올바르게 이해하는 것이 중요하다.
  • 요구사항을 올바르게 이해하는 방법이 바로, 개발자와 전문가가 직접 대화하는 것이다.

1.3 도메인 모델

  • 도메인 모델: 특정 도메인을 개념적으로 표현한 것
  • 객체를 이용한 도메인 모델
  • 상태 다이어그램을 이용한 주문의 상태 전이 모델링
  • 도메인 모델을 사용하면 여러 관계자들이 동일한 모습으로 도메인을 이해하고 도메인 지식을 공유하는 데 도움이 된다.
  • 하위 도메인과 모델
    • 도메인은 다수의 하위 도메인으로 구성됨.
    • 각 하위 도메인이 다루는 영역은 서로 다르기 때문에 같은 용어라도 하위 도메인마다 의미가 달라질 수 있음.
      • 카탈로그 도메인 - 상품: 상품 가격, 상세 내용을 담고있는 정보
        배송 도메인 - 상품: 고객에게 실제 배송되는 물리적인 상품
    • 각 하위 도메인마다 별도로 모델을 만들어야함

1.4 도메인 모델 패턴

  • 아키텍처 구성

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

  • ex) 주문 도메인의 '출고 전 배송지 변경 가능'이라는 규칙과 '주문취소는 배송 전에만 가능'과 같은 규칙을 구현한 코드가 도메인 계층에 존재한다.

  • 도메인 모델 패턴: 이런 도메인 규칙을 객체 지향 기법으로 구현하는 패턴

// 1. 배송지 변경 가능 여부 판단 기능 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;
		}
	}
}
// 2. 배송지 변경 가능 여부 판단 기능 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;
	}
}
  • 주문 도메인의 일부 기능을 도메인 모델 패턴으로 구현한 것
    - 출고 전 배송지 변경 가능
    - PAYMENT_WAITING(주문 대기중) & PREPARING(상품 준비 중) 상태의 isShippingChangable() 메서드는 true를 반환함

  • 주문과 관련된 중요 업무 규칙을 주문 도메인 모델인 Order나 OrderState에서 구현한다.

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

  • 개념 모델과 구현 모델

    • 개념 모델
      • 순수하게 문제를 분석한 결과물
        • DB, 트랜잭션 처리, 성능, 구현 기술과 같은 것을 고려 X
        • 실제 코드를 작성할 때 개념 모델을 있는 그대로 사용할 수 없음.
    • 개념 모델을 만들 때 처음부터 완벽하게 도메인을 표현하는 모델을 만드는 것은 실제로 불가능
    • 프로젝트 쵝에는 개요 수준의 개념 모델로 도메인에 대한 전체 윤곽을 이해하는 데 집중하고, 구현하는 과정에서 개념 모델을 구현 모델로 점진적으로 발전시켜 나가야함.

1.5 도메인 모델 도출

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

  • 주문 도메인과 관련된 몇 가지 요구사항
- 최소 한 종류 이상의 상품을 주문해야 한다
- 한 상품을 한 개 이상 주문할 수 있다
- 총 주문 금액은 각 상품의 구매 가격 합을 모두 더한 금액이다
- 각 상품의 구매 가격 합은 상품 가격에 구매 개수를 곱한 값이다
- 주문할 때 배송지 정보를 반드시 지정해야 한다
- 배송지 정보는 받는 사람 이름, 전화번호, 주소로 구성된다
- 출고를 하면 배송지를 변경할 수 없다
- 출고 전에 주문을 취소할 수 있다
- 고객이 결제를 완료하기 전에는 상품을 준비하지 않는다
  • 주문(Order)은 다음과 같은 기능을 제공함.
    • 출고 상태로 변경하기
    • 배송지 정보 변경하기
    • 주문 취소하기
    • 결제 완료하기
public class Order {
	public void changeShipped() { ... }
    public void changeShippingInfo(ShippingInfo newShipping) { ... }
    public void cancel() { ... }
    public void completePayment() { ... }
}
  • 다음 요구사항은 주문 항목(OrderLine)이 어떤 데이터로 구성되는지 알려준다.
    - 한 상품을 한 개 이상 주문할 수 있다.
    - 각 상품의 구매 가격의 합은 상품 가격에 구매 개수를 곱한 값이다.
  • 주문 항목(OrderLine)이 포함해야하는 정보
    • 주문할 상품
    • 상품의 가격
    • 구매 개수
    • 구매 항목의 구매 가격
public class OrderLine {
	private Product product;
	private int price;
	private int quantity;
	private int amounts;

	public OrderLine(Product product, int price, int quantity, int amounts) {
		this.product = product;
		this.price = price;
		this.quantity = quantity;
		this.amounts = calculateAmounts();
	}

	private int calculateAmounts() {
		return price * quantity;
	}

	public int getAmounts() {
		return amounts;
	}
}
  • 다음 요구사항은 Order과 OrderLine과의 관계를 알려준다.
    - 최소 한 종류 이상의 상품을 주문해야 한다.
    - 총 주문 금액은 각 상품의 구매 가격 합을 모두 더한 금액이다.
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() {
		int sum = orderLines.stream()
			.mapToInt(x -> x.getAmounts())
			.sum();
		this.totalAmounts = new Money(sum);
	}
}
  • ShippingInfo(배송지 정보)는 다음과 같은 데이터를 가진다.
    • 이름
    • 전화번호
    • 주소 데이터
public class ShippingInfo {
	private String receiverName;
	private String receiverPhoneNumber;
	private String shippingAddress1;
	private String shippingAddress2;
	private String shippingZipCode;
    // ... 생성자, getter
}
  • '주문할 때 배송지 정보를 반드시 지정해야한다.'를 지키기 위해, Order를 생성할 때 OrderLine목록 뿐만 아니라 ShippingInfo도 함께 전달해야한다.
public class Order {
	private List<OrderLine> orderLines;
	private Money totalAmounts;
	private ShippingInfo shippingInfo;


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

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

// ...
}
  • 특정 조건이나 상태에 따라 제약이나 규칙이 달리 적용되는 경우가 많음.
    • 출고 상태 전 후 제약사항
      	- 출고를 하면 배송지 정보를 변경할 수 없다.
      	- 출고 전에 주문을 취소할 수 있다.
      	- 고객이 결제를 완료하기 전에는 상품을 준비하지 않는다.
    • 결제 완료 전 후 요구사항
      	- 고객이 결제를 완료하기 전에는 상품을 준비하지 않는다.
public enum OrderState {
	PAYMENT_WAITING, PREPARING, SHIPPED, DELIVERING, DELIVERY_COMPLETED, CANCELED;
}
public class Order {
	private OrderState state;
    
    public void changeShippingInfo(ShippingInfo newShippingInfo) {
    	verifyNotYetShipped();
        setShippingInfo(newShippingInfo);
    }
    
    public void cancel() {
		verifyNotYetShipped();
		this.state = OrderState.CANCELED;
	}
    
	private void verifyNotYetShipped() {
		if (state != OrderState.PAYMENT_WAITING && state != OrderState.PREPARING) {
			throw new IllegalStateException("already shipped");
		}
	}
    
    // ...
}
  • 요구사항을 분석함으로써 도메인 모델이 지니고있어야 할 상태와 기능, 그리고 제약조건 등을 찾을 수 있다.
  • 요구사항에서 도메인 모델을 점진적으로 만들어 나간다.
  • 문서화
    • 문서화를 하는 주된 이유는 지식을 공유하기 위함이다.
    • 코드를 보며 도메인을 깊게 이해하게 되므로 코드 자체도 문서화의 대상이 된다.
    • 도메인 관점에서 코드가 도메인을 잘 표현해야 비로소 코드의 가독성이 높아지고 문서로서 코드가 의미를 갖는다.

1.6 엔티티와 밸류

  • 도출한 모델은 크게 엔티티(Entity)밸류(Value)로 구분 가능함.

엔티티

엔티티의 가장 큰 특징은 식별자를 가진다는 것이다.

  • 식별자는 엔티티 객체마다 고유해서 각 엔티티는 서로 다른 식별자를 가진다.
    (ex. 주문마다 주문 번호가 다르다.)
    • 주문 도메인 모델에서 Order가 엔티티가 되며 주문번호를 식별자로 가진다.
  • 엔티티의 식별자는 바뀌지 않는다.
    엔티티를 생성하고 속성을 바꾸고 삭제할 때까지 식별자는 유지된다.
  • 두 엔티티 객체의 식별자가 같으면 두 엔티티는 같다고 판단한다.
package one.six;

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

엔티티의 식별자 생성

  • 식별자의 생성 방식

    • 특정 규칙에 따라 생성
    • UUID나 NanoID와 같은 고유 식별자 생성기 사용
    • 값을 직접 입력
    • 일련번호 사용(시퀀스나 DB의 자동 증가 칼럼 사용)
  • 식별자 생성 예시

    • 현재 시간과 다른 값을 함께 조합 (ex. 2022121231728XXXX)
    • UUID 사용 - java.util.UUID
      UUID uuid = UUID.randomUUID();
      String strUuid = uuid.toString();
  • 일련번호 방식은 주로 데이터베이스가 제공하는 자동 증가 기능 사용

  • 자동 증가 칼럼을 제외한 다른 방식은 식별자를 먼저 만들고 엔티티 객체를 생성할 때 식별자를 전달함.

    String orderNumber = orderRepository.generateOrderNumber();
    Order order = new Order(orderNumber, ...);
    orderRepository.save(order);
  • 자동 증가 칼럼은 DB 테이블에 데이터 삽입한 뒤에야 식별자 알 수 있음

    Article article = new Article(author, title, ...);
    articleRepository.save(article);
    Long savedArticleId = article.getId();

밸류 타입

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

  • 기존의 ShippingInfo의 필드들을 Receiver와 Address 밸류타입을 사용해 보다 명확하게 표현한다.
    • 즉, 밸류 타입을 사용함으로써 개념적으로 완전한 하나를 잘 표현할 수 있다.
public class Receiver {

    private String name;
    private String phoneNumber;

	// 생성자, getter
}
public class Address {

    private String address1;
    private String address2;
    private String zipcode;

	// 생성자, getter
}
  • 밸류 타입이 꼭 두 개 이상의 데이터를 가져야하는 것이 아니다.
    • 의미를 명확하게 표현하기 위해 밸류 타입을 사용하는 경우도 있다.
public class Money {

    private int value;

	// getter, 생성자
}
  • 밸류 타입을 위한 기능을 추가할 수 있다.
// Money타입의 돈 계산 기능 추가
// Money를 사용하는 코드는 '정수 타입 연산'이 아니라 '돈 계산'이라는 의미로 코드 작성
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(value * multiplier);
    }
}
  • 밸류 타입의 장점
    • 가독성이 향상된다.
    • 밸류 타입을 위한 기능을 추가할 수 있다.
    • 의미를 보다 명확하게 표현할 수 있다.
  • 밸류 객체 데이터를 변경할 때는 기존 데이터를 변경하기보다, 변경한 데이터를 갖는 새로운 밸류 객체를 생성하는 방식을 선호한다.

  • 불변(Immutable) 객체: 데이터 변경 기능을 제공하지 않는 타입

  • setter로 메서드를 제공해 값을 변경하지 못하게 막아야함.

    • 값이 잘못 반영되는 상황이 발생할 수도 있음.
  • 만약 Money 값을 변경할 수 있다면 문제를 방지하기 위해 다음과 같은 코드를 작성해야함.

public class OrderLine {

    private Product product;
    private Money price;
    private int quantity;
    private Money amounts;

    public OrderLine(Product product, Money price, int quantity, Money amounts) {
        this.product = product;
        this.price = new Money(price.getValue());
        this.quantity = quantity;
        this.amounts = calculateAmounts();
    }
	
    // ...
}
  • Money 값을 변경할 수 없으면 이런 코드 없이 전달받은 price를 안전하게 사용 가능

  • 두 밸류 객체를 비교할 때는 모든 속성이 같은지 비교해야함.

public class Receiver {

    private String name;
    private String phoneNumber;

	// 생성자, getter
    
    public boolean equals(Object other) {
        if (other == null)
            return false;
        if (this == other)
            return true;
        if (!(other instanceof Receiver))
            return false;
        Receiver that = (Receiver) other;
        return this.name.equals(that.name) && this.phoneNumber.equals(that.phoneNumber);
    }
}

엔티티 식별자와 밸류 타입

  • 엔티티 식별자의 실제 데이터는 String과 같은 문자열로 구성된 경우가 많음.
  • 식별자를 위한 밸류타입을 사용해서 의미가 잘 드러나도록 함.
    • Order의 식별자 타입으로 String 대신 OrderNo 밸류 타입을 사용하면, 타입을 통해 해당 필드가 주문번호라는 것을 알 수 있음.
public class Order {
	// OrderNo 타입 자체로 id가 주문번호임을 알 수 있음
    private OrderNo id;

	public OrderNo getId() {
		return id;
	}
}

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

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

  • set 메서드의 문제점

    • 도메인의 핵심 개념이나 의도를 코드에서 사라지게 함.
      • changeShippingInfo()가 배송지 정보를 새로 변경하는 의미를 가졌다면, setShippingInfo() 메서드는 단순히 배송지 값을 설정한다는 것을 의미
      • completePayment()는 결제를 완료했다는 의미를 갖는 반면, setOrderState()는 단순히 주문 상태 값을 설정한다는 것을 의미
    • 도메인 객체를 생성할 때 온전하지 않은 상태가 될 수 있음.
      • 도메인 객체가 불완전한 상태로 사용되는 것을 막으려면 생성 시점에 필요한 것을 전달해 주어야함.
      • 즉, 생성자를 통해 필요한 데이터를 모두 받아야함.
    Order order = new Order(orderer, lines, shippingInfo, OrderState.PREPARING);
  • 생성자로 필요한 것을 모두 받으면, 생성자를 호출하는 시점에 필요한 데이터가 올바른지 검사 가능

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

    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() {
        int sum = orderLines.stream()
                .mapToInt(x -> x.getAmounts())
                .sum();
        this.totalAmounts = new Money(sum);
    }
}
  • 위 코드의 set메서드는 접근 범위가 private임.

    • 클래스 내부에서 데이터를 변경할 목적으로 사용
  • 불변 밸류 타입을 사용하면 자연스럽게 밸류 타입에는 set 메서드를 구현하지 않음

    DTO의 get/set 메서드

  • DTO(Data Transfer Obejct): 프레젠테이션 계층과 도메인 계층이 데이터를 서로 주고받을 때 사용하는 일종의 구조체

  • DTO가 도메인 로직을 담고있지 않기 때문에 get/set 메서드를 제공해도 도메인 객체의 데이터 일관성에 영향을 줄 가능성이 높지 않다.

  • 프레임워크가 필드에 직접 값을 할당하는 기능을 제공하고 있다면 set 메서드를 만드는 대신 해당 기능을 최대한 활용하자.

    • 이렇게 하면 DTO도 불변 객체가 되어 불변의 장점을 DTO까지 확장할 수 있음.

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

  • 도메인 용어 사용의 장점
    • 코드를 도메인 용어로 해석하거나 도메인 용어를 코드로 해석하는 과정이 줄어든다.
    • 코드의 가독성을 높여 코드를 분석하고 이해하는 시간을 줄여준다.
    • 최대한 도메인 용어를 사용해서 도메인 규칙을 코드로 작성하게 되므로 의미를 변환하는 과정에서 발생하는 버그도 줄어든다.
  • 유비쿼터스 언어
    • 전문가,관계자,개발자가 도메인과 관련된 공통의 언어를 만들고 이를 대화,문서,도메인 모델,코드,테스트 등 모든 곳에서 같은 용어를 사용
    • 소통 과정에서 발생하는 용어의 모호함을 줄일 수 있고, 개발자는 도메인과 코드 사이에서 불필요한 해석 과정을 줄일 수 있음.
  • 도메인 용어를 사용해 enum 객체를 만들면 불필요한 변환 과정을 거치지 않아도 된다.
public enum OrderState {
	PAYMENT_WAITING, PREPARING, SHIPPED, DELIVERING, DELIVERY_COMPLETED, CANCELED;
}

0개의 댓글