[Domain Driven Design] 3. 엔티티와 밸류

반응형
728x90
반응형

엔티티와 밸류

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

앞서 요구사항 분석 과정에서 만든 모델은 엔티티도 존재하고 밸류도 존재한다. 앤티티와 밸류를 제대로 구분해야 도메인을 올바르게 설계하고 구현할 수 있기 때문에 이 둘의 차이를 명확하게 이해하는 것은 도메인을 구현하는 데 있어 중요하다.

https://azderica.github.io/til/docs/dev/ddd-start/ch1/

 

 

엔티티

엔티티는 식별자를 가진다.

식별자는 엔티티 객체마다 고유해서 각 엔티티는 서로 다른 식별자를 갖는다.

 

주문에서 배송지 주소가 바뀌거나 상태가 바뀌더라도 주문번호(식별자)가 바뀌지 않는 것처럼 엔티티의 식별자는 바뀌지 않는다. 엔티티를 생성하고 속성을 바꾸고 삭제할 떄까지 식별자는 유지된다.

 

엔티티의 식별자는 바뀌지 않고 고유하기 때문에 두 엔티티 객체의 식별자가 같으면 두 엔티티는 같다고 판단할 수 있다.  엔티티를 구현한 클래스는 다음과 같이 식별자를 이용해서 equals() 메서드와 hashCode() 메서드를 구현할 수 있다.

 

OrderNo.java
package com.myshop.order.command.domain;

import javax.persistence.Column;
import javax.persistence.Embeddable;
import java.io.Serializable;
import java.util.Objects;


public class OrderNo {
    private String number;

    @Override
    public boolean equals(Object o) {
        if (this == o) return true;
        if (o == null || getClass() != o.getClass()) return false;
        OrderNo orderNo = (OrderNo) o;
        return Objects.equals(number, orderNo.number);
    }

    @Override
    public int hashCode() {
        return Objects.hash(number);
    }
}

 

 

엔티티의 식별자 생성

엔티티의 식별자를 생성하는 시점은 도메인의 특징과 사용하는 기술에 따라 달라진다. 흔히 식별자는 다음 중 한가지 방식으로 생성한다.

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

 

주문번호, 운송장번호 등와 같은 식별자는 특정 규칙에 따라 생성한다. 이 규칙은 도메인에 따라 다르고, 같은 주문번호라도 회사마다 다르다. 

 

UUID 사용
UUID uuid = UUID.randomUUID();

// 615f3ab9-c237-4b50-9402-201231230f014 와 같은 형식 문자열
String strUuid = uuid.toString();

 

 

밸류 타입 

ShippingInfo 클래스는 받는 사람과 주소에 대한 데이터를 갖고 있다.

밸류 타입 Address, Receiver 클래스를 사용한 모습이다. 배송정보가 받는 사람과 주소로 구성된다는 것을 쉽게 알 수 있다. 

public class ShippingInfo {
    private Address address;
    private Receiver receiver;
}
  • Receiver : '받는 사람' 이라는 도메인 개념
  • Address : '주소' 라는 도메인 개념 

 

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

 

OrderLine.java
public class OrderLine {
    private Money price;
    private Money amounts;
    ...
}

public class Money {

    private int value;

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

    public int getValue() {
        return value;
    }
    ...
}

'돈'을 의미하는 Money 타입을 만들어서 사용하면 코드를 이해하는데 도움이 된다.

 

밸류 타입의 또다른 장점은 밸류 타입을 위한 기능을 추가할 수 있다느 것이다. 예를들어, Money 타입은 다음과 같이 돈 계산을 위한 기능을 추가할 수 있다.

 

Money.java
public class Money {

    private int value;

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

    public int getValue() {
        return value;
    }

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

1) new Money(value * multiplier)

밸류 객체의 데이터를 변경할 때는 기존 데이터를 변경하기보다는 변경할 데이터를 갖는 새로운 밸류 객체를 생성하는 방식을 선호한다.

새로운 Money 객체를 생성하여 결과를 return 한다. 

 

2) value를 변경할 수 있는 메서드 없음

Money 처럼 데이터 변경 기능을 제공하지 않는 타입을 불변(immutable)이라고 표현한다. 밸류 타입을 불변으로 구현하는 여러 이유가 있는데 가장 중요한 이유는 안전한 코드를 작성할 수 있다는데 있다.

 

 

엔티티 식별자와 밸류 타입

엔티티 식별자의 실제 데이터는 String과 같은 문자열로 구성된 경우가 많다. 신용카드 번호, 이메일 등도 문자열이다.

Money가 단순 숫자가 아닌 도메인의 '돈'을 의미하느 것처럼 이런 식별자는 단순한 문자열이 아니라 도메인에서 특별한 의미를 지니는 경우가 많기 때문에 식별자를 위한 밸류 타입을 사용해서 의미가 잘 드러나도록 할 수 있다. 예를들어 주문번호를 표현하기 위해 Order의 식별자 타입으로 String 대신 OrderNo 밸류 타입을 사용하면 타입을 통해 해당 필드가 주문번호라는 것을 알 수 있다.

// 필드의 의미가 더 분명하게 보인다!
public class Order {
    private OrderNo number;
    ...
}

public class Order {
    private String number;
    ...
}

 

 

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

get/set 메서드를 습관적으로 추가할 때가 있다. 

도메인 모델에 get/set 메서드를 무조건 추가하는 것은 좋지 않은 버릇이다. 특히 set 메서드는 도메인의 핵심 개념이나 의도를 코드에서 사라지게 한다. Order의 메서드를 다음과 같이 set 메서드를 변경해보자.

 

Order.java
public class Order {
    ...

    public void setShippingInfo(ShippingInfo shippingInfo) {
        if (shippingInfo == null) throw new IllegalArgumentException("no shipping info");
        this.shippingInfo = shippingInfo;
    }
    
    public void setOrderState(OrderState state) { ...}
}

 

setShippingInfo() 메서드는 단순히 배송지 값을 설정한다는 것을 의미한다.

setOrderState()는 단순히 주문 상태 값을 설정한다는 것을 의미한다. 

 

setOrderState()는 단순히 상태값만 변경할지 아니면 상태값에 따라 다른 처리를 위한 코드를 함께 구현할지 애매하다. 습관적으로 작성한 set 메서드는 필드값만 변경하고 끝나기 때문에 상태 변경과 관련된 도메인 지식이 코드에서 사라지게된다.

 

set 메서드의 또다른 문제점은 도메인 객체를 생성할때 온전하지 않은 상태가 될 수 있다.
// set 메서드로 데이터를 전달하도록 구현하면, 처음 Order를 생성하는 시점에 order는 완전하지 않다.
Order order = new Order();

// set 메서드로 필요한 모든 값을 전달해야한다.
setOrderLines(orderLines);
setShippingInfo(shippingInfo);

// 주문자(Orderer)를 설정하지 않은 상태에서 주문 완료 처리
order.setState(OrderState.PREPARING);

주문자 설정이 누락된 코드다.

주문자 정보를 담고있는 필드인 orderler가 null인 상황에서 setState() 메서드를 호출하여 상태값을 변경하였다. 이걸 해결하고자, setState() 메서드에 orderer 필드의 null 체크를 넣는 것도 맞지 않다.

 

도메인 객체가 불완전한 상태로 사용되는 것을 막으려면 생성 시점에 필요한 것을 전달해주어야 한다. 즉, 생성자를 통해 필요한 데이터를 모두 받아야한다.

Order oder = new Order(orderer, lines, shippingInfo, OrderState.PREPARING);

 

생성자로 필요한 것을 모두 받으므로 다음처럼 생성자를 호출하는 시점에 필요한 데이터가 올바른지 검사할 수 있다.

Order.java
public class Order {
    ...

    public Order(OrderNo number, Orderer orderer, List<OrderLine> orderLines,
                 ShippingInfo shippingInfo, OrderState state) {
        setNumber(number);
        setOrderer(orderer);
        setOrderLines(orderLines);
        setShippingInfo(shippingInfo);
        this.state = state;
        this.orderDate = LocalDateTime.now();
        Events.raise(new OrderPlacedEvent(number.getNumber(), orderer, orderLines, orderDate));
    }

    private void setNumber(OrderNo number) {
        if (number == null) throw new IllegalArgumentException("no number");
        this.number = number;
    }

    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 = new Money(orderLines.stream()
                .mapToInt(x -> x.getAmounts().getValue()).sum());
    }

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

'private' 메서드임에 주목하자.

이 코드의 set 메서드는 클래스 내부에서 데이터를 변경할 목적으로 사용된다. private 이기 때문에 외부에서 데이터를 변경할 목적으로 set 메서드를 사용할 수 없다.

 

불변 밸류 타입을 사용하면 자연스럽게 밸류 타입에는 set 메서드를 구현하지 않는다.

sret 메서드를 구현해야할 특별한 이유가 없다면 불변 타입의 장점을 살릴 수 있도록 밸류 타입은 불변으로 구현한다.

 

 

반응형

Designed by JB FACTORY