도메인 주도 개발 1장

김재연·3일 전
2
post-thumbnail

저는 도메인 주도적으로 개발하는 것을 굉장히 좋아합니다. 아무래도, 도메인 간의 협응력을 제대로 발휘하게 되면, 개발 시간이 굉장히 짧아지는 것을 많이 느끼기 때문입니다. 그래서, 지금부터 DDD를 읽고 내용을 정리해보겠습니다.

도메인이란?

출처 : DDD 1장

도메인은 우리가 해결해야 하는 문제를 의미한다.
그리고, 그 도메인은 하위 도메인으로 나누어진다.
위 그림은, 온라인 서점이라는 도메인의 하위 도메인들이다.

여기서 결제, 배송 하위 도메인을 볼 수 있다. 이 부분을 직접 제공할 수도 있겠지만, 다음 그림과 같이 외부의 도움을 받아 처리할 수 있다.

출처 : DDD 1장

이렇게 결제는 외부 PG, 배송은 외부 물류를 활용하면 훨씬 효율적으로 도메인을 구성할 수 있다.

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

여기서 말하고자 하는 부분은 그냥, 요구사항을 정확히 이해하고 도메인 지식을 완전히 갖추라는 의미이다.
그렇게 되면, 전문가가 직접적으로 요구하지 않더라도 새로운 방안을 제시할 수 있다. 전문가라고 하더라도 본인이 원하는 바를 명확하게 표현하지 못할 수도 있으니 말이다.

도메인 모델

도메인 모델은 UML을 사용해도 되고, 그래프를 활용해도 되고, 계산 규칙이 중요하다면 수학 공식을 활용해도 된다.
해당 도메인 모델을 통해서 이해만 시킬 수 있다면 어떠한 형식이든 상관이 없다는 말이다.
이는, 도메인 모델이 기본적으로 도메인 자체를 이해하기 위해 존재하기 때문이다.

도메인 모델 패턴

일반적인 애플리케이션의 아키텍처는 다음과 같이 네 개의 영역으로 구성된다.

출처 : DDD 1장

Presentation - 사용자의 요청을 처리하고, 사용자에게 정보를 보여주는 영역
App - 사용자가 요청한 기능을 실행하는 부분, 직접 구현하는 것이 아니라, 도메인 계층을 조합해서 기능을 실행한다.
Domain - 도메인 규칙을 구현한다 (가장 중요한 부분이라고 생각한다)
Infrastructure - 외부 시스템과의 연동을 처리한다.

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 가 판단을 진행하고 있다는 점이다.
핵심 규칙을 구현한 코드는 도메인 모델에만 위치하기 때문에 규칙이 바뀌거나 규칙을 확장해야 할 때 다른 코드에 영향을 덜 주고 변경 내역을 모델에 반영할 수 있게 된다.

도메인 모델 추출

• 최소 한 종류 이상의 상품을 주문해야 한다.
• 한 상품을 한 개 이상 주문할 수 있다.
• 총주문 금액은 각 상품의 구매 가격 합을 모두 더한 금액이다.
• 각 상품의 구매 가격 합은 상품 가격에 구매 개수를 곱한 값이다.
• 주문할 때 배송지 정보를 반드시 지정해야 한다.
• 배송지 정보는 받는 사람 이름, 전화번호, 주소로 구성된다.
• 출고를 하면 배송지를 변경할 수 없다.
• 출고 전에 주문을 취소할 수 있다.
• 고객이 결제를 완료하기 전에는 상품을 준비하지 않는다.

요구사항이 위와 같이 있을 때, 주문은 출고 상태로 변경하기, 배송지 정보 변경하기, 주문 취소하기, 결제 완료하기 기능을 제공한다는 것이다. 이를 이전에 했던 것과 같이 주문 도메인인 Order에 구현해 보자.

public class Order {
	public void changeShipped { ... }
	public void changeShippingInfo(ShippingInfo newShipping) { ... }
	public void cancel { ... }
	public void completePaymentO { ... }
}
  • 한 상품을 한 개 이상 주문할 수 있다.
  • 각 상품의 구매 가격 합은 상품 가격에 구매 개수를 곱한 값이다.

이를 구현해 보자.

주문 항목을 표현하는 OrderLine을 구현할 것인데, 적어도 상품, 가격, 구매 개수를 포함하고, 총구매 가격도 제공해야 한다.

이렇게, 먼저 필요한 요소들이 어떠한 것이 있을까 판단하는 것이 중요하다.

public class OrderLine {

	private Product product;
	private int price;
	private int quantity;
	private int amounts;

	생성자(){
		...
		amounts=calculateAmounts();
	}

	public int calculateAmounts(){
		return price * quantity;
	}

}

위와 같은 형식으로 제공할 수 있는 것이다.

이와 같이 OrderLine에서 Product를 관리할 수 있는 구조를 보이듯이 Order도 OrderLine을 관리할 수 있다.

  • 최소 한 종류 이상의 상품을 주문해야 한다.
  • 총주문 금액은 각 상품의 구매 가격 합을 모두 더한 금액이다.

(코드 계속 쳐보려고 했는데.. 너무 많네요;; 설명으로 대체하겠습니다.)

위 요구사항도 똑같이 처리할 수 있습니다. Order가 List<OrderLine>을 가지고 있을 것이고, 이를 통해서 개수와 모든 OrderLine의 가격도 구할 수 있다.

그렇기 때문에 validation 및 가격 계산 로직도 손쉽게 진행할 수 있는 것이다.

또한, Order에는 이만 필요한 것이 아니다.
ShippingInfo도 함께 받아, 배송지에 대한 정보도 함께 받아야 한다.
여기서도 추가적으로 Order에서 ShippingInfo가 정상적으로 넘어오지 않은 경우, Exception을 발생시킬 수 있다.

또한 OrderState를 두어, 배송지 변경이나, 주문 취소 기능은 출고 전에만 가능하다는 제약 규칙을 쉽게 구현할 수 있다.

엔티티와 밸류

엔티티와 밸류를 구분할 수 있어야 제대로 된 도메인 설계를 할 수 있다.

엔티티

엔티티의 가장 큰 특징은 식별자가 있다는 것이다.
즉, 식별자가 다르면 서로 다른 엔티티라고 볼 수 있고, 이것을 토대로 equals, hashCode를 구현하면 된다.

엔티티의 식별자 생성

엔티티의 식별자를 생성하는 방식은 다음 4가지가 있다.

  • 특정 규칙에 따라 생성
  • UUID나 Nano ID와 같은 고유 식별자 생성기 사용
  • 값을 직접 입력
  • 일련 번호 사용 (현재까지 가장 많이 쓴 방법이다. 시퀀스나 DB의 자동 증가 컬럼을 사용하는 것을 예로 들 수 있다.)

일련 번호를 사용할 때의 단점은, 테이블에 데이터를 추가하기 전까지는 식별자를 알 수 없기 때문에, 엔티티 객체를 저장하기 이전까지는 식별자를 알 수가 없다는 것이다.

밸류 타입

여기서는 VO(Value Object)의 특징과 장점에 관해서 이야기하고 있다.

방금 전까지 살펴보았던 ShippingInfo 같은 경우도 받는 사람의 정보, 주소만을 나타내고 있기 때문에 VO라고 할 수 있다.

public class ShippingInfo {
	private String receiverName;
	private String receiverPhoneNumber;
	private String shippingAddress1;
	private String shippingAddress2;
	private String shippingZipcode;
}

ShippingInfo는 다음과 같이 이루어져 있었는데, 사실 이는 더 잘게 쪼갤 수가 있다.

public class ShippingInfo {
	// Receiver
	private String receiverName;
	private String receiverPhoneNumber;

	// Address
	private String shippingAddress1;
	private String shippingAddress2;
	private String shippingZipcode;
}

위와 같이 말이다.
이렇게 더 세부적으로 나누게 되면 변수명을 굳이 읽지 않고, 타입만 보더라도 역할을 추측할 수 있고, 또한 각각의 Value에 집중하여 로직을 따로 구성할 수 있다. (Validation 혹은 이외의 로직)

즉, 여기까지 필자가 지속적으로 말하는 VO의 장점은 가독성과 해당 Value를 위한 기능들을 쉽고 편리하게 추가할 수 있다는 것이다. (역할 분리의 장점이라고 생각합니다)

또한, VO는 불변이어야 한다. 만일 불변임을 보장하지 못한다면, VO를 가지고 있는 모객체에게도 영향이 전파되기 때문이다.

애초에 사용하는 목적 자체가 값을 효율적으로 검증하고 활용하기 위함이기 때문에, 모객체에게 영향이 가지 않도록 불변으로 만드는 것이 중요한 것이다.

또한, 식별자가 존재하지 않고, 이름과 걸맞게 값을 나타내는 객체이기에 포함하고 있는 필드들의 값이 다른 Object와 완전히 동일하다면 같은 것이라고 보아도 되고, 이러한 특징을 활용해 equals, hashCode를 구현할 수 있다.

엔티티 식별자와 밸류 타입

식별자에도 VO를 적용할 수 있다.

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

setter를 넣게 되면 메서드에 의미를 부여할 수 없게 된다.
예를 들어 changeShippingInfo라고 했을 때 배송지 정보를 새로 변경한다는 의미를 가졌다면 setShippingInfo()는 명확한 의미를 제공해 주지 못한다.

또한, setter는 단지 필드의 값을 변경하는 것을 의미하는데, validation이 들어가거나 혹은 로직이 들어가야 한다면 setter를 호출하는 부분에서 이를 구현해 주어야 한다.

중복코드가 많이 발생하며, 실수를 유발하기가 굉장히 쉽다. 또한, 앞선 이유로 인해 유지보수성도 굉장히 떨어지는 결과를 낳을 수 있는 것이다.

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

코드를 작성할 때 도메인에서 사용하는 용어가 매우 중요하다.

OrderState를 만일 그냥 STEP1, STEP2... 이런 형식으로 아무 의미 없게 지어놓는다면 읽는 사람으로 하여금, 불편함을 초래하게 된다.

또한, 도메인과 가까운 용어를 찾는 과정을 통해 한발짝 도메인에 가까워질 수 있으며, 다양한 분야의 사람들과도 협업하는데 도움을 주게 된다.

profile
끊임없이 '성장'하는 개발자 김재연입니다.

0개의 댓글

관련 채용 정보