IT recording...
[DDD START!] 3장 - 애그리거트 본문
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으로 만들지 않는다.
- public setter는 도메인의 의미나 의도를 표현하지 못하고, 도메인 로직이 응용 영역이나 표현 영역으로 분산되게 만드는 원인이 된다. → 응집되어 있지 않으므로 코드 유지보수 시 시간이 많이 들어감
- 밸류 타입은 불변으로 구현한다.
- → 새로운 밸류 객체를 할당하는 것으로 구현
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. 트랜잭션 범위
- 트랜잭션 범위는 작을수록 좋다.
- 한 트랜잭션이 한 개의 테이블 수정 > 한 트랜잭션이 세 개의 테이블 수정
- 한 트랜잭션에서는 한 개의 애그리거트만 수정해야 한다. (충돌 가능성 제거)
- = 애그리거트에서 다른 애그리거트를 변경하지 않는다.
- 결합도를 제거한다.
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를 이용한 애그리거트 참조
- 한 애그리거트에서 다른 애그리거트를 참조할 때 (애그리거트 루트끼리의 참조) 필드의 참조를 이용해서 구현할 수 있다.
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)로딩의 두 가지 방식으로 로딩할 수 있다.
- 지연로딩 : 필요한 시점에 연관된 데이터를 불러오는 것
- 단순히 연관된 객체의 데이터를 함께 화면에 보어주어야 할 때
- 즉시로딩 : 데이터를 조회할 때 연관된 데이터를 모두 불러오는 것
- 애그리거트의 상태를 변경하는 기능을 실행하는 경우, 불필요한 객체를 함께 로딩할 필요 없음
- 지연로딩 : 필요한 시점에 연관된 데이터를 불러오는 것
- 애그리거트를 직접 참조하면 위와 같은 지연 로딩, 즉시 로딩 중 어떤 것을 사용해야 할 지 다양한 경우의 수를 고민해야 한다.
- JPA를 사용할 경우 참조한 객체를 지연(lazy)로딩과 즉시(eager)로딩의 두 가지 방식으로 로딩할 수 있다.
- 확장 어려움
- 초기에는 단일 DBMS를 사용할 수 있지만, 사용자가 늘고 트래픽이 증가하면 부하를 분산하기 위해 하위 도메인별로 시스템을 분리하기 시작한다.
- 하위 도메인마다 서로 다른 DBMS를 사용한다면, 애그리거트 루트를 참조하기 위해 JPA와 같은 단일 기술을 사용할 수 없다.
- 편한 탐색 오용
- 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를 이용한 참조와 조회 성능
- 다른 애그리거트를 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를 생성할 때 필요한 데이터의 일부를 직접 제공하면서 동시에 중요한 도메인 로직을 함께 구현할 수 있게 된다.
'DevBookReview' 카테고리의 다른 글
[DDD START!] 5장 - 리포지터리의 조회 기능(JPA중심) (0) | 2022.01.07 |
---|---|
[DDD START!] 4장 - 리포지터리와 모델 구현 (JPA중심) (0) | 2022.01.07 |
[DDD START!] 2장 - 아키텍처 (0) | 2021.12.28 |
[DDD START!] 1장 - 도메인 (0) | 2021.12.28 |
Comments