패키지 구조 표준에서 “패키지가 애그리거트 경계 역할을 한다”고 적었다. 그런데 그 애그리거트를 어디서 끊을지는 정하지 않았다.

테이블이 정규화되어 ordersorder_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로 가르지 않는다

흔히 쓰는 두 기준은 함정이 있다.

  • 생명주기: 주문이 삭제되면 배송도 의미가 없지만, 그렇다고 동시에 변경되지는 않는다
  • 외래 키: FK만 따라가면 애그리거트가 계속 커진다. 결국 DB 전체가 한 덩어리가 된다

변경 시점이 같은가만 본다.


원칙 2. Repository는 애그리거트당 하나다

애그리거트 내부 객체는 자기 Repository를 갖지 않는다.

// ✅ 애그리거트 루트만
public interface OrderRepository {
    OrderDomain save(OrderDomain order);
    Optional<OrderDomain> findById(Long orderId);
}

// ❌ 내부 객체용 Repository를 따로 만들지 않는다
public interface OrderItemRepository { ... }

OrderItemRepository가 생기는 순간 항목만 따로 저장하는 길이 열리고, 그러면 애그리거트가 보장하려던 일관성이 무너진다.

저장과 조회 모두 애그리거트 전체가 단위다.

@Repository
@RequiredArgsConstructor
class OrderJpaRepository implements OrderRepository {

    private final OrderJpaEntityRepository jpaEntityRepository;
    private final OrderEntityMapper mapper;

    @Override
    public OrderDomain save(OrderDomain order) {
        OrderEntity entity = mapper.toEntity(order);        // items까지 함께 변환
        return mapper.toDomain(jpaEntityRepository.save(entity));
    }

    @Override
    public Optional<OrderDomain> findById(Long orderId) {
        return jpaEntityRepository.findWithItemsById(orderId)   // 조인해서 한 번에
                .map(mapper::toDomain);
    }
}

Entity 쪽에서는 cascade로 함께 저장되게 하고, 조회는 애초에 조인해서 가져온다. 지연 로딩에 맡기면 Mapper가 Domain을 만드는 시점에 추가 쿼리가 나가고, 그게 N+1의 시작이 된다.


원칙 3. 내부는 불변으로 노출하고, 변경은 루트를 통해서만 한다

애그리거트가 일관성을 보장하려면 외부가 내부를 임의로 건드릴 수 없어야 한다.

@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 BusinessException.of(ErrorCode.ORDER_ALREADY_CONFIRMED);
        }
    }
}

생성자에서 List.copyOf()를 쓰는 것이 핵심이다. 넘겨받은 리스트를 복사하면서 동시에 불변으로 만든다. 그래서 getItems()가 필드를 그대로 반환해도 안전하다.

// ❌ 이 코드는 UnsupportedOperationException으로 막힌다
order.getItems().add(newItem);

// ✅ 루트를 통해서만 바꾼다
OrderDomain updated = order.addItem(newItem);

애그리거트 규칙(확정된 주문은 항목을 못 바꾼다)이 루트 안에 있으므로, 외부가 우회할 방법이 없다. Null-free 객체 설계에서 정한 “setter 금지, 의도 기반 변경 메서드”가 애그리거트 단위로 확장된 형태다.


원칙 4. 애그리거트 간 참조는 ID로 한다

같은 규칙이 세 층위에서 반복된다.

층위 참조 방식
애그리거트 (OrderDomainOrderItemDomain) 객체 참조
애그리거트 (OrderDomainUserDomain) ID 참조
도메인 (OrderEntityProductEntity) 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로 연결되어 있는가 기준이 아니다
생명주기를 같이 하는가 기준이 아니다

이 표준을 정하기까지

시작은 정규화 얘기였다. 테이블이 ordersorder_items로 나뉘어 있을 때 Domain을 몇 개로 만들지가 표준에 없었다. 패키지 구조 표준에서 “패키지가 애그리거트 경계”라고 써놓고 정작 그 경계를 정의하지 않은 상태였다.

우리 구조만의 제약을 먼저 발견했다. Domain은 POJO라 지연 로딩이 없다. JPA Entity를 직접 쓰면 getItems() 시점까지 미룰 수 있지만, Repository가 Domain을 만들어 넘길 때는 이미 다 채워져 있어야 한다. 애그리거트 크기가 곧 매 조회의 고정 비용이 되는 것이다. 일반적인 DDD 설명보다 “작게 유지하라”는 압력이 훨씬 강하다.

경계 기준으로는 세 가지를 놓고 비교했다. 생명주기는 직관적이지만 “함께 죽는다”와 “함께 바뀐다”가 다르다 — 배송은 주문이 사라지면 의미가 없어도 변경 시점은 완전히 다르다. FK를 따라가는 방식은 애그리거트가 계속 커져서 결국 DB 전체가 한 덩어리가 된다. 남은 건 “한 트랜잭션에서 함께 바뀌는가”였고, 이게 애그리거트가 원래 보장하려는 것(일관성)과 정확히 일치했다.

Repository를 애그리거트당 하나로 제한한 건 우회로를 막기 위해서다. OrderItemRepository가 있으면 항목만 따로 저장하는 길이 열리고, 그 순간 애그리거트가 보장하려던 게 무너진다. 저장·조회 단위를 애그리거트로 고정하니 규칙이 단순해졌다.

내부 노출은 List.copyOf() 하나로 정리됐다. 생성자에서 복사하면서 동시에 불변으로 만들면, getItems()가 필드를 그대로 반환해도 안전하다. 매번 복사할 필요도 없다. 외부에서 add()를 호출하면 예외가 나므로, 애그리거트 규칙을 우회할 방법이 사라진다.

참조 규칙이 세 층위에서 같은 모양이 된 건 뜻밖의 수확이었다. 애그리거트 안은 객체, 밖은 ID. 도메인 안은 객체, 밖은 ID. 패키지 구조 표준에서 Entity 연관관계에 대해 정한 것과 완전히 같은 규칙이 Domain 층에서도 성립했다. 규칙이 하나라 외우기 쉽다.

마지막으로 분리의 대가를 명시했다. 애그리거트를 나누면 트랜잭션 일관성을 더 이상 애그리거트가 보장하지 않는다. 이걸 적어두지 않으면 “작을수록 좋다”고 오해해서 아무 데나 쪼개게 된다.


정리

  • Domain은 지연 로딩이 없다. 애그리거트 크기가 매 조회의 고정 비용이 된다
  • 경계는 “한 트랜잭션에서 함께 바뀌는가”로 가른다. 생명주기나 FK는 기준이 아니다
  • Repository는 애그리거트당 하나다. 내부 객체용 Repository를 만들지 않는다
  • 저장·조회 단위는 애그리거트 전체다. 조회는 조인해서 한 번에 가져온다
  • 생성자에서 List.copyOf()로 복사하고, 변경은 루트의 의도 메서드로만 한다
  • 애그리거트 간 참조는 ID로 한다. 그 시점의 값(가격 등)은 복사해 둔다
  • 컬렉션 상한이 예측 불가능하면 분리한다. 대신 트랜잭션 일관성은 직접 챙겨야 한다

AI 코드 어시스턴트에 바로 적용하기

Claude Code — .claude/skills/aggregate-boundary-standard/SKILL.md

---
name: aggregate-boundary-standard
description: 애그리거트 경계와 Domain 구성 규칙. Domain 클래스, Repository 인터페이스, 내부 컬렉션을 설계하거나 리뷰할 때 반드시 적용한다.
---

# 애그리거트 경계 표준

`Domain`은 POJO라 지연 로딩이 없다. Repository가 `Domain`을 만들 때 이미 전부 채워야 하므로, **애그리거트 크기가 매 조회의 고정 비용**이 된다. 작게 유지한다.

## 경계 기준
- **한 트랜잭션에서 함께 변경되어야 하는가**로 가른다. 그렇다면 한 애그리거트, 아니면 별개다.
- 생명주기가 같다는 이유로 묶지 않는다. 함께 사라지는 것과 함께 바뀌는 것은 다르다.
- 외래 키로 연결되어 있다는 이유로 묶지 않는다. FK를 따라가면 애그리거트가 무한히 커진다.
- 내부 객체가 독립적으로 조회되고 있다면 애그리거트가 잘못 잡힌 것이다.

## Repository
- Repository는 애그리거트 루트당 하나만 만든다. 내부 객체용 Repository(`OrderItemRepository` 등)를 만들지 않는다.
- 저장과 조회의 단위는 애그리거트 전체다.
- 조회 시 내부 컬렉션을 조인으로 한 번에 가져온다. 지연 로딩에 맡기면 Mapper가 Domain을 만드는 시점에 N+1이 발생한다.

## Domain 구성
- 생성자에서 컬렉션을 `List.copyOf()`로 복사한다. 방어적 복사와 불변화가 동시에 이루어진다.
- 컬렉션 getter는 불변 리스트를 반환한다. 외부에서 `add()`를 호출하면 예외가 나야 한다.
- 내부 변경은 루트의 의도 기반 메서드로만 한다. 메서드 안에서 애그리거트 규칙을 검증하고 새 객체를 반환한다.

## 참조 규칙
- 애그리거트 안은 객체로 참조하고, 애그리거트 밖은 ID로 참조한다.
- 다른 애그리거트의 값 중 시점에 따라 달라지는 것(가격 등)은 복사해서 보관한다. 참조로 두면 과거 데이터가 나중에 바뀐다.

## 분리 기준
- 내부 컬렉션의 크기 상한이 예측 가능하면 애그리거트 안에 둔다. 주문 항목처럼 수십 개 수준이면 문제없다.
- 상한이 없으면(이력, 댓글, 로그) 별도 애그리거트로 분리하고 ID로 참조한다.
- 분리하면 트랜잭션 일관성을 애그리거트가 보장하지 않는다. Service의 `@Transactional`로 묶거나 최종 일관성을 수용해야 한다.

GitHub Copilot — .github/instructions/aggregate-boundary-standard.instructions.md

---
description: 애그리거트 경계와 Domain 구성 규칙
applyTo: "**/*.java"
---

- 애그리거트 경계는 한 트랜잭션에서 함께 변경되는지로 가른다.
- 생명주기나 외래 키를 애그리거트 경계 기준으로 삼지 않는다.
- Repository는 애그리거트 루트당 하나만 만들고 내부 객체용 Repository를 만들지 않는다.
- 저장과 조회의 단위는 애그리거트 전체로 한다.
- 애그리거트 조회 시 내부 컬렉션을 조인으로 한 번에 가져온다.
- Domain 생성자에서 컬렉션을 `List.copyOf()`로 복사해 불변으로 만든다.
- 컬렉션 getter는 불변 리스트를 반환한다.
- 내부 변경은 루트의 의도 기반 메서드로만 하고 새 객체를 반환한다.
- 애그리거트 안은 객체 참조, 애그리거트 밖은 ID 참조로 한다.
- 다른 애그리거트의 값 중 시점에 따라 달라지는 것은 복사해서 보관한다.
- 크기 상한이 없는 컬렉션은 별도 애그리거트로 분리한다.

자신만의 철학을 만들어가는 중입니다.
최상단으로 이동했습니다!
확대 이미지

댓글남기기