Code

[도메인 주도 개발 시작하기: DDD 핵심 개념 정리부터 구현까지] 4. 애그리거트

noahkim_ 2025. 6. 19. 01:48

최범균 님의 "도메인 주도 개발 시작하기: DDD 핵심 개념 정리부터 구현까지" 책을 정리한 포스팅 입니다.


1. JPA를 이용한 리포지터리 구현

모듈 위치

  • 리포지토리 인터페이스는 도메인 영역에 속함
  • 리포지토리를 구현한 클래스는 인프라스트럭쳐 영역에 속함

 

2. 스프링 데이터 JPA를 이용한 리포지터리 구현

3. 매핑 구현

4. 애그리거트 로딩 전략

1:N 관계가 여러 개 있을 경우

  • Fetch Join 쿼리의 결과는 될 수 있는 모든 경우의 행을 반환함 (카테시안 곱)
    • ❌ 실제 필요한 행의 갯수보다 더 많은 행을 얻어옴
  • Hibernate에서는 Fetch Join 시, distinct 처리함
    • 같은 엔티티가 중복 생성되지 않게 함
    • ⚠️ 성능 부하가 일어날 수 있음 (DB, JVM의 연산이 늘어남)

 

예시) Order - OrderLine, Payment

더보기
SELECT o
FROM Order o
JOIN FETCH o.orderLines
JOIN FETCH o.payments

실제 필요한 행보다 더 많은 행이 조회됨

  • 필요한 행: 3(OrderLines) + 2(Payments) = 5 행
  • 모든 행: 3(OrderLines) × 2(Payments) = 6 행  (중복된 데이터 포함)

 

해결 방법

구분 Lazy + @BatchSize EntityGraph
쿼리 분리 (DTO 조회)
핵심 개념 - fetch join은 최대 1개만 사용
- 나머지는 LAZY + @BatchSize(IN 묶음 조회)로 초기화
필요한 연관관계만 그래프로 fetch
조인으로 필요한 필드만 DTO/프로젝션으로 한 방 조회
장점 - 곱집합 방지
- 실무 기본값으로 성능 안정
- JPQL 수정 없이 유연한 로딩 제어
- 단건 조회에 적합
- 고성능
- 중복 없음
- 네트워크/메모리 효율
- 페이징 친화적
단점 - 트리거 방식(접근 시 로딩)
- @BatchSize 설정 필요
- 컬렉션 fetch join과 페이징 병행 지양
- 컬렉션을 여러 개 fetch하면 곱집합 가능
- 조합 관리 비용
- 엔티티 아님(변경 추적 X)
- 재사용 제한
- 매핑 코드/쿼리 관리 필요

 

예시) Lazy + @BatchSize

더보기
// Order 엔티티
@Entity
public class Order {
    @Id @GeneratedValue
    private Long id;

    @OneToMany(mappedBy = "order", fetch = FetchType.LAZY)
    @BatchSize(size = 100) // 컬렉션 배치 크기
    private List<OrderLine> orderLines = new ArrayList<>();

    @OneToMany(mappedBy = "order", fetch = FetchType.LAZY)
    @BatchSize(size = 100)
    private List<Payment> payments = new ArrayList<>();

    // ...
}
// 목록 조회: fetch join은 최대 1개만 (혹은 아예 없이 순수 엔티티만)
@Query("""
  select distinct o
  from Order o
  join fetch o.orderLines            -- 필요시 1개만
  where o.status = :status
""")
List<Order> findWithLines(@Param("status") OrderStatus status);
  • 이후 o.getPayments() 접근 시 @BatchSize로 IN 쿼리(묶음) 실행

 

select *
from payment
where order_id in (?, ?, ... 최대 100개 ...)
  • 영속성 컨텍스트 내 부모 fk를 기준으로 100개씩 IN 구문을 사용하여 조회함

 

예시) EntityGraph

더보기
// 엔티티에 네임드 엔티티그래프 정의
@Entity
@NamedEntityGraphs({
  @NamedEntityGraph(
    name = "Order.withLines",
    attributeNodes = @NamedAttributeNode("orderLines")
  ),
  @NamedEntityGraph(
    name = "Order.withPayments",
    attributeNodes = @NamedAttributeNode("payments")
  )
})
public class Order { /* ... */ }
// Repository 메서드에 적용 (단건/특정 케이스)
@EntityGraph(value = "Order.withLines", type = EntityGraph.EntityGraphType.LOAD)
@Query("select o from Order o where o.id = :id")
Optional<Order> findOneWithLines(@Param("id") Long id);

 

예시) 쿼리 분리 (DTO 조회)

더보기
// 조회 전용 DTO
public record OrderView(Long orderId, int lineCount, int paymentCount,
                        String userName, BigDecimal totalAmount) {}
// 한 방 조회 (조인 + 그룹)
List<OrderView> findOrderViews() {
    return em.createQuery("""
      select new com.example.api.view.OrderView(
          o.id,
          count(distinct l.id),
          count(distinct p.id),
          u.name,
          sum(l.price * l.quantity)
      )
      from Order o
      join o.user u                 -- 엔티티 연관이 없다면 on 조건으로 조인
      left join o.orderLines l
      left join o.payments p
      where o.createdAt between :from and :to
      group by o.id, u.name
      order by o.id desc
    """, OrderView.class)
    .setParameter("from", from)
    .setParameter("to", to)
    .setMaxResults(50)
    .getResultList();
}

 

5. 애그리거트의 영속성 전파

  • 애그리거트 상태가 온전하기 위해서는 루트만 저장/삭제하는 것이 아니라 루트에 속한 모든 객체를 저장/삭제해야 함

 

cascade 속성

  • 연관된 엔티티에 대해 부모의 엔티티 연산을 자식에게 전파하는 옵션
타입 저장 위치 cascade 필요 여부 이유/동작
@Entity 별도 테이블 부모에서 자식 엔티티 연산을 전파(생성/삭제 등)
@Embeddable 부모 엔티티 테이블의 컬럼 부모의 상태에 포함되어 자동 저장/삭제

 

예시) @Entity

더보기
@Entity
class Order {
    @OneToMany(mappedBy = "order",
             cascade = {CascadeType.PERSIST, CascadeType.REMOVE},
             orphanRemoval = true)
    private List<OrderLine> lines = new ArrayList<>();
}

 

예시) @Embeddable

더보기
@Embeddable
class Address {
    private String city;
    private String street;
}

@Entity
class User {
    @Embedded
    private Address address;  // cascade 불필요: User와 함께 자동 저장/삭제
}

 

6. 식별자 생성 기능

  • 사용자 직접 생성: 필드 값 사용 (email 등)
  • 도메인 로직으로 생성: 도메인 서비스에서 생성하기
  • DB를 이용한 일련번호 사용

 

7. 도메인 구현과 DIP