상위 수준 개념을 이용하여 전체 모델을 정리하면 전반적인 관계를 이해하는 데 도움이 된다.
상위 수준 모델은 개별 객체 단위로 다시 그려 세부적으로 이해할 수 있다.
주요 도메인 개념 간의 관계를 파악하기 어렵다는 것은 곧 코드를 변경하고 확장하는 것이 어려워진다는 것을 의미한다.
연관 도메인을 aggregate 로 묶어 하나의 군으로 이해하면 좀 더 상위 수준에서 도메인 모델 간의 관계를 파악할 수 있다.
aggregate는 관련된 모델을 하나로 모은 것이기 때문에 한 애그리거트에 속한 객체는 유사하거나 동일한 라이프사이클을 갖는다.
aggregate는 경계를 갖는다. 한 aggregate에 속한 객체는 다른 aggregate에 속하지 않는다.
경계를 설정할 때 기본이 되는 것은 도메인 규칙과 요구사항이다. 도메인 규칙에 따라 함께 생성되는 구성요소는 한 aggregate에 속할 가능성이 높다.
'A가 B를 갖는다' 로 설계할 수 있는 요구사항이 있더라도 이것이 반드시 A와 B가 한 애그리거트에 속한다는 것을 의미 하는 것은 아니다.
처음 도메인 모델을 만들기 시작하면 큰 aggregate로 보이는 것들이 많지만 도메인에 대한 경험이 생기고 도메인 규칙을 제대로 이해할수록 실제 aggregate의 크기는 줄어들게 된다.
주문 aggregate로 예를 들면 Order가 루트가 된다.
aggregate 루트의 핵심 역할은 애그리거트의 일관성이 깨지지 않도록 하는 것이다.
이를 위해 aggregate 루트는 aggregate가 제공해야 할 도메인 기능을 구현한다.
주문 aggregate ⇒ 배송지 변경, 상품 변경 등 제공
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("aleady shipped");
}
...
}
예를 들어, 배송이 시작되기 전까지만 배송지 징보를 변경할 수 있다는 규칙이 있다면, aggregate 루트인 Order의 changeShippingInfo() 메서드는 이 규칙에 따라 배송 시작 여부를 확인하고 변경이 가능한 경우에만 배송지 정보를 변경해야 한다.
aggregate 루트가 아닌 다른 객체가 aggregate에 속한 객체를 직접 변경하면 안 된다. 이는 aggregate 루트가 강제하는 규칙을 적용할 수 없어 모델의 일관성을 깨는 원인이 된다.
즉, 논리적인 데이터 일관성이 깨지게 되는 것.
// order의 ShippingInfo를 setter로 직접 변경할 수 있다면?
// 업무 규칙을 무시하고 DB 테이블에서 직접 데이터를 수정하는 것과 같은 효과
ShippingInfo si = order.getShippingInfo();
si.setAddress(newAddress);
// 주요 도메인 로직이 중복되는 문제 (orderState를 각 모델마다 점검해야하는가?)
ShippingInfo si = order.getShippingInfo();
if (state != OrderState.PAYMENT_WAITING && state != OrderState.WAITING) {
throw new IllegalStateException("aleady shipped");
}
si.setAddress(newAddress);
불필요한 중복을 피하고 aggregate 루트를 통해서만 도메인 로직을 구현하게 만들려면 도메인 모델에 대해 다음의 두 가지를 습관적으로 적용해야 한다.
단순히 필드를 변경하는 set 메서드를 공개(public) 범위로 만들지 않는다.
밸류 타입은 불변으로 구현한다.
밸류 객체의 값을 변경할 수 없으면 aggregate 루트에서 밸류 객체를 구해도 값을 변경할 수 없기 때문에 aggregate 외부에서 밸류 객체의 상태를 변경할 수 없게 된다.
aggregate 외부에서 내부 상태를 함부로 바꾸지 못하므로 aggregate의 일관성이 깨질 가능성이 줄어든다.
밸류 객체가 불변이면 밸류 객체의 값을 변경하는 방법은 새로운 밸류 개체를 할당하는 것뿐이다.
밸류 타입의 내부 상태를 변경하려면 aggregate 루트를 통해서만 가능하다.
public class Order {
private ShippingInfo shippingInfo;
public void changeShippingInfo(ShippingInfo newShippingInfo) {
verifyNotYetShipped();
setShippingInfo(newShippingInfo);
}
// set 메서드의 접근 허용 범위는 private.
private void setShippingInfo(ShippingInfo newShippingInfo) {
// value가 불변이면, 새로운 객체를 할당해서 값을 변경해야 한다.
// this.shippingInfo.setAddress(newShippingInfo.getAddress()) 같은 코드를 사용할 수 없다.
this.shippingInfo = newShippingInfo;
}
}
aggregate 루트가 도메인 규칙을 올바르게만 구현하면 aggregate 전체의 일관성을 올바르게 유지할 수 있다.
Order order = orderRepository.findById(orderId);
NullPointerException
같은 문제가 발생하게 된다.한 객체가 다른 객체를 참조하는 것처럼 aggregate도 다른 aggregate를 참조한다.
다른 aggregate를 참조한다는 것은 aggregate의 루트를 참조한다는 것과 같다.
aggregate 간의 참조는 필드를 통해 쉽게 구현할 수 있다.
JPA를 사용하면 @ManyToOne, @OneToOne 과 같은 annotation을 이용해서 연관된 객체를 로딩하는 기능을 제공하고 있으므로 필드를 이용해 다른 aggregate를 쉽게 참조할 수 있다.
ORM 기술 덕에 aggregate 루트에 대한 참조를 쉽게 구현할 수 있고, 필드(또는 getter)를 이용한 aggregate 참조를 사용하면 다른 aggregate의 데이터를 객체 탐색을 통해 조회할 수 있다.
지연 로딩 (lazy loading)
과 즉시 로딩 (eager loading)
의 두 가지 방식으로 로딩할 수 있다. 두 로딩 방식 중 무엇을 사용할지 여부는 aggregate의 어떤 기능을 사용하느냐에 따라 달라진다.필드를 이용한 필드를 이용한 aggregate 참조 시 발생하는 세 가지 문제를 완화할 때 사용할 수 있는 것이 ID를 이용해서 다른 aggregate를 참조하는 것이다.
ID를 이용한 참조는 DB 테이블에서의 외래키를 사용해서 참조하는 것과 비슷하게 다른 aggregate를 참조할 때 ID 참조를 사용한다는 점이다. 단, aggregate 내 엔티티를 참조할 때는 객체 레퍼런스로 참조한다.
lD 참조를 사용하면 모든 객체가 참조로 연결되지 않고 한 aggregate에 속한 객체들만 참조로 연결된다.
이는 aggregate의 경계를 명확히 하고 애그리거트 간 물리적인 연결을 제거하기 때문에 모델의 복잡도를 낮춰준다.
또한, aggregate 간의 의존을 제거하므로 응집도를 높여주는 효과도 있다.
구현 복잡도도 낮아진다. 다른 aggregate를 직접 참조하지 않으므로 aggregate 간 참조를 지연 로딩으로 할지 즉시 로딩으로 할지 고민하지 않아도 된다. 참조하는 aggregate가 필요하면 응용 서비스에서 아이디를 이용해서 로딩하면 된다.
public class ChangeOrderService {
// 응용 서비스에서 필요한 애그리거트를 로딩하므로
// aggregate 수준에서 lazy loading을 하는 것과 동일한 결과를 만든다.
@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를 이용해서 참조하는 aggregate를 구한다.
Customer customer = customerRepository.findById(order.getOrderer().getCustomerId());
customer.changeAddress(newShippingInfo.getAddress());
}
}
...
}
ID를 이용한 참조 방식을 사용하면 복잡도를 낮추는 것과 함께 한 aggregate에서 다른 aggregate를 수정하는 문제를 원천적으로 방지할 수 있다. 외부 aggregate를 직접 참조하지 않기 때문에 애초에 한 aggregate에서 다른 aggregate의 상태 를 변경할 수 없는 것이다.
aggregate별로 다른 구현 기술을 사용하는 것도 가능해진다.
다른 aggregate를 ID로 참조하면 참조하는 여러 aggregate를 읽어야 할 때 조회 속도가 문제될 수 있다.
예를 들어, 주문 목록을 보여주려면 상품 aggregate와 회원 aggregate를 함께 읽어야 한다 할 때 한 DBMS에 데이터가 있다면 조인을 이용해서 한 번에 모든 데이터를 가져올 수 있음에도 불구하고 주문마다 상품정보를 읽어오는 쿼리를 실행하게 된다.
/* N+1 조회 문제 */
// 주문을 읽어들이는 쿼리
Customer customer = customerRepository.findById(ordererId);
// 주문 내 상품목록 쿼리
List<Order> orders = orderRepository.findByOrderer(ordererId);
// 주문별 각 상품을 읽어오는 쿼리
// 연관된 데이터를 읽어오는 쿼리를 N번 실행한다.
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());
ID를 이용한 aggregate 참조는 지연 로딩과 같은 효과를 만드는데 지연 로딩과 관련된 대표적인 문제가 N+1 조회 문제이다.
N+1 조회 문제는 더 많은 쿼리를 실행해서 전체 조회 속도가 느려지는 원인이 된다. 이 문제가 발생하지 않도록 하려면 조인을 사용하도록 해야 한다.
N+1 문제가 발생하지 않도록 하려면 전용 조회 쿼리를 사용하면 된다.
데이터 조회를 위한 별도 DAO를 만들고 DAO의 조회 메서드에서 세타 조인을 이용해서 한 번의 쿼리로 필요한 데이터를 로딩하면 된다.
@Repository
public class JpaOrderViewDao implements OrderViewDao {
@PersistenceContext
private EntityManager em;
// 위 코드는 JPA를 이용해서 특정 시용자의 주문 내역을 보여주기 위한 코드이다.
// 이 코드는 JPQL을 시용하는데, 이 JPQL은 Order aggregate와 Member aggregate, 그리고 Product aggregate를 세타 조인으로 조회해서 한번의 쿼리로 로딩한다.
@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 " +
"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();
}
}
MyBatis
같은 기술을 이용해서 실행할 수도 있다.각 객체 간 모든 연관을 지연 로딩과 즉시 로딩으로 어떻게든 처리하고 싶은 욕구에 사로잡힐 수 있지만 이는 실용적이지 읺다. 앞서 코드에서 보는 것처럼 아이디를 이용해서 aggregate를 참조해도 한 번의 쿼리로 필요한 데이터를 로딩하는 것이 가능하다. (조회 전용 쿼리 실행)
aggregate마다 서로 다른 저장소를 사용하는 경우에는 한 번의 쿼리로 관련 aggregate를 조회할 수 없다.
aggregate 간 1:N 관계는 Set과 같은 컬렉션을 이용해서 표현할 수 있을 것이다.
예를 들어, 다음 코드처럼 Category가 연관된 Product를 값으로 갖는 컬렉션을 필드로 정의할 수 있다.
private Set<Product> products; // 다른 애그리거트에 대한 1:N 연관
그런데, 개념적으로 존재하는 aggregate 간의 1:N 연관을 실제 구현에 반영하는 것이 요구사항을 충족하는 것과 상관없는 경우가 종종 있다.
보통 목록 관련 요구사항은 한 번에 전체 상품을 보여주기보다는 페이징을 이용해서 제품을 나눠서 보여준다.
// 카테고리의 품목 가져오기
// 카테고리 입장에서 1:N 연관을 이용해서 구현하면 다음과 같은 방식으로 코드를 작성해야 한다.
public List<Product> getProducts(int page, int size) {
List<Product> sortedProducts = sortById(products);
return sortedProducts.subList((page - 1) * size, page * size);
}
개념적으로는 애그리거트 간에 1:N 연관이 있더라도 이런 성능상의 문제 때문에 aggregate 간의 1:N 연관을 실제 구현에 반영하는 경우는 드물다.
카테고리에 속한 상품을 구할 필요가 있다면 상품 입장에서 자신이 속한 카테고리 를 N:1로 연관지어 구하면 된다.
이를 구현 모델에 반영하면 Product에 다음과 같이 Category로의 연관을 추가하고 그 연관을 이용해서 특정 Category에 속한 Product 목록을 구하면 된다.
public class Product {
...
private CategoryId category;
...
}
// 카테고리에 속한 상품 목록을 제공하는 응용 서비스는
// 다음과 같이 productRepository를 이용해서 categoryId가 지정한 카테고리 식별자인 products 목록을 구한다.
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 연관은 개념적으로 양쪽 aggregate에 컬렉션으로 연관을 만든다.
보통 특정 카테고리에 속한 상품 목록을 보여줄 때 목록 화면에서 각 상품이 속한 모든 카테고리를 상품 정보에 표시하지는 않는다.
제품이 속한 모든 카테고리가 필요한 화면은 상품 상세 화면이다.
이 요구사항을 고려할 때 카테고리에서 상품으로의 집합 연관은 필요하지 않다. 상품에서 카테고리로의 집합 연관만 존재하면 된다.
즉, 개념적으로는 상품과 카테고리의 양방향 M:N 연관이 존재하지만 실제 구현에서는 상품에서 카테고리로의 단방향 M:N 연관만 적용하면 되는 것이다.
public class Product {
...
private Set<CategoryId> categoryIds;
...
}
RDBMS를 이용해서 M:N 연관을 구현하려면 Join 테이블을 사용한다.
JPA를 이용하면 다음과 같은 매핑 설정을 사용해서 ID 참조를 이용한 M:N 단방향 연관을 구현할 수 있다.
컬렉션 매핑을 통해 JPQL의 member of
연산자를 이용해 특정 Category에 속한 Product 목록을 구하는 기능을 구현할 수 있다.
@Entity
@Table(name = "product")
public class Product {
@EmbeddedId
private ProductId id;
// 카테고리 ID 목록을 보관하기 위해 Value Type에 대한 CollectionMapping을 이용함.
@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 categoryId, int page, int size) {
/* :catId member of p.categoryIds
=> categoryIds 컬렉션에 catId로 지정한 값이 존재하는지 여부를 검사하기 위한 검색조건 */
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();
}
}
return new Product
)