도메인, domain
소프트웨어로 해결하고자 하는 문제 영역
하나의 도메인은 여러 개의 하위 도메인으로 나눌 수 있다.
예를 들어,온라인 서점 도메인은 카탈로그, 회원, 주문, 정산, 배송 과 같이 하위 도메인으로 나눠진다.
하나의 하위 도메인은 다른 하위 도메인과 연동하여 완전한 기능을 제공한다.
즉, 고객이 물건을 구매하는 기능이 완전하기 위해 주문, 결제, 배송, 혜택 하위 도메인의 기능이 엮여야 한다.
카탈로그 하위 도메인: 고객에게 구매할 수 있는 상품 목록을 제공주문 하위 도메인: 고객의 주문 처리혜택 하위 도메인: 쿠폰이나 특별 할인과 같은 서비스 제공배송 하위 도메인: 고객에게 구매한 상품을 전달하는 일련의 과정 처리특정 도메인을 위한 소프트웨어라고 해서 도메인이 제공해야 할 모든 기능을 직접 구현하지는 않는다.
또한, 도메인마다 고정된 하위 도메인이 존재하는 것도 아니다.
하위 도메인을 어떻게 구성하는지는 상황에 따라서 달라진다.
요구사항은 첫 단추와 같다. 첫 단추를 잘못 끼우면 모든 단추가 잘못 끼워지듯이 요구사항을 올바르게 이해하지 못하면 요구하지 않은 엉뚱한 기능을 만들게 된다. 그렇기 때문에 요구사항을 올바르게 이해하는 것은 매우 중요하다.
요구사항을 올바르게 이해하기 위한 가장 간단한 방법은 개발자와 전문가가 직접 대화하는 것이다.
제품 개발과 관려된 도메인 전문가, 관계자, 개발자가 같은 지식을 공유하고 직접 소통할수록 도메인 전문가가 원하는 제품을 만들 가능성이 높아진다.
도메인 모델에는 다양한 정의가 존재한다.
기본적으로 도메인 모델은 특정 도메인을 개념적으로 표현한 것이다. 즉, 도메인 모델은 기본적으로 도메인 자체를 이해하기 위한 개념 모델이다.
도메인을 이해하기 위해서는 도메인이 제공하는 기능과 도메인의 주요 데이터 구성을 파악해야 한다.
이런 면에서 기능과 데이터를 함께 보여주는 객체 기반 주문 도메인 모델은 도메인의 모든 내용을 담고 있지는 않지만, 도메인의 전반적인 구성을 이해하기 쉽기 때문에 도메인을 모델링하기에 적합하다.
이처럼 도메인 모델을 사용하면 여러 관계자들이 동일한 모습으로 도메인을 이해하고 도메인 지식을 공유하는 데 도움이 된다.
도메인을 표현할 때 클래스 다이어그램이나 상태 다이어그램과 같은 UML 표기법만 사용해야 하는 것은 아니다.
관계가 중요한 도메인은 그래프를 이용해서, 계산 규칙은 수학 공식을 사용해서 도메인 모델을 만들 수 있다.
도메인을 이해하는 데에 도움이 될 수 있도록 적합한 표현 방식을 선택하는 것이 중요하다.
일반적인 애플리케이션의 아키텍처는 네 개의 영역으로 구성된다.
| 영역 | 설명 |
|---|---|
| UI, Presentation | 사용자의 요청을 처리하고 사용자에게 정보를 보여준다. 여기서 사용자는 소프트웨어를 사용하는 사람뿐만 아니라 외부 시스템일 수도 있다. |
| Application | 사용자가 요청한 기능을 실행한다. 업무 로직을 직접 구현하지 않으며 도메인 계층을 조합해서 기능을 실행한다. |
| domain | 시스템이 제공할 도메인 규칙을 구현한다. |
| infrastructure | 데이터베이스나 메시징 시스템과 같은 외부 시스템과의 연동을 처리한다. |
지금 살펴볼 도메인 모델은 아키텍처 상의 도메인 계층을 객체 지향 기법을 구현하는 패턴을 말한다.
도메인 계층은 도메인의 핵심 규칙을 구현한다.
다음 코드를 살펴보자. 이 코드는 주문 도메인의 일부 기능을 도메인 모델 패턴으로 구현한 것이다.
isShippingChangable() : 배송지를 변경할 수 있는지 검사하는 메서드OrderState 는 주문 대기중이거나 상품 준비 중에는 배송지를 변경할 수 있다는 도메인 규칙을 isShippingChangable() 메서드를 이용해 구현하고 있다.
public class Order {
private OrderState state;
private ShippingInfo shippingInfo;
public void changeShippingInfo(ShippingInfo newShippingInfo) {
if (!state.isShippingChangable()) {
throw ew illegalStateExecption("can't change shipping in " + state);
}
this.shippingInfo = newShippingInfo;
}
...
}
public enum OrderState {
PAYMENT_WAITING {
public boolean isShippingChangable() { return true; }
},
PREPARING {
public boolean isShippingChangable() { return true; }
},
SHIPPED, DELIVERING, DELIVERY_COMPLETED;
public boolean isShippingChangable() {
return false;
}
}
큰 틀에서 보면 OrderState 는 Order 에 속한 데이터이므로 배송지 정보 변경 가능 여부를 판단하는 코드를 이동시킬 수 있다.
public class Order {
private OrderState state;
private ShippingInfo shippingInfo;
public void changeShippingInfo(ShippingInfo newShippingInfo) {
if (!state.isShippingChangable()) {
throw ew illegalStateExecption("can't change shipping in " + state);
}
this.shippingInfo = newShippingInfo;
}
private boolean isShippingChangable() {
return state == OrderState.PAYMENT_WAITING || state == OrderState.PREPARING;
}
...
}
public enum OrderState {
PAYMENT_WAITING, PREPARING, SHIPPED, DELIVERING, DELIVERY_COMPLETED;
}
배송지 변경 판단 규칙은 OrderState 와 Order 중 어디에 위치하는 것이 좋을까?
만약 배송지 변경의 판단 규칙이 단순히 주문 상태만이 아니라, 다른 정보를 함께 사용한다면 어떨까?
OrerState 만으로는 배송지 변경 가능 여부를 판단할 수 없기 때문에 Order 에서 구현하는 것이 적합하다.
여기서 중요한 점은 배송지 변경 가능 여부가 Order 에 있느냐 OrerState 에 있느냐가 아니다.
주문과 관련된 중요 업무 규칙 (= 배송지 변경 판단 규칙) 을 주문 도메인 모델에서 구현한다는 점이다.
핵심 규칙을 구현한 코드가 도메인 모델에만 위치하기 때문에 규칙이 바뀌거나 확장될 때 다른 코드에 영향을 덜 주면서 변경 내역을 모델에 반영할 수 있다.
코드를 작성하기 위해서는 기획서, 유스케이스, 사용자 스토리와 같은 요구사항과 관련자와의 대화를 통해 도메인을 이해하고 이를 바탕으로 도메인에 대한 초기 모델이 만들어져야 한다.
도메인을 모델링할 때 기본이 되는 작업은 모델을 구성하는 핵심 구성요소, 규칙, 기능을 찾는 것이다. 이 과정은 바로 다음과 같은 요구사항에서 출발한다.
우리는 요구사항을 통해 도메인이 제공해야 하는 기능, 도메인을 구성해야 하는 데이터, 여러 도메인 간의 관계 등 많은 정보를 얻을 수 있다.
| 정보 | 요구사항 |
|---|---|
| 도메인이 제공해야 하는 기능 | 출고 상태로 변경하기 / 배송지 정보 변경하기 / 주문 취소하기 / 결제 완료하기 |
| 도메인을 구성해야 하는 데이터 | 주문할 상품 / 상품 가격 / 구매 개수 / 각 항목의 구매 가격 |
| 여러 도메인 간의 관계 | 최소 한 종류 이상의 상품 주문 ( = Order는 최소 한 개 이상의 OrderLine을 포함) / 총 주문 금액은 각 상품의 구매 가격의 합 ( = OrderLine의 책임) |
| 도메인이 검증해야 할 규칙 | 주문 시 배송지 정보를 반드시 지정해야 함 ( = Order 생성 시, ShippingInfo도 함께 전달해야 함) |
| 도메인이 가져야 할 제약 | 출고 후 배송지 변경 불가 / 출고 전 주문 취소 가능 |
| 도메인이 표현해야 하는 상태 | 결제 완료 전에는 상품을 준비하지 않음 ( = '결제 완료', '상품 준비 중' 상태가 필요) |
그리고 요구사항에서 얻은 정보를 토대로 도메인 모델을 점진적으로 만들 수 있다.
이렇게 만들어진 모델은 요구사항 정련을 위해 도메인 전문가나 다른 개발자와 논의하는 과정에서 공유하기도 한다.
도출한 모델은 크게 Entity 와 Value 로 구분할 수 있다. 이 둘의 차이를 명확하게 이해하는 것은 도메인을 올바르게 설계하고 구현하는 데에 있어 매우 중요하다.
Entity
엔티티는 식별자를 갖는다. 식별자는 엔티티마다 고유해서 각 엔티티는 서로 다른 식별자를 갖는다.
예를 들어, 주문 도메인에서 각 주문은 서로 다른 주문번호를 갖기 때문에 식별자가 된다.
엔티티의 식별자는 바뀌지 않으며, 엔티티를 생성하고 속성을 바꾸고 삭제할 때까지 유지된다.
엔티티의 식별자는 고유하기 때문에 두 엔티티 객체의 식별자가 같으면 두 엔티티를 갖다고 판단할 수 있고, 이를 이용해 equals() 와 hasoCode() 를 구현할 수 있다.
엔티티의 식별자를 생성하는 시점은 도메인의 특징과 사용 기술에 따라 달라진다. 흔히 식별자는 다음 중 한 가지 방식으로 생성된다.
- 특정 규칙에 따라 생성
- UUID나 Nano ID 같은 고유 식별자 생성기 사용
- 값을 직접 입력
- Sequence, Auto_increment 등 자동 증가 칼럼을 통한 일련번호 사용
Value
개념적으로 완전한 하나를 표현할 때 사용한다.
예를 들어, 받는 사람을 위한 Value 타입인 Receiver 는 다음과 같이 작성할 수 있다.
Receiver 는 '받는 사람' 이라는 도메인 개념을 그 자체로 표현한다.
public class Receiver {
private String name;
private String phoneNumber;
public Receiver(String name, String phoneNumber) {
this.name = name;
this.phoneNumber = phoneNumber;
}
// getter
}
Value 타입이 꼭 두 개 이상의 데이터를 가져야 하는 것은 아니다. 의미를 명확하게 표현하기 위해 사용하는 경우도 있다.
public class OrderLine {
private Product product;
private int price;
private int quantity; -> private Money price;
private int amounts; -> private Money amounts;
...
}
public class Money {
private int value;
public Money(int value) {
this.value = value;
}
// getter
}
요약하면, Value 타입은 다음과 같은 경우에 사용된다.
1. 같은 개념을 갖는 서로 다른 필드들을 실제로 하나의 개념으로 표현하기 위함
2. 의미를 명확하게 표현하기 위함
Value 타입은 주로 불변, Immutable 하게 구현하는 특징을 갖는데, 안전한 코드를 작성할 수 있기 때문이다.
그렇기 때문에 기존 데이터를 변경하는 setter보다 변경한 데이터를 새로 갖는 constructor 방식을 선호한다.
도메인 모델에 setter 사용을 왜 지양해야 할까
1. 도메인의 핵심 개념이나 의도를 코드에서 사라지게 함
2. 도메인 객체를 생성할 때 온전하지 않은 상태가 될 수 있음
public class Order {
public Order(Orderer orderer, List<OrderLine> orderLines, ShippingInfo shippingInfo, OrderState orderState) {
// 생성 시점에 필요한 데이터를 모두 전달 -> 도메인 객체가 불완전한 상태로 사용되는 것을 방지
setOrder(orderer);
setOrderLines(orderLines);
...
}
// private setter -> 외부에서 데이터를 변경할 목적으로 setter 사용을 방지
private void setOrderer(Orderer orderer) {
if (orderer == null) throw new IllegalArgumetException("no orderer");
this.orderer = orderer;
}
private void setOrderLines(List<OrderLine> orderLines) {
// 생성자 호출 시점에 도메인 제약 규칙 검증 가능
verifyAtLeastOneOrMoreOrderLines(orderLines);
this.orderLines = orderLies;
calculateTotalAmounts();
}
private void calculateTotalAmounts() {
this.totalAmounts = orderLines.stream().mapToInt(x -> x.getAmounts()).sum();
}
}
불변 Value 타입을 사용하면 자연스럽게 Value 타입에 setter를 구현하지 않는다.
setter를 구현해야 할 특별한 이유가 없다면 Value 타입은 불변으로 구현해야 한다.
- setter를 구현해야 할 특별한 이유가 없다면 불변하게 구현할 것
- setter를 사용해야 한다면, private을 통해 외부에서 데이터 변경을 방지할 것
- constructor를 통해 생성 시점에 필요한 데이터를 전달함으로써 불완전한 도메인 사용을 방지할 것
- 생성자 호출 시점에 해당 도메인의 제약 규칙, NPE를 체크하고 검증할 것
코드를 작성할 때 도메인에서 사용하는 언어는 매우 중요하다. 도메인에서 사용하는 용어를 코드에 반영하지 않으면 개발자에게 코드의 의미를 해석해야 하는 부담을 넘겨주게 된다.
OrderState 를 다음과 같이 구현했다고 가정해보자.
public OrderState {
STEP1, STEP2, STEP3, STEP4, STEP5, STEP6
}
다음 코드에는 배송지 변경이 '출고 전'에만 가능하다는 중요한 도메인 규칙이 드러나지 않는다.
그렇기 때문에 코드를 제대로 이해하기위해 '출고 전' 라는 도메인 지식을 STEP1, STEP2 라는 코드로 해석하는 불필요한 변환 과정을 거쳐야 한다.
다음 코드처럼 도메인 용어를 사용해서 구현하면 이런 불필요한 변환 과정을 거치지 않아도 된다.
public OrderState {
PAYMENT_WAITING, PREPARING, SHIPPED, DELIVERING, DELIVERY_COMPLETED;
}
도메인 용어를 사용하게 되면 다음과 같은 장점을 얻을 수 있다.
에릭 에반스는 도메인 주도 설계에서 언어의 중요함을 강조하기 위해 유비쿼터스 언어,
Ubiquitous language라는 용어를 사용했다.
도메인 용어는 좋은 코드를 만드는 데 매우 중요한 요소이다. 알맞은 영단어를 찾는 것은 쉽지 않은 일이지만 적당한 단어를 찾는 노력을 하지 않고 도메인에 어울리지 않는 단어를 사용하면 코드는 도메인과 점점 멀어지게 된다. 그러니 도메인 용어에 알맞은 단어를 찾는 시간과 노력을 아까워하지 말자!
내용을 정리하면, 도메인 모델을 설계하는 과정에서 다음 내용들을 고려하도록 하는 것이 좋겠다.
- 관련자와의 대화를 통해 도메인과 요구사항을 이해하고 이를 토대로 올바른 도메인 초기 모델을 생성할 것
- 도메인 모델을 생성할 때, 도메인을 이해를 도울 수 있는 적절한 표현 방식을 채택할 것
- 도메인과 관련된 중요 비즈니스 규칙은 해당 도메인 모델에서 구현할 것
- 요구사항을 토대로 도메인을 구성하는 핵심 구성요소, 규칙, 기능을 파악해서 모델링할 것
- 도메인을 설계하는 과정에서
Entity(= 식별자가 필요한가) 와Value 타입(= 개념적으로 명확하게 표현해야 하는가) 을 적절하게 선택할 것- 구현해야 할 특별한 이유가 없다면 setter 없이
constructor를 통해 도메인을 불변하게, 완전하도록 구현할 것- setter를 사용해야 한다면
private키워드를 통해 외부에서 데이터 변경 가능성을 방지할 것- constructor 호출 시점에 도메인 제약 규칙을 검증하고 완전하도록 체크할 것
- 도메인에 어울리는 적절한 단어를 사용하는 시간과 노력을 충분히 들일 것