IT recording...

[DDD START!] 3장 - 애그리거트 본문

DevBookReview

[DDD START!] 3장 - 애그리거트

I-one 2021. 12. 31. 00:47

1. 애그리거트

 

  • 애그리거트는 모델을 이해하는 데 도움을 준다.
  • 일관성을 관리하는 기준이 된다. (→ 복잡한 도메인을 단순한 구조로 만들어 준다.)
  • 한 애그리거트에 속한 객체들은 유사하거나 동일한 라이프사이클을 갖는다.
  • ex) 주문 애그리거트를 만들려면 Order, OrderLine, Orderer와 같은 관련 객체를 함께 생성해야 한다.
  • 한 애그리거트에 속한 객체는 다른 애그리거트에 속하지 않는다. (독립된 객체 군)
  • 각 애그리거트는 자기 자신을 관리할 뿐 다른 애그리거트를 관리하지 않는다.
    • ex) 주문 애그리거트에서 배송지를 변경하거나, 주문 상품 개수를 변경하지만, 회원의 비밀번호를 변경하거나 상품의 가격을 변경하지 않는다.
  • 함께 생성되는 구성요소는 한 애그리거트에 속할 가능성이 높다.
    • ex) 주문할 상품 개수, 배송지 정보, 주문자 정보는 주문 시점에 함께 생성되므로 한 애그리거트에 속한다.
  • A가 B를 갖는다고 해서 한 애그리거트에 속하는 것은 아니다.
    • ex) Order 가 Orderer와 ShippingInfo를 갖는다. → 애그리거트 (O)
    • ex) Product가 Review를 갖는다. → But, 상품과 리뷰는 함께 생성되지 않으며, 변경하는 주체가 다르다. 따라서 애그리거트 (X)
  • 한 개의 엔티티 객체만 갖는 애그리거트가 많다.

2. 애그리거트 루트
주문 애그리거트는 다음을 포함한다.

  • 총 금액인 totalAmounts를 갖고 있는 Order 엔티티
  • 개별 구매 상품의 개수인 quantity 와 금액인 price를 갖고 있는 OrderLine 밸류

→ 구매할 상품의 개수를 변경하면 OrderLine의 quantity를 변경하고 더불어 Order의 totalAmounts도 변경해야 한다.

일관성을 유지하기 위해서는 애그리거트 전체를 관리할 주체가 필요하다.

⇒ 애그리거트 루트 엔티티

 

3. 도메인 규칙과 일관성

  • 애그리거트 루트 : 애그리거트의 일관성이 깨지지 않도록 관리한다.ex) 주문 애그리거트 → 배송지 변경, 상품 변경과 같은 기능 제공
  • → 애그리거트 루트는 애그리거트가 제공해야 할 도메인 기능을 구현한다.
public class Order{
	public void changeShippingInfo(ShippingInfo newShippingInfo){
		verifyNotYetShipped();
		setShippingInfo(newShippingInfo);
	}
	private void verifyNotYetShipped(){
		if (state != OrderState.PAYMENT_WAITING && state != OrderState.WAITING)
			throw new IllegalStateException("already shipped");
	}
...
}
  • 애그리거트 루트가 아닌 다른 객체가 애그리거트에 속한 객체를 직접 변경하면 안된다. (일관성 깨짐)⇒ 밸류 타입은 불변으로 구현한다.
  • ⇒ 단순히 필드를 변경하는 Setter를 public으로 만들지 않는다.
  1. public setter는 도메인의 의미나 의도를 표현하지 못하고, 도메인 로직이 응용 영역이나 표현 영역으로 분산되게 만드는 원인이 된다. → 응집되어 있지 않으므로 코드 유지보수 시 시간이 많이 들어감
  2. 밸류 타입은 불변으로 구현한다.
  3. → 새로운 밸류 객체를 할당하는 것으로 구현

4. 애그리거트 루트의 기능 구현

  • 애그리거트 루트는 애그리거트 내부의 다른 객체를 조합해서 기능을 완성한다.
    • ex) Order는 총 주문 금액을 구하기 위해 OrderLine 목록을 사용한다.
public class Order{
	private Money totalAmounts;
	private List<OrderLine> orderLines;
	
	private void calculateTotalAmounts(){
		int sum = orderLines.stream().mapToInt(ol -> ol.getPrice() * ol.quantitiy()).sum();
		this.totalAmounts = new Money(sum);
	}
}

ex) Member는 암호를 변경하기 위해 Password 객체에 암호가 일치하는지 여부를 확인한다.

public class Member{
	private Password password;

	public void changePassword(String currentPassword, String newPassword){
		if (!password.match(currentPassword)){
			throw new PasswordNotMatchException();
		}
		this.password = new Password(newPassword);
	}
}
  • 애그리거트는 기능 실행을 위임하기도 한다.
    • 다음은 구현 기술의 제약이나 내부 모델링 규칙 때문에 OrderLine 목록을 별도 클래스로 분리한 것이다.
//별도의 클래스로 분리
public class OrderLines{
	private List<OrderLine> lines;

	public int getTotalAmounts(){...}
	public void changeOrderLines(List<OrderLine> newLines){
		this.lines = newLines;
	}
}
public class Order {
	private OrderLines orderLines;
	
	public void changeOrderLines(List<OrderLine> newLines){
		orderLines.changeOrderLines(newLines);
		this.totalAmounts = orderLines.getTotalAmounts();
	}
}

→ Order의 changeOrderLines 메소드는 내부의 orderLines 필드에 상태 변경을 위임하는 방식으로 기능을 구현했다.

OrderLines lines = order.getOrderLines();

lines.changeOrderLInes(newOrderLines);
//이런식으로 외부에서 orderlines를 변경하게 되면 일관성이 깨지게 된다.
//totalAmount값은 변경되지 않음

⇒ OrderLine 목록을 변경할 수 없도록 OrderLines객체를 불변으로 구현한다.

5. 트랜잭션 범위

  • 트랜잭션 범위는 작을수록 좋다.
  • 한 트랜잭션이 한 개의 테이블 수정 > 한 트랜잭션이 세 개의 테이블 수정
  • 한 트랜잭션에서는 한 개의 애그리거트만 수정해야 한다. (충돌 가능성 제거)
    • = 애그리거트에서 다른 애그리거트를 변경하지 않는다.
    • 결합도를 제거한다.
    ex) 배송지 정보를 변경하면서 동시에 배송지 정보를 회원의 주소로 설정하는 기능 구현
public class Order{
	private Orderer orderer;

	public void shipTo(ShippingInfo newshippingInfo, boolean useNewShippingAddrAsMemberAddr){
		verifyNotYetShipped();
		setShippingInfo(newShippingInfo);
		if (useNewShippingAddrAsMemberAddr){
			//다른 애그리거트의 상태를 변경하면 안된다.
			orderer.getcustomer().changeAddress(newShippingInfo.getAddress());
		}
	}
}
  • ⇒ 애그리거트 내부에서 수정하지 않고, 응용 서비스에서 두 애그리거트를 수정하도록 구현해야 한다.
public class ChangeOrderService{
	//두 개 이상의 애그리거트를 변경해야 하면, 응용 서비스에서 각 애그리거트의 상태를 변경한다.
	@Transactional
	public void changeShippingInfo(OrderId id, ShippingInfo newShippingInfo, boolean useNewSHippingAddrAsMemberAddr){
		Order order = orderRepository.findbyId(id);
		if (order == null) throw new OrderNotFoundException();
		order.shipTo(newShippingInfo); //1
		if (useNewShippingAddrAsMemberAddr) {
			order.getOrderer().getCustomer().changeAddress(newShippingInfo.getAddress());
		} //2
	}
}

6. 리포지터리와 애그리거트

  • 리포지터리는 애그리거트 단위로 존재한다.
    • Order와 OrderLine을 물리적으로 각각 별도의 DB테이블에 저장한다고 해서 리포지터리를 각각 만들지 않는다.
    • Order가 애그리거트 루트이고 OrderLine이 애그리거트에 속하는 구성요소이므로 Order를 위한 리포지터리만 존재한다.
  • 리포지터리의 기본 기능
    • save - 애그리거트 저장
    • findById - ID로 애그리거틀르 구함
  • 애그리거트 전체를 저장소에 영속화해야 한다.
//리포지터리에 애그리거트 저장 시 애그리거트 전체를 영속화해야 한다.
orderRepository.save(order);

//리포지터리는 완전한 Order를 제공해야 한다.
Order order = orderRepository.findById(orderId);
//order가 온전한 애그리거트가 아니라면
//기능 실행 도중 NullPointerException과 같은 문제가 발생한다.
order.cancel();

7. ID를 이용한 애그리거트 참조

  1. 한 애그리거트에서 다른 애그리거트를 참조할 때 (애그리거트 루트끼리의 참조) 필드의 참조를 이용해서 구현할 수 있다.
public class Order {
	private Orderer orderer;
	
	public void changeShippingInfo(ShippingInfo newShippingInfo, boolean useNewShippingAddrAsMemberAddr){
		...
		if(useNewShippingAddrAsMemberAddr){
				//한 애그리거트 내부에서 다른 애그리거트 접근 가능 -> 상태 변경 유혹에 빠지기 쉬움
			orderer.getCustomer().changeAddress(newShippingInfo.getAddress());
		}
	}
}
  • 필드 직접 참조를 통한 구현 장점
    • JPA의 @ManyToOne, @OneToOne과 같은 어노테이션을 이용해 필드를 통한 다른 애그리거트 참조를 쉽게 구현할 수 있다.
    • 다른 애그리거트의 데이터를 객체 탐색을 통해 조회할 수 있다.
  • 필드 직접 참조를 통한 구현 단점
    • 편한 탐색 오용
      • 한 애그리거트에서 다른 애그리거트 객체에 접근할 수 있다면, 다른 애그리거트의 상태를 쉽게 변경할 수 있다.
      • 한 트랜잭션 내에서는 한 애그리거트만 관리해야 하는데 그렇지 않는 것은 애그리거트 간의 의존 결합도를 높여서 애그리거트의 변경을 어렵게 만든다.
    • 성능에 대한 고민
      • JPA를 사용할 경우 참조한 객체를 지연(lazy)로딩과 즉시(eager)로딩의 두 가지 방식으로 로딩할 수 있다.
        • 지연로딩 : 필요한 시점에 연관된 데이터를 불러오는 것
          • 단순히 연관된 객체의 데이터를 함께 화면에 보어주어야 할 때
        • 즉시로딩 : 데이터를 조회할 때 연관된 데이터를 모두 불러오는 것
          • 애그리거트의 상태를 변경하는 기능을 실행하는 경우, 불필요한 객체를 함께 로딩할 필요 없음
      • 애그리거트를 직접 참조하면 위와 같은 지연 로딩, 즉시 로딩 중 어떤 것을 사용해야 할 지 다양한 경우의 수를 고민해야 한다.
    • 확장 어려움
      • 초기에는 단일 DBMS를 사용할 수 있지만, 사용자가 늘고 트래픽이 증가하면 부하를 분산하기 위해 하위 도메인별로 시스템을 분리하기 시작한다.
      • 하위 도메인마다 서로 다른 DBMS를 사용한다면, 애그리거트 루트를 참조하기 위해 JPA와 같은 단일 기술을 사용할 수 없다.
  1. ID를 이용하여 애그리거트 참조를 구현하자.
  • 애그리거트 내의 엔티티를 참조할 때는 객체 레퍼런스로, 다른 애그리거트를 참조할 때는 ID 참조를 사용한다.→ 애그리거트 간의 의존을 제거하므로 응집도를 높여준다.
  • → 구현 복잡도가 낮아진다. (애그리거트 참조를 지연 로딩으로 할지 즉시 로딩으로 할지 고민하지 않아도 된다. )
  • → 애그리거트 간 물리적인 연결을 제거하기 때문에 모델의 복잡도를 낮춰준다.
public class ChangeOrderService{
	@Transactional
	public void changeShippingInfo(OrderID id, ShippingInfo newShippingInfo, 
		boolean useNewShippingAddrAsMemberAddr) {

		Order order = orderRepository.findbyId(id);
		if (order == null) throw new OrderNotFoundException();
		order.changeShippingInfo(newShippingInfo);

		if(useNewShippingAddrAsMemberAddr){
			//아이디를 이용한 참조
			Customer customer = customerRepository.findById(
					order.getOrderer().getCustomerId());
			customer.changeAddress(newShippingInfo.getAddress());
		}
	}
...
}
  • 편한 탐색 오용 해결
    • 한 애그리거트에서 다른 애그리거트를 수정하는 문제를 원천적으로 방지할 수 있다.
  • 성능에 대한 고민 해결
    • 응용 서비스에서 필요한 애그리거트를 ID참조를 통해 로딩한다. (애그리거트 수준에서 지연 로딩을 하는 것과 동일한 결과를 만든다.) - 구현 복잡도 낮아짐
  • 확장 어려움 해결
    • 중요한 데이터인 주문 애그리거트는 RDBMS에 저장하고, 조회 성능이 중요한 상품 애그리거트는 NoSQL에 저장할 수 있다.
    • 각 도메인을 별도 프로세스로 서비스하도록 구현할 수도 있다.

8. ID를 이용한 참조와 조회 성능

🖊️[JPA] N+1문제

  • 다른 애그리거트를 ID로 참조하면 참조하는 여러 애그리거트를 읽어야 할 때 조회 속도가 문제될 수 있다.
  • 예를 들어, 주문 목록과 연관된 상품 애그리거트와 회원 애그리거트를 읽어올 때 각 주문마다 상품과 회원 애그리거트를 읽어온다고 해보자.
  • 한 DBMS에 데이터가 있다면 조인을 통해 한 번에 모든 데이터를 가져올 수 있음에도 불구하고 주문마다 상품과 회원 정보를 읽어오는 쿼리를 실행하게 된다.
Customer customer = customerRepository.findById(ordererId);
List<Order> orders = orderRepository.findByOrderer(ordererId);
List<OrderView> dtos = orders.stream()
			.map(order -> {
				ProductId prodId = order.getOrderLines().get(0).getProductId();
				//각 주문마다 첫 번째 주문 상품 정보 로딩 위한 쿼리 실행
				Product product = productRepository.findById(prodId);
				return new OrderView(order,customer,product);
		} ).collect(toList());
  • N+1 조회 문제 발생
    • 주문 개수가 10개면 주문을 읽어오기 위한 1번의 쿼리와 주문별로 각 상품을 읽어오기 위한 10번의 쿼리를 실행해야 한다.
  • 해결 방안
    • 전용 조회 쿼리를 사용한다.
    • 데이터 조회를 위한 별도 DAO를 만들고 DAO의 조회 메소드에서 세타 조인을 이용해서 한 번의 쿼리로 필요한 데이터를 로딩하면 된다.
@Repository
public 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.memeberId = m.id"+
				"and index(ol) = 0"+
				"and ol.productId = p.id" +
				"order by o.number.number desc";
		TypedQuery<OrderView> query = 
					em.createQuery(selectQuery, OrderView.calss);
		query.setParameter("ordererId", ordererId);
		return query.getResultList();
	}
}
  • JPA를 이용하여 특정 사용자의 주문 내역을 보여주기 위한 코드
  • JPQL을 사용하여 Order 애그리거트와 Member 애그리거트, Product 애그리거트를 세타 조인으로 조회해서 한 번의 쿼리로 로딩한다.
    • 즉시 로딩이나 지연 로딩을 고민하지 않아도 조회 화면에서 필요한 애그리거트 데이터를 한 번의 쿼리로 로딩할 수 있다.
    • 쿼리가 복잡하거나 SQL에 특화된 기능을 사용해야 한다면 조회를 위한 부분만 MyBatis와 같은 기술을 이용해서 실행할 수 있다.
  • 애그리거트마다 서로 다른 저장소를 사용하는 경우는 한 번의 쿼리로 관련 애그리거트를 조회할 수 없다.
    • 이런 경우 조회 성능을 높이기 위해 캐시를 적용하거나 조회 전용 저장소를 따로 구성한다.
      • 장점 : 시스템의 처리량을 높일 수 있다.
      • 단점 : 코드가 복잡해진다.

9. 애그리거트 간 집합 연관

  • 애그리거트 간 연관에는 1:N 연관과 M:N 연관이 존재한다.

1:N , N:1 연관

  • 카테고리와 상품을 예로 들어보자.
    • 한 카테고리에 여러 상품이 존재할 수 있다.
    • 한 상품은 한 카테고리에만 존재할 수 있다.
  • 카테고리와 상품은 1:N관계, 상품과 카테고리는 N:1관계로 나타낼 수 있다.
    • 하지만 1:N 연관을 실제 구현에 반영하는 것은 요구사항을 충족하지 않는 경우가 존재한다.
    • 특정 카테고리에 있는 상품 목록을 보여주는 요구사항이 존재할 때, 한 번에 보여주기 보다는 페이징을 이용하여 제품을 나눠서 보여준다.
    • 이 기능을 카테고리 입장에서 1:N 연관을 이용해서 구현하면 다음과 같이 코드를 작성해야 한다.
public class Category{
	//카테고리-상품 1:N관계
	private Set<Product> products;
	
	public List<Product> getProducts(int page, int size){
		List<Product> sortedProducts = sortById(products);
		return sortedProducts.subList((page-1) * size, page * size);
	}
...
}
  • 위 코드를 실제 DBMS와 연동해서 구현하면 Category에 속한 모든 Product를 조회하게 된다.
    • Product 수가 매우 많다면 성능에 심각한 문제를 일으킨다.
  • 따라서 개념적으로는 1:N 연관이 있더라도, 실제 구현에 반영하지는 않는다.

  • 대신 N:1 관계를 사용하자!
  • 상품-카테고리 N:1 관계를 사용하여 구현해보자
public class Product{
	...
	private CategoryId category;
	...
}
public class ProductListService{
	public Page<Product> getProductOfCategory(Long categoryId, int page, int size){
		Category category = categoryRepository.findById(categoryId);
		
		List<Product> products = 
			productRepository.findByCategoryId(category.getId(),page,size);
		int totalCount = productRepository.countsByCategoryId(category.getId());
		return new Page(page,size,totalCount,products);
	}
...
}
  • 카테고리에 속한 상품 목록을 제공하는 응용 서비스에서 ProductRepository를 이용하여 categoryId가 지정한 카테고리 식별자인 Product 목록을 구현한다.

M:N연관

  • 개념적으로는 양쪽 애그리거트에 컬렉션으로 연관을 만들지만, 실제 구현에서는 단방향 M:N연관만 구현하는 경우가 많다.
  • 요구사항
    • 한 카테고리는 여러 상품을 가질 수 있다.
    • 한 상품은 여러 카테고리에 속할 수 있다.
  • 그렇다면 카테고리-상품 M:N 관계를 구현해야 할까?
    • 보통 특정 카테고리에 속한 상품 목록을 보여줄 때, 각 상품이 또 어떤 카테고리에 속해있는지 속한 다른 카테고리의 정보를 표시하지는 않는다.
    • 제품이 속한 모든 카테고리가 필요한 화면은 상품 상세 화면이다
  • ⇒ 상품-카테고리 M:N관계를 구현하자.
public class Product {
	private Set<CategoryId> categoryIds;
	...
}
  • RDBMS를 이용해서 M:N연관을 구현하려면 조인 테이블을 사용한다.
  • JPA를 이용하면 매핑 설정을 통해 ID참조를 이용한 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;
...
}
  • 매핑을 이용해서 JPQL의 member of 연산자를 이용해서 특정 카테고리에 속한 상품 목록을 구하는 기능을 구현할 수 있다.
@Repository
public class JpaProductRepository implements ProductRepository{
	@PersistenceContext
	private EntityManager entityManager;

	@Override
	public List<Product> findByCategoryId(CategoryId categoryId, int page, int size){
		TypedQuery<Product> query = entityManager.createQuery(
				"select p from Product p **where :catId member of p.categoryIds** order by p.id.id desc",
				Product.class);
		query.setParameter("catId",categoryId);
		query.setFirstResult((page-1)*size);
		query.setMaxResults(size);
		return query.getResultList();
	}
...
}
  • 이 응용 서비스를 이용해서 지정한 카테고리에 속한 Product 목록을 구할 수 있다.

10. 애그리거트를 팩토리로 사용하기

  • 요구사항
    • 특정 상점이 신고를 여러 번 받으면 상품 등록을 하지 못한다.
public class RegisterProductService{
	public ProductId registerNewProduct(NewProductRequest req){
		Store account = accountRepository.findStoreById(req.getStoreId());
		checkNull(account);
		***if (!account.isBlocked()){
			throw new StoreBlockedException();	
		}***	
		ProductId id = productRepository.nextId();
		***Product product = new Product(id, account.getId(), ...생략);***
		productRepository.save(product);
		return id;
	}
...
}
  • 이 응용 서비스에는 상점이 product를 생성 가능한지를 판단하는 코드와 Product를 생성하는 코드가 분리되어 있다.
  • 하지만 이 두 기능은 논리적으로 하나의 도메인 기능인데 응용 서비스에서 구현하고 있다.
  • ⇒ 도메인 기능을 구현하기 위해 애그리거트를 팩토리로 사용해보자
public class Store {
		public Product createProduct(ProductId newProductId, ...생략){
		if (isBlocked()) throw new StoreBlockedException();
		return new Product(newProductId, getId(), ...생략);
	}
}
public class RegisterProductService{
	public ProductId registerNewProduct(NewProductRequest req){
		Store account = accountRepository.findStoreById(req.getStoreId()));
		checkNull(account);
		ProductId id = productRepository.nextId();
		***Product product = account.createProduct(id, ...생략);***
		productRepository.save(product);
		return id;
	}
}
  • Store 애그리거트의 createProduct에서 Product 애그리거트를 생성하는 팩토리 역할을 하면서 중요한 도메인 로직을 구현하고 있다.
  • 응용 서비스에서는 Store의 상태를 확인하는 도메인 로직을 구현하지 않고 Product를 생성할 수 있다.
  • Product의 생성 가능 여부를 확인하는 도메인 로직을 변경해도 도메인 영역의 store만 변경하면 되고 응용 서비스는 영향을 받지 않는다.
    • 도메인의 응집도가 높아졌다.

⇒ 애그리거트가 갖고 있는 데이터를 이용해서 다른 애그리거트를 생성해야 한다면, 애그리거트에 팩토리 메소드를 구현해보자

  • Store의 데이터를 이용해서 Product를 생성한다.
    • 따라서 Store에 Product를 생성하는 팩토리 메소드를 추가하면 Product를 생성할 때 필요한 데이터의 일부를 직접 제공하면서 동시에 중요한 도메인 로직을 함께 구현할 수 있게 된다.
Comments