[Domain Driven Design] 10. 리포지토리와 애그리거트, 애그리거트 간의 참조 관계

반응형
728x90
반응형

리포지토리와 애그리거트

애그리거트는 개념상 완전한 한개의 도메인 모델을 표현하므로 객체의 영속성을 처리하는 리포지터리는 애그리거트 단위로 존재한다. Order와 OrderLine을 물리적으로 각각 별도의 DB 테이블에 저장한다고 해서 Order와 OrderLine을 위한 리포지터리를 각각 만들지 않는다. Order가 애그리거트 루트고 OrderLine은 애그리거트에 속하는 구성요소이므로 Order를 위한 리포지토리만 존재한다.

 

새로운 애그리거트를 만들면 저장소에 애그리거트를 영속화하고 애그리거트를 사용하려면 저장소에서 애그리거트를 읽어야 하므로, 리포지터리는 보통 다음의 두 메서드를 기본으로 제공한다. 이 두 메서드 외에 필요한 다양한 조건으로 애그리거트를 검색하는 메서드나 애그리거트를 삭제하는 메서드를 추가할 수 있다.

  • save() : 애그리거트 저장
  • findById() : ID로 애그리거트를 구함

애그리거트는 개념적으로 하나이므로 리포지터리는 애그리거트 전체를 저장소에 영속화해야한다. 예를들어, Order 애그리거트와 관련된 테이블이 세개라면 Order 애그리거트를 저장할때 애그리거트 루트와 매핑되는 테이블뿐만 아니라 애그리거트에 속한 모든 구성요소에 매핑된 테이블에 데이터를 저장해야 한다.

// 리포지터리에 애그리거트를 저장하면 애그리거트 전체를 영속화해야 한다.
orderRepository.save(order);

동일하게 애그리거트를 구하는 리포지터리 메서드는 완전한 애그리거트를 제공해야한다. 즉, 다음 코드를 실행하면 order 애그리거트는 OrderLine, Orderer 등 모든 구성요소를 포함하고 있어야 한다.

// 리포지터리는 완전한 order를 제공해야한다.
Order order = orderRepository.findById(orderId);

// order가 온전한 애그리거트가 아니면, 기능 실행 도중 NullPointerExcpetion과 같은 문제가 발생한다.
order.cancel();

애그리거트의 상태가 변경되면 모든 변경을 원자적으로 저장소에 반영해야한다. 그렇지 않으면 데이터 일관성이 깨지게된다.  

RDBMS를 이용해서 리포지터리를 구현하면 트랜잭션을 이용해서 애그리거트의 변경이 저장소에 반영되는 것을 보장할 수 있다. 

 

 

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

한 객체가 다른 객체를 참조하는 것처럼 애그리거트도 다른 애그리거트를 참조한다. 애그리거트 관리 주체는 애그리거트 루트이므로 애그리거트에서 다른 애그리거트를 참조한다는 것이 다른 애그리거트의 루트를 참조한다는 것과 같다. 

 

애그리거트 간의 참조는 필드를 통해 쉽게 구현할 수 있다. 예를 들어, 주문 애그리거트에 속해있는 Orderer는 주문한 회원을 참조하기 위해 회원 애그리거트 루트인 Member를 필드로 참조할 수 있다.

https://hwannny.tistory.com/74

필드를 이용해서 다른 애그리거트를 직접 참조하는 것은 개발자에게 구현의 편리함을 제공한다. 예를들어 주문 정보 조회 화면에서 회원 ID를 이용해 링크를 제공해야 할 경우 Order로부터 시작해서 회원 ID를 구할 수 있다.

order.getOrderer().getMember().getId()

ORM 기술 덕에 애그리거트 루트에 대한 참조를 쉽게 구현할 수 있고 필드 또는 get 메서드를 이용한 애그리거트 참조를 사용하면 다른 애그리거트의 데이터를 쉽게 조회할 수 있다. 하지만 필드를 이용한 애그리거트 참조는 다음 문제를 야기할 수 있다.

  • 편한 탐색 오용
  • 성능에 대한 고민
  • 확장 어려움

 

 

애그리거트 직접 참조의 문제점

1) 애그리거트를 직접 참조할때 발생할 수 있는 가장 큰 문제는 편리함을 오용할 수 있다는 것이다.

한 애그리거트 내부에서 다른 애그리거트 객체에 접근할 수 있으면 다른 애그리거트의 상태를 쉽게 변경할 수 있게 된다. 트랜잭션 범위에서 언급한 것처럼 한 애그리거트가 관리하는 범위는 자기 자신으로 한정해야한다. 

 

애그리거트 내부에서 다른 애그리거트 객체에 접근할 수 있으면 다음 코드처럼 구현의 편리함 때문에 다른 애그리거트를 수정하고자하는 유혹에 빠지기 쉽다.

public class Order {
    public void changeShippingInfo(ShippingInfo newShippingInfo, boolean newShipping) {
        ...
        if (newShipping) {
            // 한 애그리거트 내부에서 다른 애그리거트에 접근할 수 있으면, 구현이 쉬워지기 때문에
            // 다른 애그리거트의 상태를 변경하는 유혹에 빠지기 쉽다.
            orderer.getMember().changeAddress(newShippingInfo.getAddress());
        }
    }
    ...
}

한 애그리거트에서 다른 애그리그터의 상태를 변경하는 것은 애그리거트간의 의존 결합도를 높여서 결과적으로 애그리거트의 변경을 어렵게 만든다.

 

2) 애그리거트를 직접 참조하면 성능과 관련된 여러가지 고민을 해야한다.

JPA를 사용하면 참조한 객체를 지연(lazy) 로딩즉시(eager) 로딩의 두가지 방식으로 로딩할 수 있다. 두 로딩 방식 중 무엇을 사용할지는 애그리거트의 어떤 기능을 사용하느냐에 따라 달라진다. 단순히 연관된 객체의 데이터를 함께 화면에 보여줘야 하면 즉시 로딩이 조회 성능에 유리하지만 애그리거트의 상태를 변경하는 기능을 실행하는 경우에는 불필요한 객체를 함께 로딩할 필요가 없으므로 지연 로딩이 유리할 수 있다. 이런 다양한 경우의 수를 고려해서 로딩 전략을 결정해야 한다.

 

3) 확장성의 문제다.

사용자가 늘고 트래픽이 증가하면서 부하를 분산시키기 위해 도메인마다 다른 종류의 데이터 저장소를 사용하기도 한다. 한 하위 도메인은 마리아DB를 사용하고, 다른 하위 도메인은 몽고DB를 사용할 수 있다. 이러한 상황은 더이상 다른 애그리거트 루트를 참조하기 위해 JPA와 같은 단일 기술을 사용할 수 없음을 의미한다.

 

이러한 문제점들을 완화할때 사용할 수 있는것이 ID를 이용해서 다른 애그리거트를 참조하는 것이다. DB 테이블에서 외래키로 참조하는 것과 비슷하게 ID를 이용한 참조는 다른 애그리거트를 참조할때 ID를 사용한다.

 

ID 참조를 사용하면 모든 객체가 참조로 연결되지 않고 애그리거트에 속한 객체들만 참조로 연결된다.

[장점]

1) 애그리거트의 경계를 명확히 하고 애그리거트 간 물리적인 연결을 제거하기 때문에 모델의 복잡도를 낮춰준다.

2) 또한 애그리거트 간의 의존을 제거하므로 응집도를 높여주는 효과도 있다. 

https://hwannny.tistory.com/74

 

3) 구현 복잡도도 낮아진다.

다른 애그리거트를 직접 참조하지 않으므로 애그리거트 간 참조를 지연 로딩으로 할지 즉시 로딩으로 할지 고민하지 않아도 된다. 참조하는 애그리거트가 필요하면 응용 서비스에서 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 (useNewshippingAddrAsMemberAddr) {
            //  ID를 이용해서 참조하는 애그리거트를 구한다.
            Customer customer = customerRepository.findById(
            	order.getOrderer().getCustomerId());
            customer.changeAddress(newShippingInfo.getAddress());
        }
    }
    ...
}

 

응용 서비스에서 필요한 애그리거트를 로딩하므로 애그리거트 수준에서 지연 로딩을 하는 것과 동일한 결과를 만든다. ID를 이용한 참조 방식을 사용하면 복잡도를 낮추는 것과 함께 한 애그리거트에서 다른 애그리거트를 수정하는 문제를 근원적으로 방지할 수 있다. 외부 애그리거트를 직접 참조하지 않기 때문에 애초에 한 애그리거트에서 다른 애그리거트의 상태를 변경할 수 없는 것이다. 

 

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

https://hwannny.tistory.com/74

 

 

ID를 이용한 참조와 조회 성능 (N+1 조회 문제)

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

[예시]

주문 목록을 보여주려면 상품 애그리거트와 회원 애그리거트를 함께 읽어야 하는데, 이를 처리할때 다음과 같이 각 주문마다 상품과 회원 애그리거트를 읽어온다고 해보자.

한 DBMS에 데이터가 있다면 조인을 이용해서 한번에 모든 데이터를 가져올 수 있음에도, 주문마다 상품 정보를 읽어오는 쿼리를 실행하게된다.

Customer customer = customerRepository.findById(ordererId);
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, customer, product);
                             }).collect(toList());

위 코드의 실행 쿼리는 다음과 같다.

주문 개수가 10개가 되면 주문을 읽어오기 위한 1번의 쿼리 + 주문별로 각 상품을 일겅오기 위한 10번의 쿼리
N+1 조회 문제 발생

'조회 대상이 N개일때 N개를 읽어오는 한번의 쿼리와 연관된 데이터를 읽어온느 쿼리를 N번 실행한다.' 

 

ID를 이용한 애그리거트 참조는 지연 로딩과 같은 효과를 만드는데 지연 로딩과 관련된 대표적인 문제가 N + 1 문제이다.

N + 1 조회 문제는 더 많은 쿼리를 실행하기 때문에 전체 조회 속도가 느려지는 원인이 된다.

 

 

N + 1 조회 문제 해결방안

이를 해결하기 위해, 조인을 사용해야한다. 조인을 사용하는 가장 쉬운 방법은 ID 참조 방식을 객체 참조 방식으로 바꾸고 즉시 로딩을 사용하도록 매핑 설정을 바꾸는 것이다. 이 방식은 ID 참조 방식 -> 객체 참조 방식으로 다시 되돌리는 것이다.

ID 참조 방식을 사용하면서 N + 1 조회 문제를 해결하기 위한 방법은 '조회 전용 쿼리'를 사용하는 것이다.

예를들어 데이터 조회를 위한 별도 DAO를 만들고 DAO의 조회 메서드에서 조인을 이용해 한번의 쿼리로 필요한 데이터를 로딩하면 된다.

@Repository
public class JpaOrderViewDao implements OrderViewDao {
    @PersistenceContext
    private EntityManager em;

    @Override
    public List<OrderView> selectByOrder(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 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();
    }
}

위 코드는 JPA를 이용해서 특정 사용자의 주문 내역을 보여주기 위한 코드이다. 이 코드는 JQPL을 사용하는데, 이 JPQL은 Order 애그리거트와 Member 애그리거트, Product 애그리거트를 조인으로 조회하여 한번의 쿼리로 로딩한다. 

 

애그리거트마다 서로 다른 저장소를 사용하면 한번의 쿼리로 관련 애그리거트를 조회할 수 없다. 이때는 조회 성능을 높이기 위해 캐시를 적용하거나 조회 전용 저장소를 따로 구성한다. 이 방법은 코드가 복잡해지더라도 시스템의 처리량을 높일 수 있다. 

 

 

반응형

Designed by JB FACTORY