[DDD] 엔티티와 벨류

오랜만에 DDD 글을 쓴다. 원래 이번달까지 다 읽기로 했는데..ㅠㅠ 이제 첫장 다 읽어서..나원

엔티티와 벨류

도출한 모델은 크게 엔티티와 벨류로 구분할 수 있다. 앞서 만든 모델에도 엔티티와 벨류가 존재 한다.

엔티티

엔티티의 가장 큰 특징은 식별자를 갖는다는 점이다. 식별자는 엔티티 객체마다 고유해서 각 엔티티는 서로 다른 식별자를 갖는다. 예를 들어, 주문 도메인에서 각 주문은 주문번호를 갖는데 이 주문번호는 각 주문마다 서로 다르다. 따라서 주문번호는 식별자가 된다.
주문에서 배송지 주소가 바뀌거나 상태가 바뀌더라도 주문번호가 바뀌지 않는 것처럼 엔티티의 식별자는 바뀌지 않는다. 엔티티를 생성하고 엔티티의 속성을 바꾸고 엔티티를 삭제할 때까지 식별자는 유지된다.

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

public class Order {
  private String orderNumber;

  @Override
  public boolean equals(Object o) {
    if (this == o) return true;
    if (!(o instanceof Order)) return false;
    Order order = (Order) o;
    return Objects.equals(orderNumber, order.orderNumber);
  }

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

위의 코드는 인텔리j에서 기본으로 만들어준 코드이다.

엔티티의 식별자 생성

엔티티의 식별자를 생성하는 시점은 도메인의 특징과 사용하는 기술에 따라 달라진다. 흔히 식별자는 다음 중 한가지 방식으로 생성한다.
– 특정 규칙에 따라 생성
– UUID 사용
– 값을 직접 입력
– 일련번호 사용(시퀀스, 자동증가 등등)

  1. 특정 규칙
    주문번호, 운송장번호, 카드번호와 같은 식별자는 특정 규칙에 따라 생성한다. 이 규칙은 도메인에 따라 다르고, 같은 주문번호라도 회사마다 다르다. 흔히 사용하는 규칙은 현재 시간과 다른 값을 조합하는 것이다. 예를들어 20150529091113302918 인데 이 주문번호의 앞 번호인
    20150529091113는 현재 시간을 의미한다.

  2. UUID
    UUID를 사용해서 식별자를 생성할 수 있다. 다수의 개발언어가 UUID 생성기를 제공하고 있으므로 마땅한 규칙이 없다면 UUID를 식별자로 사용해도 된다. 자바의 경우 java.util.UUID 클래스를 사용하면 UUID를 생성할 수 있다.

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

  4. 일련번호 사용
    식별자로 일련번호를 사용하기도 한다. 예로 다음(daum)의 한 게시글 URL의 일부 발췌한 것이다.

.../sports/soccer/netize/...?articleID=693254&...

일련번호 방식은 주로 데이터베이스가 제공하는 자동증가 기능을 사용한다. 오라클은 시퀀스 Mysql은 자동증가.. 기타 다른 db도 비슷하다.
자동증가 컬럼을 제외한 다른 방식은 다음과 같이 식별자를 먼저 만들고 엔티티 객체를 생성할 때 식별자를 전달할 수 있다.
하지만 자동증가 컬럼은 DB에 데이터를 삽입해야 비로소 값을 알 수 있기 때문에 테이블에 데이터를 추가하기 전에는 식별자를 알 수 없다. 이는 엔티티 객체를 생성 할 때 식별자를 전달할 수 없음을 뜻한다.

벨류 타입

벨류 타입은 개념적으로 완전한 하나를 표현할 때 사용한다. 예를들어 받는 사람을 위한 밸류타입인 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는 받는 사람 이라는 도메인 개념을 표현한다. receiverName필드와 receiverPhoneNumber 필드가 필드 이름을 통해서 받는 사람을 위한 데이터라는 것을 유처한다면, Receiver는 그 자체로 받는 사람을 뜻한다.

벨류 타입을 사용할 때의 또다른 장점은 밸류타입을 위한 기능을 추가 할 수 있다. 예로 money 타입은 다음과 같이 돈 계산을 위한 기능을 추가할 수 있다.

public class Money {
  private int value;

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

  public Money add(Money money){
    return new Money(this.value * money.value);
  }

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

  public int getValue() {
    return value;
  }
}

Money처럼 데이터 변경 기능을 제공하지 않는 타입을 불변이라고 한다. 벨류타입을 불변으로 구현하는 이유는 여러 가지가 있는데 가장 중요한 이유는 불변 타입을 사용하면 보다 안전한 코드를 작성할 수 있다.
그런데 만약 Money가 setValue()와 같은 메서드를 제공해서 값을 변경할 수 있다면 어떻게 될까?

Money price = new Money(100);
OrderLine orderLine = new OrderLine(product, price, quantity); // price = 100
price.setValue(200);                                           // price = 200

참조 투명성과 관련된 문제

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

private String name;
private String phoneNumber;

@Override
public boolean equals(Object o) {
  if (this == o) return true;
  if (!(o instanceof Receiver)) return false;
  Receiver receiver = (Receiver) o;
  return Objects.equals(name, receiver.name) &&
    Objects.equals(phoneNumber, receiver.phoneNumber);
}

@Override
public int hashCode() {
  return Objects.hash(name, phoneNumber);
}

엔티티 식별자와 밸류 타입

엔티티 식별자의 실제 데이터는 String과 같은 문자열로 구성된 경우가 많다. 신용카드 번호, 회원 이메일등 모두 문자열이다.
Money가 단순 숫자가 아니니 도메인의 을 의미하는 것처럼 이런 식별자는 단순한 문자열이 아니라 도메인에서 특별한 의미를 지니는 경우가 많기 때문에 식별자를 위한 밸류 타입을 사용해서 의미가 잘 드러나도록 할 수 있다.

public class Order {

  private OrderNo id;

  public OrderNo getId() {
    return id;
  }
}

식별자 타입으로 String 대신 OrderNo 밸류타입을 사용하면 타입을 통해 해당 필드가 주문번호라는 것을 알 수 있다.

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

도메인 모델에서는 get/set 메서드를 무조건 추가하는 것은 좋지 않은 버릇이다. 특히 set메서드는 도메인의 핵심 개념이나 의도를 코드에서 사라지게 한다.
set 메서드는 여러 문제점이 있는데 그중에 하나가 도메인 객체를 생성할 때 완전한 상태가 아닐 수도 있다는 것이다.

//완전하지 않는 Order
Order order = new Order();
order.setOrderLine(line);
order.setShippingInfo(info);
//주문자(orderer)를 설정하지 않은 상태애서 완료
order.setState(OrderState.PREPARING);

위 처럼 주문자를 설정하는 것을 누락 하고 있다. 주문자가 정상인지 확인하기 위해 orderer가 null인지 검사하는 코드를 setState()에 위치하는 것도 맞지 않다.

위와 같은 불완전한 상태로 사용되는 것을 막으려면 생성 시점에 필요한 것을 전달해 주어야 한다.

Order order = new Order(orderer, line, sinfo, OrderState.PREPARING);

그럼 다음처럼 생성자를 호출 시점에 필요한 데이터가 올바른지 검사 할 수 있다.

public Order(Orderer orderer, List<OrderLine> line, ShippingInfo info, OrderState state){
  //...
  //...
}

이렇게 우리는 엔티티와 벨류에 대해서 알아봤다. 쉽지 않구만….

[DDD] 도메인 모델 (1)

DDD 의 첫번째 시간이다.

도메인

도메인이란 무엇인가? 온라인 서점을 예를 들어보자. 어떤 책이 나왔는지 검색을하고 평가로 하고 장바구니에 담고 구매도 한다. 구매를 할 때는 카드를 사용하기도 하고 가상계좌을 사용하기도 한다.
개발자 입장에서 온라인 서점은 구현해야 할 소프트웨어이다. 책을 판매하는데 조회, 구매, 결제, 배송등의 기능을 제공해야 하는데 이때 온라인서점은 소프트웨어로 해결하고자 하는 문제영역, 즉 도메인에 해당된다.
특정 도메인을 위한 소프트웨어라고 해서 도메인이 제공해야 할 모든 것을 구현해야 하는 것은 아니다. 배송시스템을 구축하기보다는 외부 배송업체의 시스템을 사용하고 필요한 기능만 일부 연동한다.

도메인 모델

도메인 모델에는 다양한 정의가 존재하는데 기본적으로 도메인 모델은 특정 도메인을 개념적으로 표현한 것이다. 예를 들어 주문도메인을 생각해보자. 예를 들어 쇼핑몰에서 주문을 하려면 상품을 몇개 살지 선택하고 배송지를 입력한다. 선택한 상품의 총 지불 금액을 계산하고 결제 수단을 선탥한다. 주문한 뒤에도 배송 전이면 배송지 주소를 변경하거나 주문을 취소할 수도 있다.
도메인을 이해하려면 도메인이 제공하는 기능과 도메인의 주요 데이터 구성을 파악해야 하는데 이런면에서 기능과 데이터를 함께 보여주는 객체 모델은 도메인을 모델링하기 적합하다. 도메인 모델을 객체로만 모델링할 수 있는 것은 아니다. 상태다이어그램을 이용해도 상관없다.
도메인 모델은 기본적으로 도메인 자체를 이해하기 위한 개념 모델이다. 개념 모델을 이용해서 바로 코드를 작성할 수 있는 것은 아니기에 구현 기술에 맞는 구현 모델이 따로 필요 하다. 개념 모델과 구현 모델은 서로 다른 것이지만 구현 모델이 개념 모델을 최대한 따르도록 할 수는 있다. 예를 들어 객체 기반 모델을 이용해서 도메인을 표현했다면 객체 지향 언어를 이용해서 개념 모델에 가깝게 구현할 수 있다.

도메인 모델 패턴

일반적으로 어플리케이션의 아키텍처는 네개의 계층으로 구성된다.
1. 사용자 인터페이스 : 사용자의 요청을 처리하고 사용자에게 정보를 보여준다. 여기서 사용자는 소프트웨어를 사용하는 사람뿐만 아니라 외부 시스템도 사용자가 될 수 있다.
2. 응용 : 사용자가 요청한 기능을 실행한다. 업무 로직을 직접구현하지 않으며 도메인 계층을 조합해서 기능을 실행한다.
3. 도메인 : 시스템이 제공할 도메인 규칙을 구현한다.
4. 인프라스트럭처 : 데이터 베이스나 메시징 시스템과 같은 외부 시스템과의 연동을 처리한다.

도메인 계층은 도메인의 핵심 규칙을 구현한다. 주문 도메인의 경우 출고 전에 배송지를 변경할 수 있다. 는 규칙과 주문 취소는 배송 전에만 할 수 있다.는 규칙을 구현한 코드가 도메인 계층에 위치하게 된다.

public class Order {

  private OrderState state;
  private ShippingInfo shippingInfo;

  public void changeSippingInfo(ShippingInfo newShippingInfo){
    if(state.isShippingChangeable()){
      throw new IllegalStateException("can't change shipping in " + state);
    }
    this.shippingInfo = newShippingInfo;
  }

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

public enum OrderState {

  PAYMET_WATTING {
    public boolean isShippingChangeable(){
      return true;
    }
  },
  PREPARING {
    public boolean isShippingChangeable(){
      return true;
    }
  },
  SHIPPED, DELIVERING, DELIVERY_COMPLETED;

  public boolean isShippingChangeable(){
    return false;
  }
}

이 코드는 주문 도메인의 일부 기능을 도메인 모델 패턴으로 구현한 것이다. 주문상태를 표현하는 OrderState는 배송지를 변경할 수 있는지 여부를 검사할 수 있는 isShippingChangeable() 메서드를 제공하고 있다.
주문대기(PAYMET_WATTING) 상태와 상품 준비중(PREPARING) 상태의 isShippingChangeable() 메서드는 true를 리턴한다.
즉 OrderState는 주문대기거나 상품 준비중에는 배송지를 변경할 수 있다는 도메인 규칙을 구현하고 있다. 실제 배송지 정보를 변경하는 Order클래스의 changeSippingInfo() 메서드는 OrderState의 isShippingChangeable() 메서드를 이용해서 변경 가능여부를 확인한 후 변경 가능한 경우에만 배송지를 변경한다.

큰 틀에서 보면 OrderState는 Order에 속한 데이터이므로 배송지 정보 변경 가능 여부를 판단하는 코드는 Order로 이동할 수 도 있다.

public class Order {

  private OrderState state;
  private ShippingInfo shippingInfo;

  public void changeSippingInfo(ShippingInfo newShippingInfo){
    if(state.isShippingChangeable()){
      throw new IllegalStateException("can't change shipping in " + state);
    }
    this.shippingInfo = newShippingInfo;
  }


  private boolean isShippingChangeable(){
    return state == OrderState.PAYMET_WATTING || state == OrderState.PREPARING;
  }
}

public enum OrderState {

  PAYMET_WATTING, PREPARING, SHIPPED, DELIVERING, DELIVERY_COMPLETED;
}

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

도메인 모델 도출

천재 개발자라 할지라도 도메인에 대한 이해 없이 코딩을 시작할 수는 없다. 요구사항과 관련자와의 대화를 통해 도메인을 이해하고 이를 바탕으로 도메인 모델 초안을 만들어야 비로소 코드를 작성할 수 있다. 모메인을 모델링할 때 기본이 되는 작업은 모델을 구성하는 핵심구성요소, 규칙, 기능을 찾는 것이다.
쇼핑몰 요구사항 중 출고 상태로 변경하기, 배송지 정보 변경하기, 주문 취소하기, 결제 완료로 변경하기 네 기능을 제공한다면 상세 구현까지 할 수 있는 수준은 아니지만 Order에 관련 기능을 메서드를 추가 할 수 있다.

public void changeShipped(){}
public void changeSippingInfo(ShippingInfo newShippingInfo){}
public void cancel(){}
public void completePayment(){}

다음 요구사항은 주문 항목이 어떤 데이터로 구성되는지 알려준다.
1. 한 상품을 한개 이상 주문할 수 있다.
2. 각 상품의 구매 가격 합은 상품가격에 구매 개수를 곱한 값이다.

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.amounts = calculateAmounts();
  }

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

  public int getAmounts(){

  }
}

Order와 OrderLine의 관계는 이렇다.
1. 최소 한 종류 이상의 상품을 주문해야 한다.
2. 총 주문 금액은 각 상품의 구매 가격 합을 모두 더한 값이다.

우리는 Order에 다음과 같이 반영할 수 있다.

public class Order {

  private List<OrderLine> orderLines;
  private int totalAmounts;

  public Order(List<OrderLine> orderLines) {
    this.orderLines = orderLines;
  }

  private void setOrderLines(List<OrderLine> orderLines){
    verifyAtLeastOneOrMoreOrderLines(orderLines);
    this.orderLines = orderLines;
    calculateTotalAmounts();
  }

  private void calculateTotalAmounts() {
    this.totalAmounts = new Money(orderLines.stream()
    .mapToInt(x -> x.getAmounts()).sum());
  }

  private void verifyAtLeastOneOrMoreOrderLines(List<OrderLine> orderLines) {
    if(orderLines == null || orderLines.isEmpty()){
      throw new IllegalArgumentException("no OrderLine");
    }
  }
}

verifyAtLeastOneOrMoreOrderLines() 메서드는 한개 이상 존재하는지 calculateTotalAmounts() 메서드는 총 주문 금액을 계산한다.

배송지 정보는 이름, 전화번호, 주소 데이터를 가지므로 ShippingInfo 클래스를 다음과 같이 정의 할 수 있다.

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

  //getter setter
}

주문할 때는 배송지 정보를 입력해야 된다. 그래서 Order를 생성할 때 ShippingInfo를 주가 하였다.

public class Order {

  private List<OrderLine> orderLines;
  private int totalAmounts;
  private ShippingInfo shippingInfo;

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

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

  private void setOrderLinse(List<OrderLine> orderLinse) {
    //...
  }
}

도메인을 구현하다 보면 특정 조건이나 상태에 따라 제약이나 규칙이 달리 적용되는 경우가 많다. 주문 요구사항의 경우에는 다음과 같다.
1. 출고를 하면 배송지 정보를 변경할 수 없다.
2. 출고 전에 주문을 취소할 수 있다.

출고 상태에 따라 배송지 정보 변경과 취소기능이 제약 받는다. 이 요구사항을 충족하려면 주문은 적어도 출고 상태를 표현할 수 있어야 한다.
다음 요구사항도 상태와 관련이 있다.
1. 고객이 결제를 완료하기 전에는 상품을 준비하지 않는다.

배송지 변경이나 주문 취소 기능은 출고 전에만 가능하다는 제약 규칙이 있으므로 이 규칙을 적용하기 위해 changeShippingInfo()와 cancel()은 veriyNotYetShipped() 메서드를 먼저 호출한다.

public class Order3 {
  private OrderState state;

  public void changeShippingInfo(ShippingInfo newShippingInfo){
    veriyNotYetShipped();
    setShippingInfo(newShippingInfo);
  }

  public void cancel(){
    veriyNotYetShipped();
    this.state = OrderState.CANCELED;
  }

  private void veriyNotYetShipped() {
    if(state != OrderState.PAYMET_WATTING && state != OrderState.PREPARING){
      throw new IllegalStateException("aleady shipped");
    }
  }
}

지금까지 주문과 관련된 요구사항에서 도메인 모델을 점진적으로 만들어 나갔다.
이렇게 아주 조금이지만 도메인에 대해 조금더 잘 알게 된거 같다.
중요한 부분만 서술 할려고 했는데 다 중요해서…ㅠㅠㅠ 다음 편은 좀더 줄여야겠다. 중간중간에 말이 안맞을 수도 있다. 필자가 중요하다고 생각하는 부분만 글로 남기니 만약 더 보고 싶다면 책을 구매하길 바란다. 강추!

DDD 도메인 주도 설계 구현

저번주에 최범균님의 DDD책 도메인 주도 설계 구현과 핵심 개념 익히기를 샀다.
몇 개월전부터 DDD에 관심이 있었고 한번 코드로 짜봤지만 영 시원찮고 맞는지도 모르는 아니 더 이상해진 코드를 보고 잠시 묵혀든 코드가 있다. 필자는 DDD 한개도 모르는 초짜다. 그런데 마침 DDD책이 나와서 샀다. 일단 최범균님이 쓴 책이니 더 볼 것도 없이 믿고 걍 질렀다. (최범균님 책이 2권이나 있어서 뭐.. 조금 옛날 책이긴 하지만)
아직 책은 읽지는 않았지만 대충 훓어봤는데 일단 맘에 쏙든다.
일단 그 이유는 DDD의 입문책이다. DDD를 한개도 모르는 나에게 아주 주옥같은 책인 것이다. 책에도 나와 있지만 3~4년 정도 개발 경험이 있으면 내용을 이해하는데 좋다고 하니 딱 나다. 하하하
더더더 맘에 드는 이유는 예제가 java, (최범균님이 뭐 자바 개발자이니) spring 그리고 jpa로 되어있어서 완전 좋다. 아마 코드를 읽는데는 어렵지 않을 것(?)으로 예상된다. 하지만 글을 이해하는게.. 잘 이해 할 수 있을지..
아무튼 틈틈히 읽어서 이번달안에는 정독으로 꼭 다 읽도록 해야겠다. 읽으면서 포스팅도 같이 해야지