도메인 주도 개발 스터디

Chapter 1 도메인 모델 시작하기

막이86 2023. 11. 8. 17:34
728x90

도메인 주도 개발 시작하기 1장을 요약한 내용입니다.

1.1 도메인이란?

  • 온라인 서점을 구현할 소프트웨어라고 봤을때 도메인이란?
    • 상품 조회, 구매, 결제, 배송 추적 등
  • 온라인 서점 도메인은 몇개의 하위 도메인으로 나눌 수 있다.
    • 카탈로그 하위 도메인은 고객에게 구매할 수 있는 상품 목록을 제공하고, 주문 하위 도메인은 고객의 주문을 처리
    • 혜택의 하위 도메인은 쿠폰이나 특별 할인과 같은 서비스 제공
  • 하위 도메인은 다른 하위 도메인과 연동하여 완전한 기능을 제공 한다.
    • 고객이 물건을 구매하면 주문, 결제, 배송, 혜택 하위 도메인의 기능이 엮이게 된다.
  • 특정 도메인을 위한 소프트웨어라고 해서 도메인이 제공해야 할 모든 기능을 직접 구현하는 것은 아님
    • 배송 도메인의 일부 기능은 자체 시스템으로 구현하고 나머지 기능은 외부 업체의 시스템을 사용
    • 결제 시스템도 결제 대행업체를 이용해서 처리할 때가 많다
  • 하위 도메인을 어떻게 구성할지 여부는 상황에 따라 달라진다.
    • 기업 고객을 대상으로 대형 장비를 판매하는 곳은 온라인으로 카탈로그를 제공하고 주문서를 받는 정도만 필요
    • 일반 고객을 대상으로 물건을 판매한다면 카탈로그, 리뷰, 주문, 결제, 배송, 회원 기능 등이 필요

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

  • 도메인 전문가는 해당 도메인에 대한 지식과 경험을 바탕으로 본인들이 원하는 기능 개발을 요구
    • 회계 담당자는 엑셀로 맞추던 정산 금액 계산을 자동화 해주는 기능 요구
    • AS 기사는 고객에게 보내느 문자 메세지를 빠르게 입력할 수 있는 템플릿 추천 기능 요구
  • 개발자는 요구사항을 분석하고 설계하여 코드를 작성하며 테스트하고 배포
    • 요구사항은 첫 단추와 같다
    • 첫 단추를 잘못 끼우면 모든 단추가 잘못 끼워지듯 요구사항을 올바르게 이해하지 못하면 엉뚱한 기능이 만들어진다.
    • 잘못 개발한 코드를 올바르게 고치려면 많은 노력이 필요
  • 개발자와 전문가가 직업 대화하는 것이 요구사항을 올바르게 이해할 수 있다
    • 개발자와 전문가 사이에 내용을 전파하는 전달자가 많으면 많을수록 왜곡되고 손실이 발생
  • 도메인 전문가 만큼은 아니겠지만 이해관계자와 개발자도 도메인 지식을 갖춰야 한다.

1.3 도메인 모델

  • 도메인 모델은 다양한 정의가 존재
  • 도메인 모델은 특정 도메인을 개념적으로 표현한 것
  • 주문 도메인을 주문 모델 객체 모델로 구성하면 그림과 같이 만들 수 있다.
    • 모델은 도메인의 모든 내용을 담고 있지는 않음
    • 여러 관계자들이 동일한 모습으로 도메인을 이해하고 도메인 지식을 공유하는데 도움이 됨
  • 도메인을 이해하려면 도메인이 제공하는 기능과 도메인의 주요 데이터 구성을 파악 해야함
  • 기능과 데이터를 함께 보여주는 객체 모델은 도메인을 모델링하기에 적합
  • 상태 다이어그램을 이용해서 주문의 상태 전이를 모델링 할 수 있다.
  • 도메인 모델을 표현할 때 클래스 다이어그램이나 상태 다이어그램과 같은 UML표기법만 사용해야하는 것은 아니다.
    • 도메인을 이해하는 데 도움이 된다면 표현 방식이 무엇인지는 중요하지 않음
  • 도메인 모델은 기본적으로 도메인 자체를 이해하기 위한 개념 모델
    • 개념 모델을 이용해서 바로 코드를 작성할 수 있는 것은 아님
  • 객체 기반 모델을 기반으로 도메인을 표현했다면 객체 지향 언어를 이용해 개념모델에 가깝게 구현할 수 있다.

1.4 도메인 모델 패턴

  • 일반적인 애플리케이션의 아키텍처는 네 개의 영역으로 구성된다
  • 각 영역의 역활영역 설명 
    사용자 인터페이스(UI) 또는 표현 사용자의 요청을 처리하고 사용자에게 정보를 보여준다. 사용자는 소프트웨어를 사용하는 사람뿐만 아니라 외부 시스템일 수 있다.
    응용 사용자가 요청한 기능을 실행한다. 업무 로직을 직접 구현하지 않으며 도메인 계층을 조합해서 기능을 실행한다.
    도메인 시스템이 제공할 도메인 규칙을 구현한다.
    인프라스트럭처 데이터베이스나 메시징 시스템과 같은 외부 시스템과의 연동을 처리한다.
  • 도메인 모델은 아키텍처 상의 도메인 계층을 객체 지향 기법으로 구현하는 패턴을 말한다.
  • 도메인 계층은 도메인의 핵심 규칙을 구현한다.
    • “출고 전에 배송지를 변경할 수 있다” 규칙과 “주문 취소는 배송 전에만 할 수 있다”라는 규칙을 구현한 코드가 도메인 계층에 위치하게 된다.
  • 도메인 규칙을 객체지향 기법으로 구현하는 패턴이 도메인 모델 패턴이다.
  • 주문 도메인의 일부 기능을 도메인 모델 패턴으로 구현한 코드
    • OrderState는 배송지를 변경할 수 있는지 검사할 수 있는 isShippingChangeable() 메서드를 제공
    • 주문 대기 중, 상품 준비 중 상태의 isShippingChangeable() 메서드는 true를 리턴
      • 주문 대기중이거나 상품 준비중에는 배송지를 변겨할 수 있다는 도메인 규칙을 구현
    • chagneShippingInfo() 메서드는 isshippingChangeable() 메서드를 이용해서 변경 가능한 경우에만 배송지를 변경한다.
    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 OrdderState {
    	PAYMENT_WAITING {
    		public boolean isShippingChangeable() {
    			return true;
    		}
    	},
    	PREPARING {
    		public boolean isShippingChangeable() {
    			return true;
    		}
    	},
    	SHIPPED, DELIVERING, DELIVERY_COMPLETED;
    
    	public boolean isShippingChangeable() {
    		return false;
    	}
    }
    
  • OrderState는 Order에 속한 데이터이므로 배송지 정보 변경 가능 여부를 판단하는 코드를 Ordre로 이동
    • 배송지 변경이 가능한지를 판단할 규칙이 주문 상태와 다른 정보를 함꼐 사용한다면 OrderState만으로는 배송지 변경 가능 여부를 판단할 수 없으므로 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 OrdderState {
    	PAYMENT_WAITING, PREPARING, SHIPPED, DELIVERING, DELIVERY_COMPLETED;
    }
    
  • 배송지 변경 가능 여부를 판단하는 기능이 Order에 있든 OrderState에 있든 중요한 점은 주문과 관련된 중요 업무 규칙을 주문 도메인 모델인 Order나 OrderState에서 구현한다는 점
  • 핵심 규칙을 구현한 코든느 도메인 모델에만 위치하기 때문에 규칙이 바뀌거나 규칙을 확장해야할 때 다른 코드에 영향을 덜 주고 변경 내역을 모델에 반영할 수 있다.

개념 모델과 구현 모델

  • 개념 모델은 데이터베이스, 트렌잭션 처리, 성능, 구현 기술과 같은 것을 고려하고 있지 않기 때문에 실제 코드를 작성할 때 개념 모델을 있는 그대로 사용할 수 없다.
  • 개념 모델을 만들 때 처음부터 완벽하게 도메인을 표현하는 모델을 만드는 시도를 할 수 있지만 실제로는 불가능하다.
  • 프로젝트 초기에 완벽한 도메인 모델을 만들더라도 결국 도메인에 대한 새로운 지식이 쌓이면서 모델을 보완하거나 변경하는 일이 발생한다.
  • 처음부터 완벽한 개념 모델을 만들기보다는 전반적인 개요를 알수 있는 수준으로 개념 모델을 작성해야한다.
  • 프로젝트 초기에는 개요 수준의 개념 모델로 도메인에 대한 전체 윤곽을 이해하는데 집중하고, 구현하는 과정에서 개념 모델을 구현 모델로 점진적으로 발전시켜 나가야 한다.

1.5 도메인 모델 도출

  • 뛰어난 개발자라 할지라도 도메인에 대한 이해 없이 코딩을 시작할 수는 없다.
    • 기획서, 유스케이스, 사용자 스토리보드와 같은 요구사항
    • 관련자와의 대화를 통해 도메인을 이해
  • 도메인에 대한 이해를 해야 도메인 모델 초안을 만들어야 코드를 작성할 수 있다.
  • 도메인 모델링할 때 기본이 되는 작업은 모델을 구성하는 핵심 구성요소, 규칙, 기능을 찾는 것
    • ex) 주문 도메인과 관련된 몇 가지 요구사항
      • 최소 한 종류 이상의 상품을 주문해야 한다.
      • 한 상품은 한 개 이상 주문할 수 있다.
      • 총 주문 금액은 각 상품의 구매 가격 합을 모두 더한 금액
      • 각 상품의 구매 가격 합은 상품 가격에 구매 개수를 곱한 값
      • 주문할 때 배송지 정보를 반드시 지정해야 한다.
      • 출고를 하면 배송지를 변경할 수 없다.
      • 출고 전에 주문을 취소할 수 있다.
      • 고객이 결제를 완료하기 전에는 상품을 준비하지 않는다.
    • 주문은 “출고 상태로 변경하기”, “배송지 정보 변경하기”, “주문 취소하기”, “결제 완료하기” 기능을 제공 해야한다.
      • Order 기능 관련 메서드
      • public class Order { public void changeShipped() {...} public void changeShippingInfo(ShippingInfo newShipping) {...} public void cancel() {...} public void completedPayment() {...} }
    • 주문 항목의 데이터 구성을 알려준다.
      • 한 상품은 한 개 이상 주문할 수 있다.
      • 각 상품의 구매 가격 합은 상품 가격에 구매 개수를 곱한 값
    • 주문 항목은 “주문할 상품”, “상품의 가격”, “구매 개수”를 포함 해야한다.
    • 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(); } private int calculateAmounts() { return price * quantity } public int getAmounts() {...} }
    • 주문과 주문 항목의 관계를 알려준다.
      • 최소 한 종류 이상의 상품을 주문해야 한다.
      • 총 주문 금액은 각 상품의 구매 가격 합을 모두 더한 금액이다.
      public class Order {
      	private List<OrderLine> orderLines;
      	private Money totalAmounts;
      
      	public Order(List<OrderLine> orderLines) {
      		setOrderLines(orderLines);
      	}
      
      	private void setOrderLines(List<OrderLine> orderLines) {
      		verifyAtLeastOneOrMoreOrderLine(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);
      	}
      	...
      }
      
    • 배송지 정보 클래스
    • public class ShippingInfo { private String receiverName; private String receiverPhoneNumber; private String shippingAddress1; private String shippingAddress2; private String shippingZipcode; ... }
    • “주문할 때 배송지 정보를 반드시 지정해야 한다” 요구사항은 주문을 생성할 때 주문항목의 목록뿐만 아니라 배송지 정보도 함께 전달 해야한다.
    • public class Order { private List<OrderLine> orderLines; 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; } ... }
    • 도메인의 특정 조건이나 상태에 따라 제약이나 규칙이 달리 적용되는 경우가 많다.
      • 출고를 하면 배송지 정보를 변경할 수 없다.
      • 출고 전에 주문을 취소할 수 있다.
      • 고객이 결제를 완료하기 전에는 상품을 준비하지 않는다.
    • 다른 요구사항을 확인해서 추가로 존재할 수 있는 상태를 분석한 뒤, 열거 타입을 이용해서 상태 정보를 표현할 수 있다.
    • 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 IllegalArgumentException("aleady shipped"); } } ... }
  • 만들어진 모델 요구사항을 도메인 전문가나 다른 개발자와 논의하는 과정에서 공유하기도 한다.
  • 모델을 공유할 때는 화이트보드나 위키와 같은 도구를 사용해서 누구나 쉽게 접근할 수 있도록 하면 좋다.

1.6 엔티티와 밸류

  • 요구사항에서 도출한 주문 도메인 모델은 크게 엔티티와 밸류로 구분된다.

1.6.1 엔티티

  • 엔티티의 가장 큰 특징은 식별자를 가진다는 것
  • 식별자는 엔티티 객체마다 고유해서 각 엔티티는 서로 다른 식별자를 갖는다.
  • 주문에서 배송지 주소가 바뀌거나 상태가 바뀌더라도 주문번호가 바뀌지는 않는 것처럼 엔티티의 식별자는 바뀌지 않는다.
    • 엔티티를 생성하고 속성을 바꾸고 삭제할 때까지 식별자는 유지된다.
  • 엔티티의 식별자는 바뀌지 않고 고유하기 때문에 두 엔티티 객체의 식별자가 같으면 두 엔티티는 같다고 판단할 수 있다.
    • 엔티티를 구현한 클래스는 식별자를 이용해서 equals() 메서드와 hasCode() 메서드를 구현할 수 있다.
    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;
    	}
    }
    

1.6.2 엔티티의 식별자 생성

  • 엔티티의 식별자를 생성하는 시점은 도메인의 특징과 사용하는 기술에 따라 달라진다.
    • 특정 규치에 따라 생성
    • UUID나 Nano ID와 같은 고유 식별자 생성기 사용
    • 값을 직접 입력
    • 일련번호 사용(시퀀스나 DB의 자동 증가 컬럼 사용)
  • 흔히 사용되는 규칙은 현재 시간과 다른 값을 함께 조합하는 것
    • 날짜와 시간을 이용해서 식별자를 생성할 때 주의할 점은 같은 시간에 동시에 식별자를 생성해도 같은 식별자가 만들어지면 안 된다는 것이다.
  • UUID를 사용해서 식별자를 생성할 수 있다.
    • 다수의 개발언어가 UUID 생성기를 제공하고 있으므로 마땅한 규칙이 없다면 UUID를 식별자로 사용
    • 자바는 java.util.UUID 클래스를 사용해서 UUID를 생성할 수 있다.
  • 회원의 아이디나 이메일과 같은 식별자는 값을 직접 입력한다.
    • 사용자가 직접 입력하는 값이기 때문에 식별자를 중복해서 입력되지 않도록 사전에 방지하는 것이 중요
  • 일련번호를 식별자로 사용하기도 한다.
    • 일련번호 방식은 주로 데이터베이스가 제공하는 자동 증가 기능을 사용한다.
    • 자동 증가 칼럼은 DB 테이블에 데이터를 삽입해야 비로소 값을 알수 있기 때문에 테이블에 테이터를 추가하기 전에는 식별자를 알수 없다.

1.6.3 밸류 타입

  • 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;
    	}
    }
    
    • ShippingInfo의 주소 관련 데이터도 다음의 Address 밸류 타입을 사용해서 보다 명확하게 표현할 수 있다.
    public class Address {
    	private String address1;
    	private String address2;
    	private String zipcode;
    
    	public Address(String address1, String address2, String zipcode) {
    		this.address1 = address1;
    		this.address2 = address2;
    		this.zipcode = zipcode;
    	}
    	...
    }
    
    • 밸류 타입을 이용해서 ShippingInfo 클래스를 다시 구현해보기
    public class ShippingInfo {
    	private Receiver receiver;
    	private Address address;
    
    	...
    }
    
  • 밸류 타입을 사용함으로써 개념적으로 완전한 하나를 잘 표현할 수 있다.
  • OrderLine의 price와 amounts는 int 타입의 숫자를 사용하고 있지만 이들은 ‘돈’을 의미하는 값이다.
    • Money 타입을 만들어 사용하면 코드를 이해하는데 도움이 된다.
    public class Money {
    	private int value;
    	
    	public Money(int value) {
    		this.value = value;
    	}
    
    	public int getValue() {
    		return this.value;
    	}
    }
    
    • Money를 사용하도록 OrderLine을 변경
    public class OrderLine {
    	private Product product;
    	private Money price;
    	private int quantity;
    	private Money amounts;
    	...
    }
    
  • 밸류타입의 또 다른 장점은 밸류 타입을 위한 기능을 추가할 수 있다.
    • Money 타입은 돈 계산을 위한 기능을 추가할 수 있다.
    public class Money {
    	private int value;
    	
    	public Money add(Money money) {
    		return new Money(this.value + money.getValue());
    	}
    
    	public Money multiply(int multiplier) {
    		return new Money(value * multiplier)
    	}
    }
    
  • 밸류 객체의 데이터를 변경할 때는 기존 데이터를 변경하기보다는 변경한 데이터를 갖는 새로운 밸류 객체를 생성하는 방식을 선호한다.
    • Money 처럼 데이터 변경 기능을 제공하지 않는 타입을 불변이라고 표현한다.
    • 밸류 타입을 불변으로 구현하는 여러 이유가 있는데 가장 중요한 이유는 안전한 코드를 작성할 수 있다는 데 있다.
    • Money가 setValue()와 같은 메서드를 제공해서 값을 변경할 수 있다면 어떻게 될까?
      • OrderLine의 price 값이 잘못 반영되는 상황이 발생하게 된다.
      Money price = new Money(1000);
      OrderLine line = new OrderLine(product, price, 2);   -> [price=1000, auantity=2, amount=2000]
      price.setValue(2000);                                -> [price=2000, auantity=2, amount=2000]
      
      • 문제가 발생하는 것을 방지하려면 OrderLine 생성자는 새로운 Money 객체를 생성하도록 코드를 작성 해야한다.
      public class OrderLine {
      	...
      	private Money price;
      	
      	public OrderLine(Product product, int quantity, Money price) {
      		this.product = product;
      		this.price = new Money(price.getValue());
      		this.quantity = quantity;
      		this.amounts = caculateAmounts();
      	}
      }
      
  • 두 밸류 객체를 비교할 때는 모든 속성이 같은지 비교한다.
  • public class Receiver { private String name; private String phoneNumber; @Override public boolean equals(Object obj) { if (this == obj) return true; if (obj == null) return false; if (obj.getClass() != Receiver.class) return false; Receiver that = (Receiver) obj; return this.name.equals(that.name) && this.phoneNumber.equals(that.phoneNumber); } }

1.6.4 엔티티 식별자와 밸류 타입

  • 엔티티 식별자의 실제 데이터는 String과 같은 문자열로 구성된 경우가 많다.
    • 신용카드 번호 16개의 숫자로 구성된 문자열
    • 회원을 구분할 때 사용하는 이메일 주소
  • Order의 식별자 타입으로 String 대신 OrderNo 밸류 타입을 사용하면 타입을 통해 해당 필드가 주문번호라는 것을 알수 있다.
    • OrderNo 대시에 String 타입을 사용한다면 ‘id’란 이름만으로는 해당 필드가 주문번호인지 알수 없다.
    • 필드의 의미가 드러나도록 하려면 ‘id’라는 필드 이름 대신 ‘orderNo’라는 필드 이름ㅇ르 사용해야 한다.

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

  • 도메인 모델에서 get/set 메서드를 무조건 추가하는 것은 좋지 않은 버릇이다.
  • set 메서드는 도메인의 핵심 개념이나 의도를 코드에서 사라지게 한다.
  • Order 메서드를 set메서드로 변경
    • changeShippingInfo()가 배송지를 새로 변경한다는 의미를 가졌다면 setShippingInfo() 메서드는 단순히 배송지 값을 설정한다는 것을 의미
    • completePayment()는 결제를 완료했다는 의미를 갖는 반면 setOrderState() 단순히 주문 상태 값을 설정한다는 것을 의미
    public class Order {
    	...
    	public void setShippingInfo(ShippingInfo newShipping) {...}
    	public void setOrderState(OrderState state) {...}
    }
    
  • set 메서드는 필드값만 변경하고 끝나기 때문에 상태 변경과 관련된 도메인 지식이 코드에서 사라지게 된다.
  • set 메서드의 또다른 문제는 도매인 객체를 생성할 때 온전하지 않는 상태가 될수 있다.
  • Order order = new Order(); order.setOrderLine(lines); order.setShippingInfo(shippingInfo); // 주문자를 설정하지 않은 상태로 주문 완료 처리 order.setState(OrderState.PREPARING);
  • 도메인 객체가 불완전한 상태로 사용되는 것을 막으려면 생성 시점에 필요한 것을 전달해 주어야 한다.
    • 생성자를 통해 필요한 데이터를 모두 받아야 한다.
    • 생성자로 필요한 것을 모두 받으므로 생성자를 호출하는 시점에 필요한 데이터가 올바른지 검사할 수 있다.
  • 불변 밸류 타입을 사용하면 자연스럽게 밸류 타입에는 set 메서드를 구현하지 않는다.
    • set 메서드를 구현해야 할 특별한 이유가 없다면 불변 타입의 장점을 살릴 수 있도록 밸류 타입은 불변으로 구현한다.

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

  • 코드를 작성할 때 도메인에서 사용하는 용어는 매우 중요하다
  • 도메인에서 사용하는 용어를 코드에 반영하지 않으면 그 코드는 개발자에게 코드의 이미를 해석해야 하는 부담을 준다.
    • ex) 주문 상태
      public class Order {
      	public void changeShippingInfo(ShippingInfo newShippingInfo) {
      		verifyStep1OrStep2();
      		setShippingInfo(newShippingInfo);
      	}
      
      	private void verifyStep1OrStep2() {
      		if (state != OrderState.STEP1 && state != OrderState.STEP2) {
      			throw new IllegalArgumentException("aleady shipped");
      		}
      	}}
      
    • public enum OrderState { STEP1, STEP2, STEP3, STEP4, STEP5, STEP6 }
    • 실제 코드의 의미를 이해하려면 STEP1, STEP2가 각각 “결제 대기 중” 상태와 “상품 준비 중” 상태를 의미한다는 것을 알아야 한다.
    • 업무 회의에서 “출고 전”이라는 단어를 사용하면 개발자는 머릿속으로 “출고 전은 STEP1, STEP2라고 도메인 지식을 코드로 해석해야 한다.
    • 도메인 용어를 사용하면 불필요한 변환 과정을 거치지 않아도 된다.
    • public enum OrderState { PAYMENT_WAITING, PREPARING, SHIPPED, DELIVERING, DELIVERY_COMPLETED, CANCELED }
    • 코드의 가독성을 높여서 코드를 분석하고 이해하는 시간을 줄여준다.
    • 최대한 도메인 용어를 사용해서 도메인 규칙을 코드로 작성하게 되므로 버그도 줄어 든다.
  • 에릭 에반스는 도메인 주도 설계에서 언어의 중요함을 강조하기 위해 유비쿼터스 언어라는 용어를 사용했다.
  • 전문가, 관계자, 개발자가 도메인과 관련된 공통의 언어를 만들고 이를 대화, 문서, 도메인 모델, 코드, 텍스트 등 모든 곳에서 같은 용어를 사용한다.
  • 소통 과정에서 발생하는 용어의 모호함을 줄일 수 있고 개발자는 도메인과 코드 사이에서 불필요한 해석 과정을 줄일 수 있다.
  • 새로 발견한 용어는 코드나 문서에도 반영해서 산출물에 최신 모델을 적용한다.
  • 알맞은 영어 단어를 찾는 것은 쉽지 않은 일이지만 시간을 들여 찾는 노력을 해야 한다.
  • 도메인 용어에 알맞은 단어를 찾는 시간을 아까워하지 말자

1.1 도메인이란?

  • 온라인 서점을 구현할 소프트웨어라고 봤을때 도메인이란?
    • 상품 조회, 구매, 결제, 배송 추적 등
  • 온라인 서점 도메인은 몇개의 하위 도메인으로 나눌 수 있다.
    • 카탈로그 하위 도메인은 고객에게 구매할 수 있는 상품 목록을 제공하고, 주문 하위 도메인은 고객의 주문을 처리
    • 혜택의 하위 도메인은 쿠폰이나 특별 할인과 같은 서비스 제공
  • 하위 도메인은 다른 하위 도메인과 연동하여 완전한 기능을 제공 한다.
    • 고객이 물건을 구매하면 주문, 결제, 배송, 혜택 하위 도메인의 기능이 엮이게 된다.
  • 특정 도메인을 위한 소프트웨어라고 해서 도메인이 제공해야 할 모든 기능을 직접 구현하는 것은 아님
    • 배송 도메인의 일부 기능은 자체 시스템으로 구현하고 나머지 기능은 외부 업체의 시스템을 사용
    • 결제 시스템도 결제 대행업체를 이용해서 처리할 때가 많다
  • 하위 도메인을 어떻게 구성할지 여부는 상황에 따라 달라진다.
    • 기업 고객을 대상으로 대형 장비를 판매하는 곳은 온라인으로 카탈로그를 제공하고 주문서를 받는 정도만 필요
    • 일반 고객을 대상으로 물건을 판매한다면 카탈로그, 리뷰, 주문, 결제, 배송, 회원 기능 등이 필요

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

  • 도메인 전문가는 해당 도메인에 대한 지식과 경험을 바탕으로 본인들이 원하는 기능 개발을 요구
    • 회계 담당자는 엑셀로 맞추던 정산 금액 계산을 자동화 해주는 기능 요구
    • AS 기사는 고객에게 보내느 문자 메세지를 빠르게 입력할 수 있는 템플릿 추천 기능 요구
  • 개발자는 요구사항을 분석하고 설계하여 코드를 작성하며 테스트하고 배포
    • 요구사항은 첫 단추와 같다
    • 첫 단추를 잘못 끼우면 모든 단추가 잘못 끼워지듯 요구사항을 올바르게 이해하지 못하면 엉뚱한 기능이 만들어진다.
    • 잘못 개발한 코드를 올바르게 고치려면 많은 노력이 필요
  • 개발자와 전문가가 직업 대화하는 것이 요구사항을 올바르게 이해할 수 있다
    • 개발자와 전문가 사이에 내용을 전파하는 전달자가 많으면 많을수록 왜곡되고 손실이 발생
  • 도메인 전문가 만큼은 아니겠지만 이해관계자와 개발자도 도메인 지식을 갖춰야 한다.

1.3 도메인 모델

  • 도메인 모델은 다양한 정의가 존재
  • 도메인 모델은 특정 도메인을 개념적으로 표현한 것
  • 주문 도메인을 주문 모델 객체 모델로 구성하면 그림과 같이 만들 수 있다.
    • 모델은 도메인의 모든 내용을 담고 있지는 않음
    • 여러 관계자들이 동일한 모습으로 도메인을 이해하고 도메인 지식을 공유하는데 도움이 됨
  • 도메인을 이해하려면 도메인이 제공하는 기능과 도메인의 주요 데이터 구성을 파악 해야함
  • 기능과 데이터를 함께 보여주는 객체 모델은 도메인을 모델링하기에 적합
  • 상태 다이어그램을 이용해서 주문의 상태 전이를 모델링 할 수 있다.
  • 도메인 모델을 표현할 때 클래스 다이어그램이나 상태 다이어그램과 같은 UML표기법만 사용해야하는 것은 아니다.
    • 도메인을 이해하는 데 도움이 된다면 표현 방식이 무엇인지는 중요하지 않음
  • 도메인 모델은 기본적으로 도메인 자체를 이해하기 위한 개념 모델
    • 개념 모델을 이용해서 바로 코드를 작성할 수 있는 것은 아님
  • 객체 기반 모델을 기반으로 도메인을 표현했다면 객체 지향 언어를 이용해 개념모델에 가깝게 구현할 수 있다.

1.4 도메인 모델 패턴

  • 일반적인 애플리케이션의 아키텍처는 네 개의 영역으로 구성된다
  • 각 영역의 역활영역 설명
    사용자 인터페이스(UI) 또는 표현 사용자의 요청을 처리하고 사용자에게 정보를 보여준다. 사용자는 소프트웨어를 사용하는 사람뿐만 아니라 외부 시스템일 수 있다.
    응용 사용자가 요청한 기능을 실행한다. 업무 로직을 직접 구현하지 않으며 도메인 계층을 조합해서 기능을 실행한다.
    도메인 시스템이 제공할 도메인 규칙을 구현한다.
    인프라스트럭처 데이터베이스나 메시징 시스템과 같은 외부 시스템과의 연동을 처리한다.
  • 도메인 모델은 아키텍처 상의 도메인 계층을 객체 지향 기법으로 구현하는 패턴을 말한다.
  • 도메인 계층은 도메인의 핵심 규칙을 구현한다.
    • “출고 전에 배송지를 변경할 수 있다” 규칙과 “주문 취소는 배송 전에만 할 수 있다”라는 규칙을 구현한 코드가 도메인 계층에 위치하게 된다.
  • 도메인 규칙을 객체지향 기법으로 구현하는 패턴이 도메인 모델 패턴이다.
  • 주문 도메인의 일부 기능을 도메인 모델 패턴으로 구현한 코드
    • OrderState는 배송지를 변경할 수 있는지 검사할 수 있는 isShippingChangeable() 메서드를 제공
    • 주문 대기 중, 상품 준비 중 상태의 isShippingChangeable() 메서드는 true를 리턴
      • 주문 대기중이거나 상품 준비중에는 배송지를 변겨할 수 있다는 도메인 규칙을 구현
    • chagneShippingInfo() 메서드는 isshippingChangeable() 메서드를 이용해서 변경 가능한 경우에만 배송지를 변경한다.
    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 OrdderState {
    	PAYMENT_WAITING {
    		public boolean isShippingChangeable() {
    			return true;
    		}
    	},
    	PREPARING {
    		public boolean isShippingChangeable() {
    			return true;
    		}
    	},
    	SHIPPED, DELIVERING, DELIVERY_COMPLETED;
    
    	public boolean isShippingChangeable() {
    		return false;
    	}
    }
    
  • OrderState는 Order에 속한 데이터이므로 배송지 정보 변경 가능 여부를 판단하는 코드를 Ordre로 이동
    • 배송지 변경이 가능한지를 판단할 규칙이 주문 상태와 다른 정보를 함꼐 사용한다면 OrderState만으로는 배송지 변경 가능 여부를 판단할 수 없으므로 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 OrdderState {
    	PAYMENT_WAITING, PREPARING, SHIPPED, DELIVERING, DELIVERY_COMPLETED;
    }
    
  • 배송지 변경 가능 여부를 판단하는 기능이 Order에 있든 OrderState에 있든 중요한 점은 주문과 관련된 중요 업무 규칙을 주문 도메인 모델인 Order나 OrderState에서 구현한다는 점
  • 핵심 규칙을 구현한 코든느 도메인 모델에만 위치하기 때문에 규칙이 바뀌거나 규칙을 확장해야할 때 다른 코드에 영향을 덜 주고 변경 내역을 모델에 반영할 수 있다.

개념 모델과 구현 모델

  • 개념 모델은 데이터베이스, 트렌잭션 처리, 성능, 구현 기술과 같은 것을 고려하고 있지 않기 때문에 실제 코드를 작성할 때 개념 모델을 있는 그대로 사용할 수 없다.
  • 개념 모델을 만들 때 처음부터 완벽하게 도메인을 표현하는 모델을 만드는 시도를 할 수 있지만 실제로는 불가능하다.
  • 프로젝트 초기에 완벽한 도메인 모델을 만들더라도 결국 도메인에 대한 새로운 지식이 쌓이면서 모델을 보완하거나 변경하는 일이 발생한다.
  • 처음부터 완벽한 개념 모델을 만들기보다는 전반적인 개요를 알수 있는 수준으로 개념 모델을 작성해야한다.
  • 프로젝트 초기에는 개요 수준의 개념 모델로 도메인에 대한 전체 윤곽을 이해하는데 집중하고, 구현하는 과정에서 개념 모델을 구현 모델로 점진적으로 발전시켜 나가야 한다.

1.5 도메인 모델 도출

  • 뛰어난 개발자라 할지라도 도메인에 대한 이해 없이 코딩을 시작할 수는 없다.
    • 기획서, 유스케이스, 사용자 스토리보드와 같은 요구사항
    • 관련자와의 대화를 통해 도메인을 이해
  • 도메인에 대한 이해를 해야 도메인 모델 초안을 만들어야 코드를 작성할 수 있다.
  • 도메인 모델링할 때 기본이 되는 작업은 모델을 구성하는 핵심 구성요소, 규칙, 기능을 찾는 것
    • ex) 주문 도메인과 관련된 몇 가지 요구사항
      • 최소 한 종류 이상의 상품을 주문해야 한다.
      • 한 상품은 한 개 이상 주문할 수 있다.
      • 총 주문 금액은 각 상품의 구매 가격 합을 모두 더한 금액
      • 각 상품의 구매 가격 합은 상품 가격에 구매 개수를 곱한 값
      • 주문할 때 배송지 정보를 반드시 지정해야 한다.
      • 출고를 하면 배송지를 변경할 수 없다.
      • 출고 전에 주문을 취소할 수 있다.
      • 고객이 결제를 완료하기 전에는 상품을 준비하지 않는다.
    • 주문은 “출고 상태로 변경하기”, “배송지 정보 변경하기”, “주문 취소하기”, “결제 완료하기” 기능을 제공 해야한다.
      • Order 기능 관련 메서드
      • public class Order { public void changeShipped() {...} public void changeShippingInfo(ShippingInfo newShipping) {...} public void cancel() {...} public void completedPayment() {...} }
    • 주문 항목의 데이터 구성을 알려준다.
      • 한 상품은 한 개 이상 주문할 수 있다.
      • 각 상품의 구매 가격 합은 상품 가격에 구매 개수를 곱한 값
    • 주문 항목은 “주문할 상품”, “상품의 가격”, “구매 개수”를 포함 해야한다.
    • 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(); } private int calculateAmounts() { return price * quantity } public int getAmounts() {...} }
    • 주문과 주문 항목의 관계를 알려준다.
      • 최소 한 종류 이상의 상품을 주문해야 한다.
      • 총 주문 금액은 각 상품의 구매 가격 합을 모두 더한 금액이다.
      public class Order {
      	private List<OrderLine> orderLines;
      	private Money totalAmounts;
      
      	public Order(List<OrderLine> orderLines) {
      		setOrderLines(orderLines);
      	}
      
      	private void setOrderLines(List<OrderLine> orderLines) {
      		verifyAtLeastOneOrMoreOrderLine(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);
      	}
      	...
      }
      
    • 배송지 정보 클래스
    • public class ShippingInfo { private String receiverName; private String receiverPhoneNumber; private String shippingAddress1; private String shippingAddress2; private String shippingZipcode; ... }
    • “주문할 때 배송지 정보를 반드시 지정해야 한다” 요구사항은 주문을 생성할 때 주문항목의 목록뿐만 아니라 배송지 정보도 함께 전달 해야한다.
    • public class Order { private List<OrderLine> orderLines; 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; } ... }
    • 도메인의 특정 조건이나 상태에 따라 제약이나 규칙이 달리 적용되는 경우가 많다.
      • 출고를 하면 배송지 정보를 변경할 수 없다.
      • 출고 전에 주문을 취소할 수 있다.
      • 고객이 결제를 완료하기 전에는 상품을 준비하지 않는다.
    • 다른 요구사항을 확인해서 추가로 존재할 수 있는 상태를 분석한 뒤, 열거 타입을 이용해서 상태 정보를 표현할 수 있다.
    • 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 IllegalArgumentException("aleady shipped"); } } ... }
  • 만들어진 모델 요구사항을 도메인 전문가나 다른 개발자와 논의하는 과정에서 공유하기도 한다.
  • 모델을 공유할 때는 화이트보드나 위키와 같은 도구를 사용해서 누구나 쉽게 접근할 수 있도록 하면 좋다.

1.6 엔티티와 밸류

  • 요구사항에서 도출한 주문 도메인 모델은 크게 엔티티와 밸류로 구분된다.

1.6.1 엔티티

  • 엔티티의 가장 큰 특징은 식별자를 가진다는 것
  • 식별자는 엔티티 객체마다 고유해서 각 엔티티는 서로 다른 식별자를 갖는다.
  • 주문에서 배송지 주소가 바뀌거나 상태가 바뀌더라도 주문번호가 바뀌지는 않는 것처럼 엔티티의 식별자는 바뀌지 않는다.
    • 엔티티를 생성하고 속성을 바꾸고 삭제할 때까지 식별자는 유지된다.
  • 엔티티의 식별자는 바뀌지 않고 고유하기 때문에 두 엔티티 객체의 식별자가 같으면 두 엔티티는 같다고 판단할 수 있다.
    • 엔티티를 구현한 클래스는 식별자를 이용해서 equals() 메서드와 hasCode() 메서드를 구현할 수 있다.
    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;
    	}
    }
    

1.6.2 엔티티의 식별자 생성

  • 엔티티의 식별자를 생성하는 시점은 도메인의 특징과 사용하는 기술에 따라 달라진다.
    • 특정 규치에 따라 생성
    • UUID나 Nano ID와 같은 고유 식별자 생성기 사용
    • 값을 직접 입력
    • 일련번호 사용(시퀀스나 DB의 자동 증가 컬럼 사용)
  • 흔히 사용되는 규칙은 현재 시간과 다른 값을 함께 조합하는 것
    • 날짜와 시간을 이용해서 식별자를 생성할 때 주의할 점은 같은 시간에 동시에 식별자를 생성해도 같은 식별자가 만들어지면 안 된다는 것이다.
  • UUID를 사용해서 식별자를 생성할 수 있다.
    • 다수의 개발언어가 UUID 생성기를 제공하고 있으므로 마땅한 규칙이 없다면 UUID를 식별자로 사용
    • 자바는 java.util.UUID 클래스를 사용해서 UUID를 생성할 수 있다.
  • 회원의 아이디나 이메일과 같은 식별자는 값을 직접 입력한다.
    • 사용자가 직접 입력하는 값이기 때문에 식별자를 중복해서 입력되지 않도록 사전에 방지하는 것이 중요
  • 일련번호를 식별자로 사용하기도 한다.
    • 일련번호 방식은 주로 데이터베이스가 제공하는 자동 증가 기능을 사용한다.
    • 자동 증가 칼럼은 DB 테이블에 데이터를 삽입해야 비로소 값을 알수 있기 때문에 테이블에 테이터를 추가하기 전에는 식별자를 알수 없다.

1.6.3 밸류 타입

  • 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;
    	}
    }
    
    • ShippingInfo의 주소 관련 데이터도 다음의 Address 밸류 타입을 사용해서 보다 명확하게 표현할 수 있다.
    public class Address {
    	private String address1;
    	private String address2;
    	private String zipcode;
    
    	public Address(String address1, String address2, String zipcode) {
    		this.address1 = address1;
    		this.address2 = address2;
    		this.zipcode = zipcode;
    	}
    	...
    }
    
    • 밸류 타입을 이용해서 ShippingInfo 클래스를 다시 구현해보기
    public class ShippingInfo {
    	private Receiver receiver;
    	private Address address;
    
    	...
    }
    
  • 밸류 타입을 사용함으로써 개념적으로 완전한 하나를 잘 표현할 수 있다.
  • OrderLine의 price와 amounts는 int 타입의 숫자를 사용하고 있지만 이들은 ‘돈’을 의미하는 값이다.
    • Money 타입을 만들어 사용하면 코드를 이해하는데 도움이 된다.
    public class Money {
    	private int value;
    	
    	public Money(int value) {
    		this.value = value;
    	}
    
    	public int getValue() {
    		return this.value;
    	}
    }
    
    • Money를 사용하도록 OrderLine을 변경
    public class OrderLine {
    	private Product product;
    	private Money price;
    	private int quantity;
    	private Money amounts;
    	...
    }
    
  • 밸류타입의 또 다른 장점은 밸류 타입을 위한 기능을 추가할 수 있다.
    • Money 타입은 돈 계산을 위한 기능을 추가할 수 있다.
    public class Money {
    	private int value;
    	
    	public Money add(Money money) {
    		return new Money(this.value + money.getValue());
    	}
    
    	public Money multiply(int multiplier) {
    		return new Money(value * multiplier)
    	}
    }
    
  • 밸류 객체의 데이터를 변경할 때는 기존 데이터를 변경하기보다는 변경한 데이터를 갖는 새로운 밸류 객체를 생성하는 방식을 선호한다.
    • Money 처럼 데이터 변경 기능을 제공하지 않는 타입을 불변이라고 표현한다.
    • 밸류 타입을 불변으로 구현하는 여러 이유가 있는데 가장 중요한 이유는 안전한 코드를 작성할 수 있다는 데 있다.
    • Money가 setValue()와 같은 메서드를 제공해서 값을 변경할 수 있다면 어떻게 될까?
      • OrderLine의 price 값이 잘못 반영되는 상황이 발생하게 된다.
      Money price = new Money(1000);
      OrderLine line = new OrderLine(product, price, 2);   -> [price=1000, auantity=2, amount=2000]
      price.setValue(2000);                                -> [price=2000, auantity=2, amount=2000]
      
      • 문제가 발생하는 것을 방지하려면 OrderLine 생성자는 새로운 Money 객체를 생성하도록 코드를 작성 해야한다.
      public class OrderLine {
      	...
      	private Money price;
      	
      	public OrderLine(Product product, int quantity, Money price) {
      		this.product = product;
      		this.price = new Money(price.getValue());
      		this.quantity = quantity;
      		this.amounts = caculateAmounts();
      	}
      }
      
  • 두 밸류 객체를 비교할 때는 모든 속성이 같은지 비교한다.
  • public class Receiver { private String name; private String phoneNumber; @Override public boolean equals(Object obj) { if (this == obj) return true; if (obj == null) return false; if (obj.getClass() != Receiver.class) return false; Receiver that = (Receiver) obj; return this.name.equals(that.name) && this.phoneNumber.equals(that.phoneNumber); } }

1.6.4 엔티티 식별자와 밸류 타입

  • 엔티티 식별자의 실제 데이터는 String과 같은 문자열로 구성된 경우가 많다.
    • 신용카드 번호 16개의 숫자로 구성된 문자열
    • 회원을 구분할 때 사용하는 이메일 주소
  • Order의 식별자 타입으로 String 대신 OrderNo 밸류 타입을 사용하면 타입을 통해 해당 필드가 주문번호라는 것을 알수 있다.
    • OrderNo 대시에 String 타입을 사용한다면 ‘id’란 이름만으로는 해당 필드가 주문번호인지 알수 없다.
    • 필드의 의미가 드러나도록 하려면 ‘id’라는 필드 이름 대신 ‘orderNo’라는 필드 이름ㅇ르 사용해야 한다.

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

  • 도메인 모델에서 get/set 메서드를 무조건 추가하는 것은 좋지 않은 버릇이다.
  • set 메서드는 도메인의 핵심 개념이나 의도를 코드에서 사라지게 한다.
  • Order 메서드를 set메서드로 변경
    • changeShippingInfo()가 배송지를 새로 변경한다는 의미를 가졌다면 setShippingInfo() 메서드는 단순히 배송지 값을 설정한다는 것을 의미
    • completePayment()는 결제를 완료했다는 의미를 갖는 반면 setOrderState() 단순히 주문 상태 값을 설정한다는 것을 의미
    public class Order {
    	...
    	public void setShippingInfo(ShippingInfo newShipping) {...}
    	public void setOrderState(OrderState state) {...}
    }
    
  • set 메서드는 필드값만 변경하고 끝나기 때문에 상태 변경과 관련된 도메인 지식이 코드에서 사라지게 된다.
  • set 메서드의 또다른 문제는 도매인 객체를 생성할 때 온전하지 않는 상태가 될수 있다.
  • Order order = new Order(); order.setOrderLine(lines); order.setShippingInfo(shippingInfo); // 주문자를 설정하지 않은 상태로 주문 완료 처리 order.setState(OrderState.PREPARING);
  • 도메인 객체가 불완전한 상태로 사용되는 것을 막으려면 생성 시점에 필요한 것을 전달해 주어야 한다.
    • 생성자를 통해 필요한 데이터를 모두 받아야 한다.
    • 생성자로 필요한 것을 모두 받으므로 생성자를 호출하는 시점에 필요한 데이터가 올바른지 검사할 수 있다.
  • 불변 밸류 타입을 사용하면 자연스럽게 밸류 타입에는 set 메서드를 구현하지 않는다.
    • set 메서드를 구현해야 할 특별한 이유가 없다면 불변 타입의 장점을 살릴 수 있도록 밸류 타입은 불변으로 구현한다.

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

  • 코드를 작성할 때 도메인에서 사용하는 용어는 매우 중요하다
  • 도메인에서 사용하는 용어를 코드에 반영하지 않으면 그 코드는 개발자에게 코드의 이미를 해석해야 하는 부담을 준다.
    • ex) 주문 상태
      public class Order {
      	public void changeShippingInfo(ShippingInfo newShippingInfo) {
      		verifyStep1OrStep2();
      		setShippingInfo(newShippingInfo);
      	}
      
      	private void verifyStep1OrStep2() {
      		if (state != OrderState.STEP1 && state != OrderState.STEP2) {
      			throw new IllegalArgumentException("aleady shipped");
      		}
      	}}
      
    • public enum OrderState { STEP1, STEP2, STEP3, STEP4, STEP5, STEP6 }
    • 실제 코드의 의미를 이해하려면 STEP1, STEP2가 각각 “결제 대기 중” 상태와 “상품 준비 중” 상태를 의미한다는 것을 알아야 한다.
    • 업무 회의에서 “출고 전”이라는 단어를 사용하면 개발자는 머릿속으로 “출고 전은 STEP1, STEP2라고 도메인 지식을 코드로 해석해야 한다.
    • 도메인 용어를 사용하면 불필요한 변환 과정을 거치지 않아도 된다.
    • public enum OrderState { PAYMENT_WAITING, PREPARING, SHIPPED, DELIVERING, DELIVERY_COMPLETED, CANCELED }
    • 코드의 가독성을 높여서 코드를 분석하고 이해하는 시간을 줄여준다.
    • 최대한 도메인 용어를 사용해서 도메인 규칙을 코드로 작성하게 되므로 버그도 줄어 든다.
  • 에릭 에반스는 도메인 주도 설계에서 언어의 중요함을 강조하기 위해 유비쿼터스 언어라는 용어를 사용했다.
  • 전문가, 관계자, 개발자가 도메인과 관련된 공통의 언어를 만들고 이를 대화, 문서, 도메인 모델, 코드, 텍스트 등 모든 곳에서 같은 용어를 사용한다.
  • 소통 과정에서 발생하는 용어의 모호함을 줄일 수 있고 개발자는 도메인과 코드 사이에서 불필요한 해석 과정을 줄일 수 있다.
  • 새로 발견한 용어는 코드나 문서에도 반영해서 산출물에 최신 모델을 적용한다.
  • 알맞은 영어 단어를 찾는 것은 쉽지 않은 일이지만 시간을 들여 찾는 노력을 해야 한다.
  • 도메인 용어에 알맞은 단어를 찾는 시간을 아까워하지 말자

1.1 도메인이란?

  • 온라인 서점을 구현할 소프트웨어라고 봤을때 도메인이란?
    • 상품 조회, 구매, 결제, 배송 추적 등
  • 온라인 서점 도메인은 몇개의 하위 도메인으로 나눌 수 있다.
    • 카탈로그 하위 도메인은 고객에게 구매할 수 있는 상품 목록을 제공하고, 주문 하위 도메인은 고객의 주문을 처리
    • 혜택의 하위 도메인은 쿠폰이나 특별 할인과 같은 서비스 제공
  • 하위 도메인은 다른 하위 도메인과 연동하여 완전한 기능을 제공 한다.
    • 고객이 물건을 구매하면 주문, 결제, 배송, 혜택 하위 도메인의 기능이 엮이게 된다.
  • 특정 도메인을 위한 소프트웨어라고 해서 도메인이 제공해야 할 모든 기능을 직접 구현하는 것은 아님
    • 배송 도메인의 일부 기능은 자체 시스템으로 구현하고 나머지 기능은 외부 업체의 시스템을 사용
    • 결제 시스템도 결제 대행업체를 이용해서 처리할 때가 많다
  • 하위 도메인을 어떻게 구성할지 여부는 상황에 따라 달라진다.
    • 기업 고객을 대상으로 대형 장비를 판매하는 곳은 온라인으로 카탈로그를 제공하고 주문서를 받는 정도만 필요
    • 일반 고객을 대상으로 물건을 판매한다면 카탈로그, 리뷰, 주문, 결제, 배송, 회원 기능 등이 필요

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

  • 도메인 전문가는 해당 도메인에 대한 지식과 경험을 바탕으로 본인들이 원하는 기능 개발을 요구
    • 회계 담당자는 엑셀로 맞추던 정산 금액 계산을 자동화 해주는 기능 요구
    • AS 기사는 고객에게 보내느 문자 메세지를 빠르게 입력할 수 있는 템플릿 추천 기능 요구
  • 개발자는 요구사항을 분석하고 설계하여 코드를 작성하며 테스트하고 배포
    • 요구사항은 첫 단추와 같다
    • 첫 단추를 잘못 끼우면 모든 단추가 잘못 끼워지듯 요구사항을 올바르게 이해하지 못하면 엉뚱한 기능이 만들어진다.
    • 잘못 개발한 코드를 올바르게 고치려면 많은 노력이 필요
  • 개발자와 전문가가 직업 대화하는 것이 요구사항을 올바르게 이해할 수 있다
    • 개발자와 전문가 사이에 내용을 전파하는 전달자가 많으면 많을수록 왜곡되고 손실이 발생
  • 도메인 전문가 만큼은 아니겠지만 이해관계자와 개발자도 도메인 지식을 갖춰야 한다.

1.3 도메인 모델

  • 도메인 모델은 다양한 정의가 존재
  • 도메인 모델은 특정 도메인을 개념적으로 표현한 것
  • 주문 도메인을 주문 모델 객체 모델로 구성하면 그림과 같이 만들 수 있다.
    • 모델은 도메인의 모든 내용을 담고 있지는 않음
    • 여러 관계자들이 동일한 모습으로 도메인을 이해하고 도메인 지식을 공유하는데 도움이 됨
  • 도메인을 이해하려면 도메인이 제공하는 기능과 도메인의 주요 데이터 구성을 파악 해야함
  • 기능과 데이터를 함께 보여주는 객체 모델은 도메인을 모델링하기에 적합
  • 상태 다이어그램을 이용해서 주문의 상태 전이를 모델링 할 수 있다.
  • 도메인 모델을 표현할 때 클래스 다이어그램이나 상태 다이어그램과 같은 UML표기법만 사용해야하는 것은 아니다.
    • 도메인을 이해하는 데 도움이 된다면 표현 방식이 무엇인지는 중요하지 않음
  • 도메인 모델은 기본적으로 도메인 자체를 이해하기 위한 개념 모델
    • 개념 모델을 이용해서 바로 코드를 작성할 수 있는 것은 아님
  • 객체 기반 모델을 기반으로 도메인을 표현했다면 객체 지향 언어를 이용해 개념모델에 가깝게 구현할 수 있다.

1.4 도메인 모델 패턴

  • 일반적인 애플리케이션의 아키텍처는 네 개의 영역으로 구성된다
  • 각 영역의 역활영역 설명
    사용자 인터페이스(UI) 또는 표현 사용자의 요청을 처리하고 사용자에게 정보를 보여준다. 사용자는 소프트웨어를 사용하는 사람뿐만 아니라 외부 시스템일 수 있다.
    응용 사용자가 요청한 기능을 실행한다. 업무 로직을 직접 구현하지 않으며 도메인 계층을 조합해서 기능을 실행한다.
    도메인 시스템이 제공할 도메인 규칙을 구현한다.
    인프라스트럭처 데이터베이스나 메시징 시스템과 같은 외부 시스템과의 연동을 처리한다.
  • 도메인 모델은 아키텍처 상의 도메인 계층을 객체 지향 기법으로 구현하는 패턴을 말한다.
  • 도메인 계층은 도메인의 핵심 규칙을 구현한다.
    • “출고 전에 배송지를 변경할 수 있다” 규칙과 “주문 취소는 배송 전에만 할 수 있다”라는 규칙을 구현한 코드가 도메인 계층에 위치하게 된다.
  • 도메인 규칙을 객체지향 기법으로 구현하는 패턴이 도메인 모델 패턴이다.
  • 주문 도메인의 일부 기능을 도메인 모델 패턴으로 구현한 코드
    • OrderState는 배송지를 변경할 수 있는지 검사할 수 있는 isShippingChangeable() 메서드를 제공
    • 주문 대기 중, 상품 준비 중 상태의 isShippingChangeable() 메서드는 true를 리턴
      • 주문 대기중이거나 상품 준비중에는 배송지를 변겨할 수 있다는 도메인 규칙을 구현
    • chagneShippingInfo() 메서드는 isshippingChangeable() 메서드를 이용해서 변경 가능한 경우에만 배송지를 변경한다.
    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 OrdderState {
    	PAYMENT_WAITING {
    		public boolean isShippingChangeable() {
    			return true;
    		}
    	},
    	PREPARING {
    		public boolean isShippingChangeable() {
    			return true;
    		}
    	},
    	SHIPPED, DELIVERING, DELIVERY_COMPLETED;
    
    	public boolean isShippingChangeable() {
    		return false;
    	}
    }
    
  • OrderState는 Order에 속한 데이터이므로 배송지 정보 변경 가능 여부를 판단하는 코드를 Ordre로 이동
    • 배송지 변경이 가능한지를 판단할 규칙이 주문 상태와 다른 정보를 함꼐 사용한다면 OrderState만으로는 배송지 변경 가능 여부를 판단할 수 없으므로 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 OrdderState {
    	PAYMENT_WAITING, PREPARING, SHIPPED, DELIVERING, DELIVERY_COMPLETED;
    }
    
  • 배송지 변경 가능 여부를 판단하는 기능이 Order에 있든 OrderState에 있든 중요한 점은 주문과 관련된 중요 업무 규칙을 주문 도메인 모델인 Order나 OrderState에서 구현한다는 점
  • 핵심 규칙을 구현한 코든느 도메인 모델에만 위치하기 때문에 규칙이 바뀌거나 규칙을 확장해야할 때 다른 코드에 영향을 덜 주고 변경 내역을 모델에 반영할 수 있다.

개념 모델과 구현 모델

  • 개념 모델은 데이터베이스, 트렌잭션 처리, 성능, 구현 기술과 같은 것을 고려하고 있지 않기 때문에 실제 코드를 작성할 때 개념 모델을 있는 그대로 사용할 수 없다.
  • 개념 모델을 만들 때 처음부터 완벽하게 도메인을 표현하는 모델을 만드는 시도를 할 수 있지만 실제로는 불가능하다.
  • 프로젝트 초기에 완벽한 도메인 모델을 만들더라도 결국 도메인에 대한 새로운 지식이 쌓이면서 모델을 보완하거나 변경하는 일이 발생한다.
  • 처음부터 완벽한 개념 모델을 만들기보다는 전반적인 개요를 알수 있는 수준으로 개념 모델을 작성해야한다.
  • 프로젝트 초기에는 개요 수준의 개념 모델로 도메인에 대한 전체 윤곽을 이해하는데 집중하고, 구현하는 과정에서 개념 모델을 구현 모델로 점진적으로 발전시켜 나가야 한다.

1.5 도메인 모델 도출

  • 뛰어난 개발자라 할지라도 도메인에 대한 이해 없이 코딩을 시작할 수는 없다.
    • 기획서, 유스케이스, 사용자 스토리보드와 같은 요구사항
    • 관련자와의 대화를 통해 도메인을 이해
  • 도메인에 대한 이해를 해야 도메인 모델 초안을 만들어야 코드를 작성할 수 있다.
  • 도메인 모델링할 때 기본이 되는 작업은 모델을 구성하는 핵심 구성요소, 규칙, 기능을 찾는 것
    • ex) 주문 도메인과 관련된 몇 가지 요구사항
      • 최소 한 종류 이상의 상품을 주문해야 한다.
      • 한 상품은 한 개 이상 주문할 수 있다.
      • 총 주문 금액은 각 상품의 구매 가격 합을 모두 더한 금액
      • 각 상품의 구매 가격 합은 상품 가격에 구매 개수를 곱한 값
      • 주문할 때 배송지 정보를 반드시 지정해야 한다.
      • 출고를 하면 배송지를 변경할 수 없다.
      • 출고 전에 주문을 취소할 수 있다.
      • 고객이 결제를 완료하기 전에는 상품을 준비하지 않는다.
    • 주문은 “출고 상태로 변경하기”, “배송지 정보 변경하기”, “주문 취소하기”, “결제 완료하기” 기능을 제공 해야한다.
      • Order 기능 관련 메서드
      • public class Order { public void changeShipped() {...} public void changeShippingInfo(ShippingInfo newShipping) {...} public void cancel() {...} public void completedPayment() {...} }
    • 주문 항목의 데이터 구성을 알려준다.
      • 한 상품은 한 개 이상 주문할 수 있다.
      • 각 상품의 구매 가격 합은 상품 가격에 구매 개수를 곱한 값
    • 주문 항목은 “주문할 상품”, “상품의 가격”, “구매 개수”를 포함 해야한다.
    • 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(); } private int calculateAmounts() { return price * quantity } public int getAmounts() {...} }
    • 주문과 주문 항목의 관계를 알려준다.
      • 최소 한 종류 이상의 상품을 주문해야 한다.
      • 총 주문 금액은 각 상품의 구매 가격 합을 모두 더한 금액이다.
      public class Order {
      	private List<OrderLine> orderLines;
      	private Money totalAmounts;
      
      	public Order(List<OrderLine> orderLines) {
      		setOrderLines(orderLines);
      	}
      
      	private void setOrderLines(List<OrderLine> orderLines) {
      		verifyAtLeastOneOrMoreOrderLine(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);
      	}
      	...
      }
      
    • 배송지 정보 클래스
    • public class ShippingInfo { private String receiverName; private String receiverPhoneNumber; private String shippingAddress1; private String shippingAddress2; private String shippingZipcode; ... }
    • “주문할 때 배송지 정보를 반드시 지정해야 한다” 요구사항은 주문을 생성할 때 주문항목의 목록뿐만 아니라 배송지 정보도 함께 전달 해야한다.
    • public class Order { private List<OrderLine> orderLines; 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; } ... }
    • 도메인의 특정 조건이나 상태에 따라 제약이나 규칙이 달리 적용되는 경우가 많다.
      • 출고를 하면 배송지 정보를 변경할 수 없다.
      • 출고 전에 주문을 취소할 수 있다.
      • 고객이 결제를 완료하기 전에는 상품을 준비하지 않는다.
    • 다른 요구사항을 확인해서 추가로 존재할 수 있는 상태를 분석한 뒤, 열거 타입을 이용해서 상태 정보를 표현할 수 있다.
    • 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 IllegalArgumentException("aleady shipped"); } } ... }
  • 만들어진 모델 요구사항을 도메인 전문가나 다른 개발자와 논의하는 과정에서 공유하기도 한다.
  • 모델을 공유할 때는 화이트보드나 위키와 같은 도구를 사용해서 누구나 쉽게 접근할 수 있도록 하면 좋다.

1.6 엔티티와 밸류

  • 요구사항에서 도출한 주문 도메인 모델은 크게 엔티티와 밸류로 구분된다.

1.6.1 엔티티

  • 엔티티의 가장 큰 특징은 식별자를 가진다는 것
  • 식별자는 엔티티 객체마다 고유해서 각 엔티티는 서로 다른 식별자를 갖는다.
  • 주문에서 배송지 주소가 바뀌거나 상태가 바뀌더라도 주문번호가 바뀌지는 않는 것처럼 엔티티의 식별자는 바뀌지 않는다.
    • 엔티티를 생성하고 속성을 바꾸고 삭제할 때까지 식별자는 유지된다.
  • 엔티티의 식별자는 바뀌지 않고 고유하기 때문에 두 엔티티 객체의 식별자가 같으면 두 엔티티는 같다고 판단할 수 있다.
    • 엔티티를 구현한 클래스는 식별자를 이용해서 equals() 메서드와 hasCode() 메서드를 구현할 수 있다.
    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;
    	}
    }
    

1.6.2 엔티티의 식별자 생성

  • 엔티티의 식별자를 생성하는 시점은 도메인의 특징과 사용하는 기술에 따라 달라진다.
    • 특정 규치에 따라 생성
    • UUID나 Nano ID와 같은 고유 식별자 생성기 사용
    • 값을 직접 입력
    • 일련번호 사용(시퀀스나 DB의 자동 증가 컬럼 사용)
  • 흔히 사용되는 규칙은 현재 시간과 다른 값을 함께 조합하는 것
    • 날짜와 시간을 이용해서 식별자를 생성할 때 주의할 점은 같은 시간에 동시에 식별자를 생성해도 같은 식별자가 만들어지면 안 된다는 것이다.
  • UUID를 사용해서 식별자를 생성할 수 있다.
    • 다수의 개발언어가 UUID 생성기를 제공하고 있으므로 마땅한 규칙이 없다면 UUID를 식별자로 사용
    • 자바는 java.util.UUID 클래스를 사용해서 UUID를 생성할 수 있다.
  • 회원의 아이디나 이메일과 같은 식별자는 값을 직접 입력한다.
    • 사용자가 직접 입력하는 값이기 때문에 식별자를 중복해서 입력되지 않도록 사전에 방지하는 것이 중요
  • 일련번호를 식별자로 사용하기도 한다.
    • 일련번호 방식은 주로 데이터베이스가 제공하는 자동 증가 기능을 사용한다.
    • 자동 증가 칼럼은 DB 테이블에 데이터를 삽입해야 비로소 값을 알수 있기 때문에 테이블에 테이터를 추가하기 전에는 식별자를 알수 없다.

1.6.3 밸류 타입

  • 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;
    	}
    }
    
    • ShippingInfo의 주소 관련 데이터도 다음의 Address 밸류 타입을 사용해서 보다 명확하게 표현할 수 있다.
    public class Address {
    	private String address1;
    	private String address2;
    	private String zipcode;
    
    	public Address(String address1, String address2, String zipcode) {
    		this.address1 = address1;
    		this.address2 = address2;
    		this.zipcode = zipcode;
    	}
    	...
    }
    
    • 밸류 타입을 이용해서 ShippingInfo 클래스를 다시 구현해보기
    public class ShippingInfo {
    	private Receiver receiver;
    	private Address address;
    
    	...
    }
    
  • 밸류 타입을 사용함으로써 개념적으로 완전한 하나를 잘 표현할 수 있다.
  • OrderLine의 price와 amounts는 int 타입의 숫자를 사용하고 있지만 이들은 ‘돈’을 의미하는 값이다.
    • Money 타입을 만들어 사용하면 코드를 이해하는데 도움이 된다.
    public class Money {
    	private int value;
    	
    	public Money(int value) {
    		this.value = value;
    	}
    
    	public int getValue() {
    		return this.value;
    	}
    }
    
    • Money를 사용하도록 OrderLine을 변경
    public class OrderLine {
    	private Product product;
    	private Money price;
    	private int quantity;
    	private Money amounts;
    	...
    }
    
  • 밸류타입의 또 다른 장점은 밸류 타입을 위한 기능을 추가할 수 있다.
    • Money 타입은 돈 계산을 위한 기능을 추가할 수 있다.
    public class Money {
    	private int value;
    	
    	public Money add(Money money) {
    		return new Money(this.value + money.getValue());
    	}
    
    	public Money multiply(int multiplier) {
    		return new Money(value * multiplier)
    	}
    }
    
  • 밸류 객체의 데이터를 변경할 때는 기존 데이터를 변경하기보다는 변경한 데이터를 갖는 새로운 밸류 객체를 생성하는 방식을 선호한다.
    • Money 처럼 데이터 변경 기능을 제공하지 않는 타입을 불변이라고 표현한다.
    • 밸류 타입을 불변으로 구현하는 여러 이유가 있는데 가장 중요한 이유는 안전한 코드를 작성할 수 있다는 데 있다.
    • Money가 setValue()와 같은 메서드를 제공해서 값을 변경할 수 있다면 어떻게 될까?
      • OrderLine의 price 값이 잘못 반영되는 상황이 발생하게 된다.
      Money price = new Money(1000);
      OrderLine line = new OrderLine(product, price, 2);   -> [price=1000, auantity=2, amount=2000]
      price.setValue(2000);                                -> [price=2000, auantity=2, amount=2000]
      
      • 문제가 발생하는 것을 방지하려면 OrderLine 생성자는 새로운 Money 객체를 생성하도록 코드를 작성 해야한다.
      public class OrderLine {
      	...
      	private Money price;
      	
      	public OrderLine(Product product, int quantity, Money price) {
      		this.product = product;
      		this.price = new Money(price.getValue());
      		this.quantity = quantity;
      		this.amounts = caculateAmounts();
      	}
      }
      
  • 두 밸류 객체를 비교할 때는 모든 속성이 같은지 비교한다.
  • public class Receiver { private String name; private String phoneNumber; @Override public boolean equals(Object obj) { if (this == obj) return true; if (obj == null) return false; if (obj.getClass() != Receiver.class) return false; Receiver that = (Receiver) obj; return this.name.equals(that.name) && this.phoneNumber.equals(that.phoneNumber); } }

1.6.4 엔티티 식별자와 밸류 타입

  • 엔티티 식별자의 실제 데이터는 String과 같은 문자열로 구성된 경우가 많다.
    • 신용카드 번호 16개의 숫자로 구성된 문자열
    • 회원을 구분할 때 사용하는 이메일 주소
  • Order의 식별자 타입으로 String 대신 OrderNo 밸류 타입을 사용하면 타입을 통해 해당 필드가 주문번호라는 것을 알수 있다.
    • OrderNo 대시에 String 타입을 사용한다면 ‘id’란 이름만으로는 해당 필드가 주문번호인지 알수 없다.
    • 필드의 의미가 드러나도록 하려면 ‘id’라는 필드 이름 대신 ‘orderNo’라는 필드 이름ㅇ르 사용해야 한다.

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

  • 도메인 모델에서 get/set 메서드를 무조건 추가하는 것은 좋지 않은 버릇이다.
  • set 메서드는 도메인의 핵심 개념이나 의도를 코드에서 사라지게 한다.
  • Order 메서드를 set메서드로 변경
    • changeShippingInfo()가 배송지를 새로 변경한다는 의미를 가졌다면 setShippingInfo() 메서드는 단순히 배송지 값을 설정한다는 것을 의미
    • completePayment()는 결제를 완료했다는 의미를 갖는 반면 setOrderState() 단순히 주문 상태 값을 설정한다는 것을 의미
    public class Order {
    	...
    	public void setShippingInfo(ShippingInfo newShipping) {...}
    	public void setOrderState(OrderState state) {...}
    }
    
  • set 메서드는 필드값만 변경하고 끝나기 때문에 상태 변경과 관련된 도메인 지식이 코드에서 사라지게 된다.
  • set 메서드의 또다른 문제는 도매인 객체를 생성할 때 온전하지 않는 상태가 될수 있다.
  • Order order = new Order(); order.setOrderLine(lines); order.setShippingInfo(shippingInfo); // 주문자를 설정하지 않은 상태로 주문 완료 처리 order.setState(OrderState.PREPARING);
  • 도메인 객체가 불완전한 상태로 사용되는 것을 막으려면 생성 시점에 필요한 것을 전달해 주어야 한다.
    • 생성자를 통해 필요한 데이터를 모두 받아야 한다.
    • 생성자로 필요한 것을 모두 받으므로 생성자를 호출하는 시점에 필요한 데이터가 올바른지 검사할 수 있다.
  • 불변 밸류 타입을 사용하면 자연스럽게 밸류 타입에는 set 메서드를 구현하지 않는다.
    • set 메서드를 구현해야 할 특별한 이유가 없다면 불변 타입의 장점을 살릴 수 있도록 밸류 타입은 불변으로 구현한다.

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

  • 코드를 작성할 때 도메인에서 사용하는 용어는 매우 중요하다
  • 도메인에서 사용하는 용어를 코드에 반영하지 않으면 그 코드는 개발자에게 코드의 이미를 해석해야 하는 부담을 준다.
    • ex) 주문 상태
      public class Order {
      	public void changeShippingInfo(ShippingInfo newShippingInfo) {
      		verifyStep1OrStep2();
      		setShippingInfo(newShippingInfo);
      	}
      
      	private void verifyStep1OrStep2() {
      		if (state != OrderState.STEP1 && state != OrderState.STEP2) {
      			throw new IllegalArgumentException("aleady shipped");
      		}
      	}}
      
    • public enum OrderState { STEP1, STEP2, STEP3, STEP4, STEP5, STEP6 }
    • 실제 코드의 의미를 이해하려면 STEP1, STEP2가 각각 “결제 대기 중” 상태와 “상품 준비 중” 상태를 의미한다는 것을 알아야 한다.
    • 업무 회의에서 “출고 전”이라는 단어를 사용하면 개발자는 머릿속으로 “출고 전은 STEP1, STEP2라고 도메인 지식을 코드로 해석해야 한다.
    • 도메인 용어를 사용하면 불필요한 변환 과정을 거치지 않아도 된다.
    • public enum OrderState { PAYMENT_WAITING, PREPARING, SHIPPED, DELIVERING, DELIVERY_COMPLETED, CANCELED }
    • 코드의 가독성을 높여서 코드를 분석하고 이해하는 시간을 줄여준다.
    • 최대한 도메인 용어를 사용해서 도메인 규칙을 코드로 작성하게 되므로 버그도 줄어 든다.
  • 에릭 에반스는 도메인 주도 설계에서 언어의 중요함을 강조하기 위해 유비쿼터스 언어라는 용어를 사용했다.
  • 전문가, 관계자, 개발자가 도메인과 관련된 공통의 언어를 만들고 이를 대화, 문서, 도메인 모델, 코드, 텍스트 등 모든 곳에서 같은 용어를 사용한다.
  • 소통 과정에서 발생하는 용어의 모호함을 줄일 수 있고 개발자는 도메인과 코드 사이에서 불필요한 해석 과정을 줄일 수 있다.
  • 새로 발견한 용어는 코드나 문서에도 반영해서 산출물에 최신 모델을 적용한다.
  • 알맞은 영어 단어를 찾는 것은 쉽지 않은 일이지만 시간을 들여 찾는 노력을 해야 한다.
  • 도메인 용어에 알맞은 단어를 찾는 시간을 아까워하지 말자

1.1 도메인이란?

  • 온라인 서점을 구현할 소프트웨어라고 봤을때 도메인이란?
    • 상품 조회, 구매, 결제, 배송 추적 등
  • 온라인 서점 도메인은 몇개의 하위 도메인으로 나눌 수 있다.
    • 카탈로그 하위 도메인은 고객에게 구매할 수 있는 상품 목록을 제공하고, 주문 하위 도메인은 고객의 주문을 처리
    • 혜택의 하위 도메인은 쿠폰이나 특별 할인과 같은 서비스 제공
  • 하위 도메인은 다른 하위 도메인과 연동하여 완전한 기능을 제공 한다.
    • 고객이 물건을 구매하면 주문, 결제, 배송, 혜택 하위 도메인의 기능이 엮이게 된다.
  • 특정 도메인을 위한 소프트웨어라고 해서 도메인이 제공해야 할 모든 기능을 직접 구현하는 것은 아님
    • 배송 도메인의 일부 기능은 자체 시스템으로 구현하고 나머지 기능은 외부 업체의 시스템을 사용
    • 결제 시스템도 결제 대행업체를 이용해서 처리할 때가 많다
  • 하위 도메인을 어떻게 구성할지 여부는 상황에 따라 달라진다.
    • 기업 고객을 대상으로 대형 장비를 판매하는 곳은 온라인으로 카탈로그를 제공하고 주문서를 받는 정도만 필요
    • 일반 고객을 대상으로 물건을 판매한다면 카탈로그, 리뷰, 주문, 결제, 배송, 회원 기능 등이 필요

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

  • 도메인 전문가는 해당 도메인에 대한 지식과 경험을 바탕으로 본인들이 원하는 기능 개발을 요구
    • 회계 담당자는 엑셀로 맞추던 정산 금액 계산을 자동화 해주는 기능 요구
    • AS 기사는 고객에게 보내느 문자 메세지를 빠르게 입력할 수 있는 템플릿 추천 기능 요구
  • 개발자는 요구사항을 분석하고 설계하여 코드를 작성하며 테스트하고 배포
    • 요구사항은 첫 단추와 같다
    • 첫 단추를 잘못 끼우면 모든 단추가 잘못 끼워지듯 요구사항을 올바르게 이해하지 못하면 엉뚱한 기능이 만들어진다.
    • 잘못 개발한 코드를 올바르게 고치려면 많은 노력이 필요
  • 개발자와 전문가가 직업 대화하는 것이 요구사항을 올바르게 이해할 수 있다
    • 개발자와 전문가 사이에 내용을 전파하는 전달자가 많으면 많을수록 왜곡되고 손실이 발생
  • 도메인 전문가 만큼은 아니겠지만 이해관계자와 개발자도 도메인 지식을 갖춰야 한다.

1.3 도메인 모델

  • 도메인 모델은 다양한 정의가 존재
  • 도메인 모델은 특정 도메인을 개념적으로 표현한 것
  • 주문 도메인을 주문 모델 객체 모델로 구성하면 그림과 같이 만들 수 있다.
    • 모델은 도메인의 모든 내용을 담고 있지는 않음
    • 여러 관계자들이 동일한 모습으로 도메인을 이해하고 도메인 지식을 공유하는데 도움이 됨
  • 도메인을 이해하려면 도메인이 제공하는 기능과 도메인의 주요 데이터 구성을 파악 해야함
  • 기능과 데이터를 함께 보여주는 객체 모델은 도메인을 모델링하기에 적합
  • 상태 다이어그램을 이용해서 주문의 상태 전이를 모델링 할 수 있다.
  • 도메인 모델을 표현할 때 클래스 다이어그램이나 상태 다이어그램과 같은 UML표기법만 사용해야하는 것은 아니다.
    • 도메인을 이해하는 데 도움이 된다면 표현 방식이 무엇인지는 중요하지 않음
  • 도메인 모델은 기본적으로 도메인 자체를 이해하기 위한 개념 모델
    • 개념 모델을 이용해서 바로 코드를 작성할 수 있는 것은 아님
  • 객체 기반 모델을 기반으로 도메인을 표현했다면 객체 지향 언어를 이용해 개념모델에 가깝게 구현할 수 있다.

1.4 도메인 모델 패턴

  • 일반적인 애플리케이션의 아키텍처는 네 개의 영역으로 구성된다
  • 각 영역의 역활영역 설명
    사용자 인터페이스(UI) 또는 표현 사용자의 요청을 처리하고 사용자에게 정보를 보여준다. 사용자는 소프트웨어를 사용하는 사람뿐만 아니라 외부 시스템일 수 있다.
    응용 사용자가 요청한 기능을 실행한다. 업무 로직을 직접 구현하지 않으며 도메인 계층을 조합해서 기능을 실행한다.
    도메인 시스템이 제공할 도메인 규칙을 구현한다.
    인프라스트럭처 데이터베이스나 메시징 시스템과 같은 외부 시스템과의 연동을 처리한다.
  • 도메인 모델은 아키텍처 상의 도메인 계층을 객체 지향 기법으로 구현하는 패턴을 말한다.
  • 도메인 계층은 도메인의 핵심 규칙을 구현한다.
    • “출고 전에 배송지를 변경할 수 있다” 규칙과 “주문 취소는 배송 전에만 할 수 있다”라는 규칙을 구현한 코드가 도메인 계층에 위치하게 된다.
  • 도메인 규칙을 객체지향 기법으로 구현하는 패턴이 도메인 모델 패턴이다.
  • 주문 도메인의 일부 기능을 도메인 모델 패턴으로 구현한 코드
    • OrderState는 배송지를 변경할 수 있는지 검사할 수 있는 isShippingChangeable() 메서드를 제공
    • 주문 대기 중, 상품 준비 중 상태의 isShippingChangeable() 메서드는 true를 리턴
      • 주문 대기중이거나 상품 준비중에는 배송지를 변겨할 수 있다는 도메인 규칙을 구현
    • chagneShippingInfo() 메서드는 isshippingChangeable() 메서드를 이용해서 변경 가능한 경우에만 배송지를 변경한다.
    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 OrdderState {
    	PAYMENT_WAITING {
    		public boolean isShippingChangeable() {
    			return true;
    		}
    	},
    	PREPARING {
    		public boolean isShippingChangeable() {
    			return true;
    		}
    	},
    	SHIPPED, DELIVERING, DELIVERY_COMPLETED;
    
    	public boolean isShippingChangeable() {
    		return false;
    	}
    }
    
  • OrderState는 Order에 속한 데이터이므로 배송지 정보 변경 가능 여부를 판단하는 코드를 Ordre로 이동
    • 배송지 변경이 가능한지를 판단할 규칙이 주문 상태와 다른 정보를 함꼐 사용한다면 OrderState만으로는 배송지 변경 가능 여부를 판단할 수 없으므로 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 OrdderState {
    	PAYMENT_WAITING, PREPARING, SHIPPED, DELIVERING, DELIVERY_COMPLETED;
    }
    
  • 배송지 변경 가능 여부를 판단하는 기능이 Order에 있든 OrderState에 있든 중요한 점은 주문과 관련된 중요 업무 규칙을 주문 도메인 모델인 Order나 OrderState에서 구현한다는 점
  • 핵심 규칙을 구현한 코든느 도메인 모델에만 위치하기 때문에 규칙이 바뀌거나 규칙을 확장해야할 때 다른 코드에 영향을 덜 주고 변경 내역을 모델에 반영할 수 있다.

개념 모델과 구현 모델

  • 개념 모델은 데이터베이스, 트렌잭션 처리, 성능, 구현 기술과 같은 것을 고려하고 있지 않기 때문에 실제 코드를 작성할 때 개념 모델을 있는 그대로 사용할 수 없다.
  • 개념 모델을 만들 때 처음부터 완벽하게 도메인을 표현하는 모델을 만드는 시도를 할 수 있지만 실제로는 불가능하다.
  • 프로젝트 초기에 완벽한 도메인 모델을 만들더라도 결국 도메인에 대한 새로운 지식이 쌓이면서 모델을 보완하거나 변경하는 일이 발생한다.
  • 처음부터 완벽한 개념 모델을 만들기보다는 전반적인 개요를 알수 있는 수준으로 개념 모델을 작성해야한다.
  • 프로젝트 초기에는 개요 수준의 개념 모델로 도메인에 대한 전체 윤곽을 이해하는데 집중하고, 구현하는 과정에서 개념 모델을 구현 모델로 점진적으로 발전시켜 나가야 한다.

1.5 도메인 모델 도출

  • 뛰어난 개발자라 할지라도 도메인에 대한 이해 없이 코딩을 시작할 수는 없다.
    • 기획서, 유스케이스, 사용자 스토리보드와 같은 요구사항
    • 관련자와의 대화를 통해 도메인을 이해
  • 도메인에 대한 이해를 해야 도메인 모델 초안을 만들어야 코드를 작성할 수 있다.
  • 도메인 모델링할 때 기본이 되는 작업은 모델을 구성하는 핵심 구성요소, 규칙, 기능을 찾는 것
    • ex) 주문 도메인과 관련된 몇 가지 요구사항
      • 최소 한 종류 이상의 상품을 주문해야 한다.
      • 한 상품은 한 개 이상 주문할 수 있다.
      • 총 주문 금액은 각 상품의 구매 가격 합을 모두 더한 금액
      • 각 상품의 구매 가격 합은 상품 가격에 구매 개수를 곱한 값
      • 주문할 때 배송지 정보를 반드시 지정해야 한다.
      • 출고를 하면 배송지를 변경할 수 없다.
      • 출고 전에 주문을 취소할 수 있다.
      • 고객이 결제를 완료하기 전에는 상품을 준비하지 않는다.
    • 주문은 “출고 상태로 변경하기”, “배송지 정보 변경하기”, “주문 취소하기”, “결제 완료하기” 기능을 제공 해야한다.
      • Order 기능 관련 메서드
      • public class Order { public void changeShipped() {...} public void changeShippingInfo(ShippingInfo newShipping) {...} public void cancel() {...} public void completedPayment() {...} }
    • 주문 항목의 데이터 구성을 알려준다.
      • 한 상품은 한 개 이상 주문할 수 있다.
      • 각 상품의 구매 가격 합은 상품 가격에 구매 개수를 곱한 값
    • 주문 항목은 “주문할 상품”, “상품의 가격”, “구매 개수”를 포함 해야한다.
    • 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(); } private int calculateAmounts() { return price * quantity } public int getAmounts() {...} }
    • 주문과 주문 항목의 관계를 알려준다.
      • 최소 한 종류 이상의 상품을 주문해야 한다.
      • 총 주문 금액은 각 상품의 구매 가격 합을 모두 더한 금액이다.
      public class Order {
      	private List<OrderLine> orderLines;
      	private Money totalAmounts;
      
      	public Order(List<OrderLine> orderLines) {
      		setOrderLines(orderLines);
      	}
      
      	private void setOrderLines(List<OrderLine> orderLines) {
      		verifyAtLeastOneOrMoreOrderLine(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);
      	}
      	...
      }
      
    • 배송지 정보 클래스
    • public class ShippingInfo { private String receiverName; private String receiverPhoneNumber; private String shippingAddress1; private String shippingAddress2; private String shippingZipcode; ... }
    • “주문할 때 배송지 정보를 반드시 지정해야 한다” 요구사항은 주문을 생성할 때 주문항목의 목록뿐만 아니라 배송지 정보도 함께 전달 해야한다.
    • public class Order { private List<OrderLine> orderLines; 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; } ... }
    • 도메인의 특정 조건이나 상태에 따라 제약이나 규칙이 달리 적용되는 경우가 많다.
      • 출고를 하면 배송지 정보를 변경할 수 없다.
      • 출고 전에 주문을 취소할 수 있다.
      • 고객이 결제를 완료하기 전에는 상품을 준비하지 않는다.
    • 다른 요구사항을 확인해서 추가로 존재할 수 있는 상태를 분석한 뒤, 열거 타입을 이용해서 상태 정보를 표현할 수 있다.
    • 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 IllegalArgumentException("aleady shipped"); } } ... }
  • 만들어진 모델 요구사항을 도메인 전문가나 다른 개발자와 논의하는 과정에서 공유하기도 한다.
  • 모델을 공유할 때는 화이트보드나 위키와 같은 도구를 사용해서 누구나 쉽게 접근할 수 있도록 하면 좋다.

1.6 엔티티와 밸류

  • 요구사항에서 도출한 주문 도메인 모델은 크게 엔티티와 밸류로 구분된다.

1.6.1 엔티티

  • 엔티티의 가장 큰 특징은 식별자를 가진다는 것
  • 식별자는 엔티티 객체마다 고유해서 각 엔티티는 서로 다른 식별자를 갖는다.
  • 주문에서 배송지 주소가 바뀌거나 상태가 바뀌더라도 주문번호가 바뀌지는 않는 것처럼 엔티티의 식별자는 바뀌지 않는다.
    • 엔티티를 생성하고 속성을 바꾸고 삭제할 때까지 식별자는 유지된다.
  • 엔티티의 식별자는 바뀌지 않고 고유하기 때문에 두 엔티티 객체의 식별자가 같으면 두 엔티티는 같다고 판단할 수 있다.
    • 엔티티를 구현한 클래스는 식별자를 이용해서 equals() 메서드와 hasCode() 메서드를 구현할 수 있다.
    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;
    	}
    }
    

1.6.2 엔티티의 식별자 생성

  • 엔티티의 식별자를 생성하는 시점은 도메인의 특징과 사용하는 기술에 따라 달라진다.
    • 특정 규치에 따라 생성
    • UUID나 Nano ID와 같은 고유 식별자 생성기 사용
    • 값을 직접 입력
    • 일련번호 사용(시퀀스나 DB의 자동 증가 컬럼 사용)
  • 흔히 사용되는 규칙은 현재 시간과 다른 값을 함께 조합하는 것
    • 날짜와 시간을 이용해서 식별자를 생성할 때 주의할 점은 같은 시간에 동시에 식별자를 생성해도 같은 식별자가 만들어지면 안 된다는 것이다.
  • UUID를 사용해서 식별자를 생성할 수 있다.
    • 다수의 개발언어가 UUID 생성기를 제공하고 있으므로 마땅한 규칙이 없다면 UUID를 식별자로 사용
    • 자바는 java.util.UUID 클래스를 사용해서 UUID를 생성할 수 있다.
  • 회원의 아이디나 이메일과 같은 식별자는 값을 직접 입력한다.
    • 사용자가 직접 입력하는 값이기 때문에 식별자를 중복해서 입력되지 않도록 사전에 방지하는 것이 중요
  • 일련번호를 식별자로 사용하기도 한다.
    • 일련번호 방식은 주로 데이터베이스가 제공하는 자동 증가 기능을 사용한다.
    • 자동 증가 칼럼은 DB 테이블에 데이터를 삽입해야 비로소 값을 알수 있기 때문에 테이블에 테이터를 추가하기 전에는 식별자를 알수 없다.

1.6.3 밸류 타입

  • 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;
    	}
    }
    
    • ShippingInfo의 주소 관련 데이터도 다음의 Address 밸류 타입을 사용해서 보다 명확하게 표현할 수 있다.
    public class Address {
    	private String address1;
    	private String address2;
    	private String zipcode;
    
    	public Address(String address1, String address2, String zipcode) {
    		this.address1 = address1;
    		this.address2 = address2;
    		this.zipcode = zipcode;
    	}
    	...
    }
    
    • 밸류 타입을 이용해서 ShippingInfo 클래스를 다시 구현해보기
    public class ShippingInfo {
    	private Receiver receiver;
    	private Address address;
    
    	...
    }
    
  • 밸류 타입을 사용함으로써 개념적으로 완전한 하나를 잘 표현할 수 있다.
  • OrderLine의 price와 amounts는 int 타입의 숫자를 사용하고 있지만 이들은 ‘돈’을 의미하는 값이다.
    • Money 타입을 만들어 사용하면 코드를 이해하는데 도움이 된다.
    public class Money {
    	private int value;
    	
    	public Money(int value) {
    		this.value = value;
    	}
    
    	public int getValue() {
    		return this.value;
    	}
    }
    
    • Money를 사용하도록 OrderLine을 변경
    public class OrderLine {
    	private Product product;
    	private Money price;
    	private int quantity;
    	private Money amounts;
    	...
    }
    
  • 밸류타입의 또 다른 장점은 밸류 타입을 위한 기능을 추가할 수 있다.
    • Money 타입은 돈 계산을 위한 기능을 추가할 수 있다.
    public class Money {
    	private int value;
    	
    	public Money add(Money money) {
    		return new Money(this.value + money.getValue());
    	}
    
    	public Money multiply(int multiplier) {
    		return new Money(value * multiplier)
    	}
    }
    
  • 밸류 객체의 데이터를 변경할 때는 기존 데이터를 변경하기보다는 변경한 데이터를 갖는 새로운 밸류 객체를 생성하는 방식을 선호한다.
    • Money 처럼 데이터 변경 기능을 제공하지 않는 타입을 불변이라고 표현한다.
    • 밸류 타입을 불변으로 구현하는 여러 이유가 있는데 가장 중요한 이유는 안전한 코드를 작성할 수 있다는 데 있다.
    • Money가 setValue()와 같은 메서드를 제공해서 값을 변경할 수 있다면 어떻게 될까?
      • OrderLine의 price 값이 잘못 반영되는 상황이 발생하게 된다.
      Money price = new Money(1000);
      OrderLine line = new OrderLine(product, price, 2);   -> [price=1000, auantity=2, amount=2000]
      price.setValue(2000);                                -> [price=2000, auantity=2, amount=2000]
      
      • 문제가 발생하는 것을 방지하려면 OrderLine 생성자는 새로운 Money 객체를 생성하도록 코드를 작성 해야한다.
      public class OrderLine {
      	...
      	private Money price;
      	
      	public OrderLine(Product product, int quantity, Money price) {
      		this.product = product;
      		this.price = new Money(price.getValue());
      		this.quantity = quantity;
      		this.amounts = caculateAmounts();
      	}
      }
      
  • 두 밸류 객체를 비교할 때는 모든 속성이 같은지 비교한다.
  • public class Receiver { private String name; private String phoneNumber; @Override public boolean equals(Object obj) { if (this == obj) return true; if (obj == null) return false; if (obj.getClass() != Receiver.class) return false; Receiver that = (Receiver) obj; return this.name.equals(that.name) && this.phoneNumber.equals(that.phoneNumber); } }

1.6.4 엔티티 식별자와 밸류 타입

  • 엔티티 식별자의 실제 데이터는 String과 같은 문자열로 구성된 경우가 많다.
    • 신용카드 번호 16개의 숫자로 구성된 문자열
    • 회원을 구분할 때 사용하는 이메일 주소
  • Order의 식별자 타입으로 String 대신 OrderNo 밸류 타입을 사용하면 타입을 통해 해당 필드가 주문번호라는 것을 알수 있다.
    • OrderNo 대시에 String 타입을 사용한다면 ‘id’란 이름만으로는 해당 필드가 주문번호인지 알수 없다.
    • 필드의 의미가 드러나도록 하려면 ‘id’라는 필드 이름 대신 ‘orderNo’라는 필드 이름ㅇ르 사용해야 한다.

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

  • 도메인 모델에서 get/set 메서드를 무조건 추가하는 것은 좋지 않은 버릇이다.
  • set 메서드는 도메인의 핵심 개념이나 의도를 코드에서 사라지게 한다.
  • Order 메서드를 set메서드로 변경
    • changeShippingInfo()가 배송지를 새로 변경한다는 의미를 가졌다면 setShippingInfo() 메서드는 단순히 배송지 값을 설정한다는 것을 의미
    • completePayment()는 결제를 완료했다는 의미를 갖는 반면 setOrderState() 단순히 주문 상태 값을 설정한다는 것을 의미
    public class Order {
    	...
    	public void setShippingInfo(ShippingInfo newShipping) {...}
    	public void setOrderState(OrderState state) {...}
    }
    
  • set 메서드는 필드값만 변경하고 끝나기 때문에 상태 변경과 관련된 도메인 지식이 코드에서 사라지게 된다.
  • set 메서드의 또다른 문제는 도매인 객체를 생성할 때 온전하지 않는 상태가 될수 있다.
  • Order order = new Order(); order.setOrderLine(lines); order.setShippingInfo(shippingInfo); // 주문자를 설정하지 않은 상태로 주문 완료 처리 order.setState(OrderState.PREPARING);
  • 도메인 객체가 불완전한 상태로 사용되는 것을 막으려면 생성 시점에 필요한 것을 전달해 주어야 한다.
    • 생성자를 통해 필요한 데이터를 모두 받아야 한다.
    • 생성자로 필요한 것을 모두 받으므로 생성자를 호출하는 시점에 필요한 데이터가 올바른지 검사할 수 있다.
  • 불변 밸류 타입을 사용하면 자연스럽게 밸류 타입에는 set 메서드를 구현하지 않는다.
    • set 메서드를 구현해야 할 특별한 이유가 없다면 불변 타입의 장점을 살릴 수 있도록 밸류 타입은 불변으로 구현한다.

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

  • 코드를 작성할 때 도메인에서 사용하는 용어는 매우 중요하다
  • 도메인에서 사용하는 용어를 코드에 반영하지 않으면 그 코드는 개발자에게 코드의 이미를 해석해야 하는 부담을 준다.
    • ex) 주문 상태
      public class Order {
      	public void changeShippingInfo(ShippingInfo newShippingInfo) {
      		verifyStep1OrStep2();
      		setShippingInfo(newShippingInfo);
      	}
      
      	private void verifyStep1OrStep2() {
      		if (state != OrderState.STEP1 && state != OrderState.STEP2) {
      			throw new IllegalArgumentException("aleady shipped");
      		}
      	}}
      
    • public enum OrderState { STEP1, STEP2, STEP3, STEP4, STEP5, STEP6 }
    • 실제 코드의 의미를 이해하려면 STEP1, STEP2가 각각 “결제 대기 중” 상태와 “상품 준비 중” 상태를 의미한다는 것을 알아야 한다.
    • 업무 회의에서 “출고 전”이라는 단어를 사용하면 개발자는 머릿속으로 “출고 전은 STEP1, STEP2라고 도메인 지식을 코드로 해석해야 한다.
    • 도메인 용어를 사용하면 불필요한 변환 과정을 거치지 않아도 된다.
    • public enum OrderState { PAYMENT_WAITING, PREPARING, SHIPPED, DELIVERING, DELIVERY_COMPLETED, CANCELED }
    • 코드의 가독성을 높여서 코드를 분석하고 이해하는 시간을 줄여준다.
    • 최대한 도메인 용어를 사용해서 도메인 규칙을 코드로 작성하게 되므로 버그도 줄어 든다.
  • 에릭 에반스는 도메인 주도 설계에서 언어의 중요함을 강조하기 위해 유비쿼터스 언어라는 용어를 사용했다.
  • 전문가, 관계자, 개발자가 도메인과 관련된 공통의 언어를 만들고 이를 대화, 문서, 도메인 모델, 코드, 텍스트 등 모든 곳에서 같은 용어를 사용한다.
  • 소통 과정에서 발생하는 용어의 모호함을 줄일 수 있고 개발자는 도메인과 코드 사이에서 불필요한 해석 과정을 줄일 수 있다.
  • 새로 발견한 용어는 코드나 문서에도 반영해서 산출물에 최신 모델을 적용한다.
  • 알맞은 영어 단어를 찾는 것은 쉽지 않은 일이지만 시간을 들여 찾는 노력을 해야 한다.
  • 도메인 용어에 알맞은 단어를 찾는 시간을 아까워하지 말자

1.1 도메인이란?

  • 온라인 서점을 구현할 소프트웨어라고 봤을때 도메인이란?
    • 상품 조회, 구매, 결제, 배송 추적 등
  • 온라인 서점 도메인은 몇개의 하위 도메인으로 나눌 수 있다.
    • 카탈로그 하위 도메인은 고객에게 구매할 수 있는 상품 목록을 제공하고, 주문 하위 도메인은 고객의 주문을 처리
    • 혜택의 하위 도메인은 쿠폰이나 특별 할인과 같은 서비스 제공
  • 하위 도메인은 다른 하위 도메인과 연동하여 완전한 기능을 제공 한다.
    • 고객이 물건을 구매하면 주문, 결제, 배송, 혜택 하위 도메인의 기능이 엮이게 된다.
  • 특정 도메인을 위한 소프트웨어라고 해서 도메인이 제공해야 할 모든 기능을 직접 구현하는 것은 아님
    • 배송 도메인의 일부 기능은 자체 시스템으로 구현하고 나머지 기능은 외부 업체의 시스템을 사용
    • 결제 시스템도 결제 대행업체를 이용해서 처리할 때가 많다
  • 하위 도메인을 어떻게 구성할지 여부는 상황에 따라 달라진다.
    • 기업 고객을 대상으로 대형 장비를 판매하는 곳은 온라인으로 카탈로그를 제공하고 주문서를 받는 정도만 필요
    • 일반 고객을 대상으로 물건을 판매한다면 카탈로그, 리뷰, 주문, 결제, 배송, 회원 기능 등이 필요

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

  • 도메인 전문가는 해당 도메인에 대한 지식과 경험을 바탕으로 본인들이 원하는 기능 개발을 요구
    • 회계 담당자는 엑셀로 맞추던 정산 금액 계산을 자동화 해주는 기능 요구
    • AS 기사는 고객에게 보내느 문자 메세지를 빠르게 입력할 수 있는 템플릿 추천 기능 요구
  • 개발자는 요구사항을 분석하고 설계하여 코드를 작성하며 테스트하고 배포
    • 요구사항은 첫 단추와 같다
    • 첫 단추를 잘못 끼우면 모든 단추가 잘못 끼워지듯 요구사항을 올바르게 이해하지 못하면 엉뚱한 기능이 만들어진다.
    • 잘못 개발한 코드를 올바르게 고치려면 많은 노력이 필요
  • 개발자와 전문가가 직업 대화하는 것이 요구사항을 올바르게 이해할 수 있다
    • 개발자와 전문가 사이에 내용을 전파하는 전달자가 많으면 많을수록 왜곡되고 손실이 발생
  • 도메인 전문가 만큼은 아니겠지만 이해관계자와 개발자도 도메인 지식을 갖춰야 한다.

1.3 도메인 모델

  • 도메인 모델은 다양한 정의가 존재
  • 도메인 모델은 특정 도메인을 개념적으로 표현한 것
  • 주문 도메인을 주문 모델 객체 모델로 구성하면 그림과 같이 만들 수 있다.
    • 모델은 도메인의 모든 내용을 담고 있지는 않음
    • 여러 관계자들이 동일한 모습으로 도메인을 이해하고 도메인 지식을 공유하는데 도움이 됨
  • 도메인을 이해하려면 도메인이 제공하는 기능과 도메인의 주요 데이터 구성을 파악 해야함
  • 기능과 데이터를 함께 보여주는 객체 모델은 도메인을 모델링하기에 적합
  • 상태 다이어그램을 이용해서 주문의 상태 전이를 모델링 할 수 있다.
  • 도메인 모델을 표현할 때 클래스 다이어그램이나 상태 다이어그램과 같은 UML표기법만 사용해야하는 것은 아니다.
    • 도메인을 이해하는 데 도움이 된다면 표현 방식이 무엇인지는 중요하지 않음
  • 도메인 모델은 기본적으로 도메인 자체를 이해하기 위한 개념 모델
    • 개념 모델을 이용해서 바로 코드를 작성할 수 있는 것은 아님
  • 객체 기반 모델을 기반으로 도메인을 표현했다면 객체 지향 언어를 이용해 개념모델에 가깝게 구현할 수 있다.

1.4 도메인 모델 패턴

  • 일반적인 애플리케이션의 아키텍처는 네 개의 영역으로 구성된다
  • 각 영역의 역활영역 설명
    사용자 인터페이스(UI) 또는 표현 사용자의 요청을 처리하고 사용자에게 정보를 보여준다. 사용자는 소프트웨어를 사용하는 사람뿐만 아니라 외부 시스템일 수 있다.
    응용 사용자가 요청한 기능을 실행한다. 업무 로직을 직접 구현하지 않으며 도메인 계층을 조합해서 기능을 실행한다.
    도메인 시스템이 제공할 도메인 규칙을 구현한다.
    인프라스트럭처 데이터베이스나 메시징 시스템과 같은 외부 시스템과의 연동을 처리한다.
  • 도메인 모델은 아키텍처 상의 도메인 계층을 객체 지향 기법으로 구현하는 패턴을 말한다.
  • 도메인 계층은 도메인의 핵심 규칙을 구현한다.
    • “출고 전에 배송지를 변경할 수 있다” 규칙과 “주문 취소는 배송 전에만 할 수 있다”라는 규칙을 구현한 코드가 도메인 계층에 위치하게 된다.
  • 도메인 규칙을 객체지향 기법으로 구현하는 패턴이 도메인 모델 패턴이다.
  • 주문 도메인의 일부 기능을 도메인 모델 패턴으로 구현한 코드
    • OrderState는 배송지를 변경할 수 있는지 검사할 수 있는 isShippingChangeable() 메서드를 제공
    • 주문 대기 중, 상품 준비 중 상태의 isShippingChangeable() 메서드는 true를 리턴
      • 주문 대기중이거나 상품 준비중에는 배송지를 변겨할 수 있다는 도메인 규칙을 구현
    • chagneShippingInfo() 메서드는 isshippingChangeable() 메서드를 이용해서 변경 가능한 경우에만 배송지를 변경한다.
    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 OrdderState {
    	PAYMENT_WAITING {
    		public boolean isShippingChangeable() {
    			return true;
    		}
    	},
    	PREPARING {
    		public boolean isShippingChangeable() {
    			return true;
    		}
    	},
    	SHIPPED, DELIVERING, DELIVERY_COMPLETED;
    
    	public boolean isShippingChangeable() {
    		return false;
    	}
    }
    
  • OrderState는 Order에 속한 데이터이므로 배송지 정보 변경 가능 여부를 판단하는 코드를 Ordre로 이동
    • 배송지 변경이 가능한지를 판단할 규칙이 주문 상태와 다른 정보를 함꼐 사용한다면 OrderState만으로는 배송지 변경 가능 여부를 판단할 수 없으므로 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 OrdderState {
    	PAYMENT_WAITING, PREPARING, SHIPPED, DELIVERING, DELIVERY_COMPLETED;
    }
    
  • 배송지 변경 가능 여부를 판단하는 기능이 Order에 있든 OrderState에 있든 중요한 점은 주문과 관련된 중요 업무 규칙을 주문 도메인 모델인 Order나 OrderState에서 구현한다는 점
  • 핵심 규칙을 구현한 코든느 도메인 모델에만 위치하기 때문에 규칙이 바뀌거나 규칙을 확장해야할 때 다른 코드에 영향을 덜 주고 변경 내역을 모델에 반영할 수 있다.

개념 모델과 구현 모델

  • 개념 모델은 데이터베이스, 트렌잭션 처리, 성능, 구현 기술과 같은 것을 고려하고 있지 않기 때문에 실제 코드를 작성할 때 개념 모델을 있는 그대로 사용할 수 없다.
  • 개념 모델을 만들 때 처음부터 완벽하게 도메인을 표현하는 모델을 만드는 시도를 할 수 있지만 실제로는 불가능하다.
  • 프로젝트 초기에 완벽한 도메인 모델을 만들더라도 결국 도메인에 대한 새로운 지식이 쌓이면서 모델을 보완하거나 변경하는 일이 발생한다.
  • 처음부터 완벽한 개념 모델을 만들기보다는 전반적인 개요를 알수 있는 수준으로 개념 모델을 작성해야한다.
  • 프로젝트 초기에는 개요 수준의 개념 모델로 도메인에 대한 전체 윤곽을 이해하는데 집중하고, 구현하는 과정에서 개념 모델을 구현 모델로 점진적으로 발전시켜 나가야 한다.

1.5 도메인 모델 도출

  • 뛰어난 개발자라 할지라도 도메인에 대한 이해 없이 코딩을 시작할 수는 없다.
    • 기획서, 유스케이스, 사용자 스토리보드와 같은 요구사항
    • 관련자와의 대화를 통해 도메인을 이해
  • 도메인에 대한 이해를 해야 도메인 모델 초안을 만들어야 코드를 작성할 수 있다.
  • 도메인 모델링할 때 기본이 되는 작업은 모델을 구성하는 핵심 구성요소, 규칙, 기능을 찾는 것
    • ex) 주문 도메인과 관련된 몇 가지 요구사항
      • 최소 한 종류 이상의 상품을 주문해야 한다.
      • 한 상품은 한 개 이상 주문할 수 있다.
      • 총 주문 금액은 각 상품의 구매 가격 합을 모두 더한 금액
      • 각 상품의 구매 가격 합은 상품 가격에 구매 개수를 곱한 값
      • 주문할 때 배송지 정보를 반드시 지정해야 한다.
      • 출고를 하면 배송지를 변경할 수 없다.
      • 출고 전에 주문을 취소할 수 있다.
      • 고객이 결제를 완료하기 전에는 상품을 준비하지 않는다.
    • 주문은 “출고 상태로 변경하기”, “배송지 정보 변경하기”, “주문 취소하기”, “결제 완료하기” 기능을 제공 해야한다.
      • Order 기능 관련 메서드
      • public class Order { public void changeShipped() {...} public void changeShippingInfo(ShippingInfo newShipping) {...} public void cancel() {...} public void completedPayment() {...} }
    • 주문 항목의 데이터 구성을 알려준다.
      • 한 상품은 한 개 이상 주문할 수 있다.
      • 각 상품의 구매 가격 합은 상품 가격에 구매 개수를 곱한 값
    • 주문 항목은 “주문할 상품”, “상품의 가격”, “구매 개수”를 포함 해야한다.
    • 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(); } private int calculateAmounts() { return price * quantity } public int getAmounts() {...} }
    • 주문과 주문 항목의 관계를 알려준다.
      • 최소 한 종류 이상의 상품을 주문해야 한다.
      • 총 주문 금액은 각 상품의 구매 가격 합을 모두 더한 금액이다.
      public class Order {
      	private List<OrderLine> orderLines;
      	private Money totalAmounts;
      
      	public Order(List<OrderLine> orderLines) {
      		setOrderLines(orderLines);
      	}
      
      	private void setOrderLines(List<OrderLine> orderLines) {
      		verifyAtLeastOneOrMoreOrderLine(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);
      	}
      	...
      }
      
    • 배송지 정보 클래스
    • public class ShippingInfo { private String receiverName; private String receiverPhoneNumber; private String shippingAddress1; private String shippingAddress2; private String shippingZipcode; ... }
    • “주문할 때 배송지 정보를 반드시 지정해야 한다” 요구사항은 주문을 생성할 때 주문항목의 목록뿐만 아니라 배송지 정보도 함께 전달 해야한다.
    • public class Order { private List<OrderLine> orderLines; 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; } ... }
    • 도메인의 특정 조건이나 상태에 따라 제약이나 규칙이 달리 적용되는 경우가 많다.
      • 출고를 하면 배송지 정보를 변경할 수 없다.
      • 출고 전에 주문을 취소할 수 있다.
      • 고객이 결제를 완료하기 전에는 상품을 준비하지 않는다.
    • 다른 요구사항을 확인해서 추가로 존재할 수 있는 상태를 분석한 뒤, 열거 타입을 이용해서 상태 정보를 표현할 수 있다.
    • 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 IllegalArgumentException("aleady shipped"); } } ... }
  • 만들어진 모델 요구사항을 도메인 전문가나 다른 개발자와 논의하는 과정에서 공유하기도 한다.
  • 모델을 공유할 때는 화이트보드나 위키와 같은 도구를 사용해서 누구나 쉽게 접근할 수 있도록 하면 좋다.

1.6 엔티티와 밸류

  • 요구사항에서 도출한 주문 도메인 모델은 크게 엔티티와 밸류로 구분된다.

1.6.1 엔티티

  • 엔티티의 가장 큰 특징은 식별자를 가진다는 것
  • 식별자는 엔티티 객체마다 고유해서 각 엔티티는 서로 다른 식별자를 갖는다.
  • 주문에서 배송지 주소가 바뀌거나 상태가 바뀌더라도 주문번호가 바뀌지는 않는 것처럼 엔티티의 식별자는 바뀌지 않는다.
    • 엔티티를 생성하고 속성을 바꾸고 삭제할 때까지 식별자는 유지된다.
  • 엔티티의 식별자는 바뀌지 않고 고유하기 때문에 두 엔티티 객체의 식별자가 같으면 두 엔티티는 같다고 판단할 수 있다.
    • 엔티티를 구현한 클래스는 식별자를 이용해서 equals() 메서드와 hasCode() 메서드를 구현할 수 있다.
    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;
    	}
    }
    

1.6.2 엔티티의 식별자 생성

  • 엔티티의 식별자를 생성하는 시점은 도메인의 특징과 사용하는 기술에 따라 달라진다.
    • 특정 규치에 따라 생성
    • UUID나 Nano ID와 같은 고유 식별자 생성기 사용
    • 값을 직접 입력
    • 일련번호 사용(시퀀스나 DB의 자동 증가 컬럼 사용)
  • 흔히 사용되는 규칙은 현재 시간과 다른 값을 함께 조합하는 것
    • 날짜와 시간을 이용해서 식별자를 생성할 때 주의할 점은 같은 시간에 동시에 식별자를 생성해도 같은 식별자가 만들어지면 안 된다는 것이다.
  • UUID를 사용해서 식별자를 생성할 수 있다.
    • 다수의 개발언어가 UUID 생성기를 제공하고 있으므로 마땅한 규칙이 없다면 UUID를 식별자로 사용
    • 자바는 java.util.UUID 클래스를 사용해서 UUID를 생성할 수 있다.
  • 회원의 아이디나 이메일과 같은 식별자는 값을 직접 입력한다.
    • 사용자가 직접 입력하는 값이기 때문에 식별자를 중복해서 입력되지 않도록 사전에 방지하는 것이 중요
  • 일련번호를 식별자로 사용하기도 한다.
    • 일련번호 방식은 주로 데이터베이스가 제공하는 자동 증가 기능을 사용한다.
    • 자동 증가 칼럼은 DB 테이블에 데이터를 삽입해야 비로소 값을 알수 있기 때문에 테이블에 테이터를 추가하기 전에는 식별자를 알수 없다.

1.6.3 밸류 타입

  • 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;
    	}
    }
    
    • ShippingInfo의 주소 관련 데이터도 다음의 Address 밸류 타입을 사용해서 보다 명확하게 표현할 수 있다.
    public class Address {
    	private String address1;
    	private String address2;
    	private String zipcode;
    
    	public Address(String address1, String address2, String zipcode) {
    		this.address1 = address1;
    		this.address2 = address2;
    		this.zipcode = zipcode;
    	}
    	...
    }
    
    • 밸류 타입을 이용해서 ShippingInfo 클래스를 다시 구현해보기
    public class ShippingInfo {
    	private Receiver receiver;
    	private Address address;
    
    	...
    }
    
  • 밸류 타입을 사용함으로써 개념적으로 완전한 하나를 잘 표현할 수 있다.
  • OrderLine의 price와 amounts는 int 타입의 숫자를 사용하고 있지만 이들은 ‘돈’을 의미하는 값이다.
    • Money 타입을 만들어 사용하면 코드를 이해하는데 도움이 된다.
    public class Money {
    	private int value;
    	
    	public Money(int value) {
    		this.value = value;
    	}
    
    	public int getValue() {
    		return this.value;
    	}
    }
    
    • Money를 사용하도록 OrderLine을 변경
    public class OrderLine {
    	private Product product;
    	private Money price;
    	private int quantity;
    	private Money amounts;
    	...
    }
    
  • 밸류타입의 또 다른 장점은 밸류 타입을 위한 기능을 추가할 수 있다.
    • Money 타입은 돈 계산을 위한 기능을 추가할 수 있다.
    public class Money {
    	private int value;
    	
    	public Money add(Money money) {
    		return new Money(this.value + money.getValue());
    	}
    
    	public Money multiply(int multiplier) {
    		return new Money(value * multiplier)
    	}
    }
    
  • 밸류 객체의 데이터를 변경할 때는 기존 데이터를 변경하기보다는 변경한 데이터를 갖는 새로운 밸류 객체를 생성하는 방식을 선호한다.
    • Money 처럼 데이터 변경 기능을 제공하지 않는 타입을 불변이라고 표현한다.
    • 밸류 타입을 불변으로 구현하는 여러 이유가 있는데 가장 중요한 이유는 안전한 코드를 작성할 수 있다는 데 있다.
    • Money가 setValue()와 같은 메서드를 제공해서 값을 변경할 수 있다면 어떻게 될까?
      • OrderLine의 price 값이 잘못 반영되는 상황이 발생하게 된다.
      Money price = new Money(1000);
      OrderLine line = new OrderLine(product, price, 2);   -> [price=1000, auantity=2, amount=2000]
      price.setValue(2000);                                -> [price=2000, auantity=2, amount=2000]
      
      • 문제가 발생하는 것을 방지하려면 OrderLine 생성자는 새로운 Money 객체를 생성하도록 코드를 작성 해야한다.
      public class OrderLine {
      	...
      	private Money price;
      	
      	public OrderLine(Product product, int quantity, Money price) {
      		this.product = product;
      		this.price = new Money(price.getValue());
      		this.quantity = quantity;
      		this.amounts = caculateAmounts();
      	}
      }
      
  • 두 밸류 객체를 비교할 때는 모든 속성이 같은지 비교한다.
  • public class Receiver { private String name; private String phoneNumber; @Override public boolean equals(Object obj) { if (this == obj) return true; if (obj == null) return false; if (obj.getClass() != Receiver.class) return false; Receiver that = (Receiver) obj; return this.name.equals(that.name) && this.phoneNumber.equals(that.phoneNumber); } }

1.6.4 엔티티 식별자와 밸류 타입

  • 엔티티 식별자의 실제 데이터는 String과 같은 문자열로 구성된 경우가 많다.
    • 신용카드 번호 16개의 숫자로 구성된 문자열
    • 회원을 구분할 때 사용하는 이메일 주소
  • Order의 식별자 타입으로 String 대신 OrderNo 밸류 타입을 사용하면 타입을 통해 해당 필드가 주문번호라는 것을 알수 있다.
    • OrderNo 대시에 String 타입을 사용한다면 ‘id’란 이름만으로는 해당 필드가 주문번호인지 알수 없다.
    • 필드의 의미가 드러나도록 하려면 ‘id’라는 필드 이름 대신 ‘orderNo’라는 필드 이름ㅇ르 사용해야 한다.

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

  • 도메인 모델에서 get/set 메서드를 무조건 추가하는 것은 좋지 않은 버릇이다.
  • set 메서드는 도메인의 핵심 개념이나 의도를 코드에서 사라지게 한다.
  • Order 메서드를 set메서드로 변경
    • changeShippingInfo()가 배송지를 새로 변경한다는 의미를 가졌다면 setShippingInfo() 메서드는 단순히 배송지 값을 설정한다는 것을 의미
    • completePayment()는 결제를 완료했다는 의미를 갖는 반면 setOrderState() 단순히 주문 상태 값을 설정한다는 것을 의미
    public class Order {
    	...
    	public void setShippingInfo(ShippingInfo newShipping) {...}
    	public void setOrderState(OrderState state) {...}
    }
    
  • set 메서드는 필드값만 변경하고 끝나기 때문에 상태 변경과 관련된 도메인 지식이 코드에서 사라지게 된다.
  • set 메서드의 또다른 문제는 도매인 객체를 생성할 때 온전하지 않는 상태가 될수 있다.
  • Order order = new Order(); order.setOrderLine(lines); order.setShippingInfo(shippingInfo); // 주문자를 설정하지 않은 상태로 주문 완료 처리 order.setState(OrderState.PREPARING);
  • 도메인 객체가 불완전한 상태로 사용되는 것을 막으려면 생성 시점에 필요한 것을 전달해 주어야 한다.
    • 생성자를 통해 필요한 데이터를 모두 받아야 한다.
    • 생성자로 필요한 것을 모두 받으므로 생성자를 호출하는 시점에 필요한 데이터가 올바른지 검사할 수 있다.
  • 불변 밸류 타입을 사용하면 자연스럽게 밸류 타입에는 set 메서드를 구현하지 않는다.
    • set 메서드를 구현해야 할 특별한 이유가 없다면 불변 타입의 장점을 살릴 수 있도록 밸류 타입은 불변으로 구현한다.

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

  • 코드를 작성할 때 도메인에서 사용하는 용어는 매우 중요하다
  • 도메인에서 사용하는 용어를 코드에 반영하지 않으면 그 코드는 개발자에게 코드의 이미를 해석해야 하는 부담을 준다.
    • ex) 주문 상태
      public class Order {
      	public void changeShippingInfo(ShippingInfo newShippingInfo) {
      		verifyStep1OrStep2();
      		setShippingInfo(newShippingInfo);
      	}
      
      	private void verifyStep1OrStep2() {
      		if (state != OrderState.STEP1 && state != OrderState.STEP2) {
      			throw new IllegalArgumentException("aleady shipped");
      		}
      	}}
      
    • public enum OrderState { STEP1, STEP2, STEP3, STEP4, STEP5, STEP6 }
    • 실제 코드의 의미를 이해하려면 STEP1, STEP2가 각각 “결제 대기 중” 상태와 “상품 준비 중” 상태를 의미한다는 것을 알아야 한다.
    • 업무 회의에서 “출고 전”이라는 단어를 사용하면 개발자는 머릿속으로 “출고 전은 STEP1, STEP2라고 도메인 지식을 코드로 해석해야 한다.
    • 도메인 용어를 사용하면 불필요한 변환 과정을 거치지 않아도 된다.
    • public enum OrderState { PAYMENT_WAITING, PREPARING, SHIPPED, DELIVERING, DELIVERY_COMPLETED, CANCELED }
    • 코드의 가독성을 높여서 코드를 분석하고 이해하는 시간을 줄여준다.
    • 최대한 도메인 용어를 사용해서 도메인 규칙을 코드로 작성하게 되므로 버그도 줄어 든다.
  • 에릭 에반스는 도메인 주도 설계에서 언어의 중요함을 강조하기 위해 유비쿼터스 언어라는 용어를 사용했다.
  • 전문가, 관계자, 개발자가 도메인과 관련된 공통의 언어를 만들고 이를 대화, 문서, 도메인 모델, 코드, 텍스트 등 모든 곳에서 같은 용어를 사용한다.
  • 소통 과정에서 발생하는 용어의 모호함을 줄일 수 있고 개발자는 도메인과 코드 사이에서 불필요한 해석 과정을 줄일 수 있다.
  • 새로 발견한 용어는 코드나 문서에도 반영해서 산출물에 최신 모델을 적용한다.
  • 알맞은 영어 단어를 찾는 것은 쉽지 않은 일이지만 시간을 들여 찾는 노력을 해야 한다.
  • 도메인 용어에 알맞은 단어를 찾는 시간을 아까워하지 말자

1.1 도메인이란?

  • 온라인 서점을 구현할 소프트웨어라고 봤을때 도메인이란?
    • 상품 조회, 구매, 결제, 배송 추적 등
  • 온라인 서점 도메인은 몇개의 하위 도메인으로 나눌 수 있다.
    • 카탈로그 하위 도메인은 고객에게 구매할 수 있는 상품 목록을 제공하고, 주문 하위 도메인은 고객의 주문을 처리
    • 혜택의 하위 도메인은 쿠폰이나 특별 할인과 같은 서비스 제공
  • 하위 도메인은 다른 하위 도메인과 연동하여 완전한 기능을 제공 한다.
    • 고객이 물건을 구매하면 주문, 결제, 배송, 혜택 하위 도메인의 기능이 엮이게 된다.
  • 특정 도메인을 위한 소프트웨어라고 해서 도메인이 제공해야 할 모든 기능을 직접 구현하는 것은 아님
    • 배송 도메인의 일부 기능은 자체 시스템으로 구현하고 나머지 기능은 외부 업체의 시스템을 사용
    • 결제 시스템도 결제 대행업체를 이용해서 처리할 때가 많다
  • 하위 도메인을 어떻게 구성할지 여부는 상황에 따라 달라진다.
    • 기업 고객을 대상으로 대형 장비를 판매하는 곳은 온라인으로 카탈로그를 제공하고 주문서를 받는 정도만 필요
    • 일반 고객을 대상으로 물건을 판매한다면 카탈로그, 리뷰, 주문, 결제, 배송, 회원 기능 등이 필요

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

  • 도메인 전문가는 해당 도메인에 대한 지식과 경험을 바탕으로 본인들이 원하는 기능 개발을 요구
    • 회계 담당자는 엑셀로 맞추던 정산 금액 계산을 자동화 해주는 기능 요구
    • AS 기사는 고객에게 보내느 문자 메세지를 빠르게 입력할 수 있는 템플릿 추천 기능 요구
  • 개발자는 요구사항을 분석하고 설계하여 코드를 작성하며 테스트하고 배포
    • 요구사항은 첫 단추와 같다
    • 첫 단추를 잘못 끼우면 모든 단추가 잘못 끼워지듯 요구사항을 올바르게 이해하지 못하면 엉뚱한 기능이 만들어진다.
    • 잘못 개발한 코드를 올바르게 고치려면 많은 노력이 필요
  • 개발자와 전문가가 직업 대화하는 것이 요구사항을 올바르게 이해할 수 있다
    • 개발자와 전문가 사이에 내용을 전파하는 전달자가 많으면 많을수록 왜곡되고 손실이 발생
  • 도메인 전문가 만큼은 아니겠지만 이해관계자와 개발자도 도메인 지식을 갖춰야 한다.

1.3 도메인 모델

  • 도메인 모델은 다양한 정의가 존재
  • 도메인 모델은 특정 도메인을 개념적으로 표현한 것
  • 주문 도메인을 주문 모델 객체 모델로 구성하면 그림과 같이 만들 수 있다.
    • 모델은 도메인의 모든 내용을 담고 있지는 않음
    • 여러 관계자들이 동일한 모습으로 도메인을 이해하고 도메인 지식을 공유하는데 도움이 됨
  • 도메인을 이해하려면 도메인이 제공하는 기능과 도메인의 주요 데이터 구성을 파악 해야함
  • 기능과 데이터를 함께 보여주는 객체 모델은 도메인을 모델링하기에 적합
  • 상태 다이어그램을 이용해서 주문의 상태 전이를 모델링 할 수 있다.
  • 도메인 모델을 표현할 때 클래스 다이어그램이나 상태 다이어그램과 같은 UML표기법만 사용해야하는 것은 아니다.
    • 도메인을 이해하는 데 도움이 된다면 표현 방식이 무엇인지는 중요하지 않음
  • 도메인 모델은 기본적으로 도메인 자체를 이해하기 위한 개념 모델
    • 개념 모델을 이용해서 바로 코드를 작성할 수 있는 것은 아님
  • 객체 기반 모델을 기반으로 도메인을 표현했다면 객체 지향 언어를 이용해 개념모델에 가깝게 구현할 수 있다.

1.4 도메인 모델 패턴

  • 일반적인 애플리케이션의 아키텍처는 네 개의 영역으로 구성된다
  • 각 영역의 역활영역 설명
    사용자 인터페이스(UI) 또는 표현 사용자의 요청을 처리하고 사용자에게 정보를 보여준다. 사용자는 소프트웨어를 사용하는 사람뿐만 아니라 외부 시스템일 수 있다.
    응용 사용자가 요청한 기능을 실행한다. 업무 로직을 직접 구현하지 않으며 도메인 계층을 조합해서 기능을 실행한다.
    도메인 시스템이 제공할 도메인 규칙을 구현한다.
    인프라스트럭처 데이터베이스나 메시징 시스템과 같은 외부 시스템과의 연동을 처리한다.
  • 도메인 모델은 아키텍처 상의 도메인 계층을 객체 지향 기법으로 구현하는 패턴을 말한다.
  • 도메인 계층은 도메인의 핵심 규칙을 구현한다.
    • “출고 전에 배송지를 변경할 수 있다” 규칙과 “주문 취소는 배송 전에만 할 수 있다”라는 규칙을 구현한 코드가 도메인 계층에 위치하게 된다.
  • 도메인 규칙을 객체지향 기법으로 구현하는 패턴이 도메인 모델 패턴이다.
  • 주문 도메인의 일부 기능을 도메인 모델 패턴으로 구현한 코드
    • OrderState는 배송지를 변경할 수 있는지 검사할 수 있는 isShippingChangeable() 메서드를 제공
    • 주문 대기 중, 상품 준비 중 상태의 isShippingChangeable() 메서드는 true를 리턴
      • 주문 대기중이거나 상품 준비중에는 배송지를 변겨할 수 있다는 도메인 규칙을 구현
    • chagneShippingInfo() 메서드는 isshippingChangeable() 메서드를 이용해서 변경 가능한 경우에만 배송지를 변경한다.
    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 OrdderState {
    	PAYMENT_WAITING {
    		public boolean isShippingChangeable() {
    			return true;
    		}
    	},
    	PREPARING {
    		public boolean isShippingChangeable() {
    			return true;
    		}
    	},
    	SHIPPED, DELIVERING, DELIVERY_COMPLETED;
    
    	public boolean isShippingChangeable() {
    		return false;
    	}
    }
    
  • OrderState는 Order에 속한 데이터이므로 배송지 정보 변경 가능 여부를 판단하는 코드를 Ordre로 이동
    • 배송지 변경이 가능한지를 판단할 규칙이 주문 상태와 다른 정보를 함꼐 사용한다면 OrderState만으로는 배송지 변경 가능 여부를 판단할 수 없으므로 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 OrdderState {
    	PAYMENT_WAITING, PREPARING, SHIPPED, DELIVERING, DELIVERY_COMPLETED;
    }
    
  • 배송지 변경 가능 여부를 판단하는 기능이 Order에 있든 OrderState에 있든 중요한 점은 주문과 관련된 중요 업무 규칙을 주문 도메인 모델인 Order나 OrderState에서 구현한다는 점
  • 핵심 규칙을 구현한 코든느 도메인 모델에만 위치하기 때문에 규칙이 바뀌거나 규칙을 확장해야할 때 다른 코드에 영향을 덜 주고 변경 내역을 모델에 반영할 수 있다.

개념 모델과 구현 모델

  • 개념 모델은 데이터베이스, 트렌잭션 처리, 성능, 구현 기술과 같은 것을 고려하고 있지 않기 때문에 실제 코드를 작성할 때 개념 모델을 있는 그대로 사용할 수 없다.
  • 개념 모델을 만들 때 처음부터 완벽하게 도메인을 표현하는 모델을 만드는 시도를 할 수 있지만 실제로는 불가능하다.
  • 프로젝트 초기에 완벽한 도메인 모델을 만들더라도 결국 도메인에 대한 새로운 지식이 쌓이면서 모델을 보완하거나 변경하는 일이 발생한다.
  • 처음부터 완벽한 개념 모델을 만들기보다는 전반적인 개요를 알수 있는 수준으로 개념 모델을 작성해야한다.
  • 프로젝트 초기에는 개요 수준의 개념 모델로 도메인에 대한 전체 윤곽을 이해하는데 집중하고, 구현하는 과정에서 개념 모델을 구현 모델로 점진적으로 발전시켜 나가야 한다.

1.5 도메인 모델 도출

  • 뛰어난 개발자라 할지라도 도메인에 대한 이해 없이 코딩을 시작할 수는 없다.
    • 기획서, 유스케이스, 사용자 스토리보드와 같은 요구사항
    • 관련자와의 대화를 통해 도메인을 이해
  • 도메인에 대한 이해를 해야 도메인 모델 초안을 만들어야 코드를 작성할 수 있다.
  • 도메인 모델링할 때 기본이 되는 작업은 모델을 구성하는 핵심 구성요소, 규칙, 기능을 찾는 것
    • ex) 주문 도메인과 관련된 몇 가지 요구사항
      • 최소 한 종류 이상의 상품을 주문해야 한다.
      • 한 상품은 한 개 이상 주문할 수 있다.
      • 총 주문 금액은 각 상품의 구매 가격 합을 모두 더한 금액
      • 각 상품의 구매 가격 합은 상품 가격에 구매 개수를 곱한 값
      • 주문할 때 배송지 정보를 반드시 지정해야 한다.
      • 출고를 하면 배송지를 변경할 수 없다.
      • 출고 전에 주문을 취소할 수 있다.
      • 고객이 결제를 완료하기 전에는 상품을 준비하지 않는다.
    • 주문은 “출고 상태로 변경하기”, “배송지 정보 변경하기”, “주문 취소하기”, “결제 완료하기” 기능을 제공 해야한다.
      • Order 기능 관련 메서드
      • public class Order { public void changeShipped() {...} public void changeShippingInfo(ShippingInfo newShipping) {...} public void cancel() {...} public void completedPayment() {...} }
    • 주문 항목의 데이터 구성을 알려준다.
      • 한 상품은 한 개 이상 주문할 수 있다.
      • 각 상품의 구매 가격 합은 상품 가격에 구매 개수를 곱한 값
    • 주문 항목은 “주문할 상품”, “상품의 가격”, “구매 개수”를 포함 해야한다.
    • 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(); } private int calculateAmounts() { return price * quantity } public int getAmounts() {...} }
    • 주문과 주문 항목의 관계를 알려준다.
      • 최소 한 종류 이상의 상품을 주문해야 한다.
      • 총 주문 금액은 각 상품의 구매 가격 합을 모두 더한 금액이다.
      public class Order {
      	private List<OrderLine> orderLines;
      	private Money totalAmounts;
      
      	public Order(List<OrderLine> orderLines) {
      		setOrderLines(orderLines);
      	}
      
      	private void setOrderLines(List<OrderLine> orderLines) {
      		verifyAtLeastOneOrMoreOrderLine(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);
      	}
      	...
      }
      
    • 배송지 정보 클래스
    • public class ShippingInfo { private String receiverName; private String receiverPhoneNumber; private String shippingAddress1; private String shippingAddress2; private String shippingZipcode; ... }
    • “주문할 때 배송지 정보를 반드시 지정해야 한다” 요구사항은 주문을 생성할 때 주문항목의 목록뿐만 아니라 배송지 정보도 함께 전달 해야한다.
    • public class Order { private List<OrderLine> orderLines; 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; } ... }
    • 도메인의 특정 조건이나 상태에 따라 제약이나 규칙이 달리 적용되는 경우가 많다.
      • 출고를 하면 배송지 정보를 변경할 수 없다.
      • 출고 전에 주문을 취소할 수 있다.
      • 고객이 결제를 완료하기 전에는 상품을 준비하지 않는다.
    • 다른 요구사항을 확인해서 추가로 존재할 수 있는 상태를 분석한 뒤, 열거 타입을 이용해서 상태 정보를 표현할 수 있다.
    • 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 IllegalArgumentException("aleady shipped"); } } ... }
  • 만들어진 모델 요구사항을 도메인 전문가나 다른 개발자와 논의하는 과정에서 공유하기도 한다.
  • 모델을 공유할 때는 화이트보드나 위키와 같은 도구를 사용해서 누구나 쉽게 접근할 수 있도록 하면 좋다.

1.6 엔티티와 밸류

  • 요구사항에서 도출한 주문 도메인 모델은 크게 엔티티와 밸류로 구분된다.

1.6.1 엔티티

  • 엔티티의 가장 큰 특징은 식별자를 가진다는 것
  • 식별자는 엔티티 객체마다 고유해서 각 엔티티는 서로 다른 식별자를 갖는다.
  • 주문에서 배송지 주소가 바뀌거나 상태가 바뀌더라도 주문번호가 바뀌지는 않는 것처럼 엔티티의 식별자는 바뀌지 않는다.
    • 엔티티를 생성하고 속성을 바꾸고 삭제할 때까지 식별자는 유지된다.
  • 엔티티의 식별자는 바뀌지 않고 고유하기 때문에 두 엔티티 객체의 식별자가 같으면 두 엔티티는 같다고 판단할 수 있다.
    • 엔티티를 구현한 클래스는 식별자를 이용해서 equals() 메서드와 hasCode() 메서드를 구현할 수 있다.
    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;
    	}
    }
    

1.6.2 엔티티의 식별자 생성

  • 엔티티의 식별자를 생성하는 시점은 도메인의 특징과 사용하는 기술에 따라 달라진다.
    • 특정 규치에 따라 생성
    • UUID나 Nano ID와 같은 고유 식별자 생성기 사용
    • 값을 직접 입력
    • 일련번호 사용(시퀀스나 DB의 자동 증가 컬럼 사용)
  • 흔히 사용되는 규칙은 현재 시간과 다른 값을 함께 조합하는 것
    • 날짜와 시간을 이용해서 식별자를 생성할 때 주의할 점은 같은 시간에 동시에 식별자를 생성해도 같은 식별자가 만들어지면 안 된다는 것이다.
  • UUID를 사용해서 식별자를 생성할 수 있다.
    • 다수의 개발언어가 UUID 생성기를 제공하고 있으므로 마땅한 규칙이 없다면 UUID를 식별자로 사용
    • 자바는 java.util.UUID 클래스를 사용해서 UUID를 생성할 수 있다.
  • 회원의 아이디나 이메일과 같은 식별자는 값을 직접 입력한다.
    • 사용자가 직접 입력하는 값이기 때문에 식별자를 중복해서 입력되지 않도록 사전에 방지하는 것이 중요
  • 일련번호를 식별자로 사용하기도 한다.
    • 일련번호 방식은 주로 데이터베이스가 제공하는 자동 증가 기능을 사용한다.
    • 자동 증가 칼럼은 DB 테이블에 데이터를 삽입해야 비로소 값을 알수 있기 때문에 테이블에 테이터를 추가하기 전에는 식별자를 알수 없다.

1.6.3 밸류 타입

  • 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;
    	}
    }
    
    • ShippingInfo의 주소 관련 데이터도 다음의 Address 밸류 타입을 사용해서 보다 명확하게 표현할 수 있다.
    public class Address {
    	private String address1;
    	private String address2;
    	private String zipcode;
    
    	public Address(String address1, String address2, String zipcode) {
    		this.address1 = address1;
    		this.address2 = address2;
    		this.zipcode = zipcode;
    	}
    	...
    }
    
    • 밸류 타입을 이용해서 ShippingInfo 클래스를 다시 구현해보기
    public class ShippingInfo {
    	private Receiver receiver;
    	private Address address;
    
    	...
    }
    
  • 밸류 타입을 사용함으로써 개념적으로 완전한 하나를 잘 표현할 수 있다.
  • OrderLine의 price와 amounts는 int 타입의 숫자를 사용하고 있지만 이들은 ‘돈’을 의미하는 값이다.
    • Money 타입을 만들어 사용하면 코드를 이해하는데 도움이 된다.
    public class Money {
    	private int value;
    	
    	public Money(int value) {
    		this.value = value;
    	}
    
    	public int getValue() {
    		return this.value;
    	}
    }
    
    • Money를 사용하도록 OrderLine을 변경
    public class OrderLine {
    	private Product product;
    	private Money price;
    	private int quantity;
    	private Money amounts;
    	...
    }
    
  • 밸류타입의 또 다른 장점은 밸류 타입을 위한 기능을 추가할 수 있다.
    • Money 타입은 돈 계산을 위한 기능을 추가할 수 있다.
    public class Money {
    	private int value;
    	
    	public Money add(Money money) {
    		return new Money(this.value + money.getValue());
    	}
    
    	public Money multiply(int multiplier) {
    		return new Money(value * multiplier)
    	}
    }
    
  • 밸류 객체의 데이터를 변경할 때는 기존 데이터를 변경하기보다는 변경한 데이터를 갖는 새로운 밸류 객체를 생성하는 방식을 선호한다.
    • Money 처럼 데이터 변경 기능을 제공하지 않는 타입을 불변이라고 표현한다.
    • 밸류 타입을 불변으로 구현하는 여러 이유가 있는데 가장 중요한 이유는 안전한 코드를 작성할 수 있다는 데 있다.
    • Money가 setValue()와 같은 메서드를 제공해서 값을 변경할 수 있다면 어떻게 될까?
      • OrderLine의 price 값이 잘못 반영되는 상황이 발생하게 된다.
      Money price = new Money(1000);
      OrderLine line = new OrderLine(product, price, 2);   -> [price=1000, auantity=2, amount=2000]
      price.setValue(2000);                                -> [price=2000, auantity=2, amount=2000]
      
      • 문제가 발생하는 것을 방지하려면 OrderLine 생성자는 새로운 Money 객체를 생성하도록 코드를 작성 해야한다.
      public class OrderLine {
      	...
      	private Money price;
      	
      	public OrderLine(Product product, int quantity, Money price) {
      		this.product = product;
      		this.price = new Money(price.getValue());
      		this.quantity = quantity;
      		this.amounts = caculateAmounts();
      	}
      }
      
  • 두 밸류 객체를 비교할 때는 모든 속성이 같은지 비교한다.
  • public class Receiver { private String name; private String phoneNumber; @Override public boolean equals(Object obj) { if (this == obj) return true; if (obj == null) return false; if (obj.getClass() != Receiver.class) return false; Receiver that = (Receiver) obj; return this.name.equals(that.name) && this.phoneNumber.equals(that.phoneNumber); } }

1.6.4 엔티티 식별자와 밸류 타입

  • 엔티티 식별자의 실제 데이터는 String과 같은 문자열로 구성된 경우가 많다.
    • 신용카드 번호 16개의 숫자로 구성된 문자열
    • 회원을 구분할 때 사용하는 이메일 주소
  • Order의 식별자 타입으로 String 대신 OrderNo 밸류 타입을 사용하면 타입을 통해 해당 필드가 주문번호라는 것을 알수 있다.
    • OrderNo 대시에 String 타입을 사용한다면 ‘id’란 이름만으로는 해당 필드가 주문번호인지 알수 없다.
    • 필드의 의미가 드러나도록 하려면 ‘id’라는 필드 이름 대신 ‘orderNo’라는 필드 이름ㅇ르 사용해야 한다.

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

  • 도메인 모델에서 get/set 메서드를 무조건 추가하는 것은 좋지 않은 버릇이다.
  • set 메서드는 도메인의 핵심 개념이나 의도를 코드에서 사라지게 한다.
  • Order 메서드를 set메서드로 변경
    • changeShippingInfo()가 배송지를 새로 변경한다는 의미를 가졌다면 setShippingInfo() 메서드는 단순히 배송지 값을 설정한다는 것을 의미
    • completePayment()는 결제를 완료했다는 의미를 갖는 반면 setOrderState() 단순히 주문 상태 값을 설정한다는 것을 의미
    public class Order {
    	...
    	public void setShippingInfo(ShippingInfo newShipping) {...}
    	public void setOrderState(OrderState state) {...}
    }
    
  • set 메서드는 필드값만 변경하고 끝나기 때문에 상태 변경과 관련된 도메인 지식이 코드에서 사라지게 된다.
  • set 메서드의 또다른 문제는 도매인 객체를 생성할 때 온전하지 않는 상태가 될수 있다.
  • Order order = new Order(); order.setOrderLine(lines); order.setShippingInfo(shippingInfo); // 주문자를 설정하지 않은 상태로 주문 완료 처리 order.setState(OrderState.PREPARING);
  • 도메인 객체가 불완전한 상태로 사용되는 것을 막으려면 생성 시점에 필요한 것을 전달해 주어야 한다.
    • 생성자를 통해 필요한 데이터를 모두 받아야 한다.
    • 생성자로 필요한 것을 모두 받으므로 생성자를 호출하는 시점에 필요한 데이터가 올바른지 검사할 수 있다.
  • 불변 밸류 타입을 사용하면 자연스럽게 밸류 타입에는 set 메서드를 구현하지 않는다.
    • set 메서드를 구현해야 할 특별한 이유가 없다면 불변 타입의 장점을 살릴 수 있도록 밸류 타입은 불변으로 구현한다.

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

  • 코드를 작성할 때 도메인에서 사용하는 용어는 매우 중요하다
  • 도메인에서 사용하는 용어를 코드에 반영하지 않으면 그 코드는 개발자에게 코드의 이미를 해석해야 하는 부담을 준다.
    • ex) 주문 상태
      public class Order {
      	public void changeShippingInfo(ShippingInfo newShippingInfo) {
      		verifyStep1OrStep2();
      		setShippingInfo(newShippingInfo);
      	}
      
      	private void verifyStep1OrStep2() {
      		if (state != OrderState.STEP1 && state != OrderState.STEP2) {
      			throw new IllegalArgumentException("aleady shipped");
      		}
      	}}
      
    • public enum OrderState { STEP1, STEP2, STEP3, STEP4, STEP5, STEP6 }
    • 실제 코드의 의미를 이해하려면 STEP1, STEP2가 각각 “결제 대기 중” 상태와 “상품 준비 중” 상태를 의미한다는 것을 알아야 한다.
    • 업무 회의에서 “출고 전”이라는 단어를 사용하면 개발자는 머릿속으로 “출고 전은 STEP1, STEP2라고 도메인 지식을 코드로 해석해야 한다.
    • 도메인 용어를 사용하면 불필요한 변환 과정을 거치지 않아도 된다.
    • public enum OrderState { PAYMENT_WAITING, PREPARING, SHIPPED, DELIVERING, DELIVERY_COMPLETED, CANCELED }
    • 코드의 가독성을 높여서 코드를 분석하고 이해하는 시간을 줄여준다.
    • 최대한 도메인 용어를 사용해서 도메인 규칙을 코드로 작성하게 되므로 버그도 줄어 든다.
  • 에릭 에반스는 도메인 주도 설계에서 언어의 중요함을 강조하기 위해 유비쿼터스 언어라는 용어를 사용했다.
  • 전문가, 관계자, 개발자가 도메인과 관련된 공통의 언어를 만들고 이를 대화, 문서, 도메인 모델, 코드, 텍스트 등 모든 곳에서 같은 용어를 사용한다.
  • 소통 과정에서 발생하는 용어의 모호함을 줄일 수 있고 개발자는 도메인과 코드 사이에서 불필요한 해석 과정을 줄일 수 있다.
  • 새로 발견한 용어는 코드나 문서에도 반영해서 산출물에 최신 모델을 적용한다.
  • 알맞은 영어 단어를 찾는 것은 쉽지 않은 일이지만 시간을 들여 찾는 노력을 해야 한다.
  • 도메인 용어에 알맞은 단어를 찾는 시간을 아까워하지 말자

 

728x90