도메인 요소 간의 관계를 잘 파악하여야 코드의 변경과 확장이 쉬워진다.
그렇다면 도메인 요소 간의 관계는 어떻게 잘 파악할 수 있을까?
복잡한 도메인을 이해하기 쉬운 단계로 만들려면 상위 수준에서 모델을 바라보아야 하는데, 이때 애그리거트 개념을 적용할 수 있다.
애그리거트의 특징은 다음과 같다.
애그리거트에 속한 모든 객체는 일관된(정상) 상태를 유지해야 하는데, 이를 관리하는 것이 애그리거트 루트이다. 결과적으로 애그리거트에 속한 객체는 애그리거트 루트 엔티티에 직/간접적으로 속하게 된다.
도메인 기능
을 제공한다. 이때 애그리거트는 애그리거트에 속한 객체의 일관성이 깨지지 않도록 구현해야 한다.public class Order{
//애그리거트 루트인 Order에서 도메인 규칙을 구현한 기능을 제공한다.
public void changeShippingInfo(ShippingInfo newShippingInfo){
verifyNotYetShipped();
this.shippingInfo = newShippingInfo;
}
private void verifyNotYetShipped(){
if(state != OrderState.PAYMENT_WAITING && state != OrderState.PREPARING){
throw new IllegalStateException("already shipped");
}
}
}
ShippingInfo si = order.getShippingInfo();
si.setAddress(newAddress);
도메인 모델에서 공개 set 메소드는 가급적 피하자. 같은 맥락에서 도메인에 @Setter를 무분별하게 사용하는 것도 지양해야 한다.
밸류는 불변 타입으로 구현한다. 밸류 타입의 내부 상태를 변경하려면 애그리거트 루트를 통해서만 가능하다.
public class Order{
private ShippingInfo shippingInfo;
//애그리거트 루트인 Order에서 도메인 규칙을 구현한 기능을 제공한다.
public void changeShippingInfo(ShippingInfo newShippingInfo){
verifyNotYetShipped();
setShippingInfo(newShippingInfo)
}
private void setShippingInfo(ShippingInfo newShippingInfo){
//밸류가 불변이면 새로운 객체를 할당하는 방법으로 값을 변경한다.
//따라서 this.ShippingInfo.setAddress(newShippingInfo.getAddress()) 같은 방법은 불가능
this.shippingInfo = newShippingInfo;
}
private void verifyNotYetShipped(){
if(state != OrderState.PAYMENT_WAITING && state != OrderState.PREPARING){
throw new IllegalStateException("already shipped");
}
}
}
public class Order{
private Money totalAmounts;
private List<OrderLine> orderLines;
private void calculateTotalAmounts(){
int sum = orderLines.stream()
.mapToInt(ol->ol.getPrice()*ol.getQuantity())
.sum();
this.totalAmounts = new Money(sum);
}
}
public class Member {
private Password password;
public void changePassword(String currentPassword, String newPassword){
if(!password.match(currentPassword)){
throw new PasswordNotMatchException();
}
this.password = new Password(password);
}
}
예를 들어,
public class Order {
private Orderer orderer;
public void shipTo(ShippingInfo newShippingInfo, boolean useNewShippingAddrAsMemberAddr) {
verifyNotYetShipped();
setShippingInfo(newShippingInfo);
if(useNewShippingAddrAsMemberAddr){
orderer.getMember().changeAddress(newShippingInfo.getAddress()); //Order 애그리거트에서 Member 애그리거트의 상태를 변경하려는 시도
//이런식으로 다른 애그리거트의 상태를 변경하면 안 된다.
}
}
}
public class ChangeOrderService{
@Transactional
public void changeShippingInfo(OrderId id, ShippingInfo shippingInfo, boolean useNewShippingAddrAsMemberAddr){
Order order = orderReppsitory.findById(id);
if(order == null) throw new OrderNotFoundException();
order.shipTo(newShippingInfo);
if(useNewShippingAddrAsMemberAddr){
Member member = findMember(order.getOrderer());
member.changeAddress(newShippingInfo.getAddress());
}
}
}
완전한 한 개의 도메인 모델
을 표현한다.애그리거트는 다른 애그리거트를 참조할 수 있다.애그리거트 관리의 주체는 애그리거트 루트이므로 결국 애그리거트를 참조한다는 것은 애그리거트 루트를 참조한다는 것이다.
ORM 기술을 이용해 애그리거트 루트에 대한 참조를 쉽게 구현할 수 있고, 필드(or get메소드)를 이용한 애그리거트 참조를 사용하면 다른 애그리거트를 쉽게 조회할 수 있다. 하지만, 이러한 특성의 단점도 있다.
한 애그리거트 내부에서 다른 애그리거트를 접근할 수 있으면 다른 애거리거트의 상태를 쉽게 변화시킬 수 있다. 이는 애그리거트의 간의 결합도를 높여 애그리거트의 변경을 어렵게 만든다.
성능에 대한 고민이 필요하다.
: 지연로딩/ 즉시로딩 등에 대한 고민 필요
확장의 문제
ID를 이용해 다른 애그리거트를 참조한다!
ID 참조를 사용하면 모든 객체가 통으로 참조로 연결되지 않고, 한 애그리거트에 속한 객체들만 참조로 연결된다.
다른 애그리거트를 참조하지 않기 때문에 애그리거트 간 참조를 지연로딩으로 할지, 즉시로딩으로 할지 고민할 필요도 없다.
public class ChangeOrderService{
@Transactional
public void changeShippingInfo(OrderId orderId, ShippingInfo newShippingInfo, boolean useNewShippingAddrAsMemberAddr){
Order order = orderRepository.findbyId(id);
if(order == null){
throw new OrderNotFoundException();
}
order.changeShippingInfo(newShippingInfo);
if(useNewShippingAddrAsMemberAddr){
// ID를 이용하여 참조하는 애트리거트를 얻는다.
Member member = memberRepository.findById(order.getOrderer().getMemberId());
}
}
}
ID를 이용한 애그리거트 참조는 N+1 문제를 낳을 수 있다.
이를 해결하기 위해서는 조인을 이용하는 조회 전용 쿼리
를 사용하면 된다.
예를 들어 데이터 조회를 위한 별도의 DAO를 만들고 DAO의 조회 메소드에 조인을 이용해 하나의 쿼리를 원하는 데이터를 불러 오자.
@Repositorypublic
class JpaOrderViewDao implements OrderViewDao {
@PersistenceContext
private EntityManager em;
@Override
public List<OrderView> selectByOrderer(String ordererId) {
String selectQuery =
"select new com.myshop.order.application.dto.OrderView(o,m,p)"+
"from Order o join o.orderLines ol, Member m, Product p"+
"where o.orderer.memberId.id = :ordererId"+
"and o.orderer.memberId = m.id"+
"and index(ol) = 0"+
"and ol.productId = p.id"+
"order by o.number.number desc";
TypedQuery<OrderView> query = em.createQuery(selectQuery,OrderView.class);
query.setParameter(ordererId, ordererId);
return query.getResultList();
}
}
애그리거트마다 서로 다른 db를 채택하면 한 번의 쿼리로 관련 애그리거트를 조회할 수 없을 것이다. 그렇다면
개념적으로 존재하는 애그리거트 간의 연관을 항상 실제 구현에 반영할 필요는 없다. 요구사항에 맞춰 필요한 부분만 구현하면 된다.
아래 코드의 경우 Category에 속한 모든 Product를 조회하게 때문에 성능상의 이슈가 발생한다. (1:N)
public class Category {
private Set<Product> products;
public List<Product> getProducts(int page, int size) {
만약 카테고리 안의 상품을 구하고 싶다면 상품 입장에 자신이 속한 카테고리를 N-1로 연관지어 구하자.
M:N 연관도 요구사항에 맞춰 구현할 부분을 결정하면 된다.
이번에는 하나의 카테고리를 여러 상품을 가질 수 있고 하나의 상품은 여러 카테고리를 가질 수 있다고 해보자.
@Entity
@Table(name = "product")
public class Product {
@EmbeddedId
private ProductId id;
@ElementCollection
@CollectionTable(name = "product_category", joinColumns = @JoinColumn(name = "product_id"))
private Set<CategoryId> categoryIds;
//생략
}
상품 등록 기능은 상점 계정이 차단 상태가 아닐 때만 실행할 수 있다고 가정해보자. 그러면 Store의 isBlocked() 메소드를 사용해 상점 계정 상태를 체크한 뒤에 Product를 생성해야 한다.
만약 RegisterProductService라는 응용 서비스에서 store의 상태를 확인하고 product를 추가하는 로직을 넣으면 어떨까? 🤔
작동에는 문제가 없지만 도메인 로직 처리가 응용 서비스단에 노출되었다는 점에서 바람직하지는 않다.
그렇다면 Product를 생성하는 기능을 Store 애그리거트에 넣으면 어떨까?
해당 기능을 통해서 도메인 내에서 Store의 상태를 확인하고 Product를 생성하는 기능을 모두 수행한다.
이로써 Store 애그리거트는 Product의 팩토리 역할을 하면서 도메인 로직을 구현하게 된다.
그렇게 되면 응용 서비스단에서는 팩토리 기능을 통해 Product를 생성하면 될 것이다.
이를 아래처럼 구현할 수 있다.
public class Store {
public Product createProduct(ProductId newProductId{
if(isBlocked()){
throw new StoreBlockedException(); //Product를 생성하는 로직을 Store 애그리거트에 넣었다.
}
return new Product(newProductId, getId());
}
}
public class RegisterProductService {
public ProductId registerNewProduct(NewProductRequest req){
Store store = storeRepository.findById(req.getStoreId);
checkNull(store);
ProductId id = productRepository.nextId();
Product product = store.createProduct(id,...생략);
productRepository.save(product);
return id;
}
}
결론: 애그리거트가 갖고 있는 데이터를 활용하여 다른 애그리거트를 만들어야 한다면?
--> 애그리거트에 팩토리 메서드를 구현하는 것을 고려해보자!!