❗️ 주의할 점
함께 변경되는 빈도가 높은 객체는 한 애그리거트에 속할 가능성이 높지만, 반드시 한 애그리거트에 속한다는 것을 의미하는 것은 아님.
한 객체의 변경이 다른 객체에 영향을 주지 않으면 다른 애그리거트에 속하는 것.
3.2.1 도메인 규칙과 일관성
애그리거트 루트의 핵심 역할: 애그리거트의 일관성이 깨지지 않는 것.
➡️ 그러기 위해 애그리거트가 제공해야 할 도메인 기능을 구현. 즉, 기능이 구현된 메서드를 제공함.
실습 코드
public class Order {
// 애그리거트 루트는 도메인 규칙을 구현한 기능을 제공함
public void changeShippingInfo(ShippingInfo newShippingInfo) {
verifyNotYetShipped();
setShippingInfo(newShippingInfo);
}
private void verifyNotYetShipped() {
if (state != OrderState.PAYMENT_WAITING && STATE != OrderState.PREPARING throw new IllegalStateException("already shipped");
}
...
애그리거트 외부에서 애그리거트에 속한 객체를 직접 변경하지 않으면 애그리거트 루트가 강제하는 규칙을 적용할 수 없어 모델의 일관성을 깨는 원인이 됨.
ShippingInfo si = order.getShippingInfo(); //애그리거트 루트인 order에서 ShippingInfo를 가져와 직접 정보를 변경하는 코드
si.setAddress(newAddress);
// 변경 후
ShippingInfo si = order.getShippingInfo();
if (state != OrderState..PAYMENT_WAITING && state != OrderState.PREPARING) {
throw new IllegalArgumentException();
}
si.setAddress(newAddress);
애그리거트 루트를 통해서만 로메인 로직을 구현하는 방법
- 단순히 필드를 변경하는 set 메서드를 public으로 만들지 않기
- 밸류 타입은 불변으로 구현
➡️ 밸류 객체가 불변일 때 밸류 객체의 값을 변경하는 방법: 새로운 밸류 객체 할당
ex.
```
public class Order {
private ShippingInfo shippingInfo;
public void changeShippingInfo(ShippingInfo newShippingInfo) {
vefifyNotYetShipped);
setShippingInfo(newShippingInfo);
}
// set 메서드의 접근 허용범위를 private로 설정
private void setShippingInfo(ShippingInfo newShippingInfo_ {
// 밸류 불변 ➡️ 새로운 객체를 할당해서 값을 변경.
this.shippingInfo = newShippingInfo;
}
...
}
```
3.2.2 애그리거트 루트의 기능 구현
애그리거트 루트는 애그리거트 내부의 다른 객체를 조합해서 기능을 완성함.
ex 1. Order(애그리거트 루트)가 총 주문 금액을 구하기 위해 OrderLine 목록을 사용
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);
}
}
ex 2. Member 애그리거트 루트는 암호를 변경하기 위해 Password 객체에 암호가 일치하는지를 확인
public class Member {
private Password password;
public woid changePassword(String currentPassword, String newPassword) {
if (!password.match(currentPassword)) {
throw new PasswordNotMatchException();
}
this.password = new Password(newPassword);
}
}
애그리거트 루트가 구성요소에 기능 실행을 위임하기도 함
public class OrderLines {
private List<OrderLine> lines;
public Money getTotalAmounts() {
...
}
public void chageOrderLines(List<OrderLine> newLines) {
this.lines = newLines;
}
}
// orderLines 필드에 상태 변경을 위임하는 방식으로 기능 구현
public class Order {
private OrderLines orderLines;
public void changeOrderLines(List<OrderLine> newLines) {
orderLines.changeOrderLines(newLines);
this.totalAmounts = orderLines.getTotalAmounts();
}
}
3.2.3 트랜잭션 범위
트랜잭션 범위는 작을수록 좋음.
한 트랜잭션에서는 한 개의 애그리거트만 수정해야 함. 애그리거트에서 다른 애그리거트를 변경하지 않는다는 것을 의미함.
public class Order {
private Orderer orderer;
public void shipTo(ShippingInfo newShippingInfo, boolean useNewShippingAddrAsMemberAddr) {
verifyNotYetShipped();
setShippingInfo(newShippingInfo);
if (userNewShippingaddrAsMemberAddr) {
// 다른 애그리거트의 상태를 변경하면 안 됨
orderer.getMember().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);
if (userNewShippingAsMemberAddr) {
Member member = findMemeber(order.getOrderer());
member.changeAddress(newShippingInfo.getAddress());
}
}
}
도메인 이벤트를 사용하면 한 트랜잭션에서 한 개의 애그리거트를 수정하면서도 동기나 비동기로 다른 애그리거트의 상태를 변경하는 코드를 작성할 수 있음.
한 트랜잭션에서 한 개의 애그리거트를 변경하는 것을 권장하지만 아래의 경우에는 두 개 이상의 애그리거트를 변경하는 것을 고려할 수 있음.
orderRepository.save(order);
Order order = orderRepository.findById(orderId);
order.cancel();
4장에서 더 알아보기
애그리거트는 다른 애그리거트를 참조할 수 있음.
= 다른 애그리거트의 루트를 참조한다는 것과 같은 의미.
참조 방법: 필드를 통해 구현.
개발자에게 구현의 편리함을 제공
ex. 주문한 회원을 참조하기 위해 회원 애그리거트 루트인 Member 참조
애그리거트 참조의 장단점
// 이런 식으로 편하게 회원 Id를 구할 수 있음.
order.getOrderer().getMember().getId()
// JPA의 경우 @ManyToOne, @OneToOne과 같은 애너테이션 사용하여 다른 애그리거트 참조
public class Order {
private Orderer orderer;
public void changeShippingInfo(ShippingInfo newShippingInfo, boolean useNewShippingAddrAsMemberAddr) {
...
if (usenewShippingAddrAsMemberAddr) {
// 한 애그리거트 내에서 다른 애그리거트에 접근할 수 있으면 다른 애그리거트의 상태를 변경하는 유혹에 빠지기 쉬움
// 하지만 이는 애그리거트 간의 의존 결합도를 높여 애그리거트의 변경을 어렵게 만듦.
orderer.getMember().changeAddress(newShippingInfo.getAddress());
}
}
}
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 (useNewShippingAsMemberAddr) {
// ID를 이용하여 참조하는 애그리거트를 구하기
Member member = memberRepository.findById(order.getOrderer().getMemberId());
// 외부 애그리거트를 직접 참조하지 않으므로 애초에 다른 애그리거트의 상태를 변경할 수 없음. member.changeAddress(newShippingInfo.getAddress());
}
}
}
3.4.1 ID를 이용한 참조와 조회 성능
Member member = memberRepository.findById(orderId)
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, member, product);
}).collect(toList());
// 문제 해결을 위해 ID 참조 방식을 사용하면서 join을 사용해야 함.
// 데이터 조회를 위한 별도 DAO를 만들고, DAO의 조회 메서드에서 join을 이용해 한 번에 필요한 데이터 로딩
@Repository
public class JapOrderViewDao implements OrderViewDao {
@PersistenceContext
private EntityManager em;
@Override
public List<OrderView> selectByOrderer(String ordererId) {
// Order, Member, Product 애그리거트를 join으로 조회하여 한 번의 쿼리로 로딩
String selectQuery =
"select new com.myshop.order.application.dto.OrderView(o, m, p)" +
"from Order o join o.orderLines ol, Memeber 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();
}
}
5장에서 JPA에서 조회 전용 쿼리를 실행하는 방법 알아보기
11장에서 명령 모델과 조회 전용 모델을 분리해서 구현하는 패턴 살펴보기
public class Category {
private Set <Product> products; // 다른 애그리거트에 대한 1-N 연관
}
// 요구사항: 특정 카테고리에 속한 상품 목록 보여주기
public class Category {
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를 조회하게 되는데, 이는 데이터가 많을 경우 실행 속도를 느려지게 함. 따라서, 개념적으로 1-N 관계와 관련이 있어도 애그리거트 간의 1-N 연관을 실제 구현에 반영하지는 않음.public class Product {
...
private CategoryId categoryId;
...
}
public class ProductListService {
public Page<Product> getProductOfCategory(Long categoryId, int page, int size) {
Category category = categoryRepository.findById(categoryId);
checkCategory(category);
List<Product> products = productRepository.findByCategoryId(category.getId(), page, size);
int totalCount = productRepository.countsByCategoryId(category.getId());
return new Page(page, size, totalCount, products);
}
...
}
양쪽 애그리거트에 컬렉션으로 연관을 만듬.
실제 요구사항을 고려하여 M-N 연관을 구현에 포함시킬지 결정하기.
ex. 개념적으로는 상품-카테고리가 양방향으로 M-N 연관을 가지고 있지만, 실제 구현에서는 상품에서 카테고리로의 단방향 M-N 연관만 적용
public class Product {
private Set<CategoryId> categoryIds;
...
}
조인 테이블을 이용한 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;
...
}
ex. 카테고리 ID 목록을 보관하기 위해 밸류 타입에 대한 컬렉션 매핑을 이용
@Repository
public class JpaProductRepository implements ProductRepository {
@PersistenceContext
private EntityManager entityManagetr;
@Override
public List<Product> findByCategoryId(CategoryId catId, int page, int size) {
TypedQuery<Product> query = entityManager.createQuery(
"select p from Product p " +
// categoryIds 컬렉션에 catId로 지정한 값이 존재하는지를 검사하기 위한 검색 조건
// 해당 조건을 이용해서 응용 서비스는 지정한 카테고리에 속한 Product 목록을 구할 수 있음
"where :catId member of p.categoryIds order by p.id.id desc",
Product.class
);
query,setParameter("catId", catId);
query.setFirstResult((page - 1) * size);
query.setMaxResults(size);
return query.getResultList();
}
...
}
4장에서 'JPA를 이용한 모델 매핑'과 '컬렉션을 사용할 때의 성능 관련 문제' 더 알아보기
상점 계정이 차단 상태가 아닌 경우에만 상품을 생성할 수 있음
public class RegisterProductService {
public ProductId registerNewProduct(NewProductRequest req) {
Store store = storeRepository.findById(req.getStoreId());
checkNull(store);
// product를 생성 가능한지 판단하는 코드
if (store.isBlocked()) {
throw new StoreBlockedException();
}
ProductId id = productRepository.nextId();
// product를 생성하는 코드
Product product = new Product(id, store,getId(), ... );
productRepository.save(product);
return id;
// product 생성 가능 여부를 판별, product 생성하는 코드가 분리되어 있음
// 도메인 로직 처리가 응용 서비스에 노출됨. 즉, 도메인에서 구현할 기능을 응용 서비스에서 구현하고 있는 것.
}
...
}
// Product 생성 기능을 Store 애그리거트에 구현해보면,
public class Store {
// Proudct 애그리거트를 생성하는 팩토리 역할 && 도메인 로직 구현
public Product createProduct(ProductId newProductId, ... ) {
if (isBlocked()) throw new StoreBlockedException();
return new Product(newProductId, getId(), ... )
}
}
// 팩토리 기능을 구현했으므로, 응용서비스는 팩토리 기능을 이용하여 Product를 생성할 수 있음
public class REegisterProudctService {
public ProductId registerNewProduct(NewProductRequest req) {
// 앞선 코드와 다르게, Store의 상태를 확인하지 않음. 해당 로직은 Store에서 구현했으므로!)
Store store = storeRepository.findById(req.getStoreId());
checkNull(store);
ProductId id = productRepository.nextId()
Product product = store.createProduct(id, ... );
productRepository.save(product);
return id;
}
...
}
애그리거트가 갖고 있는 데이터를 이용해서 다른 애그리거트를 생성해야 할 때, 애그리거트에 팩토리 메서드를 구현하는 것을 고려해보기.