본문 바로가기

JAVA/DDD

엔티티와 밸류

반응형

엔티티와 밸류

도출한 모델은 크게 엔티티(Entity)와 밸류(Value)로 구분한다.
엔티티와 밸류를 제대로 구분해야 도메인을 올바르게 설계하고 구현할 수 있다.

엔티티
엔티티의 가장 큰 특징은 식별자를 갖는다.
식별자는 엔티티의 객체마다 고유해서 각 엔티티는 서로 다른 식별자를 갖는다.
예)
주문 도메인에서 각 주문은 주문번호를 갖는데 이 주문번호는 각 주문마다 서로 다르다.
주문번호가 주문의 식별자가 된다.
주문 도메인 모델에서 주문에 해당하는 클래스가 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;

}


엔티티의 식별자 생성
엔티티의 식별자를 생성하는 시점은 도메인의 특징과 사용하는 기술에 따라 달리진다.
- 특정규칙에 따라 생성
- UUID 사용
- 값을 직접 입력
- 일련번호 사용(시퀀스나 DB자동증가 칼럼 사용)

주문번호, 운송장번호, 카드번호와 같은 식별자는 특정 규칙에 따라 생성한다.
이 규칙은 도메인에 따라 다르고, 같은 주문번호라도 회사마다 다르다.
흔히 사용하는 규칙은 현재 시간과 다른값을 함께 조합하는 것이다.
책의 주문번호가 20171215095034
2017년 12월 15일 09시 50분 34초의미 한다.
시간을 이용해서 식별자를 생성 할때 주의할 점은 같은 시간에 동시에 식별자를 생성하도록 같은 식별자가 만들어지면 안된다.

UUID(universally unique identifier)를 사용해서 식별자를 생성 할 수 있다.
UUID uuid = UUID.randomUUID();
// bf566a24-f982-49c0-8dc5-0563be839237
String strUuid = uuid.toString();

회원의 아이디나 이메일과 강튼 식별자는 값을 직접 입력한다.
사용자가 직접 입력하는 값이기 때문에 식별자를 중복해서 입력하지 않도록 사전에 방지하는 것이 중요하다.

일련번호 방식은 주로 데이터베이스가 제공하는 자동 증가 기능을 사용한다.
오라클 시퀀스를 이용해서 자동 증가 식별자를 구하고 MySQL을 자동증가 칼럼 Auto_increment를 이용해서 일련번호 식별자를 생성한다.

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

String orderNumber = orderRepository.generate();

Order order = new Order(orderNumber, ...);
orderRepository.save(order);

자동증가 칼람은 DB 테이블에 데이터를 삽입해야 비로소 값을 알 수 있기 때문에 테이블에 데이터를 추가하기전에 식별자를 알 수 없다.
이는 엔티티 객체를 생성할 때 식별자를 전달 할 수 없을 뜻한다.

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

리포지터리(Repository)는 도메인 객체를 데이터베이스에 저장할 때 사용하는 구성요소이다.
자동증가 칼럼을 사용할 경우 리포지터리는 DB가 생성한 식별자를 구해서 엔티티 객체에 반영하게 된다.

밸류타입

public class ShippingInfo {

  private String receiverName;

  private String receiverPhoneNumber;


  private String shippingAddress1;

  private String shippingAddress2;

  private String shippingZipcode;

}

ShippingInfo 클래스의 receiverName 필드와 receiverPhoneNumber 필드는 서로 다른 두 데이터를 담고 있지만 두 필드는 개념적으로 받는 사람을 의미한다.
두 필드는 실제로 한 개의 개념을 표현하고 있다.
비슷하게 shippingAddress1 필드 shippingAddress2 필드, shippingZipcode 필드는 주소라는 하나의 개념을 표현한다.

밸류타입은 개념적으로 완전한 하나를 표현할 때 사용한다.
받는 사람을 위한 밸류 타입은 Receiver를 만들수 있다.

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;

  }

}

Receiver는 받는 사람이라는 도메인 개념을 표현한다.
ShippingInfo의 receiverName 필드와 receiverPhoneNumber 필드가 필드 이름을 통해서 받는 사람을 위한 데이터라는 것을 유추한다면 Receiver는 그 자체로 받는 사람을 뜻한다.

밸류 타입을 사용함으로써 개념적으로 완전한 하나를 잘 표현할 수 있는 것이다.


ShippingInfo의 주소 관련 데이터도 다음의 Address 밸류타입을 사용해서 보다 명확하게 표현할 수 있다.

public class Address {

  private String address1;

  private String address2;

  private String zipCode;

}

밸류타입을 통해 ShippingInfo 클래스를 다시 구현해보자

public class ShippingInfo {

  private Receiver receiver;

  private Address address;

}

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

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.value = value;

}

public int getValue() {

return value;

}

}

Money를 사용하도록 OrderLine을 변경한다.
Money타입 덕에 price나 amounts가 금액을 의미한다는 것을 쉽게 알수 있다.

public class OrderLine {

  private Product product;

  private Money price;

  private int quantity;

  private Money amounts;

}

밸류타입을 사용할 때의 또 다른 장점은 밸류 타입을 위한 기능을 추가 할 수 있다.
Money타입은 돈 계산을 위한 기능을 추가할 수 있다.

public class Money {

private int value;


public Money(int value) {

this.value = value;

}


public int getValue() {

return value;

}


public Money add(Money money) {

return new Money(this.value + money.value);

}


public Money multiply(int multiplier) {

return new Money(value * multiplier) ;

}

Money를 사용하는 코드는 이제 '정수 타입 연산'이 아니라 '돈 계산'이라는 의미로 코드를 작성할 수 있게 된다.
코드의 가독성 향상

밸류 객체의 데이터를 변경할 때는 기존 데이터를 변경하기보다는 변경한 데이터를 갖는 새로운 밸류 객체를 생성하는 방식으로 선호한다.
Money 클래스의 add()메서드를 보면 Money를 새로 생성하고 있다.

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

OrderLine을 생성하러면 Money객체를 전달해야한다.
OrderLine line = new OrderLine(product, price, quantity);
//만약 price.setValue(0)로 값을 변경할 수 있다면???

그런데 만약 Money가 setValue()와 같은 메서드를 제공해서 값을 변경할수 있다면 어떻게 될까?
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]
참조 투명성과 관련된 문제

이런 문제가 발생하지 않도록 하려면 OrderLine 생성자는 새로운 Money 객체를 생성하도록 코드를 작성해야 한다.

public OrderLine(Product product, Money price, int quantity) {

  this.product = product;
  // Money가 불변 객체가 아니라면
  // price 파라미터가 변경 될때 발생하는 문제를 방지 하기 위해
  // 데이터를 복사한 새로운 객체를 생성해야 한다.
  this.price = new Money(price.getValue());
  this.quantity = quantity;
  this.amounts = calculateAmounts();
}

Money가 불변이면 이런 코드를 작성할 필요가 없다.
Money의 데이터를 바꿀 수 없기 때문에 파라미터로 전달 받은 price는 안전하게 사용 할 수 있다.

엔티티 타입의 두 객체가 같은지 비교할 때 주로 식별자를 사용한다면 두 밸류 객체가 같은지 비교할 때는 모든 속성이 같은지 비교해야한다.

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

  @Override
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과 같은 문자열로 구성된 경우가 많다.
신용카드번호도 16개의 숫자로 구성된 문자열이며, 많은 온라인 서비스에서 회원을 구분할 때도 사용하는 이메일 주소도 문자열이다.

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

public class Order {

  // OrderNo 타입 자체로 id가 주문번호임을 알 수 있다.
  private OrderNo id;


  public OrderNo getId() {
    return id;
  }
}

OrderNo 대신 String 타입을 사용한다면 'id'라는 이름만으로는 해당 필드가 주문번호인지 여부를 알수 없다.

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


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

get/set 메서드는 습관적으로 추가하는 메서드이다. 사용자 정보를 담는 UserInfo 클래스를 작성할 때 get/set 메서드를 습관처럼 작성하곤 한다.

public class UserInfo {

private String id;
private String name;

public UserInfo() {
}

public String getId() {
return id;
}
public void setId(String id) {
this.id = id;
}
public String getName() {
return name;
}
public void setName(String name) {
this.name = name;
}

}

get/set 메서드를 습관적으로 만드는 이유는 여러가지가 있겠지만 가장 큰 이유라면 프로그래밍에 입문할 때 읽은 책의 예제 코드 때문이라고 생각한다.
처음 프로그래밍을 배울 때 익힌 예제 코드를 그대로 따라하다보니 상황에 관계없이 get/set 메서드를 습관적으로 추가하는 것이다.
도메인 모델에 get/set 메서드를 무조건 추가하는 것은 좋지 않는 버릇이다.
특히 set 메서드는 도메인의 핵심 개념이나 의도를 코드에서 사라지게 한다.

public class Order {

private String orderNumber;

private OrderState OrderState;
private ShippingInfo shippingInfo;

public void setOrderState(OrderState state) {
this.state = state;
}

public void setShippingInfo(ShippingInfo shippingInfo) {
this.shippingInfo = shippingInfo;
}

changeShippingInfo()가 배송지 정보를 새로 변경한다는 의미를 가졌다면 setShippingInfo()메서드는 단순히 배송지 값을 설정한다는 것을 뜻한다.
complatePayment()는 결제가 완료 햇다는 의미를 갖는 반면에 setOrderState()는 단순히 주문 상태값을 설정한다는 것을 뜻한다.
구현할 때에도 complatePayment()는 결제 완료와 관련된 처리 코드를 함께 구현하기 때문에 결제 완료와 관련된 도메인 지식을 코드로 구현하는 것이 자연스럽다.
setOrderState()는 단순한 상태 값만 변경할지 아니면 상태 값에 따라 다른 처리를 위한 코드를 함께 구현할지 애매하다.
습관적으로 코드를 작성하는 경우라면 필드 값만 변경하고 끝나는 경우가 많기 때문에 상태 변경과 관련된 도메인 지식이 코드에서 사라지게 된다.

set메서드의 또 다른 문제는 도메인 객체를 생성할 때 완전한 상태가 아닐 수도 있다는 것이다.

//set 메서드로 데이터를 전달하도록 구현하면
// 처음 Order를 생서하는 시점에 order는 완전하지 않다.
Order order = new Order();

// set 메서드로 피료한 모든 값을 전달해야 함
order.setOrderLine(lines);
order.setShippingInfo(shippingInfo);

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

주문자를 설정하는 것을 누락하고 있다. 주문자 정보를 담고 있는 필드인 orderer가 null인 상황에서 order.setState() 메서드로 상품 준비 중 상태로 바뀌는 것이다.
orderer가 정상인지 확인하기 위해 orderer가 null인지 검사하는 코드를 setState()메서드에 위치하는 것도 맞지 않다.
도메인 객체가 불완전한 상태로 사용되는 것을 막으려면 생성 시점에 필요한 것을 전달해 주어야 한다.
즉 생성자를 통해 필요한 데이터를 모두 받아야 한다.

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

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

 public class Order {
   
    private OrderState state;
    private List<OrderLine> orderLines;
    private int totalAmounts;

    private ShippingInfo shippingInfo;
    private Orderer orderer;

    public Order(Orderer orderer, List<OrderLine> orderLines, ShippingInfo shippingInfo, OrderState state) {
      setOrderer(orderer);
      setOrderLines(orderLines);
      setShippingInfo(shippingInfo);
    }

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

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

    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메서드는 앞서 set 메서드와 중요한 차이점이 있는데 그것은 바로 접근 범위가 private라는 점이다.
set메서드는 클래스 내부에서 데이터를 변경할 목적으로 사용된다. private이기 때문에 외부에서 데이터를 변경할 목적으로 set 메서드를 사용할 수 없다.
불변 밸류 타입을 사용하면 자연스럽게 밸류 타입에는 set메서드를 구현하지 않는다.
set 메서드를 구현해야 할 특별한 이유가 없다면 불변 타입의 장점을 살릴 수 있도록 밸류 타입은 불변으로 구현한다.


도메인 용어
코드를 작성할 때 도메인에서 사용하는 용어는 매우 중요하다.
도메인에서 사용하는 용어를 코드에 반영하지 않으면 그 코드는 개발자에게 코드의 의미를 해석해야하는 부담을 준다.

public OrderState {

  STEP1, STEP2, STEP3, STEP4, STEP5, STEP6
}

실제 주문 상태는 '결제 대기중', '상품준비중', '출고 완료됨', '배송 중', '배송완료됨','주문 취소됨'인데
이 코드는 개발자가 전체 상태를 6단계를 보고 코드로 표현한 것이다.
이 개발자는 Order 코드를 다음과 같이 작성할 가능성이 높다.

public void changeShippingInfo(ShippingInfo newShippingInfo) {

  verifyStep1OrStep2();
  setShippingInfo(newShippingInfo);
}

private void verifyStep1OrStep2() {
if(state != OrderState.STEP1 && state != OrderState.STEP2) {
  throw new IllegalStateException("aleady shipped");
}

배송지 변경은 '출고 전'에 가능한데, 이 코드의 verifyStep1OrStep2라는 이름은 도메인의 중요 규칙이 드러나지 않는다.
그저 STEP1과 STEP2인지 검사만 할 뿐이다.
실제 이 코드의 의미를 이해하려면 STEP1과 STEP2가 각각 결제 대기중 상태와 상품 준비중 상태를 의미한다는 것을 알아야한다.

public enum OrderState {

PAYMENT_WATING, PREPARING, SHIPPED, DELIVERING, DELIVERY_COMPLATED, CANCELD;
}

코드를 도메인 용어로 해석하거나 도메인 용어를 코드로 해석하는 과정이 줄어든다.
코드의 가독성을 높여서 코드를 분석하고 이해하는 시간을 절약된다.


DDD START! 도메인주도설꼐구현과 핵심개념 익히기 저자-최범균






반응형

'JAVA > DDD' 카테고리의 다른 글

트랜잭션 범위  (0) 2017.12.23
Aggregate 애그리거트  (0) 2017.12.19
아키텍처  (0) 2017.12.18
도메인 모델 도출  (0) 2017.12.15
DDD - 도메인 모델 패턴  (0) 2017.12.15