DDD의 애그리거트
애그리거트는 관련된 객체를 하나의 군으로 묶어 준다. 수 많은 객체를 애그리거트로 묶어서 바라보면 상위 수준에서 도메인 모델 간의 관계를 파악할 수 있다.
애그리거트의 특징
- 애그리거트는 경계를 갖는다. 한 애그리거트에 속한 객체는 다른 애그리거트에 속하지 않는다.
- 애그리거트는 독립된 객체 군이며, 각 애그리거트는 자기자신을 관리할 뿐 다른 애그리거트를 관리하지 않는다.
주문 애거리거트는 배송지를 변경하거나, 주문 상품 개수를 변경하는 등 자기 자신은 관리하지만, 주문 애그리거트에서 회원의 비밀번호를 변경하거나 상품의 가격은 변경하지는 않는다.
- 경계를 설정할 때 기준: 도메인 규칙과 요구사항
주문할 상품의 개수, 배송지 정보, 주문자 정보는 주문 시점에 함께 생성되므로 이들은 한 애그리거트에 속한다.
- 도메인 규칙을 제대로 이해할수록 애그리거트의 실제 크기는 줄어든다. 애그리거트가 하나의 엔티티 객체만 갖는 경우도 많고, 두 개 이상의 엔티티로 구성되는 애그리거트가 드물었다.
주의사항
‘A가 B를 갖는다’는 개념으로 설계할 수 있는 요구사항이 있으면, A와 B를 한 애그리거트로 묶어서 생각하기 쉬운데,
이는 반드시 A와 B가 한 애그리거트에 속한다는 것을 의미하지는 않는다.
상품은 리뷰를 가지지만, 함께 생성되는 것도 아니고, 함께 변경되는 것도 아니고 변경 주체(상품: 상품담당자, 리뷰: 고객)도 서로 다르다. 서로 다른 애그리거트에 속한다
애그리거트 루트
애그리거트에 속한 객체가 일관된 상태를 유지하려면 애그리거트 전체를 관리할 주체가 필요한데, 그 책임을 지는 것이 루트 엔티티다. 루트 엔티티는 애그리거트의 대표 엔티티이고, 애그리거트에 속한 객체는 애그리거트 루트 엔티티에 직접 또는 간접적으로 속하게 된다.
루트 엔티티가 도메인 규칙과 일관성을 지키는 방법
- 애그리거트 루트의 핵심 역할: 애그리거트의 일관성이 깨지지 않도록 하는 것
- ex) 배송이 시작되기 전까지만 배송지 정보를 변경할 수 있다는 규칙 -> 루트인
Order
엔티티에서 이 도메인 규칙을 구현한 기능을 제공한다.
- ex) 배송이 시작되기 전까지만 배송지 정보를 변경할 수 있다는 규칙 -> 루트인
- 애그리거트 외부에서 애그리거트에 속한 객체를 직접 변경하면, 루트가 강제하는 규칙을 적용할 수 없어 모델의 일관성을 깨치는 원인이 될 수 있다.
- 응용 서비스에도 규칙을 강제할 수 있지만, 중복이 발생할 수 있다.
불필요한 중복을 피하고, 애그리거트 루트를 통해서만 도메인 로직을 구현하게 만들려면
- 단순히 필드를 변경하는 set메서드를 공개(public) 범위로 만들지 않는다.
- 밸류 타입은 불변으로 구현한다.
1. public setter 지양하기
- public으로 선언된 setter는 도메인의 의미나 의도를 표현하지 못하고, 도메인 로직을 도메인 객체가 아닌 응용 영역이나 표현영역으로 분산시킨다.
-
도메인 로직이 한 곳에 응집되지 않으므로 코드를 유지 보수할 때에도 더 많은 시간이 필요해진다
- 공개 setter를 사용하지 않고, 의미가 드러나는 메서드를 사용해서 구현하자. (ex. setPassword -> changePassword)
2. 밸류는 불변 타입으로 구현하기
- 밸류 객체의 값을 변경할 수 없으면, 애그리거트 루트에서 밸류 객체를 구해도, 외부에서 밸류 객체의 상태를 변경할 수 없다.
- 밸류 객체가 불변일 경우, 새로운 밸류 객체를 할당해서 (애그리거트 루트가 제공하는 메서드에 새로운 밸류 객체를 전달해서) 값을 변경하는 방법 밖에 없다.
- 따라서 애그리거트 루트를 통해서만 내부 상태를 변경할 수 있기 때문에, 애그리거트 전체의 일관성을 올바르게 유지할 수 있게 된다.
애그리거트 기능 구현 예시
- 주문
Order
애그리거트는 총 주문 금액을OrderLine
목록을 사용하여 구한다. - 그런데 만약 외부에서
OrderLine
의 아이템을 변경할 수 있게 되어 있다면, 루트를 통해서 주문 목록을 변경한 것이 아니라서 총 주문금액이 변하지 않아, 일관성이 깨지게 된다.
주문 Order
1
2
3
4
5
6
7
8
public class Order {
private OrderLines orderLines;
public void changeOrderLines(List<OrderLine> newLines) {
orderLines.changeOrderLines(newLines);
this.lines = newLines;
}
}
주문목록 OrderLines
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
public class OrderLines {
private List<OrderLine> lines;
private Money totalAmounts;
public Money getTotalAmounts() {
//
}
private void calculateTotalAmounts() {
int sum = lines.stream()
.mapToInt(ol -> ol.getPrice() * ol.getQuantity())
.sum();
this.totalAmounts = new Money(sum);
}
// protected를 사용해 애그리거트 외부에서 상태 변경 방지
protected void changeOrderLines(List<OrderLine> newLines) {
calculateTotalAmounts();
this.lines = newLines;
}
}
트랜잭션의 범위
- 트랜잭션의 범위는 작을수록 좋다.
- 한 트랜잭션이 한 개의 테이블을 수정하면, lock 의 대상이 테이블 한 행으로 한정되지만 여러 테이블을 수정하면 잠금 대상이 많아진다. 잠금 대상이 많아지면 동시에 처리할 수 있는 처리량을 떨어뜨린다.
- 한 트랜잭션에서는 한 개의 애그리거트만 수정해야 한다. 한 번에 수정하는 애그리거트 수가 많아질수록 트랜잭션 충돌 발생 가능성이 높아지고, 전체 처리량이 떨어진다.
- 한 트랜잭션에서 한 애그리거트만 수정한다 = 다른 애그리거트를 변경하지 않는다는 것을 의미한다. 다른 애그리거트를 변경하면, 애그리거트 간 결합도가 높아지고, 결합도가 높아지면 향후 수정 비용이 증가하게 된다.
- 부득이하게 여러 개의 애그리거트를 수정해야 한다면, 응용 서비스에서 두 애그리거트를 수정하도록 구현하자.
어쩔 수 없이 한 트랜잭션에서 여러 애그리거트를 변경해야 하는 경우도 물론 있다.
리포지터리와 애그리거트
애그리거트는 완전한 한 개의 도메인 모델을 표현하므로, 객체의 영속성을 처리하는 리포지터리는 애그리거트 단위로 존재한다.
Order와 OrderLine이 물리적으로 각각의 테이블에 저장한다고 해도, 각각의 리포지터리를 만드는 것이 아니라 루트엔티티인 Order를 위한 리포지터리만 만든다.
애그리거트 루트와 매핑되는 테이블뿐만 아니라 애그리거트에 속한 모든 구성요소에 매핑된 테이블에 데이터를 원자적으로 저장해야한다.
리포지터리
애그리거트를 영속화하고, 사용하기 위해 두 가지 메소드가 기본으로 필요하다.
- save: 애그리거트 저장
- findById: ID로 애그리거트를 구함
ID를 이용한 애그리거트 참조
필드 참조 예시
1
order.getOrderer().getMember().getId();
- 필드를 이용해서 애그리거트를 참조하면 다른 애그리거트의 데이터를 쉽게 조회할 수 있지만, 편리함을 오용하게 될 수도 있다.
- 이를 테면 한 애그리거트 내부에서 다른 애그리거트 객체에 접근할 수 있으면 다른 애그리거트의 상태를 쉽게 변경할 수 있게 되기 때문에, 의존 결합도를 높여서 변경을 어렵게 만든다.
- JPA사용시 참조 객체를 lazy로딩 할지, eager로딩 할 지에 따라 성능도 차이가 난다.
- 시스템 확장에 따른 DBMS 확장시에 도메인별로 서로 다른 DBMS를 사용하게 될 수도 있어, 확장하는데 문제가 생긴다.
- 위와 같은 문제를 완화하기 위해 사용하는 것이 ID를 이용한 다른 애그리거트 참조다.
- ID참조를 하면 모든 객체가 참조로 연결되지 않고, 한 애그리거트에 속한 객체들만 참조로 연결된다.
- 애그리거트의 경계를 명확히 하고 애그리거트 간 물리적인 연결을 제거하기 때문에 모델의 복잡도를 낮춰준다.
- 애그리거트 간 의존을 제거하므로 응집도를 높여준다.
ID를 이용한 애그리거트 참조 예시
1
order.getOrderer().getMemberId();
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
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) {
// ID로 Member애그리거트 가져오기
Member member = memberRepository.findById(
order.getOrderer().getMemberID();
)
member.changeAddress(newShippingInfo.getAddress());
}
}
}
ID를 이용한 애그리거트 참조의 조회 성능
주문마다 상품과 회원 애그리거트를 읽어온다고 했을 때, N+1 문제가 발생할 수 있다. 조회대상이 N개일 때 N개를 읽어오는 한 번이 쿼리와 연관된 데이터를 읽어오는 쿼리를 N번 실행한다.
N+1문제 예시
1
2
3
4
5
6
7
8
9
10
11
12
Member member = memberRepository.findById(ordererId);
List<Order> orders = orderRepository.findByOrderer(ordererId);
List<OrderView> dtos = orders.stream()
.map(order -> {
ProductId prodId = order.getOrderLine().get(0).getProductId()l
// 각 주문마다 첫 번째 주문 상품 정보 로딩을 위한 쿼리 실행
Product product = productRepository.findById(prodId);
return new OrderView(order, member, product);
}).collect(toList());
N+1 조회 문제 해결 방법 : 조회 전용 쿼리 사용하기
- 예를 들어, 데이터 조회를 위한 별도의 DAO를 만들고, DAO의 조회 메서드에서 조인을 이용해 한 번의 쿼리로 필요한 데이터를 로딩한다.
- 어떻게든 모든 연관을 지연 로딩과 즉시 로딩으로 처리하지 말고, 한 번의 쿼리로 해결하는 방법도 있는 것이다.
JPQL을 활용해 조인하여 조회하기 예시
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
@Repository
public class JpaOrderViewDao implements OrderViewDao {
@PersistenceContext
private EntityManaget em;
@Override
public List<OrderView> selectByOrderer(String orderId) {
String selectQuery = """
select new com.myshop.order.application.dto.OrderView(o, m, p)
from Order o join o.orderines ol, Member m, Product p
where o.orderer.memberId.id = :ordererId
and o.orderer.memberID = m.id
and index(ol) = p.id
order by o.number.number desc
""";
TypedQuery<OrderView> query = em.createQuery(selectQuery, OrderView.class);
query.setParameter("ordererId", ordererId);
return query.setResultList();
}
}
애그리거트 간 집합 연관
- 컬렉션을 이용한 연관
1:N
,M:N
만약 카테고리와 상품 1: N
을 아래와 같이 구현한다면, Product 개수가 수만개 정도 된다면 이 코드를 실행할 때마다 실행 속도가 급격히 느려질 것이다.
1
2
3
public class Category {
private Set<Product> products; //다른 애그리거트에 대한 1:N 연관관계
}
카테고리에 속한 상품을 구할 필요가 있다면 상품 입장에서 자신이 속한 카테고리를 N:1
연관관계를 지어 구하면 된다. 그 연관을 활용해서 특정 Category에 속한 Product의 목록을 구하면 된다.
1
2
3
public class Product {
private CategoryId categoryId;
}
만약 하나의 상품이 여러 개의 Category에 속할 수 있으면, Category: Product = M : N
의 관계가 될 것인데,
개념적으로는 양방향 연관이 존재하지만, 실제로는 카테고리의 목록은 상품 상세 화면에서 필요하지, 상품 목록에서 카테고리가 필요하지는 않으니까 단방향 연관만 적용해도 기능적으로 문제가 없다.
1
2
3
public class Product {
private Set<CategoryId> categoryId;
}
M:N
연관을 구현하려면 조인테이블 사용한다.
아래는 JPQL의 member of 연산자를 이용해 특정 Category에 속한 Product의 목록을 구하는 것이다.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
@Repository
public class JpaProductRepository implements ProductRepository {
@PersistenceContext
private EntityManager entityManager;
@Override
public List<Product> findByCategoryId(CategoryId catId, 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)l
query.setParameter("catId", catId);
query.setFirstResult((page - 1) * size);
query.setMaxResult(size);
return query.getResultList();
}
}
애그리거트를 팩토리로 사용하기
애그리거트가 갖고 있는 데이터를 이용해서 다른 애그리거트를 생성해야 한다면, 애그리거트에 팩토리 메서드를 구현하는 것을 고려해보자,
예시) Product를 등록할 때, Store라는 애그리거트가 Product를 등록할 수 있도록 팩토리 메서드 적용하면, 해당 스토어가 상품을 등록할 수 있는 상태인지 확인하는 책임은 Store가 진다.
reference
도메인 주도 개발 시작하기
DDD - 도메인 모델, 도메인 모델 패턴, 도메인 모델 도출 과정, 엔티티와 밸류
[MySQL] UUID의 개념과 성능 개선 결과