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"); } } ... }
- ex) 주문 도메인과 관련된 몇 가지 요구사항
- 만들어진 모델 요구사항을 도메인 전문가나 다른 개발자와 논의하는 과정에서 공유하기도 한다.
- 모델을 공유할 때는 화이트보드나 위키와 같은 도구를 사용해서 누구나 쉽게 접근할 수 있도록 하면 좋다.
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 필드는 주소라는 하나의 개념을 표현
- receiverName, receiverPhoneNumber 필드는 서로 다른 두 데이터를 담고 있지만 두 필드는 개념적으로 받는 사람을 의미한다.
- 밸류 타입은 개념적으로 완전한 하나를 표현할 때 사용
- 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 }
- 코드의 가독성을 높여서 코드를 분석하고 이해하는 시간을 줄여준다.
- 최대한 도메인 용어를 사용해서 도메인 규칙을 코드로 작성하게 되므로 버그도 줄어 든다.
- ex) 주문 상태
- 에릭 에반스는 도메인 주도 설계에서 언어의 중요함을 강조하기 위해 유비쿼터스 언어라는 용어를 사용했다.
- 전문가, 관계자, 개발자가 도메인과 관련된 공통의 언어를 만들고 이를 대화, 문서, 도메인 모델, 코드, 텍스트 등 모든 곳에서 같은 용어를 사용한다.
- 소통 과정에서 발생하는 용어의 모호함을 줄일 수 있고 개발자는 도메인과 코드 사이에서 불필요한 해석 과정을 줄일 수 있다.
- 새로 발견한 용어는 코드나 문서에도 반영해서 산출물에 최신 모델을 적용한다.
- 알맞은 영어 단어를 찾는 것은 쉽지 않은 일이지만 시간을 들여 찾는 노력을 해야 한다.
- 도메인 용어에 알맞은 단어를 찾는 시간을 아까워하지 말자
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"); } } ... }
- ex) 주문 도메인과 관련된 몇 가지 요구사항
- 만들어진 모델 요구사항을 도메인 전문가나 다른 개발자와 논의하는 과정에서 공유하기도 한다.
- 모델을 공유할 때는 화이트보드나 위키와 같은 도구를 사용해서 누구나 쉽게 접근할 수 있도록 하면 좋다.
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 필드는 주소라는 하나의 개념을 표현
- receiverName, receiverPhoneNumber 필드는 서로 다른 두 데이터를 담고 있지만 두 필드는 개념적으로 받는 사람을 의미한다.
- 밸류 타입은 개념적으로 완전한 하나를 표현할 때 사용
- 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 }
- 코드의 가독성을 높여서 코드를 분석하고 이해하는 시간을 줄여준다.
- 최대한 도메인 용어를 사용해서 도메인 규칙을 코드로 작성하게 되므로 버그도 줄어 든다.
- ex) 주문 상태
- 에릭 에반스는 도메인 주도 설계에서 언어의 중요함을 강조하기 위해 유비쿼터스 언어라는 용어를 사용했다.
- 전문가, 관계자, 개발자가 도메인과 관련된 공통의 언어를 만들고 이를 대화, 문서, 도메인 모델, 코드, 텍스트 등 모든 곳에서 같은 용어를 사용한다.
- 소통 과정에서 발생하는 용어의 모호함을 줄일 수 있고 개발자는 도메인과 코드 사이에서 불필요한 해석 과정을 줄일 수 있다.
- 새로 발견한 용어는 코드나 문서에도 반영해서 산출물에 최신 모델을 적용한다.
- 알맞은 영어 단어를 찾는 것은 쉽지 않은 일이지만 시간을 들여 찾는 노력을 해야 한다.
- 도메인 용어에 알맞은 단어를 찾는 시간을 아까워하지 말자
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"); } } ... }
- ex) 주문 도메인과 관련된 몇 가지 요구사항
- 만들어진 모델 요구사항을 도메인 전문가나 다른 개발자와 논의하는 과정에서 공유하기도 한다.
- 모델을 공유할 때는 화이트보드나 위키와 같은 도구를 사용해서 누구나 쉽게 접근할 수 있도록 하면 좋다.
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 필드는 주소라는 하나의 개념을 표현
- receiverName, receiverPhoneNumber 필드는 서로 다른 두 데이터를 담고 있지만 두 필드는 개념적으로 받는 사람을 의미한다.
- 밸류 타입은 개념적으로 완전한 하나를 표현할 때 사용
- 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 }
- 코드의 가독성을 높여서 코드를 분석하고 이해하는 시간을 줄여준다.
- 최대한 도메인 용어를 사용해서 도메인 규칙을 코드로 작성하게 되므로 버그도 줄어 든다.
- ex) 주문 상태
- 에릭 에반스는 도메인 주도 설계에서 언어의 중요함을 강조하기 위해 유비쿼터스 언어라는 용어를 사용했다.
- 전문가, 관계자, 개발자가 도메인과 관련된 공통의 언어를 만들고 이를 대화, 문서, 도메인 모델, 코드, 텍스트 등 모든 곳에서 같은 용어를 사용한다.
- 소통 과정에서 발생하는 용어의 모호함을 줄일 수 있고 개발자는 도메인과 코드 사이에서 불필요한 해석 과정을 줄일 수 있다.
- 새로 발견한 용어는 코드나 문서에도 반영해서 산출물에 최신 모델을 적용한다.
- 알맞은 영어 단어를 찾는 것은 쉽지 않은 일이지만 시간을 들여 찾는 노력을 해야 한다.
- 도메인 용어에 알맞은 단어를 찾는 시간을 아까워하지 말자
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"); } } ... }
- ex) 주문 도메인과 관련된 몇 가지 요구사항
- 만들어진 모델 요구사항을 도메인 전문가나 다른 개발자와 논의하는 과정에서 공유하기도 한다.
- 모델을 공유할 때는 화이트보드나 위키와 같은 도구를 사용해서 누구나 쉽게 접근할 수 있도록 하면 좋다.
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 필드는 주소라는 하나의 개념을 표현
- receiverName, receiverPhoneNumber 필드는 서로 다른 두 데이터를 담고 있지만 두 필드는 개념적으로 받는 사람을 의미한다.
- 밸류 타입은 개념적으로 완전한 하나를 표현할 때 사용
- 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 }
- 코드의 가독성을 높여서 코드를 분석하고 이해하는 시간을 줄여준다.
- 최대한 도메인 용어를 사용해서 도메인 규칙을 코드로 작성하게 되므로 버그도 줄어 든다.
- ex) 주문 상태
- 에릭 에반스는 도메인 주도 설계에서 언어의 중요함을 강조하기 위해 유비쿼터스 언어라는 용어를 사용했다.
- 전문가, 관계자, 개발자가 도메인과 관련된 공통의 언어를 만들고 이를 대화, 문서, 도메인 모델, 코드, 텍스트 등 모든 곳에서 같은 용어를 사용한다.
- 소통 과정에서 발생하는 용어의 모호함을 줄일 수 있고 개발자는 도메인과 코드 사이에서 불필요한 해석 과정을 줄일 수 있다.
- 새로 발견한 용어는 코드나 문서에도 반영해서 산출물에 최신 모델을 적용한다.
- 알맞은 영어 단어를 찾는 것은 쉽지 않은 일이지만 시간을 들여 찾는 노력을 해야 한다.
- 도메인 용어에 알맞은 단어를 찾는 시간을 아까워하지 말자
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"); } } ... }
- ex) 주문 도메인과 관련된 몇 가지 요구사항
- 만들어진 모델 요구사항을 도메인 전문가나 다른 개발자와 논의하는 과정에서 공유하기도 한다.
- 모델을 공유할 때는 화이트보드나 위키와 같은 도구를 사용해서 누구나 쉽게 접근할 수 있도록 하면 좋다.
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 필드는 주소라는 하나의 개념을 표현
- receiverName, receiverPhoneNumber 필드는 서로 다른 두 데이터를 담고 있지만 두 필드는 개념적으로 받는 사람을 의미한다.
- 밸류 타입은 개념적으로 완전한 하나를 표현할 때 사용
- 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 }
- 코드의 가독성을 높여서 코드를 분석하고 이해하는 시간을 줄여준다.
- 최대한 도메인 용어를 사용해서 도메인 규칙을 코드로 작성하게 되므로 버그도 줄어 든다.
- ex) 주문 상태
- 에릭 에반스는 도메인 주도 설계에서 언어의 중요함을 강조하기 위해 유비쿼터스 언어라는 용어를 사용했다.
- 전문가, 관계자, 개발자가 도메인과 관련된 공통의 언어를 만들고 이를 대화, 문서, 도메인 모델, 코드, 텍스트 등 모든 곳에서 같은 용어를 사용한다.
- 소통 과정에서 발생하는 용어의 모호함을 줄일 수 있고 개발자는 도메인과 코드 사이에서 불필요한 해석 과정을 줄일 수 있다.
- 새로 발견한 용어는 코드나 문서에도 반영해서 산출물에 최신 모델을 적용한다.
- 알맞은 영어 단어를 찾는 것은 쉽지 않은 일이지만 시간을 들여 찾는 노력을 해야 한다.
- 도메인 용어에 알맞은 단어를 찾는 시간을 아까워하지 말자
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"); } } ... }
- ex) 주문 도메인과 관련된 몇 가지 요구사항
- 만들어진 모델 요구사항을 도메인 전문가나 다른 개발자와 논의하는 과정에서 공유하기도 한다.
- 모델을 공유할 때는 화이트보드나 위키와 같은 도구를 사용해서 누구나 쉽게 접근할 수 있도록 하면 좋다.
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 필드는 주소라는 하나의 개념을 표현
- receiverName, receiverPhoneNumber 필드는 서로 다른 두 데이터를 담고 있지만 두 필드는 개념적으로 받는 사람을 의미한다.
- 밸류 타입은 개념적으로 완전한 하나를 표현할 때 사용
- 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 }
- 코드의 가독성을 높여서 코드를 분석하고 이해하는 시간을 줄여준다.
- 최대한 도메인 용어를 사용해서 도메인 규칙을 코드로 작성하게 되므로 버그도 줄어 든다.
- ex) 주문 상태
- 에릭 에반스는 도메인 주도 설계에서 언어의 중요함을 강조하기 위해 유비쿼터스 언어라는 용어를 사용했다.
- 전문가, 관계자, 개발자가 도메인과 관련된 공통의 언어를 만들고 이를 대화, 문서, 도메인 모델, 코드, 텍스트 등 모든 곳에서 같은 용어를 사용한다.
- 소통 과정에서 발생하는 용어의 모호함을 줄일 수 있고 개발자는 도메인과 코드 사이에서 불필요한 해석 과정을 줄일 수 있다.
- 새로 발견한 용어는 코드나 문서에도 반영해서 산출물에 최신 모델을 적용한다.
- 알맞은 영어 단어를 찾는 것은 쉽지 않은 일이지만 시간을 들여 찾는 노력을 해야 한다.
- 도메인 용어에 알맞은 단어를 찾는 시간을 아까워하지 말자
728x90
'도메인 주도 개발 스터디' 카테고리의 다른 글
Chapter 7 도메인 서비스 (0) | 2023.11.08 |
---|---|
Chapter 6 응용 서비스와 표현 영역 (0) | 2023.11.08 |
Chapter 5 스프링 데이터 JPA를 이용한 조회 기능 (0) | 2023.11.08 |
Chapter 4 리포지터리와 모델 구현 (1) | 2023.11.08 |
Chapter 2 아키텍처 개요 (0) | 2023.11.08 |