이 포스팅은 [DDD START! 도메인 주도 설계 구현과 핵심 개념 익히기 - 최범균]를 읽고 난 후에 이에 대한 내용을 정리하여 기록을 남기려고 합니다.
_Chapter 1. 도메인 모델 시작
이번 포스팅은 chapter 1. 의
- 도메인 모델
- 엔티티와 밸류
- 도메인 용어
중에서 두 번째 엔티티와 밸류에 대해 정리해봤습니다.
- 엔티티와 밸류
- 엔티티
- 엔티티의 식별자 생성
- 밸류 타입
- 엔티티 식별자와 밸류 타입
- 도메인 모델에 set 메서드 넣지 않기
엔티티와 밸류
도출한 모델은 크게 엔티티(Entity)와 밸류(value)로 구분할 수 있다. 엔티티와 밸류를 제대로 구분해야 도메인을 올바르게 설계하고 구현할 수 있다. 그래서 이 둘의 차이를 명확하게 이해하는 것이 도메인을 구현하는데 있어 중요하다.
엔티티
엔티티의 가장 큰 특징은 식별자를 갖는다는 것. 식별자는 엔티티 객체마다 고유해서 각 엔티티는 서로 다른 식별자를 갖는다.
// TODO 그림1.7 필요
엔티티의 식별자는 바뀌지 않는다. 엔티티를 생성하고 엔티티의 속성을 바꾸고 엔티티를 삭제할 때까지 식별자는 유지된다.
엔티티의 식별자는 바뀌지 않고 고유하기 때문에 두 엔티티 객체의 식별자가 같으면 두 엔티티는 같다고 판단할 수 있다. 엔티티를 구현한 클래스는 다음과 같이 식별자를 이용해서 equals() 메서드와 hashCode() 메서드를 구현할 수 있다.
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 order = (Order)obj;
if(this.orderNumber == null) return false;
return this.orderNumber.equals(other.orderNumber);
}
@Override
public int haschCode() {
final int prime = 31;
int result = 1;
result = prime * result * ((orderNumber == null) ? 0 : orderNumber.hashCode());
return result;
}
...
}
엔티티의 식별자 생성
엔티티의 식별자를 생성하는 시점은 도메인의 특징과 사용하는 기술에 따라 달라진다. 흔히 식별자는 다음 중 한 가지 방식으로 생성한다.
- 특정 규칙에 따라 생성
- UUID 사용
- 값을 직접 입력
- 일련번호 사용(시퀀스나 DB의 자동 증가 컬럼 사용)
주문번호, 운송장번호 같은 식별자는 특정 규칙에 따라 생성한다. 이 규칙은 도메인에 따라 다르고, 같은 주문번호라도 회사마다 다르다.
흔히 사용하는 규칙은 현재 시간과 다른 값을 함께 조합하는 것. ex) '20241025205534000001' -> '20241025205534' 는 시간
시간을 이용해서 식별자를 생성할 때 주의할 점은 같은 시간에 동시에 식별자를 생성할 때 같은 식별자가 만들어지면 안 된다는 것.
UUID(universally unique identifier)를 사용해서 식별자를 생성할 수 있다. 다수의 개발언어가 UUID 생성기를 제공하고 있으므로 마땅한 규칙이 없다면 사용해도 된다. ex) 자바의 경우 java.util.UUID 클래스를 사용하면 생성할 수 있다.
UUID uuid = UUID.randomUUID();
// 615f2ab9-c123-4h20-9420-2341234af123 과 같은 형식 문자열
String strUuid = uuid.toString();
식별자로 일련번호를 사용하기도 한다. 이 방식은 주로 데이터베이스가 제공하는 자동 증가 기능을 사용한다. ex) 오라클은 시퀀스를 이용해서 자동 증가 식별자를 구하고, MySQL을 사용한다면 자동 증가 컬럼(auto_increment)을 이용해서 일련번호 식별자를 생성.
자동 증가 컬럼을 제외한 다른 방식은 다음과 같이 식별자를 먼저 만들고 엔티티 객체를 생성할 때 식별자를 전달할 수 있다.
// 엔티티를 생성하기 전에 식별자 생성
String orderNumber = orderRepository.generate();
Order order = new Order(orderNumber, ....);
orderRepository.save(order);
자동 증가 컬럼은 DB 테이블에 데이터를 삽입해야 비로소 값을 알 수 있기 때문에 테이블에 데이터를 추가하기 전에는 식별자를 알 수 없다. 이는 엔티티 객체 생성을 할 때 식별자를 전달할 수 없음을 뜻한다.
Article article = new Article(author title, ...);
articleRepository.save(article); // DB에 저장한 뒤 구한 식별자를 엔티티에 반영
Long savedArticleId = article.getId(); // DB에 저장한 후 식별자 참조 가능
밸류 타입
밸류 타입은 개념적으로 완전한 하나를 표현할 때 사용한다.
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;
}
}
Receiver는 '받는 사람' 이라는 도메인 개념을 표현한다. 앞서 ShippingInfo의 receiverName 필드와 receiverPhoneNumber 필드가 필드이름을 통해서 받는 사람을 위한 데이터라는 것을 유추한다면, Receiver는 그 자체로 받는 사람을 뜻한다.
밸류 타입을 사용함으로써 개념적으로 완전한 하나를 잘 표현할 수 있는 것이다.
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;
}
// get 메서드
}
밸류 타입을 이용해서 ShippingInfo 클래스를 다시 구현해보자. 배송정보가 받는 사람과 주소로 구성된다는 것을 쉽게 알 수 있다.
public class ShippingInfo {
private Receiver receiver;
private Address address;
... 생성자, get 메서드
}
밸류 타입이 꼭 두 개 이상의 데이터를 가져야 하는 것은 아니다. 의미를 명확하게 표현하기 위해 밸류 타입을 사용하는 경우도 있다.
이를 위한 좋은 예가 OrderLine 이다.
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.money = money;
}
public int getValue() {
return this.value;
}
}
다음은 Money 사용하도록 OrderLine 을 변경한 코드이다. Money 타입 덕에 price, amounts 가 금액을 의미한다는 것을 쉽게 알 수 있다.
public class OrderLine {
private Product product;
private Money price;
private int quantity;
private Money amounts;
...
}
밸류 타입을 사용할 때의 또 다른 장점은 밸류 타입을 위한 기능 추가를 할 수 있다. ex) Money 타입은 돈 계산을 위한 기능을 추가할 수 있다.
public class Money {
private int value;
... 생성자, getValue()
public Money add(Money money) {
return new Money(this.value + money.value);
}
public Money multiply(int multiplier) {
return new Money(value * multiplier);
}
}
Money를 사용하는 코드는 이제 '정수타입 연산'이 아니라 '돈 계산' 이라는 의미로 코드를 작성할 수 있게 된다. 코드의 가독성 향상!!
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; } private int calculateAmounts() { return price*quantity; } public int getAmounts() {...} ... } |
public class OrderLine { private Product product; private Money price; private int quantity; private Money amounts; public OrderLine(Product product, Money price, int quantity) { this.product = product; this.price = price; } private Money calculateAmounts() { return price.multiply(quantity); } public int getAmounts() {...} ... } |
밸류 객체의 데이터를 변경할 때는 기존 데이터를 변경하기보다는 변경한 데이터를 갖는 새로운 밸류 객체를 생성하는 방식을 선호한다.
ex) Money 클래스의 add() 메서드를 보면 Money를 새로 생성하고 있다.
public class Money {
private int value;
public Money add(Money money) {
return new Money(this.value + money.value);
}
// value를 변경할 수 있는 메서드 없음
}
Money처럼 데이터 변경 기능을 제공하지 않는 타입을 불변(immutable)이라고 한다. 밸류 타입을 불변으로 구현하는 이유는 여러 가지가 있는데 가장 중요한 이유는 불변 타입을 사용하면 보다 안전한 코드를 작성할 수 있다.
ex) OrderLine 클래스를 생성하려면 다음 코드처럼 Money 객체를 전달해야 한다.
Money price = ...;
OrderLine line = new OrderLine(product, price, quantity);
// 만약 price.setValue(0)으로 값을 변경할 수 있다면?
그런데, Money가 setValue()와 같은 메서드를 제공하여 값을 변경할 수 있다면? 아래와 같이 OrderLine의 price값이 잘못 반영되는 상황이 발생한다.
Money price = new Money(1000);
OrderLine line = new OrderLine(product, price, 2); -> [price=1000, quatity=2, amounts=2000]
price.setValue(2000); -> [price=2000, quatity=2, amounts=2000]
(참조 투명성과 관련된 문제)
이런 문제가 발생하지 않도록 하려면 OrderLine 생성자는 다음과 같이 새로운 Money 객체를 생성하도록 코드를 작성해야 한다.
public class OrderLine {
...
private Money price;
public OrderLine(Product product, Money price, int quantity) {
this.product = product;
// Money가 불변 객체가 아니라면,
// price 파라미터가 변경될 때 발생하는 문제를 방지하기 위해
// 데이터를 복사한 새로운 객체를 생성해야 한다.
this.price = new Money(price.getValue());
this.quantity = quantity;
this.amounts = calculateAmounts();
}
}
Money 가 불변이면 이런 코드를 작성할 필요가 없다. Money의 데이터를 바꿀 수 없기 때문에 파라미터로 전달받은 price를 안전하게 사용할 수 있다.
불변 객체는 참조 투명성과 스레드에 안전한 특징을 갖고 있다. 불변 객체에 대해 더 많은 내용을 알고 싶다면 https://goo.gl/2Lo4pU 문서를 참고하자.
엔티티 타입의 두 객체가 같은지 비교할 때 주로 식별자를 사용한다면 두 밸류 객체가 같은지 비교할 때는 모든 속성이 같은지 비교해야 한다.
public class Receiver{
private String name;
private String phoneNumber;
public boolean equals(Object other) {
if(other == null) return false;
if(this == other) return true;
if(!(other instanceof Receiver)) return false;
Receiver that = (Receiver)other;
return this.name.equals(that.name) &&
this.phoneNumber.equals(that.phoneNumber)
}
...
}
엔티티 식별자와 밸류 타입
엔티티 식별자의 실제 데이터는 String 과 같은 문자열로 구성된 경우가 많다.
Money가 단순 숫자가 아닌 도메인의 '돈'을 의미하는 것처럼 이런 식별자는 단순한 문자열이 아니라 도메인에서 특별한 의미를 지니는 경우가 많기 때문에 식별자를 위한 밸류 타입을 사용해서 의미가 잘 드러날 수 있도록 할 수 있다.
ex) 주문번호를 표현하기 위해 Order의 식별자 타입으로 String 대신 OrderNo 밸류 타입을 사용하면 타입을 통해 해당 필드가 주문번호라는 것을 알 수 있다.
public class Order {
// OrderNo 타입 자체로 id가 주문번호임을 알 수 있다.
private OrderNo id;
...
public OrderNo getId() {
return id;
}
}
OrderNo 대신에 String 타입을 사용한다면 'id'라는 이름만으로는 해당 필드가 주문번호인지 여부를 알 수 없다. 필드의 의미가 드러나도록 하려면 'id' 라는 필드 이름 대신 'orderNo' 라는 필드 이름을 사용해야 한다. 반면에, 식별자를 위해 OrderNo 타입을 만들면 타입 자체로 주문번호라는 것을 알 수 있으므로 필드 이름이 'id' 여도 실제 의미를 찾는 것은 어렵지 않다.
도메인 모델에 set 메서드 넣지 않기
get/set 메서드는 습관적으로 추가하는 메서드이다. 습관적으로 만드는 이유는 여러 가지가 있겠지만 가장 큰 이유는 입문용 프로그래밍에 예제 코드 때문이라고 생각한다.
도메인 모델에 get/set 메서드를 무조건 추가하는 것은 좋지 않은 버릇이다. 특히 set 메서드는 도메인의 핵심 개념이나 의도를 코드에서 사라지게 한다. Order의 메서드를 다음과 같이 set 메서드로 변경해보자.
public class Order {
...
public void setShippingInfo(ShippingInfo newShipping) {...}
public void setOrderState(OrderState state) {...}
}
앞서 changeShippingInfo() 가 배송지 정보를 새로 변경한다는 의미를 가졌다면 setShippingInfo() 메서드는 단순히 배송지 값을 설정한다는 것을 뜻한다.
구현할 때에도 chageShippingInfo() 는 배송지 정보 변경과 관련된 처리 코드를 함께 구현하기 때문에 배송지 정보변경과 관련된 도메인 지식을 코드로 구현하는 것이 자연스럽다. 습관적으로 코드를 작성하는 경우라면 필드 값만 변경하고 끝나는 경우가 많기 때문에 상태 변경과 관련된 도메인 지식이 코드에서 사라지게 된다.
set 메서드의 또 다른 문제는 도메인 객체를 생성할 때 완전한 상태가 아닐 수 있다는 것이다.
// set 메서드로 데이터를 전달하도록 구현하면
// 처음 Order를 생성하는 시점에 Order는 완전하지 않다.
Order order = new Order();
// set 메서드로 필요한 모든 값을 전달해야 함.
order.setOrderLine(lines);
order.setShippingInfo(shippingInfo);
// 주문자(Orderer)를 설정하지 않은 상태에서 주문 완료 처리
order.setState(OrderState.PREPARING);
위 코드는 주문자를 설정하는 것을 누락하고 있다. 주문자 정보를 담고 있는 필드인 orderer가 null인 상황에서 order.setState() 메서드로 상품 준비 중 상태로 바꾸는 것이다. orderer가 정상인지 확인하기 위해 orderer가 null인지 검사하는 코드를 setState() 메서드에 위치하는 것도 맞지 않다.
도메인 객체가 불완전한 상태로 사용되는 것을 막으려면 생성 시점에 필요한 것을 전달해 주어야 한다. 즉, 생성자를 통해 필요한 데이터를 모두 받아야 한다.
Order order = new Order(orderer, lines, shippingInfo, OrderState.PREPARING);
생성자로 필요한 것을 모두 받으므로 다음처럼 생성자를 호출하는 시점에 필요한 데이터가 올바른지 검사할 수 있다.
public class Order {
public Order(Orderer orderer, List<OrderLine> orderLines,
ShippingInfo shippingInfo, OrderState state) {
setOrderer(orderer);
setOrderLines(orderLines);
...
}
private void setOrderer(Orderer orderer) {
if(orderer == null) throw new IllegalArgumentException("no orderer");
this.orderer = orderer;
}
private void verifyAtLeastOneOrMoreOrderLines(List<OrderLine> orderLines) {
if(orderLines == null || orderLines.isEmpty()) {
throw new IllegalArgumentException("no OrderLine");
}
}
private void calculateTotalAmounts() {
this.totalAmounts = orderLines.stream().mapToInt(x -> x.getAmounts()).sum();
}
...
}
이 코드의 set 메서든 앞서 set 메서드와 중요한 차이점이 있다. 그것은 접근 범위가 private이라는 점이다. private이기 때문에 클래스 내부에서만 데이터를 변경할 목적으로 사용되며 외부에서 데이터를 변경할 목적으로 set 메서드를 사용할 수 없다.
불변 밸류 타입을 사용하면 자연스럽게 밸류 타입에는 set 메서드를 구현하지 않는다. set 메서드를 구현해야 할 특별한 이유가 없다면 불변 타입의 장점을 살릴 수 있도록 밸류 타입은 불변으로 구현한다.
Reference
- DDD Start! - 도메인 주도 설계 구현과 핵심 개념 익히기 - 최범균
'DDD' 카테고리의 다른 글
[DDD/도메인 주도 설계] 🏛️ chap 1. 도메인 모델 시작 - 도메인 용어 (1) | 2024.10.28 |
---|---|
[DDD/도메인 주도 설계] 🏛️ chap 1. 도메인 모델 시작 - 도메인 모델 (3) | 2024.10.22 |