Code

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

noahkim_ 2025. 6. 18. 23:18

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

 

0. 설계 관점

구분 개별 객체 수준 상위 수준(모델 관점)
관심 범위 단일 클래스, 엔티티, 메서드 등 여러 객체들의 관계와 구조 전체
적용 시점 세부 로직 구현 단계
시스템 설계 초기 또는 리팩터링 단계
장점 구현 세부사항에 집중
 객체 내부 로직 이해에 유리
✅ 도메인 전반의 흐름 파악
✅ 책임, 경계, 협력 구조 설계에 유리
단점 ❌ 객체 간 관계를 놓치기 쉬움
구조 왜곡 위험
❌ 직관적이지 않을 수 있음 (추상적 흐름부터 정의하므로)
변경에 대한 민감도 ⚠️ 구조 고려 없이 변경하면 다른 객체에 영향 확산 됨 ✅ 경계 기반 변경 가능
모델링 초점 객체 내부 (필드, 메서드, 생성자 등) 객체 간 관계, 애그리거트 경계, 도메인 흐름
확장성‧유지보수성 낮음 (개별 객체 추가/변경 시 일관성 유지 어려움)
높음 (경계 안에서 변경 관리 가능) 
예시 OrderLine 객체만 보고 기능 설계
Order 애그리거트를 기준으로 함께 설계
(ex: OrderLine, ShippingInfo 등)

 

1. 애그리거트

  • 비즈니스에서 논리적으로 하나로 묶이는 도메인 객체들의 집합
특징 설명
도메인 규칙 기반
경계는 도메인 요구사항·규칙으로 결정됨
동일·유사 라이프사이클
한 애그리거트에 속한 객체들은 비슷한 생성·소멸 주기를 가짐
명확한 경계 서로 다른 애그리거트는 독립적 객체 군으로 경계가 구분됨

 

2. 애그리거트 루트

도메인 규칙과 일관성

  • 애그리거트의 모든 객체 상태는 비즈니스 규칙에 맞는 '정상 상태' 여야 함
  • ❌ 한 객체만 상태가 정상이면 안됨
항목 설명 목적/효과
캡슐화 루트 엔티티가 애그리거트 전체를 관리함
(외부에서는 루트 엔티티를 통해서만 상태 변경 가능)
애그리거트 내부 모든 객체의 일관성 유지
밸류 타입 불변으로 구현 (불변 불가 시 protected 범위로 한정)
일관성과 스레드 안정성 보장

 

기능 구현

  • 애그리거트 내부의 다른 객체들을 조합해서 기능을 완성함

 

트랜잭션 범위

  • 한 트랜잭션에서는 한 개의 애그리거트만 수정하기
원인/특징 영향 결과/권고
접근 영역 좁아짐 잠금 대상 감소 ✅ 대기 시간 감소
처리량 증가
독립성 증가 결합도 낮아짐 ✅ 변경·확장 쉬움

 

3. 리포지터리와 애그리거트

리포지터리

  • 도메인 객체의 영속화 담당 (애그리거트 단위로 존재)
  • 값 객체 전용 리포지터리는 만들지 않음
    • 물리적으로 다른 테이블로 분리되어 있어도, 논리적으로는 애그리거트의 일부이므로 애그리거트 리포지토리를 통해 접근함

 

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

  • JPA 연관관계를 사용하여 필드를 통해 애그리거트를 참조할 수 있음
  • ⚠️ 문제 발생 가능성 있음
문제 유형 설명 결과/영향
편한 탐색 오용 서로 다른 애그리거트 간 객체 참조
❌ 결합도 증가 
(다른 애그리거트의 상태를 직접 변경 가능)
성능 문제 애그리거트 변경 시 불필요한 객체까지 로딩될 수 있음 ❌ 성능 저하 / 메모리 낭비
(불필요한 쿼리 실행)
확장 어려움 MSA 구조에서 애그리거트 단위의 독립 서비스 분리 시, JPA 연관관계 유지 불가
❌ JPA 직접 참조 불가능
(각 애그리거트가 서로 다른 DBMS를 가질 수 있음)

 

해결: ID 참조

  • ID를 이용해서 다른 애그리거트를 참조함
  • ✅ 한 애그리거트에 속한 객체들만 참조로 연결됨

 

장점
항목 설명 효과
애그리거트 경계 명확화
객체가 얽히지 않고 ID로만 연결
결합도 낮아짐 (객체 간 직접 참조가 사라짐)
확장성
애그리거트 별 다른 기술 스택, DBMS 사용 가능
MSA 아키텍처 적용에 제약이 없음
구현 복잡도 감소 JPA 연관 관계를 쓰지 않음 매핑, 순환 참조, 로딩 전략 고민 불필요
성능 최적화
불필요한 Join 제거
- 조회 쿼리 단순
- 쿼리 실행 속도 향상
- 불필요한 대용량 데이터 로딩 방지
테스트 용이성
외부 애그리거트를 mocking 가능
 

 

단점
  • 조회 전용 조인 쿼리 직접 정의 (N+1 조회 발생 가능)

 

코드) Order-User

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

    // ✅ FK 컬럼만 보유, JPA 연관 매핑 없음
    @Column(name = "user_id", nullable = false)
    private Long userId;

    // ...
}
public record OrderSummary(Long orderId, Long userId, String userName) {}

public List<OrderSummary> findOrderSummaries() {
    return em.createQuery("""
        SELECT new com.example.OrderSummary(o.id, o.userId, u.name)
        FROM Order o
        JOIN User u ON o.userId = u.id
    """, OrderSummary.class).getResultList();
}

 

5. 애그리거트 간 집합 연관 예시: 카테고리 - 상품

구분 1:N N:1 N:M
설명 하나의 카테고리에 여러 상품이 속함 여러 상품이 하나의 카테고리에 속함
하나의 상품이 여러 카테고리에,
하나의 카테고리가 여러 상품에 속함
구현 방식 - JPA에서 Set 자료구조로 양방향 매핑
- 카테고리 조회 시 상품 목록 로딩
상품 테이블에 category_id 저장
중간 조인 테이블로 연결
권장/주의 사항 ⚠️ 상품 수 많으면 조회 성능 저하
✅ 상품 쪽에서 카테고리 ID 참조
❌ 1-N 직접 구현 지양
✅ 서비스 레이어에서 2회 조회
✅ 조인 쿼리 활용
✅ 즉시 로딩 필요한 쪽만 매핑
❌ 양방향 모두 정의 불필요

 

코드) N: M - 즉시 로딩이 필요한 쪽에만 정의

더보기

상품: 관련 카테고리들 정보 즉시 로드 필요

@Entity
public class Product {

    @Id @GeneratedValue
    private Long id;

    @ElementCollection    
    @CollectionTable(name = "product_category", joinColumns = @JoinColumn(name=("product_id"))
    private Set<Category> categories = new HashSet<>();
}

 

카테고리: 관련 상품들 정보 즉시 로드 필요 없음

@Entity
public class Category {

    @Id @GeneratedValue
    private Long id;

    // 연관관계 정의 안 함 → 단방향
}

 

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

  • 어떤 객체를 생성할 때, 그 생성 과정에서 도메인 로직이나 다른 도메인 객체의 컨텍스트가 필요한 경우 있음
  • ❌ Application Service에서 처리하면, 도메인 규칙이 외부로 노출됨
  • ➡️ 해당 애그리거트 내 팩토리 메서드로 정의하기

 

코드) ❌ Application Service에서 처리

더보기
@Service
public class ProductAppService {
    public Product createProduct(Store store, String name, int price) {
        // 도메인 규칙을 서비스 레이어에서 직접 처리
        if (!store.isActive()) {
            throw new IllegalStateException("매장이 영업 상태여야 합니다.");
        }
        if (price < store.getPolicy().getMinPrice()) {
            throw new IllegalArgumentException("가격이 최소 금액보다 낮습니다.");
        }
        
        Product product = new Product(store.getId(), name, price);
        
        // 도메인 이벤트도 여기서 발행
        eventPublisher.publish(new ProductCreatedEvent(product.getId()));
        
        return product;
    }
}
  • ❌ 서비스 레이어가 도메인 내부 속성 및 로직을 알아야 함
  • ❌ 변경 범위 확대 (규칙이 바뀌면 Application Service 코드 까지 수정해야 함)
  • ❌ 응집도 낮아짐

 

코드) ✅ 애그리거트에서 처리

더보기
public class Store {
    private StoreId id;
    private StorePolicy policy;
    private boolean active;

    public Product createProduct(String name, int price) {
        if (!this.active) {
            throw new IllegalStateException("매장이 영업 상태여야 합니다.");
        }
        if (price < policy.getMinPrice()) {
            throw new IllegalArgumentException("가격이 최소 금액보다 낮습니다.");
        }
        
        Product product = new Product(this.id, name, price);
        DomainEvents.raise(new ProductCreatedEvent(product.getId()));
        return product;
    }
}
  • ✅ 서비스 레이어가 도메인 내부 속성 및 로직 몰라도 됨
  • ✅ 변경 범위 최소화 (애그리거트 팩토리 메서드만 수정)
  • ✅ 응집도 높아짐

 

예시) 생성 대상이 상위 도메인 엔티티의 컨텍스트에 의존

더보기

"매장(Store) ID와 정책을 기반으로 상품(Product) 생성"

// 예: 스토어 상태가 OPEN이어야 하고, 카테고리 화이트리스트 + 가격 상한
public final class DefaultProductPolicy implements ProductPolicy {
    private final Set<String> allowedCategories = Set.of("FOOD","BEVERAGE","GOODS");
    private final BigDecimal maxPrice = new BigDecimal("1000000");

    @Override
    public void validateCreatable(Store store, String category, Money price) {
        if (store.status() != StoreStatus.OPEN) throw new IllegalStateException("Store not OPEN");
        if (!allowedCategories.contains(category)) throw new IllegalArgumentException("Category not allowed");
        if (price.amount().compareTo(maxPrice) > 0) throw new IllegalArgumentException("Price too high");

        price.assertCurrency(store.currency()); // 스토어 통화와 일치
    }
}

 

public enum StoreStatus { OPEN, PAUSED, CLOSED }

public final class Store {
    private final StoreId id;
    private final StoreStatus status;
    private final String currency;

    public Store(StoreId id, StoreStatus status, String currency) {
        this.id = id; 
        this.status = status; 
        this.currency = currency;
    }

    public StoreId id() { return id; }
    public StoreStatus status() { return status; }
    public String currency() { return currency; }
}

 

public final class Product {
    private final ProductId id;
    private final StoreId storeId;
    private final String name;
    private final String category;
    private final Money price;
    private final boolean active;

    private Product(ProductId id, StoreId storeId, String name, String category, Money price) {
        this.id = id;
        this.storeId = storeId;
        this.name = name;
        this.category = category;
        this.price = price;
        this.active = true;
    }

    public static Product create(Store store, String name, String category, Money price, ProductPolicy policy) {
        // 생성 전에 상위 컨텍스트 + 정책 검증
        policy.validateCreatable(store, category, price);
        return new Product(new ProductId(UUID.randomUUID().toString()), store.id(), name, category, price);
    }

    // 게터 생략…
}

 

예시) 생성이 도메인 행위의 일부

더보기

"매장이 상품을 등록한다"

public enum StoreStatus { OPEN, PAUSED, CLOSED }

public final class Store {
    private final StoreId id;
    private final StoreStatus status;
    private final String currency;

    public Store(StoreId id, StoreStatus status, String currency) {
        this.id = id; 
        this.status = status; 
        this.currency = currency;
    }
    
    public Product registerProduct(String name, String category, Money price, ProductPolicy policy) {
        // 상위 컨텍스트 규칙 + 정책 검증은 여기서
        policy.validateCreatable(this, category, price);

        Product p = Product.create(this, name, category, price, policy);
        // 도메인 이벤트 발생
        DomainEvents.raise(new ProductRegisteredEvent(p.id(), this.id(), name, category, price));
        return p;
    }

    public StoreId id() { return id; }
    public StoreStatus status() { return status; }
    public String currency() { return currency; }
}

 

예시) 생성 시 유효성 검증이나 부가 처리 필요

더보기
public final class ProductFactory {
    public static Product createForStore(
            Store store,
            String rawName,
            String category,
            Money rawPrice,
            ProductPolicy policy,
            ProductNameUniquenessChecker nameChecker,
            ForbiddenWordFilter wordFilter,
            SkuAllocator skuAllocator
    ) {
        // 1) 상위 컨텍스트 + 정책 검증 (상태, 통화, 카테고리, 상한가)
        policy.validateCreatable(store, category, rawPrice);

        // 2) 유효성 검증
        String name = normalize(rawName);                       // trim, 중복 space 제거 등
        if (name.length() < 2 || name.length() > 60) throw new IllegalArgumentException("Invalid name length");
        if (wordFilter.containsForbidden(name)) throw new IllegalArgumentException("Forbidden word in name");

        String normalizedForUnique = name.toLowerCase(Locale.ROOT);
        if (nameChecker.exists(store.id(), normalizedForUnique))
            throw new IllegalStateException("Duplicated product name in store");

        Money price = roundToMinorUnit(rawPrice);               // 가격 라운딩 규칙 적용

        // 3) 부가 처리(부수효과)
        String sku = skuAllocator.allocate(store.id(), category, name); // 외부/내부 정책에 따른 SKU
        String slug = toSlug(name);                                     // 검색/URL용 슬러그

        // 4) 실제 생성 (엔티티 내부 불변식은 생성자/정적 팩토리에서 보장)
        Product p = Product.createWithSkuAndSlug(store, name, category, price, sku, slug);

        // 5) 후속 작업용 도메인 이벤트(비동기 처리 대상)
        DomainEvents.raise(new ProductRegisteredEvent(p.id(), store.id(), name, category, price));
        DomainEvents.raise(new ProductSearchIndexRequestedEvent(p.id(), store.id(), name, category, slug));
        DomainEvents.raise(new ProductThumbnailGenerateRequestedEvent(p.id())); // 이미지 처리 등

        return p;
    }

    private static String normalize(String s) { return s == null ? "" : s.trim().replaceAll("\\s+"," "); }
    private static String toSlug(String name) { return name.toLowerCase(Locale.ROOT).replaceAll("[^a-z0-9]+","-"); }
    private static Money roundToMinorUnit(Money m) {
        return new Money(m.amount().setScale(0, RoundingMode.HALF_UP), m.currency());
    }
}