애그리거트
: 복잡한 도메인을 이해하고 관리하기 쉬운 단위로 만들기 위해, 상위 수준에서 모델을 조망할 수 있는 방법
* 단, 'A가 B를 갖는다로 해석할 수 있는 요구사항이 있다고 하더라도, 이것이 반드시 A와 B가 한 애그리거트에 속한다는
의미는 아니다.
* 구분기준: 두 객체가 함께 생성되지 않고 함께 변경되지 않는 등, 변경의 주체가 다르면 다른 애그리거트에 속한다.
* 조건: 다수의 애그리거트가 한 개의 엔티티 객체만 갖는 경우가 많음. 두 개 이상의 엔티티로 구성되는 애그리거트는 지양
애그리거트 루트와 역할
애그리거트 루트 엔티티
: 애그리거트 전체를 관리할 주체
* 도메인 규칙을 지키기 위해선, 애그리거트에 속한 모든 객체가 정상 상태를 가져야함
애그리거트 루트 역할
1. 애그리거트에 속한 모든 객체가 일관된 상태를 유지하는 역할
ex) 애그리거트에서 한가지 기능을 제공한다면, 애그리거트루트에서 해당 기능을 하는 메서드를 구현하여 제공
2. 애그리거트 외부에서 애그리거트에 속한 객체를 직접 변경하면 안되므로,
불필요한 중복을 피하고 애그리거트 루트를 통해서만 도메인 로직을 구현하게 만듦
/* 조건 */
- 단순히 필드를 변경하는 set 메서드를 public 범위로 만들지 않는다.
- 밸류 타입은 불변으로 구현한다. <= 외부에서 set메서드로 변경이 불가능해 컴파일 에러를 발생시켜준다
3. 밸류 객체가 불변이면, 밸류 객체의 값을 변경하는 방법은 새로운 밸류객체를 할당하는 것 뿐이기 때문에,
애그리거트 루트가 제공하는 메서드에 새로운 밸류 객체를 전달해서 값을 변경하는 방법밖에 없게 할 수 있다.
ex) 쿠팡에서 배송지 변경시, 기존 배송지 정보를 수정하는 것이 아닌 신규 배송지를 추가하여 선택하는 방식
예시 코드
public class Order {
private ShippingInfo shippingInfo;
public void changeShippingInfo(ShippingInfo newShippingInfo) {
verifyNotYetShipping();
setShippingInfo(newShippingInfo);
}
/*
* set 메서드의 접근 허용 범위는 private로 하는 것이 바람직하다
* 밸류가 불변이면, 새로운 객체를 할당해서 값을 변경해야한다
* 밸류가 불변이므로, this를 이용하더라도 이전 set메서드로 일부 값만 직접 수정할 수 없다.
* this.shippingInfo.setAddress(newShippingInfo.getAddress())
* 따라서, shippingInfo라는 객체 자체를 갈아끼워준다.
* this.shippingInfo = newShippingInfo;
*/
private void setShippingInfo (ShippingInfo newShippingInfo) {
this.shippingInfo = newShippingInfo;
}
}
4. 애그리거트 루트는 구성요소의 상태를 참조할 뿐만 아니라, 기능 실행을 위임하기도 함
* 이때, 한 애그리거트에 속하는 모델은 한 패키지에 속하기 때문에, protected 혹은 package-private를 사용하여
애그리거트 외부에서의 상태 변경 기능 실행을 방지
(단, 현업에서는 package-private를 잘 사용하지 않으므로, protected와 public 사이에서 고민을 권고함)
* package-private 설명 참고 링크 : https://hyeon9mak.github.io/Java-dont-use-package-private/
* 접근 제어자 사용에 대한 설명 *
/* 즉, 권장 사항은
* 클래스 내부에서만 사용해야할 메서드는 private
* 다른 애그리거트에서도 사용되야할 메서드는 public
* 하나의 애그리거트내에서 사용되어야 할 메서드는 protected
*/
default | private | protected | public | ||
동일 패키지 접근 |
같은 클래스에서 접근 | 동일 패키지 허용 |
O | 동일 패키지 허용 |
전체 허용 |
상속 클래스에서 접근 | X | ||||
다른 클래스에서 접근 | |||||
다른 패키지 접근 |
일반 클래스에서 접근 | X | X | ||
상속 클래스에서 접근 | O |
* 예시 코드 *
특정 조건 때문에 Order에서 OrderLine List(OrderLines)를 별도의 클래스로 분리했을 경우,
기존 Order에 있던 changeOrderLines() 메서드를 OrderLines클래스에 위임함
public class OrderLines {
private List <OrderLine> lines;
public Money getTotalAmounts() { ...기능 구현; }
/*
* 파라미터로 전달받은 newLines로 기존의 lines를 대체하는 상태변환 코드 구현
* Order에서 호출해서 사용
*/
public void changeOrderLines (List<OrderLine> newLines) {
this.lines = newLines;
}
}
public class Order {
private OrderLines orderLines;
// OrderLines클래스 안에 구현해놓은 changeOrderLines()를 호출해서 사용
public void changeOrderLines (List<OrderLine> newLines ) {
orderLines.changeOrderLines(newLines);
this.totalAmounts = orderLines.getTotalAmounts();
}
}
5. 트랜잭션의 범위는 작을수록 좋음
: 한 트랜잭션에서는 한 개의 애그리거트만 수정해야 한다. (트랜잭션 충돌로 인한 전체 처리량감소 우려)
: 단, 만약 부득이하게 한 트랜잭션으로 두 개 이상의 애그리 거트를 수정해야 하는 경우,
현재 애그리거트에서 다른 애그리거트를 직접 수정하지 말고, 응용 서비스에서 두 애그리거트를 수정하도록 구현하기
* 도메인 이벤트를 사용하면, 한 트랜잭션에서 한 개의 애그리거트를 수정하면서, 동기나 비동기로 다른 애그리거트의
상태 변경이 가능함 (참고 링크 : chapter 10)
* 두 개 이상의 애그리거트를 변경해야하는 경우 => 각 애그리거트의 상태를 변경함
즉, Order에서 Address를 변경하지 않고, Member에서 Address를 변경해 줌
public class ChangeOrderService {
@Transactional
public void changeShippingInfo (OrderId id,
ShippingInfo newShippingInfo,
boolean useNewShippingAddrAsMember) {
Order order = orderRepository.findById(id);
if (order == null) throw new OrderNotFoundException();
order.shipTo(newShippingInfo);
if (useNewShippingAsMemberAddr) {
Member member = findMember(order.getOrderer());
member.changeAddress(newShippingInfo.getAddress());
}
}
}
* 한 트랜잭션에서 두 개 이상의 애그리거트를 변경하는 것을 고려해야하는 경우 3가지
1) 팀 표준 : 한 트랜잭션으로 응용 서비스의 기능을 실행해야하는 팀 표준이 있는 경우
2) 기술 제약 : 기술적으로 이벤트 방식을 도입할 수 없는 경우
3) UI 구현의 편의성 : 관리자의 편리함을 위해 주문 목록의 화면에서 여러 주문의 상태를 한 번에 변경할 수있게 할 경우
애그리거트와 레포지토리
1. 애그리거트 : 개념상 완전한 한 개의 도메인 모델을 표현
2. 레포지토리 : 객체의 영속성을 처리하며, 애그리거트 단위로 존재
* 따라서, Order와 OrderLine을 물리적으로 각각 별도의 DB 테이블에 저장한다고 해서,
Order와 OrderLine을 위한 레포지토리를 별도로 만들지는 않음 (<= 애매한 부분)
3. 애너테이션
@Component : JPA에서 밸류타입을 매핑할 때 사용
@Entity : 엔티티를 매핑할 때 사용
4. 애그리거트를 영속화 할 저장소
: RDBMS인 MySQL, MariaDB, Oracle | NoSQL인 몽고DB 등이 있음
: 어느쪽이든, 애그리거트의 상태가 변경되면 모든 병경을 원자적으로 저장소에 반영해야함
ID를 이용한 애그리거트 참조
애그리거트 참조 2가지 방식 [결론은 ID참조방식 + 쿼리문 이용이 Best]
1. 애너테이션을 이용한 객체 참조 방식
1) 특징: JPA를 이용해 손쉽지만 '편한 탐색 오용', '성능에 대한 고민', '확장의 어려움' 등의 단점이 존재함
@ManyToOne
@OneToMany
2) 단점 3가지
단점1 | 한 애그리거트 내부에서 다른 애그리거트 객체에 접근할 수 있으면, 다른 애그리거트의 상태를 쉽게 변경할 수 있음 * 트랜잭션 범위 문제상, 한 애그리거트가 관리하는 범위를 자기 자신으로 한정해야하므로 주의해야함 왜냐하면, 한 애그리거트에서 다른 애그리거트의 상태를 변경할 수 있으면, 애그리거트간의 의존 결합가 높아짐 |
단점2 | 성능에 대한 고민이 필요함 (아래 표 참고) |
단점3 | 사용자가 늘고 트래픽이 증가하면 부하를 분산하기 위해 하위 도메일별로 시스템을 분리해야함 이를 위해 서로다른 DBMS 혹은 몽고DB+마리아DB 조합과 같이 다른 종류의 저장소를 사용하기도 함 이때, JPA같은 단일 기술을 사용할 수가 없음 |
애너테이션 사용시 로딩 처리 | |
GET | 연관된 객체 데이터를 함께 제공해야 한다면 즉시로딩(Eager)가 성능상 유리 |
POST, PATCH, DELETE | 애그리거트 상태변경시 불필요한 객체를 함께 로딩할 필요가 없으므로 지연로딩(Lazy) 유리 |
2. ID를 이용한 애그리거트 참조 방식
1) 장점
장점1 | ID 참조시, 모든 객체가 참조로 연결되지 않고 한 애그리거트에 속한 객체들만 참조로 연결됨 1) 애그리거트의 경계를 명확히 함 2) 물리적인 연결을 제거해 모델의 복잡도를 낮춰줌 3) 의존 제거로 낮춰 응집도를 높여줌 |
장점2 | 참조하는 애그리거트가 필요하면, 응용서비스에서 ID를 이용해서 로딩하면 됨 |
장점3 | 응용 서비스에서 필요한 애그리거트를 로딩하므로, 애그리거트 수준에서 지연로딩하는 것과 동일한 결과가 됨 1) 복잡도를 낮추는 것과 동시에 한 애그리거트에서 다른 애그리거트를 수정하는 문제를 방지함 2) 애그리거트별로 다른 구현 기술 사용도 가능 * 중요한 애그리거트 데이터는 RDBMS에, 조회성능이 중요한 애그리거트는 NoSQL에 저장 |
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) {
Member member = memberRepository.findById(order.getOrderer().gerMemberId()); // <= Id로 참조할 애그리거트 조회
member.changeAddress(newShippingInfo.getAddress()); // <= member 애그리거트에서 Address라는 값을 수정함
}
}
}
2) 단점 : N+1문제 발생
N+1문제 해결방법 | |
방법1 | ID 참조 방식이 아닌 객체참조방식을 사용 |
방법2 | ID 참조 방식을 사용하되, 조회 전용 쿼리를 사용 * 데이터 조회를 위한 별도의 DAO를 만들고, DAO의 조회 메서드에서 조인을 이용해 한 번의 쿼리로 데이터 로딩 |
* N+1문제란? 상위 데이터를 위한 1번의 쿼리가 실행될 때, 하위 데이터를 같이 얻기위해 N번의 쿼리가 같이 실행되는 것 ex) 1번 Account를 조회할 때, 1번 Account와 연결된 1~100번 Order를 같이 얻기위해 100번의 쿼리가 같이 실행되버림 |
* 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.memberId = m.id "
+ "and index(ol) = 0 "
+ "order by o.number.number desc";
// 만든 쿼리문을 OrderView.class로 캐스팅해줌
TyperdQuery<OrderView> query = em.createQuery(selectQuery, OrderView.class);
// 캐스팅한 클래스의 파라미터인 "orderId"에 orderId를 지정해줌
query.setParameter("orderId", orderId);
// 쿼리 결과 리스트를 반환해줌
return query.getResultList();
}
}
참고링크 : DAO | DTO | VO설명 https://melonicedlatte.com/2021/07/24/231500.html
애그리거트 간 집합 연관
1. 1-N연관
: 개념적으로 1-N연관이 있더라도, 1개에 대한 모든 N개가 조회되면 성능문제가 발생하므로 실제 구현에 반영하지 않음
2. N-1연관
: 반대로 하위로 속한 N개에서 각자 속한 1에 대해 연관관계를 추가하여 조회하는 방식을 권장
ex) 하나의 카테고리에 속한 여러 상품을 조회 시, 카테고리에 대한 상품조회가 아닌,
상품들 중 해당 카테고리를 가진 상품을 조회하는 방식
3. M-N연관
: 개념적으로는 하위 개념과 상위 개념의 양방향 M-N 연관이 존재하나, 실제 구현에서는 하위 개념에서 상위 개념으로의 단방향 M-N연관만 적용하면 됨
: 조인 테이블 사용

* JPA를 이용한 M-N 단방향 연관 구현
: 카테고리 ID 목록을 보관하기 위해 밸류타입에 대한 컬렉션 매핑(Set)을 이용했음
=> 이를통해 JPQL의 member of 연산자를 이용한 특정 카테고리에 속한 product 목록을 구하는 기능 구현가능
@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 연산자를 이용한 특정 카테고리에 속한 product 목록을 구하는 기능 구현
: 지정한 카테고리에 속한 Product 목록을 구하는 코드
@Repository
public class JpaProductRepository implements ProductRepository {
@PersistenceContext
private EntityManager em;
@Override
public List<Product> findByCategoryId(CategoryId catId, int page, int size) {
TypeQuery<Product> query = em.createQuery(
"select p from Product p "
+ "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();
}
}
애그리거트를 팩토리로 사용하기
: 중요한 로직처리는 서비스계층에 노출시키지 말아야 함
즉, 논리적으로 하나의 도메인 기능인 것을 응용 서비스에서 구현하면 안됨
1) 좋지 않은 코드 예시
public class RegisterProductService {
public ProductId registerNewProduct (NewProductRequest request) {
Store store = storeRepository.findById(request.getStoreId());
checkNull(store);
// 여기서부터
if(store.isBlocked()) throw new StoreBlockedException(); // <- 혹은 BusinessLogicception처리하기
ProductId id = productRepository.nextId();
Product product = new Product(id, store.getId(), ... );
// 여기까지 1개의 팩토리로 묶어 store 엔티티에 구현하는게 바람직함
productRepository.save(product);
return id;
}
}
2) 좋은 코드 예시 : 서비스에 있던 로직을 엔티티로 옮김
public class Store {
...
public Product createProduct (ProductId newProductId, ... ) {
if (isBlocked()) throw new StoreBlockedException();
return new Product(newProductId, getId(), ... );
}
...
}
'독서 > DDD' 카테고리의 다른 글
[DDD] Chapter 4. 리포지터리와 모델 구현 (0) | 2023.06.04 |
---|---|
[DDD] Chapter 2. 아키텍처 개요 [2/11] (0) | 2023.01.08 |
[DDD] Chapter 1. 도메인 모델 시작 [1/11] (0) | 2023.01.03 |