패키지 구조 표준에서 “패키지가 애그리거트 경계 역할을 한다”고 적었다. 그런데 그 애그리거트를 어디서 끊을지는 정하지 않았다.
테이블이 정규화되어 orders와 order_items로 나뉘어 있을 때, Domain은 몇 개가 되는가.
// 안 A — 하나로 묶는다
OrderDomain { List<OrderItemDomain> items; }
// 안 B — 따로 둔다
OrderDomain, OrderItemDomain 각각 Repository
이 선택이 Repository의 저장 단위와 트랜잭션 경계까지 결정한다.
먼저: 우리 구조에는 지연 로딩이 없다
일반적인 JPA 설명과 다른 점이 하나 있다. Domain은 POJO다.
JPA Entity라면 order.getItems()를 호출하는 순간 쿼리가 나가지만, OrderDomain은 그냥 자바 객체다. Repository가 Domain을 만들어 넘기는 시점에 이미 다 채워져 있어야 한다.
// Repository 구현체 — 여기서 items를 이미 로딩해야 한다
return OrderDomain.restore(id, userId, status, items);
결과적으로 애그리거트를 크게 잡으면 그 비용을 항상 치른다. 주문 상태 하나 바꾸려 해도 아이템 500개를 매번 읽어야 한다. JPA를 직접 쓸 때는 지연 로딩으로 미룰 수 있던 것이 여기서는 불가능하다.
이것이 애그리거트를 작게 유지해야 하는 가장 실질적인 이유다.
원칙 1. 경계는 “한 트랜잭션에서 함께 바뀌는가”로 가른다
기준은 하나다. 동시에 변경되어야 일관성이 깨지지 않는 것들을 한 덩어리로 묶는다.
// ✅ 주문과 주문항목 — 함께 저장되지 않으면 말이 안 된다
OrderDomain { List<OrderItemDomain> items; }
// ❌ 주문과 배송 — 별개 시점에 별개로 바뀐다
OrderDomain { DeliveryDomain delivery; }
주문 항목 없는 주문은 성립하지 않는다. 항목 하나만 저장되고 나머지가 실패하면 금액이 틀어진다. 함께 커밋되어야 하므로 한 애그리거트다.
반면 배송은 주문이 확정된 뒤 별도로 생성되고, 배송 상태 변경이 주문을 건드리지 않는다. 함께 바뀌지 않으므로 다른 애그리거트다.
생명주기나 FK로 가르지 않는다
흔히 쓰는 두 기준은 함정이 있다.
변경 시점이 같은가만 본다.
원칙 2. Repository는 애그리거트 루트 단위로만 만든다
애그리거트 내부 객체는 자기 Repository를 갖지 않는다.
// ✅ 애그리거트 루트만
public interface OrderRepository {
OrderDomain save(OrderDomain order);
Optional<OrderDomain> findById(Long orderId);
}
// ❌ 내부 객체용 Repository를 따로 만들지 않는다
public interface OrderItemRepository { ... }
OrderItemRepository가 생기는 순간 항목만 따로 저장하는 길이 열리고, 그러면 애그리거트가 보장하려던 일관성이 무너진다.
delete가 없는 것도 의도다. 주문은 결제가 ID로 참조하므로 지울 수 없고, 지우는 대신 취소한다. 지울 수 있는 애그리거트의 Repository에만delete를 두기로 정했다 — 나머지는 메서드가 없어 지우려는 시도가 컴파일 에러가 된다. 기준은 삭제 표준에 있다.
“하나”는 인터페이스 개수가 아니라 단위를 말한다. 뒤에 Repository 설계 표준을 정하면서 이 문장을 한 번 좁혔다. 조건이 여러 개 선택적으로 조합되는 동적 검색은
XxxSearchRepository로 떼어내므로, 한 애그리거트 루트에public인터페이스가 둘 생긴다. 그래도 이 원칙은 깨지지 않는다 — 둘 다 애그리거트 루트 단위로 드나들고, 내부 객체만 따로 저장하는 문은 여전히 없다. 막으려는 건 인터페이스의 수가 아니라 애그리거트를 우회하는 진입점이다.
저장과 조회 모두 애그리거트 전체가 단위다.
@Repository
@RequiredArgsConstructor
class OrderJpaRepository implements OrderRepository {
private final OrderJpaEntityRepository jpaEntityRepository;
private final OrderEntityMapper mapper;
@Override
public OrderDomain save(OrderDomain order) {
if (order.getId() == null) { // 신규 — items까지 함께 INSERT
return mapper.toDomain(jpaEntityRepository.save(mapper.toEntity(order)));
}
OrderEntity entity = jpaEntityRepository.findWithItemsById(order.getId())
.orElseThrow(() -> ErrorCodeException.of(ErrorCode.ORDER_NOT_FOUND));
mapper.applyTo(entity, order); // items까지 id로 대조해 맞춘다
jpaEntityRepository.flush(); // 예외를 구현체 안에서 받는다
return mapper.toDomain(entity);
}
@Override
public Optional<OrderDomain> findById(Long orderId) {
return jpaEntityRepository.findWithItemsById(orderId) // 조인해서 한 번에
.map(mapper::toDomain);
}
}
한 줄씩 따라가기. @Repository는 이 클래스를 스프링이 관리하는 빈으로 등록하라는 표시다(@Component의 한 종류다). @RequiredArgsConstructor는 Lombok이 final 필드(jpaEntityRepository, mapper)만 받는 생성자를 대신 만들어 준다(@RequiredArgsConstructor). save()는 order.getId()가 null이면 아직 DB에 없는 신규 주문으로 보고 그대로 저장한다 — 이때 INSERT 문이 나간다. 이미 있는 주문이면 findWithItemsById로 기존 Entity를 조인해서 자식(items)까지 한 번에 읽어 온다. 이 메서드는 Optional을 돌려주므로, 값이 없을 때 할 일을 .orElseThrow(() -> ...)로 바로 이어 적는다 — () -> ErrorCodeException.of(...)는 람다이고 “값이 없으면 이 예외를 던져라”는 뜻이다. mapper.applyTo(entity, order)가 조회해 온 Entity에 order의 값을 옮겨 적고, flush()가 그 변경을 지금 시점에 DB로 내보낸다 — 그래야 제약 위반 같은 예외를 이 메서드 안에서 바로 받을 수 있다. findById()도 같은 방식으로 조인해서 조회한 뒤 Entity를 Domain으로 바꿔 돌려준다.
Entity 쪽에서는 cascade = CascadeType.ALL과 orphanRemoval = true로 자식이 함께 저장·삭제되게 하고, 조회는 애초에 조인해서 가져온다. 지연 로딩에 맡기면 Mapper가 Domain을 만드는 시점에 추가 쿼리가 나가고, 그게 N+1의 시작이 된다.
수정할 때
toEntity()를 그대로 넘기지 않는 이유가 있다.Domain은 POJO라toEntity()가 만드는 것은 항상 분리 상태이고, 그걸save()하면merge가 되어Domain이 나르지 않는 컬럼이 전부null로 덮인다. 그래서 기존 애그리거트는 영속 Entity를 꺼내 필드만 덮는다. 자세한 이유와 자식 컬렉션을 맞추는 방법은 Domain 영속화 표준에서 다룬다.
조인 하나로 끝나는 건 자식 컬렉션이 하나일 때다. 위 예시의
findWithItemsById는items하나만 채우면 되니join fetch한 번으로 끝난다. 자식 컬렉션이 둘 이상이면 동시에join fetch할 수 없다 — 카티션 곱(두 컬렉션을 동시에 조인하면 행 수가 서로 곱해져 불어나는 것)으로 행이 곱해지고,List라면 Hibernate가MultipleBagFetchException(컬렉션 두 개를 한 번에 조인하려 하면 Hibernate가 아예 거부하며 던지는 예외)으로 아예 거부한다. 그때는 하나만join fetch하고 나머지는 각각 따로 조회해 구현체 안에서 조립한다. 여기서 변하지 않는 것은 Repository가Domain을 넘길 때 이미 다 채워져 있어야 한다는 것이고, 쿼리를 몇 번 쏘는지는 그 아래의 전략이다. 자세한 기준은 Repository 설계 표준에 있다.
원칙 3. 내부는 불변으로 노출하고, 변경은 루트를 통해서만 한다
반증: JDK 21.0.9 List.copyOf 애그리거트 — (가) 꺼내서 add: UnsupportedOperationException, 안쪽 size 2→2 · (나) 원본 add: 예외없음, 안쪽 size 2→2 · (다) 자식 changeQuantity: 예외없음, 안쪽 quantity 7→99 / 대조군(원본 그대로 보관) — (가): 예외없음, 안쪽 size 2→3 · (나): 예외없음, 안쪽 size 2→3 · (다): 예외없음, 안쪽 quantity 7→99 — 증명
실측(supported): JDK 21.0.9 List.copyOf 루트 + 불변 자식 — (가) 꺼내서 add: UnsupportedOperationException, 안쪽 size 2→2 · (나) 원본 add: 예외없음, 안쪽 size 2→2 · (다-1) 꺼낸 자식 changeQuantity: 예외없음, 안쪽 quantity 7→7(반환 99) · (다-2) 원본의 자식 changeQuantity: 예외없음, 안쪽 quantity 7→7(반환 99) / 대조군(원본 그대로 보관 + 같은 불변 자식) — (가): 예외없음, 안쪽 size 2→3 · (나): 예외없음, 안쪽 size 2→3 · (다-1): 예외없음, 안쪽 quantity 7→7(반환 99) · (다-2): 예외없음, 안쪽 quantity 7→7(반환 99) — 증명
이 두 줄은 실험 결과를 압축해 적은 것이다. (가)·(나)·(다)는 서로 다른 시나리오다 — (가)는 getItems()로 꺼낸 목록에 직접 add()를 시도한 경우, (나)는 생성자에 넘긴 원본 리스트를 나중에 직접 바꾼 경우, (다)는 꺼낸 자식이나 원본 속 자식의 값을 직접 바꾸려 한 경우다. 안쪽 size 2→2 는 “애그리거트 안의 목록 크기가 실험 전 2에서 실험 후에도 2로 그대로였다”는 뜻이고, 2→3이면 바깥에서 건드린 값이 안쪽까지 새어 들어갔다는 뜻이다. 대조군은 이 표준이 말한 대로 만들지 않고 비교 삼아 다르게(copyOf 없이 원본을 그대로 보관하는 식으로) 만든 버전이다. 아래에서 이 결과가 실제로 무엇을 증명하는지 풀어서 설명한다.
애그리거트가 일관성을 보장하려면 외부가 내부를 임의로 건드릴 수 없어야 한다.
@Getter
public class OrderDomain {
private final Long idOrNull;
private final Long userId;
private final OrderStatus status;
private final List<OrderItemDomain> items;
private OrderDomain(Long idOrNull, Long userId, OrderStatus status, List<OrderItemDomain> items) {
this.idOrNull = idOrNull;
this.userId = Objects.requireNonNull(userId, "userId");
this.status = Objects.requireNonNull(status, "status");
this.items = List.copyOf(items); // 방어적 복사 + 불변
validateItemsNotEmpty();
}
// 변경은 의도가 드러나는 메서드로만, 새 객체를 반환한다
public OrderDomain addItem(OrderItemDomain item) {
validateModifiable();
List<OrderItemDomain> next = new ArrayList<>(items);
next.add(item);
return new OrderDomain(idOrNull, userId, status, next);
}
public BigDecimal getTotalPrice() {
return items.stream()
.map(OrderItemDomain::getSubtotal)
.reduce(BigDecimal.ZERO, BigDecimal::add);
}
private void validateModifiable() {
if (status != OrderStatus.PENDING) {
throw ErrorCodeException.of(ErrorCode.ORDER_ALREADY_CONFIRMED);
}
}
}
한 줄씩 따라가기. @Getter는 Lombok이 모든 필드에 대해 get필드명() 메서드를 자동으로 만들어 주는 표시다(@Getter). idOrNull처럼 필드 이름 끝에 OrNull을 붙인 건 이 값이 비어 있을 수 있다는 걸 이름만 보고 알게 하는 규칙이다(OrNull 접미사) — 아직 저장되지 않은 새 주문은 DB가 매기는 id가 없으므로 idOrNull이 null이다. validateItemsNotEmpty()는 이름 그대로 items가 비어 있으면 안 된다는 규칙을 검사해, 비어 있으면 예외를 던지는 메서드다 — 주문 항목이 하나도 없는 주문은 성립하지 않기 때문이다. getTotalPrice()의 items.stream().map(OrderItemDomain::getSubtotal).reduce(BigDecimal.ZERO, BigDecimal::add)는 스트림으로 각 항목의 소계(getSubtotal())를 꺼낸 뒤(메서드 참조로 “이 메서드를 그대로 써라”는 뜻이다), 0부터 시작해 전부 더한 값을 돌려준다.
생성자에서 List.copyOf()를 쓰는 것이 핵심이다. 넘겨받은 리스트를 복사하면서 동시에 불변으로 만든다. 그래서 getItems()가 필드를 그대로 반환해도 목록 자체는 안전하다.
단 copyOf가 막는 것은 목록 구조까지다. 얕은 복사라 원소는 같은 객체를 가리킨다. 자식 값까지 막는 것은 copyOf가 아니라 자식이 불변이라는 규칙이다 — 모든 필드가 final이고 변경 메서드는 새 객체를 돌려준다(DTO 생성자 표준). 표준대로 만든 루트와 자식으로 쟀더니 네 경로 모두 안쪽이 그대로였다.
목록에 더하거나 뺀다 막힌다 ← copyOf
넘긴 원본 목록을 바꾼다 막힌다 ← copyOf
목록 안 자식의 값을 바꾼다 막힌다 ← 자식 불변 (copyOf 없이 원본을 보관한 대조군에서도 막혔다)
둘 중 하나만 지키면 한쪽이 샌다. copyOf 없이 원본을 보관하면 목록 구조가 새고, 자식이 가변이면 copyOf가 있어도 밖에서 잡은 참조로 값이 샌다. 위 반증 줄의 옛 실험이 뒤의 경우였다 — 실험의 자식이 final이 아닌 필드와 void 변경 메서드를 가진, 표준이 금한 모양이었다. 그래서 그 결과는 표준을 반증하지 않는다. 옛 실험은 지우지 않고 남긴다.
// ❌ 이 코드는 UnsupportedOperationException으로 막힌다
order.getItems().add(newItem);
// ✅ 루트를 통해서만 바꾼다
OrderDomain updated = order.addItem(newItem);
애그리거트 규칙(확정된 주문은 항목을 못 바꾼다)이 루트 안에 있으므로, 외부가 우회할 방법이 없다. Null-free 객체 설계에서 정한 “setter 금지, 의도 기반 변경 메서드”가 애그리거트 단위로 확장된 형태다.
원칙 4. 애그리거트 간 참조는 ID로 한다
같은 규칙이 세 층위에서 반복된다.
| 층위 | 참조 방식 |
|---|---|
애그리거트 안 (OrderDomain → OrderItemDomain) |
객체 참조 |
애그리거트 밖 (OrderDomain → UserDomain) |
ID 참조 |
도메인 밖 (OrderEntity → ProductEntity) |
ID 참조 |
public class OrderDomain {
private final Long userId; // 다른 애그리거트 → ID
private final List<OrderItemDomain> items; // 같은 애그리거트 → 객체
}
public class OrderItemDomain {
private final Long productId; // 다른 애그리거트 → ID
private final int quantity;
private final BigDecimal unitPrice; // 주문 시점 가격을 복사해 둔다
}
unitPrice를 복사해 두는 것에 주목할 만하다. 상품 가격이 나중에 바뀌어도 과거 주문 금액은 변하면 안 되기 때문이다. 애그리거트 간 참조를 ID로 끊으면 이런 “그 시점의 값”을 직접 갖게 되고, 그게 오히려 정확하다.
원칙 5. 커지면 분리한다
내부 컬렉션이 수백 건까지 자랄 수 있으면 애그리거트에서 떼어낸다.
// ❌ 주문 이력은 무한정 쌓인다
OrderDomain { List<OrderHistoryDomain> histories; } // 조회할 때마다 전부 로딩
// ✅ 별도 애그리거트로 분리하고 ID로 참조
OrderHistoryDomain { Long orderId; ... }
판단 기준은 상한이 예측 가능한가다.
- 주문 항목은 보통 수십 개를 넘지 않는다 → 안에 둔다
- 주문 이력, 댓글, 로그는 상한이 없다 → 분리한다
분리하면 잃는 것도 인정한다
애그리거트를 나누면 트랜잭션 일관성을 더 이상 애그리거트가 보장하지 않는다. 주문과 이력을 각각 저장하다 뒤가 실패하면 앞만 남는다.
이건 Service가 @Transactional 하나로 묶어 해결하거나, 그것도 어려우면 최종 일관성을 받아들여야 한다. 경계를 나눈 대가이고, 그래서 아무 데나 나누지 않는다.
판단 기준 정리
| 질문 | 답 | 결론 |
|---|---|---|
| 한 트랜잭션에서 함께 바뀌어야 하는가 | 예 | 같은 애그리거트 |
| 변경 시점이 다른가 | 예 | 다른 애그리거트 |
| 컬렉션 크기 상한이 예측 가능한가 | 아니오 | 분리한다 |
| 내부 객체가 독립적으로 조회되는가 | 예 | 애그리거트가 잘못 잡혔다 |
| FK로 연결되어 있는가 | — | 기준이 아니다 |
| 생명주기를 같이 하는가 | — | 기준이 아니다 |
이 표준을 정하기까지
시작은 정규화 얘기였다. 테이블이 orders와 order_items로 나뉘어 있을 때 Domain을 몇 개로 만들지가 표준에 없었다. 패키지 구조 표준에서 “패키지가 애그리거트 경계”라고 써놓고 정작 그 경계를 정의하지 않은 상태였다.
우리 구조만의 제약을 먼저 발견했다. Domain은 POJO라 지연 로딩이 없다. JPA Entity를 직접 쓰면 getItems() 시점까지 미룰 수 있지만, Repository가 Domain을 만들어 넘길 때는 이미 다 채워져 있어야 한다. 애그리거트 크기가 곧 매 조회의 고정 비용이 되는 것이다. 일반적인 DDD 설명보다 “작게 유지하라”는 압력이 훨씬 강하다.
경계 기준으로는 세 가지를 놓고 비교했다. 생명주기는 직관적이지만 “함께 죽는다”와 “함께 바뀐다”가 다르다 — 배송은 주문이 사라지면 의미가 없어도 변경 시점은 완전히 다르다. FK를 따라가는 방식은 애그리거트가 계속 커져서 결국 DB 전체가 한 덩어리가 된다. 남은 건 “한 트랜잭션에서 함께 바뀌는가”였고, 이게 애그리거트가 원래 보장하려는 것(일관성)과 정확히 일치했다.
Repository를 애그리거트당 하나로 제한한 건 우회로를 막기 위해서다. OrderItemRepository가 있으면 항목만 따로 저장하는 길이 열리고, 그 순간 애그리거트가 보장하려던 게 무너진다. 저장·조회 단위를 애그리거트로 고정하니 규칙이 단순해졌다.
내부 노출은 List.copyOf() 하나로 정리됐다. 생성자에서 복사하면서 동시에 불변으로 만들면, getItems()가 필드를 그대로 반환해도 안전하다. 매번 복사할 필요도 없다. 외부에서 add()를 호출하면 예외가 나므로, 애그리거트 규칙을 우회할 방법이 사라진다.
copyOf가 막는 범위는 나중에 실측으로 좁혀 적었다(2026-09-16). 첫 실험에서 자식 값이 새어 「자식을 불변으로 할지, 깊은 복사를 할지 안 정했다」고 적었는데, 다시 보니 실험의 자식이 표준이 금한 가변 모양이었다. 표준대로 불변 자식으로 다시 재자 새지 않았고, 대조군으로 자식 값을 막는 것은 copyOf가 아니라 자식 불변 규칙이라는 게 갈렸다. 새로 정한 것은 없다. 이미 있던 두 규칙이 각각 무엇을 막는지 나눠 적었을 뿐이다.
참조 규칙이 세 층위에서 같은 모양이 된 건 뜻밖의 수확이었다. 애그리거트 안은 객체, 밖은 ID. 도메인 안은 객체, 밖은 ID. 패키지 구조 표준에서 Entity 연관관계에 대해 정한 것과 완전히 같은 규칙이 Domain 층에서도 성립했다. 규칙이 하나라 외우기 쉽다.
마지막으로 분리의 대가를 명시했다. 애그리거트를 나누면 트랜잭션 일관성을 더 이상 애그리거트가 보장하지 않는다. 이걸 적어두지 않으면 “작을수록 좋다”고 오해해서 아무 데나 쪼개게 된다.
정리
Domain은 지연 로딩이 없다. 애그리거트 크기가 매 조회의 고정 비용이 된다- 경계는 “한 트랜잭션에서 함께 바뀌는가”로 가른다. 생명주기나 FK는 기준이 아니다
- Repository는 애그리거트 루트 단위로만 만든다. 내부 객체용 Repository를
domain에 노출하지 않는다 - 저장·조회 단위는 애그리거트 전체다.
Domain을 넘기는 시점에 이미 다 채워져 있어야 한다 - 생성자에서
List.copyOf()로 복사하고, 변경은 루트의 의도 메서드로만 한다 copyOf는 목록 구조만 막는다. 자식 값은 자식 불변 규칙(모든 필드final, 변경은 새 객체 반환)이 막는다- 애그리거트 간 참조는 ID로 한다. 그 시점의 값(가격 등)은 복사해 둔다
- 컬렉션 상한이 예측 불가능하면 분리한다. 대신 트랜잭션 일관성은 직접 챙겨야 한다
자신만의 철학을 만들어가는 중입니다.
댓글남기기