DDD 도메인 주도 설계 1장
in Development on DDD
DDD - 도메인 주도 설계
앞으로 적어갈 글은 최범균님의 저서 DDD START!를 읽고 요약 - 정리한 글입니다.
제 1장 : 도메인 모델 시작
제 1장 도메인 모델 시작
도메인이란 무엇인가
- 온라인 서점을 개발한다고 가정했을 때 온라인 서점은 소프트웨어로 해결하고자 하는 문제 영역, 즉 Domain에 해당한다.
고객이 물건을 주문하면 주문, 결제, 배송, 혜택 하위 도메인의 기능이 엮이게 된다.
소프트웨어가 항상 도메인의 모든 기능을 제공하지는 않는다.
- 가령 온라인 서점에서 결제와 배송의 경우 대행 업체의 서비스를 이용해서 처리할 수도 있다.
도메인 모델
위에서 언급한 주문 모델을 객체 모델로 구성하면 위와 같다.
Order에서 주문 번호와 총 금액이 있고, 배송 정보를 변경하고 주문을 취소할 수 있음을 알 수 있다.
이러한 객체 모델은 도메인을 모델링하기에 적합하다.
도메인 모델을 객체로만 모델링할 수 있는 것은 아니다.
- 클래스 다이어그램이나 상태 다이어그램과 같은 UML 뿐만 아니라 그래프를 활용해서 모델링 할 수도 있다.
도메인을 이해하는데 도움이 된다면 수식도 상관없으며 표현 방식이 중요한 것은 아니다.
개념 모델과 구현 모델이 반드시 같지는 않지만 구현 모델이 개념 모델을 최대한 따르게 할 수는 있다.
- 객체 기반 모델을 이용해서 도메인을 표현했다면, 객체 지향 언어를 이용해 개념 모델에 가깝게 구현 가능
도메인 모델 패턴
일반적인 애플리케이션의 아키텍처는 위와 같이 네 개의 계층으로 구성된다.
UI : 사용자의 요청을 처리하고 사용자에게 정보를 보여준다. 소프트웨어 사용자 뿐만 아니라 외부 시스템도 사용자가 될 수 있다.
응용 : 사용자가 요청한 기능을 실행한다. 도메인 계층을 조합해 기능을 실행한다.
도메인 : 시스템이 제공할 도메인의 규칙을 구현한다.
인프라스트럭쳐 : DB나 메시징 시스템과 같은 외부 시스템과의 연동을 처리한다.
도메인 모델 패턴은 아키텍처 상의 도메인 계층을 객체 지향 기법으로 구현하는 패턴을 말한다.
도메인 계층은 도메인의 핵심 규칙을 구현한다.
- e.q. 주문 도메인의 경우 출고 전에 배송지를 변경할 수 있다
- e.q. 주문 취소는 배송 전에만 할 수 있다
이런 도메인 모델에 규칙을 두는 이유는, 코드가 도메인 모델에만 위치하기 때문에 규칙이 바뀌거나 확장하더라도 다른 코드에 영향을 덜 주고 변경 내역을 모델에 반영할 수 있기 때문이다.
도메인 모델 노출
주문 도메인과 관련된 아래의 요구사항이 있다.
- 최소 한 종류 이상의 상품을 주문해야 한다.
- 한 상품을 한 개 이상 주문할 수 있다.
- 총 주문 금액은 각 상품의 구매 가격 합을 모두 더한 금액이다.
- 각 상품의 구매 가격 합은 상품 가격에 구매 개수를 곱한 값이다.
- 주문할 때 배송지 정보를 반드시 지정해야 한다.
- 배송지 정보는 받는 사람 이름, 전화번호, 주소로 구성된다.
- 출고를 하면 배송지 정보를 변경할 수 없다.
- 출고 전에 주문을 취소할 수 있다.
- 고객이 결제를 완료하기 전에는 상품을 준비하지 않는다.
이 요구사항에서 알 수 있는 것은 주문은 출고 상태로 변경하기, 배송지 정보 변경 하기, 주문 취소하기, 결제 완료로 변경하기의 네 기능을 제공한다는 것이다.
위 메소드를 토대로 Order 클래스를 구현해볼 수 있다.
public class Order {
public void changeShipped() { ... }
public void changeShippingInfo(ShippingInfo newShipping) { ... }
public void cancel() { ... }
public void completePayment() { ... }
}
다음 요구 사항은 주문 항목이 어떤 데이터로 구성되는지 알려준다.
- 한 상품을 한 개 이상 주문할 수 있다.
- 각 상품의 구매 가격 합은 상품 가격에 구매 개수를 곱한 값이다.
이러면 주문 항목을 표현하는 OderLine은 적어도 주문할 상품, 상품의 가격, 구매 개수를 포함하고 있어야 한다.
추가로 각 구매 항목의 구매 가격도 제공해야한다.
public class OrderLine {
private Product product;
private int price;
private int quantity;
private int amount;
...
}
한 종류 이상의 상품을 주문할 수 있으므로 Order는 최소 한 개 이상의 OrderLine을 포함해야한다.
OrderLine으로 부터 총 구매 금액을 구할 수 있다.
public class Order {
private List<OrderLine> orderLines;
private int totalAmounts;
private void setOrderLines(List<OrderLine> orderLines) { ... }
private void verifyAtLeastOneOrMoeOrderLines(List<OrderLine> orderLines) { ... }
private void calculateTotalAmounts() { ... }
...
}
- 여기서 주목해야할 점은 Order는 반드시 한개 이상의 OrderLine을 가져야 하므로 verifyAtLeastOneOrMoeOrderLines라는 메소드로 검증한다.
- 검증 메소드를 Order에 포함시켜도 되는구나! (항상 고민이었음. Validation 메소드를 별도의 클래스로 뽑아야 하는건지?)
- DDD에서는 검증 또한 해당 도메인 모델이 담당하는 구나
public class ShipingInfo {
private String receiverName;
private String receiverPhoneNumber;
private String shipingAddress1;
private String shipingAddress2;
private String shipingZipcode;
...
}
배송지 정보는 이름, 전화번호, 주소 데이터를 가지므로 ShippingInfo 클래스는 위와 같이 정의할 수 있다.
- 주문할 때 배송지 정보를 반드시 지정해야한다는 요구사항이 있다.
- Order를 생성할 때 ShippingInfo도 함께 전달해야 한다.
public class Order {
private List<OrderLine> orderLines;
private int totalAmounts;
private ShipingInfo 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");
}
...
}
setShippingInfo에서 shippingInfo가 null이면 예외를 발생하는 것은 배송지 정보 필수라는 도메인 규칙을 구현한 것이다.
도메인을 구현하다 보면 특정 조건이나 상태에 따라 제약이나 규칙이 달리 적용되는 경우가 많다.
- 출고를 하면 배송지 정보를 변경할 수 없다.
- 출고 전에 주문을 취소할 수 있다.
- 고객이 결제를 완료하기 전에는 상품을 준비하지 않는다.
이는 열거 타입을 통해 주문 상태를 표현할 수 있다.
public enum OrderState {
PAYMENT_WAITING,
PREPARING,
SHIPPED,
DELIVERING,
DELIVERY_COMPLETED,
CANCELED
}
- 배송지 변경, 주문 취소 기능은 출고 전에만 가능하다는 제약 규칙이 있으므로 이 규칙을 적용하기 위해 changeShippingInfo()와 cancel()은 verifyNotYetShipped() 메서드를 먼저 실행한다.
public class Order {
private OrderState state;
public void changeShippingInfo(ShippingInfo newShippingInfo) {
verifyNotYetShipped();
setShippingInfo(newShippingInfo);
}
public void cancel() {
verifyNotYetShipped();
this.state = OrderState.CANCELED;
}
public void verifyNotYetShipped() {
if( state != OrderState.PAYMENT_WATING && state != OrderState.PREPARING)
throw new IllegalStateException("aleady shipped");
}
}
- 지금 까지 주문과 관련된 요구 사항에서 도메인 모델을 점진적으로 만들었다.
문서화
문서화의 주 목적은 지식의 공유
전반적인 기능 목록이나 모듈 구조, 빌드 과정은 코드를 보고 이해하는 것 보다 상위 수준에서 정리한 문서가 더 빠르게 소프트웨어를 이해하는데 도움을 줄 수 있다.
코드 자체가 문서가 될 수 있다.
그러니까 코드 잘 짜자.
엔티티와 밸류
도출한 모델은 크게 Entity와 Value로 구분할 수 있다.
Entity와 Value를 제대로 구분해야 도메인을 올바르게 설계하고 구현할 수 있다.
엔티티
Entity의 가장 큰 특징은 식별자를 갖는다는 것이다.
주문 도메인에서 각 주문은 주문번호를 갖는데, 이 주문번호는 각 주문마다 서로 다르기 때문에 식별자로 볼 수 있다.
주문 도메인 모델에서 주문에 해당하는 클래스가 Order이므로 Order가 Entity이고 주문번호를 속성으로 갖게 된다.
엔티티의 식별자 생성
흔히 식별자는 다음 중 한 가지 방식으로 생성한다.
특정 규칙에 따라 생성
UUID 사용
값을 직접 입력
일련번호 사용(시퀀스나 DB의 자동 증가 칼럼 사용)
밸류 타입
ShippingInfo 클래스는 받는 사람과 주소에 대한 데이터를 갖고 있다.
ShippingInfo 클래스의 receiverName 필드와 receiverPhoneNumber 필드는 개념적으로 받는 사람을 의미한다.
ShippingInfo 클래스의 shippingAddress1, shippingAddress2, shippingZipcode 필드는 주소라는 하나의 개념을 표현한다.
이를 코드로 표현하면 다음과 같을 수 있다.
public class Receiver {
private String name;
private String phoneNumber;
...
}
public class Address{
private String address1;
private String address2;
private String zipcode;
...
}
밸류 타입을 이용해서 ShippingInfo 클래스를 재 구현 해보자.
배송 정보가 받는 사람과 주소로 구성된다는 것을 쉽게 알 수 있다.
public class ShippingInfo {
private Receiver receiver;
private Address address;
// ... 생성자, get 메서드
}
밸류 타입이 반드시 두 개 이상의 데이터를 가져야 하는 것은 아니다.
의미를 명확하게 표현하기 위해 밸류 타입을 사용하는 경우도 존재한다. (돈을 int가 아닌 Money 클래스로 정의)
엔티티 식별자와 밸류 타입
엔티티 식별자의 실제 데이터는 주로 String과 같은 문자열로 구성된다.
식별자 타입이라는 것을 조금 더 명확히 하기 위해 Order의 식별자를 String이 아닌 OrderNo 밸류 타입을 사용하면 타입을 통해 해당 필드가 주문번호라는 것을 알 수 있다.
public class Order {
// OrderNo 타입 자체로 id가 주문번호임을 알 수 있다.
private OrderNo id;
...
}
도메인 모델에 set 메서드 넣지 않기
도메인 모델에 get/set 메서드를 무조건 추가하는 것은 좋지 않을 수 있다.
특히 set 메서드는 도메인의 핵심 개념이나 의도를 코드에서 사라지게 만든다.
무분별한 set의 남발은 상태 변경과 관련된 도메인 지식이 코드에서 사라지게 만든다.
set 메서드의 또 다른 문제는 도메인 객체를 생성할 때 완전한 상태가 아닐 수도 있다는 것이다.
// set 메서드로 데이터를 전달하도록 구현하면
// 처음 Order를 생성하는 시점에 order는 완전하지 않다.
Order order = new Order();
// set 메서드로 필요한 모든 값을 전달해야 한다.
order.setOrderLine(lines);
order.setShippingInfo(shipingInfo);
// 주문자(Orderer)를 설정하지 않은 상태에서 주문 완료 처리
order.setState(OrderState.PREPARING);
- 도메인 객체가 불완전한 상태로 사용되는 것을 막기 위해 생성 시점에 필요한 데이터를 전달해 주어야 한다.
- 생성자를 통해 필요한 데이터를 모두 받아주어야한다.
Order order = new Order(orderer, lines, shippingInfo, OrderState.PREPARING);
DTO의 get/set 메서드
- DTO가 도메인 로직을 담는 경우는 없으므로 get/set 메서드를 제공해도 큰 문제는 없다.
도메인 용어
코드를 작성할 때 도메인에 사용하는 용어는 매우 중요하다. - 이름 짓기의 중요성 -
도메인에서 사용하는 용어를 코드에 반영하지 않으면, 그 코드는 개발자에게 코드의 의미를 해석해야하는 부담을 준다.
public OrderState {
STEP1, STEP2, STEP3, STEP4, STEP5, STEP6
}
위의 상태는 매우 메롱이다.
안다. 한국인에게 영어는 어렵기 때문에 이름을 잘 짓는 것은 어렵다.
그러나 적절한 영어 단어로 이름을 잘 지어두어야 코드와 도메인을 더욱 더 가깝게 유지할 수 있다.
Reference
DDD START! - 최범균님 -
그림 및 코드 참조 : https://incheol-jung.gitbook.io/docs/study/ddd-start/1