-
[도메인 주도 개발 시작하기] 1. 도메인 모델 시작하기STUDY/DDD 2022. 11. 1. 00:13
1. 도메인(domain)이란?
- 소프트웨어로 해결하고자 하는 문제 영역
- 사용자가 소프트웨어를 사용하는 대상 영역(소프트웨어는 도메인의 문제를 해결하는 수단임)
- 일반적인 요구사항, 전문 용어, 그리고 컴퓨터 프로그래밍 분야에서 문제를 풀기위해 설계된 어떤 소프트웨어 프로그램에 대한 기능성을 정의하는 연구의 한 영역
예를 들어, 개발자 입장에서 온라인 서점은 구현해야 할 소프트웨어의 대상이 된다. 온라인 서점 소프트웨어는 온라인으로 책을 판매하는 데 필요한 상품 조회, 구매, 결제, 배송 추적 등의 기능을 제공해야 한다. 이때 온라인 서점은 소프트웨어로 해결하고자 하는 문제 영역, 즉 도메인(domain)에 해당한다.
한 도메인은 다시 하위 도메인으로 나눌 수 있다.
- 카탈로그 하위 도메인은 고객에게 구매할 수 있는 상품 목록을 제공한다.
- 주문 하위 도메인은 고객의 주문을 처리한다.
- 혜택 하위 도메인은 쿠폰이나 특별 할인과 같은 서비스를 제공한다.
- 배송 하위 도메인은 고객에게 구매한 상품을 전달하는 일련의 과정을 처리한다.
한 하위 도메인은 다른 하위 도메인과 연동하여 완전한 기능을 제공한다. 예를 들어 고객이 물건을 구매하면 주문, 결제, 배송, 혜택 하위 도메인의 기능이 엮이게 된다.
특정 도메인을 위한 소프트웨어라고 해서 도메인이 제공해야 할 모든 기능을 직접 구현하는 것은 아니다. 배송 시스템이나 결제 시스템 같은 경우에는 일부 기능만 자체 시스템으로 구현하고, 나머지 기능은 외부 업체의 시스템을 사용할 수 있다.
도메인마다 고정된 하위 도메인이 존재하는 것이 아니고, 하위 도메인을 어떻게 구성할지 여부는 상황에 따라 달라진다.
2. 도메인 요구사항
도메인 전문가들은 해당 도메인에 대한 지식과 경험을 바탕으로 원하는 기능 개발을 개발자에게 요구한다. 개발자는 이런 요구사항을 분석하고 설계하여 코드를 작성하며 테스트하고 배포한다. 요구사항을 올바르게 이해하지 못하면 요구하지 않은 엉뚱한 기능을 만들게 된다. 잘못 개발한 코드를 수정해서 올바르게 고치려면 많은 노력이 필요하기 때문에, 코딩에 앞서 요구사항을 올바르게 이해하는 것이 중요하다.
요구사항을 올바르게 이해하기 위한 가장 간단한 방법은 전문가와 직접 대화하는 것이다. 개발자와 전문가 사이에 내용을 전파하는 전달자가 많으면 많을수록 정보가 왜곡되고 손실이 발생하게 되며, 개발자는 최초에 전문가가 요구한 것과는 다른 무언가를 만들게 될 수 있다. 또한 도메인 지식을 갖춰야 한다. 도메인 전문가, 관계자, 개발자가 같은 지식을 공유하고 직접 소통할수록 도메인 전문가가 원하는 결과물을 만들 가능성이 높아지기 때문이다.
반대로 올바르지 않은 요구사항이 주어지는 경우도 존재한다.
"Garbage in, Garbage out"
소프트웨어 분야에서 유명한 문장으로 '잘못된 값이 들어가면 잘못된 결과가 나온다.'는 의미를 갖는다. 이 말은 요구사항에도 적용되어 잘못된 요구사항이 들어가면 잘못된 결과가 나오게 된다. 도메인 전문가라고 해서 항상 요구한 내용이 올바른 것은 아니며 때론 실제로 원하는 것을 정확하게 표현하지 못할 때도 있다. 따라서 개발자는 요구사항을 이해할 때 왜 이런 기능을 요구하는지 또는 실제로 원하는 것이 무엇인지 생각하고 도메인 전문가와 대화를 통해 정확히 파악해야 한다.
3. 도메인 모델
도메인 모델에는 다양한 정의가 존재하는데, 기본적으로 도메인 모델은 특정 도메인을 개념적으로 표현한 것이다. 도메인 모델을 사용하면 여러 관계자들이 동일한 모습으로 도메인을 이해하고 도메인 지식을 공유하는 데 도움이 된다.
예를 들어 주문 도메인을 생각해보면, 온라인 쇼핑몰에서 주문을 하려면 상품을 몇 개 살지 선택하고 배송지를 입력한다. 선택한 상품 가격을 이용해서 총 지불 금액을 계산하고, 금액 지불을 위한 결제 수단을 선택한다. 주문한 뒤에도 배송 전이면 배송지 주소를 변경하거나 주문을 취소할 수 있다.
이를 위한 주문 모델을 객체 모델로 구성할 수 있다. 객체 모델이 도메인의 모든 내용을 담고 있지는 않지만, 객체 모델을 통해 주문은 주문번호(orderNumber)와 지불할 총금액(totalAmounts)이 있고, 배송정보(ShippingInfo)를 변경(changeShipping)할 수 있음을 알 수 있다. 또한 주문을 취소(cancel)할 수 있다는 것도 알 수 있다.
도메인을 이해하려면 도메인이 제공하는 기능과 도멘인의 주요 데이터 구성을 파악해야 한다. 이런 면에서 기능과 데이터를 함꼐 보여주는 객체 모델은 도메인을 모델링하기에 적합하다.
도메인 모델을 객체로만 모델링할 수 있는 것은 아니다. 상태 다이어그램을 이용해서 주문의 상태전이를 모델링할 수 있다. 상태 다이어그램을 통해 상품 준비 중 상태에서 주문을 취소하면 결제 취소가 함께 이루어진다는 것을 알 수 있다.
도메인 모델을 표현할 때 클래스 다이어그램이나 상태 다이어그램과 같은 UML 표기법만 사용해야 하는 것은 아니다. 도메인을 이해하는 데 도움이 된다면 표현 방식이 무엇인지는 중요하지 않다. 관계가 중요한 도메인이라면 그래프를 이용해서 도메인을 모델링할 수 있다. 계산 규칙이 중요하다면 수학 공식을 활용해서 도메인 모델을 만들 수도 있다.
도메인 모델은 기본적으로 도메인 자체를 이해하기 위한 개념 모델이다. 개념 모델을 이용해서 바로 코드를 작성할 수 있는 것은 아니기 때문에 구현 기술에 맞는 구현 모델이 따로 필요하다.
4. 도메인 모델 패턴
일반적인 애플리케이션의 아키텍처를 네 개의 영역으로 구성된다.
- 사용자 인터페이스(UI) 또는 표현(Presentation) : 사용자의 요청을 처리하고 사용자에게 정보를 보여준다. 여기서 사용자는 소프트웨어를 사용하는 사람뿐만 아니라 외부 시스템일 수도 있다.
- 응용(Application) : 사용자가 요청한 기능을 실행한다. 업무 로직을 직접 구현하지 않으면 도메인 계층을 조합해서 기능을 실행한다.
- 도메인 : 시스템이 제공할 고메인 규칙을 구현한다.
- 인프라스트럭처(Infrastructure) : 데이터베이스나 메시징 시스템과 같은 외부 시스템과의 연동을 처리한다.
앞에서 설명한 도메인 모델은 도메인 자체를 이해하는 데 필요한 개념 모델을 의미한다면, 여기서 설명할 도메인 모델은 아키텍처 상의 도메인 계층을 객체 지향 기법으로 구현하는 패턴을 말한다.
도메인 계층은 도메인의 핵심 규칙을 구현한다. 예를 들어 주문 도메인의 경우 '출고 전에 배송지를 변경할 수 있다.'라는 규칙과 '주문 취소는 배송 전에만 할 수 있다'라는 규칙을 구현한 코드가 도메인 계층에 위치하게 된다. 이러한 도메인 규칙을 객체 지향 기법으로 구현하는 패턴이 도메인 모델 패턴이다.
주문 도메인의 일부 기능을 도메인 모델 패턴으로 구현할 수 있다.
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 OrderState { PAYMENT_WAITING { public boolean isShippingChangeable() { return true; } }, PREPARING { public boolean isShippingChangeable() { return true; } }, SHIPPED, DELIVERING, DELIVERY_COMPLETED; public boolean isShippingChangeable() { return false; } }
주문 상태를 표현하는 OrderState는 주문 대기 중이거나 상품 준비 중에는 배송지를 변경할 수 있다는 도메인 규칙을 구현하고 있다.
- OrderState는 배송지를 변경할 수 있는지를 검사할 수 있는 isShippingChangeable() 메서드를 제공한다.
- 주문 대기 중(PAYMENT_WATING) 상태와 상품 준비 중(PREPARING) 상태의 isShippingChangeable() 메서드는 true를 반환한다.
실제 배송지 정보를 변경하는 Order 클래스의 changeShippingInfo() 메서드는 OrderState의 isShippingChangeable() 메서드를 이용해서 변경 가능한 경우에만 배송지를 변경한다. OrderState는 Order에 속한 데이터이므로 배송지 정보 변경 가능 여부를 판단하는 코드를 Order로 이동할 수 있다. 또한 배송지 변경이 가능한지를 판단할 규칙이 주문 상태와 다른 정보를 함께 사용한다면 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 OrderState { PAYMENT_WAITING, PREPARING, SHIPPED, DELIVERING, DELIVERY_COMPLETED; }
배송지 변경 가능 여부를 판단하는 기능이 Order에 있는 OrderState에 있든 중요한 점은 주문과 관련된 중요 업무 규칙을 주문 도메인 모델인 Order나 OrderState에서 구현한다는 점이다. 핵심 규칙을 구현한 코드는 모데인 모델에만 위치하기 때문에 구칙이 바뀌거나 규칙을 확장해야 할 때 다른 코드에 영향을 덜 주고 변경 내역을 모델에 반영할 수 있게 된다.
5. 도메인 모델 도출
코드를 작성하기 위해서는 기획서, 유스케이스, 사용자 스토리와 같은 요구사항과 관련자와의 대화를 통해 도메인을 이해하고 이를 바탕으로 도메인 모델 초안을 만들어야 한다. 화이트보드, 종이와 연필, 모델링 툴 중 무엇을 선택하든 구현을 시작하려면 도메인에 대한 초기 모델이 필요하다.
도메인을 모델링할 때 기본이 되는 작업은 모델을 구성하는 핵심 구성요소, 규칙, 기능을 찾는 것이다. 이 과정은 요구사항에서 출발한다.
주문 도메인과 관련된 몇 가지 요구사항
- 최소 한 종류 이상의 상품을 주문해야 한다.
- 한 상품을 한 개 이상 주문할 수 있다.
- 충 주문 금액은 각 상품의 구매 가격 합을 모두 더한 금액이다.
- 각 상품의 구매 가격 합은 상품 가격에 구매 개수를 곱한 값이다.
- 주문할 때 배송지 정보를 반드시 지정해야 한다.
- 배송지 정보를 받는 사람 이름, 전화번호, 주소로 구성된다.
- 출고를 하면 배송지를 변경할 수 없다.
- 출고 전에 주문을 취소할 수 있다.
- 고객이 결제를 완료하기 전에는 상품을 준비하지 않는다.
위 요구사항을 통해 주문은 '출고 상태로 변경하기', '배송지 정보 변경하기', '주문 취소하기', '결제 완료하기' 기능을 제공한다는 것을 알 수 있다. 이를 통해 Order에 관련된 메서드를 추가할 수 있다.
public class Order { public void changeShipped() { ... } public void changeShippingInfo(ShippingInfo newShippingInfo) { ... } public void cancel() { ... } public void completePayment() { ... } }
다음 요구사항은 주문 항목이 어떤 데이터로 구성되는지 알려준다.
- 한 상품을 한 개 이상 주문할 수 있다.
- 각 상품의 구매 가격 합은 상품 가격에 구매 개수를 곱한 값이다.
두 요구사항에 따르면 주문 항목을 표현하는 OrderLine은 주문할 상품, 상품의 가격, 구매 개수를 포함하고, 추가로 각 구매 항목의 구매 가격도 제공해야 한다.
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 = calculateMounts(); } private int calculateMounts() { // 구매 가격을 구하는 메서드 return price * quantity; } public int getAmounts() { ... } // ... }
다음 요구사항은 Order와 OrderLine과의 관계를 알려준다.
- 최소 한 종류 이상의 상품을 주문해야 한다.
- 충 주문 금액은 각 상품의 구매 가격 합을 모두 더한 금액이다.
한 종류 이상의 상품을 주문할 수 있으므로 Order는 최소 한 개 이상의 OrderLine을 포함해야 한다. 또한 총 주문 금액은 OrderLine에서 구할 수 있다.
public class Order { private List<OrderLine> orderLines; private Money totalAmouts; public Order(List<OrderLine> orderLines) { setOrderLines(orderLines); } private void setOrderLines(List<OrderLine> orderLines) { verifyAtLeastOneOrMoreOrderLines(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); } ... // 다른 메서드 }
이처럼 요구사항을 통해 도메인 모델을 점진적으로 구현해 나갈 수 있다. 요구사항을 분석함으로써 도메인 모델이 지니고 있어야 할 상태, 기능, 제약조건 등을 찾을 수 있다. 만들어진 모델은 요구사항 정련을 위해 도메인 전문가나 다른 개발자와 논의하는 과정에서 공유하기도 한다.
6. 엔티티와 밸류
도출한 모델은 크게 엔티티(Entity)와 밸류(Value)로 구분할 수 있다. 엔티티와 밸류를 제대로 구분해야 모데인을 올바르게 설계하고 구현할 수 있기 때문에 이 둘의 차이를 명확하게 이해하는 것은 모데인을 구현하는 데 있어 중요하다.
엔티티(Entity)란?
엔티티의 가장 큰 특징은 식별자를 가진다는 것이다. 식별자는 엔티티 객체마다 고유해서 각 엔티티는 서로 다른 식별자를 갖는다. 예를 들어 주문 도메인에서 각 주문은 주문번호를 가지고 있는데 이 주문번호는 각 주문마다 서로 다르다. 따라서 주문번호가 주문의 식별자가 된다.
엔티티의 식별자는 바뀌지 않는다. 엔티티를 생성하고 속성을 바꾸고 삭제할 때까지 식별자는 유지된다. 엔티티의 식별자는 바뀌지 않고 고유하기 때문에 두 엔티티 객체의 식별자가 같으면 두 엔티티는 같다고 판단할 수 있다.
엔티티의 식별자를 생성하는 시점은 도메인의 특징과 사용하는 기술에 따라 달라진다. 식별자는 다음 중 한 가지 방식으로 생성할 수 있다.
- 특정 규칙에 따라 생성
- UUID나 Nano ID와 같은 고유 식별자 생성기 사용
- 값을 직접 입력
- 일련번호 사용(시퀀스나 DB의 자동 증가 컬럼 사용)
주문번호, 운송장번호, 카드번호와 같은 식별자는 특정 규칙에 따라 생성한다. 이 규칙은 도메인에 따라 다르고, 같은 주문번호라도 호사마다 다르다.
- 책의 주문번호 : '2021112831728OOOO' vs '001-A88277OOOO'
UUID(universally unique identifier)를 사용해서 식별자를 생성할 수 있다. 다수의 개발 언어가 UUID 생성기를 제공하고 있으므로 마땅한 규칙이 없다면 UUID를 식별하고 사용해도 된다.
- java.util.UUID
회원 아이디나 이메일과 같은 식별자는 값을 직접 입력한다. 사용자가 직접 입력하는 값이기 때문에 식별자를 중복해서 입력하지 않도록 사전에 방지하는 것이 중요하다.
일변번호를 식별자로 사용하기도 한다. 일련번호 방식은 주로 데이터베이스가 제공하는 자동 증가 기능을 사용한다. 예를 들어 오라클을 사용한다면 시퀀스를 이용해서 자동 증가 식별자를 구하고 MySQL을 사용한다면 자동 증가 컬럼을 이용해서 일련번호 식별자를 생성한다.
밸류 타입이란?
밸류 타입은 개념적으로 완전한 하나를 표현할 때 사용한다.
밸류 타입을 이용하여 받는 사람(Receiver)과 주소(Address)라는 도메인 개념을 표현할 수 있다.
- receiverName과 receiverPhoneNumber필드는 '받는 사람'이라는 개념을 표현한다.
- shppingAddress1, shppingAddress2, shippingZipcode 필드는 '주소'라는 개념을 표현한다.
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; } } 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; } }
또한 밸류 타입을 사용하여 의미를 명확하게 표현할 수 있다.
public class OrderLine { private Product product; private int price; private int quantity; private int amounts; ... }
OrderLine의 price와 amounts는 int 타입의 숫자를 사용하고 있지만 이들은 '돈'을 의미하는 값이다. 따라서 '돈'을 의미하는 Money 타입을 만들어 사용하면 코드를 이해하는 데 도움이 된다.
public class Money { private int value; public Money(int value) { this.value = value; } public int getValue() { return this.value; } public Money add(Money money) { // 돈 계산을 위한 추가 기능 return new Money(this.value + money.value); // 불변 } public Money multiply(int multiplier) { return new Money(value * multiplier); } } public class OrderLine { private Product product; private Money price; private int quantity; private Money amounts; }
위 예시처럼 밸류 타입을 이용해
- 코드의 가독성을 향상시키고,
- 식별자를 위한 밸류 타입을 사용해서 의미가 잘 드러나도록 하고,(int일때는 정확히 어떤걸 나타내는지 모르지만, Money 밸류 타입을 통해 돈을 의미하는 것을 나타냄)
- 마지막으로 밸류 타입을 위한 기능을 추가
할 수 있다.
마지막으로 set 메서드는 도메인의 핵심 개념이나 의도를 코드에서 사라지게 할 수 있으므로, 도메인 모델에 set 메서드는 넣지 않는 것이 좋다. 불변 밸류 타입을 사용하면 자연스럽게 밸류 타입에는 set 메서드를 구현하지 않게 된다. set 메서드를 구현해야 할 특별한 이유가 없다면 불변 타입의 장점을 살릴 수 있도록 밸류 타입은 불변으로 구현하는 것이 좋다.
7. 도메인 용어와 유비쿼터스 언어
코드를 작성할 때 도메인에서 사용하는 용어는 매우 중요하다. 도메인에서 사용하는 용어를 코드에 반영하지 않으면 그 코드는 개발자에세 코드의 의미를 해석해야 하는 부담을 주기 때문이다.
에를 들어 '결제 대기 중', '상품 준비 중', '출고 완료됨', '배송 중', '배송 완료됨', '주문 취소됨'이라는 주문 상태를 코드 상에서 STEP1, STEP2, STEP3, STEP4, STEP5, STEP6의 코드로 표현한다면, 도메인 지식을 코드로 해석해야 하는 경우가 발생한다.
대신 도메인 용어를 사용해서 PAYMENT_WATING, PREPARING, SHIPPED, DELIVERING, DELIVERY_COMPLETED로 표현하면 도메인 용어를 코드로 해석하는 과정이 줄어들 게 된다. 이는 코드의 가독성을 높여서 코드를 분석하고 이해하는 시간을 줄여준다.
에릭 에반스는 도메인 주도 설계에서 언어의 중요함을 강조하기 위해 유비쿼터스 언어(ubiquitous language)라는 용어를 사용했다. 전문가, 관계자, 개발자가 도메인과 관련된 공통의 언어를 만들고 이를 대화, 문서, 도메인 모델, 코드, 테스트 등 모든 곳에서 같은 용어를 사용한다. 이렇게 하면 소통 과정에서 발생하는 용어의 모호함을 줄일 수 있고 개발자는 도메인과 코드 사이에서 불필요한 해석 과정을 줄일 수 있다.
시간이 지날수록 도메인에 대한 이해가 높아지는데 새롭게 이해한 내용을 잘 표현할 수 있는 용어를 찾아내고 이를 다시 공통의 언어로 만들어 다 같이 사용한다. 새로 발견한 용어는 코드나 문서에도 반영해서 산출물에 최신 모델을 적용한다.
출처 : 최범균, 『도메인 주도 개발 시작하기 DDD 핵심 개념 정리부터 구현까지』, 한빛미디어(2022), p21-p59
'STUDY > DDD' 카테고리의 다른 글
[도메인 주도 개발 시작하기] 7. 도메인 서비스 (0) 2022.12.19 [도메인 주도 개발 시작하기] 6. 응용 서비스와 표현 영역 (0) 2022.12.12 [도메인 주도 개발 시작하기] 4. 리포지터리와 모델 구현 (0) 2022.12.01 [도메인 주도 개발 시작하기] 3. 애그리거트 (0) 2022.11.18 [도메인 주도 개발 시작하기] 2. 아키텍처 개요 (0) 2022.11.08