최범균 님의 "도메인 주도 개발 시작하기: 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());
}
}
'Code' 카테고리의 다른 글
[도메인 주도 개발 시작하기: DDD 핵심 개념 정리부터 구현까지] 8. 애그리거트 트랜잭션 관리 (0) | 2025.06.19 |
---|---|
[도메인 주도 개발 시작하기: DDD 핵심 개념 정리부터 구현까지] 7. 도메인 서비스 (0) | 2025.06.19 |
[도메인 주도 개발 시작하기: DDD 핵심 개념 정리부터 구현까지] 4. 애그리거트 (0) | 2025.06.19 |
[도메인 주도 개발 시작하기: DDD 핵심 개념 정리부터 구현까지] 2. 아키텍처 개요 (0) | 2025.06.18 |
[도메인 주도 개발 시작하기: DDD 핵심 개념 정리부터 구현까지] 1. 도메인 모델 시작하기 (0) | 2025.06.16 |