3장: 애그리거트

애그리거트

  • 상위 수준에서 모델을 정리하면 도메인 모델의 복잡한 관계를 이해하는 데 도움이 된다.

  • 백 개 이상의 테이블을 한 ERD에 표현하면 개별 테이블 간 관계 파악에 치우쳐 큰 틀에서 이해하기 어려운 것처럼, 도메인 객체 모델이 복잡해지면 전반적인 구조나 큰 틀에서 도메인 간의 관계를 파악하기 어려워진다.

  • 주요 도메인 요소 간의 관계를 파악하기 어려우면 코드를 변경하고 확장하는 것이 어려워진다.

  • 애그리거트 단위를 나타내기 위해 관련된 객체를 하나의 군집으로 묶으면, 상위 수준에서 도메인 모델의 관계를 파악할 수 있도록 한다.

  • 애그리거트는 복잡한 모델을 관리하는 기준을 제공한다.

  • 한 애그리거트에 속한 객체들은 대부분 유사하거나 동일한 라이프 사이클을 갖는다. 아래와 같이 주문 애그리거트를 만드려면 내부에 존재하는 모든 객체가 생성된 상태여야 한다.

  • 애그리거트는 경계를 가지며, 이를 설정할 때 기본이 되는 것은 도메인 규칙과 요구사항이다. 도메인 규칙에 따라 함께 생성되는 구성요소는 한 애그리거트에 속할 가능성이 높다.

  • ‘A가 B를 갖는다’로 설계할 수 있는 요구사항이 있을 때, A와 B가 반드시 한 애그리거트에 속한다는 것을 의미하진 않는다. 예를 들어 상품은 다수의 리뷰를 갖지만, 상품 엔티티와 리뷰 엔티티는 함께 생성/변경되지 않으며 변경 주체도 다르다. 따라서 이 둘은 서로 다른 애그리거트에 속한다.

  • 처음 도메인 모델을 만들기 시작하면 큰 애그리거트로 보이는 것들이 많지만, 도메인에 대한 경험이 생기고 도메인 규칙을 제대로 이해할수록 애그리거트의 실제 크기는 줄어든다.

애그리거트 루트

도메인 규칙과 일관성

  • 애그리거트는 여러 객체로 구성되며 도메인 규칙을 지키려면 애그리거트에 속한 모든 객체가 정상 상태를 가져야 한다.

  • 루트 엔티티는 애그리거트에 속한 모든 객체가 일관된 상태를 유지하기 위해 애그리거트 전체를 관리하는 주체이다.

  • 애그리거트에 속한 객체는 애그리거트 루트 엔티티에 직/간접적으로 속하게 된다.

  • 애그리거트 루트는 메서드를 제공하여 도메인 규칙에 따라 애그리거트에 속한 객체의 일관성이 깨지지 않도록 한다.

    • 예를 들어 주문 엔티티에 존재하는 배송지 정보 변경 메서드는 배송 시작 전에만 변경이 가능하다는 도메인 규칙이 충족할 때에만 정보를 변경하도록 구현해야 한다.

  • 애그리거트 외부에서 애그리거트에 속한 객체를 직접 변경하면 안 된다. 이렇게 구현할 경우 애그리거트 루트가 도메인 규칙을 적용할 수 없게 되어 모델의 일관성이 깨지게 된다.

  • 이를 막기 위해서는 단순히 필드를 변경하는 setter를 제공하지 말아야 하며, 밸류 타입을 불변으로 구현해야 한다.

ShippingInfo si = order.getShippingInfo();
si.setAddress(newAddress); // ShippingInfo 객체 자체가 불변이면 자연스레 컴파일 에러가 발생

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

  • 애그리거트 루트는 애그리거트 내부의 다른 객체를 조합해서 기능을 완성한다. 구성요소의 상태를 참조하기도 하고 기능 실행을 위임하기도 한다.

  • 아래는 회원 루트 엔티티에서 비밀번호 엔티티에 기존 암호가 일치하는지 확인하고 새로운 비밀번호로 변경하는 메서드의 구현이다.

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);
		}
	}
}

트랜잭션 범위

  • 트랜잭션의 범위는 작을수록 좋다. 다른 메서드에서의 수정을 막기 위해 트랜잭션에서는 락을 걸어야 하는데, 트랜잭션 내부에서 여러 테이블에 관여하게 되면 락을 거는 엔티티들이 많아지게 된다. 이로 인해 동시에 처리할 수 있는 트랜잭션 개수가 줄어들면 전체적인 성능이 떨어지게 된다.

  • 한 트랜잭션에서는 하나의 애그리거트만 수정해야 한다.

  • 예를 들어 하나의 메서드에서 배송지 정보를 변경하면서 동시에 배송지 정보를 회원의 주소로 설정하도록 하는 것은 주문 애그리거트에서 회원 애그리거트의 상태를 변경하는 것이기 때문에 좋지 않은 방법이다.

  • 애그리거트는 최대한 서로 독립적이어야 하며 한 애그리거트가 다른 애그리거트의 기능에 의존하기 시작하면 애그리거트 간 결합도가 높아져 코드 수정이 어려워진다.

  • 한 트랜잭션으로 두 개 이상의 애그리거트를 수정하고자 한다면 한 애그리거트 내부에서 다른 애그리거트를 접근하지 말고, 응용 서비스 단에서 두 애그리거트를 수정하도록 구현해야 한다.

  • 아래 두 코드 블록을 보면, Order 애그리거트에서 Member 애그리거트를 직접 접근할 경우 결합도가 높아지기 때문에 ChangeOrderService라는 응용 서비스에서 각 애그리거트에 접근하는 방법으로 리팩토링한 것을 알 수 있다.

public class Order {

	private Orderer orderer;

	public void shipTo(ShippingInfo newShippingInfo, boolean useNewShippingAddrAsMemberAddr) {
		verifyNotYetShipped();
		setShippingInfo(newShippingInfo);
		if (useNewShippingAddrAsMemberAddr) {
			// 다른 애그리거트의 상태를 변경하면 안 됨!
			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 (useNewshippingAddrAsMemberAddr) {
			Member member = findMember(order.getOrderer());
			member.changeAddress(newShippingInfo.getAddress());
		}
	}
	...
  • 팀의 표준에 따라 사용자 유스케이스와 관련된 응용 서비스의 기능을 한 트랜잭션으로 실행해야 하는 경우가 있거나 기술 제약, UI 구현의 편리를 위해서는 한 트랜잭션에서 두 개 이상의 애그리거트를 변경하는 것을 고려해볼 수 있다.

리포지토리와 애그리거트

  • 애그리거트는 개념상 완전한 한 개의 도메인 모델을 표현하므로 객체의 영속성을 처리하는 리포지토리는 애그리거트 단위로 존재한다.

  • Order와 OrderLine을 물리적으로 각각 별도의 DB 테이블에 저장할 수 있지만 이를 조회할 때에는 각각 리포지토리를 구현해 조회하는 것이 아니라 하나의 리포지토리를 통해 통합된 형태로 조회해야 한다.

  • 리포지터리가 완전한 애그리거트를 제공하지 않으면 필드나 값이 올바르지 않아 애그리거트의 기능을 실행하는 도중에 NullPointerException과 같은 문제가 발생할 수 있다.

  • 애그리거트를 영속화할 저장소로 무엇을 사용하든지 간에 애그리거트의 상태가 변경되면 모든 변경을 원자적으로 저장소에 반영해야 한다.

  • RDBMS를 사용하는 경우 트랜잭션을 이용해 변경이 모두 저장소에 반영되는 것을 보장할 수 있으며, 몽고 DB를 사용하는 경우 한 애그리거트를 한 개의 Document에 저장함으로써 모두 저장소에 반영되는 것을 보장할 수 있다.

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

  • 한 애그리거트는 다른 애그리거트를 참조할 수 있다.

  • 애그리거트 관리 주체는 애그리거트 루트이므로 애그리거트에서 다른 애그리거트를 참조할 때는 다른 애그리거트의 루트를 참조한다.

  • 아래와 같이 주문 애그리거트 내부의 주문자 객체에는 주문한 회원을 참조하기 위해 회원 애그리거트 루트인 Member를 필드로 두어 참조할 수 있다.

  • 필드를 이용한 애그리거트 참조는 편한 탐색 오용, 성능 문제, 확장의 어려움 문제를 발생시킬 수 있다.

    • 다른 애그리거트 객체에 접근하여 상태를 변경하는 불상사가 발생할 수 있다.

    • 애그리거트를 직접 참조할 경우 지연 로딩과 즉시 로딩 중 어떤 방식을 사용해야 할 지 고민해야 한다. 조회 목적이라면 즉시 로딩이 성능에 유리하고, 상태 변경이 목적이라면 지연 로딩이 유리할 수 있다.

  • 이러한 문제를 완화하기 위해 ID를 참조하는 방식을 사용한다. 이를 통해 애그리거트 간의 의존을 제거하여 응집도를 높이는 효과를 낸다.

  • ID를 참조하게 되면 해당 애그리거트 데이터가 필요한 경우에 응용 서비스에서 ID를 이용해 데이터를 로딩해오면 되므로 지연 로딩, 즉시 로딩에 대한 고민을 할 필요가 없어진다.

  • 애그리거트 별로 다른 구현 기술을 사용하는 것도 가능해진다.

문제점

  • 다른 애그리거트를 ID로 참조하면 참조하는 여러 애그리거트를 읽을 때 조회 속도가 문제 될 수 있다.

  • 조인을 통해 데이터를 한 번에 가져올 수 없기 때문에, N+1 조회 문제가 발생하게 된다. 이를 막기 위해서는 조회 전용 쿼리를 레포지토리 내부에 두어 한 번의 쿼리로 로딩할 수 있도록 하면 된다. 다만 이 방식은 애그리거트마다 서로 다른 저장소를 사용할 경우 불가능하며, 캐시나 조회 전용 저장소를 따로 구성해야 한다.

애그리거트 간 집합 연관

  • 애그리거트 간 1-N과 M-N 연관이 있다면 이를 보통 컬렉션을 이용해 표현할 것이다.

  • 하지만 개념적으로 애그리거트 간에 1-N 연관이 있더라도, 성능 문제 때문에 애그리거트 간의 1-N 연관을 실제 구현에는 반영하지 않는다.

  • 아래는 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);
	}
...
  • 위와 같이 컬렉션을 두는 대신, Product (N에 해당하는 애그리거트)쪽에 category id를 넣고 Product의 레포지토리에서 카테고리 별 상품을 조회하면 효율적인 코드를 작성할 수 있다.

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 연관은 개념적으로 양쪽 애그리거트에 컬렉션으로 연관을 만든다. 하나의 상품이 여러 카테고리에 속할 수 있고, 한 카테고리에는 여러 상품이 속하는 경우가 이에 해당한다.

  • M-N 요구사항을 실제 구현할 때는 보통 단방향 연관만 적용한다. 상품에서 카테고리로의 단방향 M-N 연관만 적용하면 되는 것이다.

  • RDBMS를 이용해서 M-N 연관을 구현하려면 조인 테이블을 사용한다.

  • JPA의 컬렉션 매핑을 이용해 상품이 속하는 카테고리를 컬렉션으로 유지할 수 있다. 그리고 JPQL의 member of 연산자를 이용해 특정 카테고리에 속하는 상품들을 조회할 수 있다.

@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;
	// ...
}

@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);
		query.setParameter(“catId”, catId);
		query.setFirstResult((page - 1) * size);
		query.setMaxResults(size);
		return query.getResultList();
	}

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

  • 애그리거트가 갖고 있는 데이터를 이용해서 다른 애그리거트를 생성해야 한다면 애그리거트에 팩토리 메서드를 구현하는 것을 고려해야 한다.

  • 상점이 상품을 등록하려 할 때 상점이 상품을 올릴 수 있는 상태인지 검증하는 과정을 응용 서비스 영역대신 애그리거트 내부에서 수행하면 도메인 기능을 캡슐화할 수 있다.

  • 즉, 상품을 생성하는 팩토리 역할을 상점 애그리거트에서 구현하고 응용 서비스는 이를 호출하기만 하면 된다. 이를 통해 상품 생성 가능 여부 확인의 코드를 변경하고 싶을 때에는 도메인 코드만 변경하면 된다.

public class Store {
	public Product createProduct(ProductId newProductId, ...) {
	if (isBlocked()) throw new StoreBlockedException();
		return new Product(newProductId, getId(), ...);
	}
}
  • 만약 다른 애그리거트 생성 시 많은 정보를 알아야 한다면 도메인 코드 대신 독립된 팩토리에 생성을 위임할 수도 있다.

public class Store {
	public Product createProduct(ProductId newProductId, ProductInfo pi) {
		if (isBlocked()) throw new StoreBlockedException();
		return ProductFactory.create(newProductId, getId(), pi);
	}

Last updated