DDD 애그리거트에서의 fetch join

처음 나는 도메인 주도 설계(DDD)의 원칙에 따라 OrderOrderItem을 하나의 애그리거트로 묶었다.
그리고 자연스럽게 모든 OrderItem의 변경은 Order 루트를 통해서만 일어나도록 설계했다.

이 구조 자체는 문제가 없었다. 문제는 그 이후, 성능 최적화에 대한 오해에서 비롯되었다.

💡 애그리거트 패턴이란?

애그리거트(Aggregate) 패턴은 도메인 모델에서 관련된 객체들을 하나의 일관된 단위로 묶어 관리하는 개념이다.
이 묶음의 중심에는 루트 엔티티(Aggregate Root)가 있으며, 외부에서는 반드시 루트를 통해서만 내부 구성 요소에 접근하거나 변경할 수 있다.
이렇게 함으로써 도메인의 불변 조건이나 비즈니스 규칙을 루트가 통제할 수 있게 된다. 구성 객체들은 루트 없이는 독립적으로 저장되거나 로딩될 수 없다.
애그리거트는 하나의 트랜잭션 단위를 구성하며, 변경은 항상 이 경계 내에서 일어난다. 여러 애그리거트가 서로 참조할 경우에는 실제 객체 참조 대신 식별자(ID)만을 사용해 결합도를 낮춘다.
이 패턴은 도메인 무결성을 보장하면서도 확장성과 성능을 고려한 실용적인 구조를 제공한다. 설계 시에는 애그리거트의 크기를 작고 응집도 높게 유지하는 것이 권장된다.
내부 구성 객체가 많거나 무거울 경우, 조회나 변경에 따른 성능 문제가 생길 수 있다. 따라서 실무에서는 애그리거트를 중심으로 CQRS나 조회 전용 DAO 같은 패턴과 병행해 사용하는 경우가 많다.


내가 처음 가졌던 오해

처음에 나는 다음과 같은 코드를 보고,
N+1 문제가 발생할 것이라고 생각했다:

Order order = orderRepository.findById(orderId).orElseThrow();
order.renameOrderItem(itemId, "새로운 이름");

orderItems는 @OneToMany(fetch = LAZY)로 설정되어 있었기 때문에 .stream() 호출 시마다 개별 아이템을 하나씩 쿼리할 것이라고 판단했다.

하지만 이건 잘못된 이해였다.

실제로는 어떻게 동작하는가?

Hibernate는 LAZY로 설정된 컬렉션(List<OrderItem>)을 프록시 객체로 감싸서 관리한다. 그리고 .stream()이나 .iterator()와 같이 컬렉션 내부로 진입하는 순간, 해당 컬렉션 전체를 한 번의 쿼리로 초기화한다.

즉, order.getOrderItems().stream().filter(...) // 이 시점에서 딱 1번, select * from order_item where order_id = ? 쿼리 발생

그리고 나머지 stream 연산은 전부 메모리 안에서 이루어진다.

결론적으로 N+1 문제는 없었다. 존재하지 않았던 문제를 해결하려고, 나는 과잉 대응을 했던 것이다… 😂

나의 과잉 대응…

내가 시도한 해결책: fetch join

문제 해결을 위해 나는 다음과 같은 코드를 작성했다:

@Query("""
    select o from Order o
    join fetch o.orderItems
    where o.id = :orderId
""")
Optional<Order> findByIdWithItems(@Param("orderId") Long orderId);

이 쿼리를 통해, Order와 연관된 모든 OrderItem을 한 번의 쿼리로 미리 불러오는 방식(fetch join) 을 택했다.

진짜 문제는 메모리에 있었다

fetch join은 분명 강력하다. 하지만 이 방식은 주문에 포함된 모든 아이템을 무조건 메모리에 올린다.

  • 아이템이 1개만 필요하더라도, 전체를 메모리에 로딩한다.
  • 주문당 1000개의 아이템이 있을 경우, 999개는 불필요한 데이터다.

이것이 바로 내가 맞닥뜨린 실질적인 성능 이슈였다. N+1은 없었다. 불필요한 메모리 낭비가 있었을 뿐이다.

Aggregate 패턴을 적용한다고 하면, 모든 루트 애그리거트에 종속된 서브 도메인에 대한 접근은 루트 애그리거트를 통해야 한다. 그에 따라 필연적으로 하나의 서브 도메인을 수정하려고 해도, OneToMany에 엮인 모든 서브 도메인 엔티티를 메모리로 가져와야 하는 것이다…

그래서 내가 내린 결론

나는 결국 다음과 같은 절충안을 선택했다:

  1. 필요한 데이터만 쿼리로 검증
@Query("""
    select 1 from OrderItem i where i.id = :itemId and i.order.id = :orderId
""")
Optional<Integer> existsByOrderIdAndItemId(...);
  • 해당 OrderItem이 진짜 Order에 속하는지만 확인
  • 우선 해당 엔티티가 실제로 존재하는지를 확인하여 불필요한 전체 로딩을 방지한다.
  • 어차피 OrderItem이 존재하면 전체 메모리가 필연적으로 올라가긴 하나… 불필요한 예외에서 메모리 점유를 막기 위한 최소한의 조치이다.
  1. 변경은 루트를 통해 수행
Order order = orderRepository.findById(orderId).orElseThrow();
order.renameOrderItem(itemId, "새로운 이름");
orderRepository.save(order);
  • 여전히 변경은 애그리거트 루트를 통해 수행
  • 한 fetch join 없이 필요한 데이터만 로딩

그리고 나아가: CQRS를 활용한 구조적 분리

이 문제를 보다 명확하게 분리하고, 향후 확장성과 유지보수를 고려했을 때 나는 CQRS (Command-Query Responsibility Segregation) 패턴을 적용하는 것도 유력한 해답이라고 판단했다.

Query Layer: 조회 전용 DAO

public record OrderItemSummaryDto(Long itemId, String itemName) {}

@Query("""
    select new com.example.OrderItemSummaryDto(i.id, i.name)
    from OrderItem i
    where i.order.id = :orderId
""")
List<OrderItemSummaryDto> findItemsByOrderId(Long orderId);
  • 조회에서는 연관된 모든 도메인 로직이나 불변성 검증 없이
  • 딱 필요한 데이터만 가져오는 가벼운 DAO 계층
  • 메모리나 복잡한 객체 그래프 로딩 없이 성능 극대화 가능

Command Layer: 도메인 중심의 변경

Order order = orderRepository.findById(orderId).orElseThrow();
order.renameOrderItem(itemId, newName);
  • 변경은 여전히 루트를 통해 일관성 있게 처리
  • 도메인 규칙과 불변성 검증은 도메인 모델 내부에 캡슐화
  • CQRS 구조상 변경 흐름은 항상 루트를 거치므로 Aggregate 무결성 일부 유지

왜 CQRS가 이 문제에 적합한가?

문제: CQRS의 해결
조회 시 불필요한 전체 로딩: Query 쪽에서 DTO projection으로 해결
명령 시 도메인 규칙 보장: Command 쪽에서 루트 기준 변경 유지
로직 책임 혼재: Query와 Command를 분리해 역할 명확화
유지보수 난이도: 계층별 코드 복잡도 분산, 테스트 쉬움

개인적인 판단

CQRS는 꼭 이벤트 소싱이나 복잡한 분산 시스템에서만 필요한 개념이 아니다. 단지 “조회는 가볍고 빠르게, 변경은 안정적이고 일관되게” 라는 요구가 있다면 그 자체로 CQRS를 적용할 충분한 이유가 된다.

위 예시 외에도 OrderItem처럼 컬렉션 규모가 크고, 특정 항목만 수정해야 하는 도메인에서는 CQRS 구조를 통해 책임을 나누고, 성능과 도메인 무결성을 모두 챙기는 전략이 실질적으로 유용하다고 판단했다.