[DDD] 왜 외부 애그리거트는 ID로 참조하는것이 좋을까?

2025. 5. 31. 01:10·DDD
반응형

시작하며


안녕하세요. 개발자 Stark입니다.

이번 글의 주제는 도메인 주도 설계(Domain-Driven Design, DDD)를 적용하다 보면 마주치는 중요한 결정 중 하나인 '어떻게 다른 애그리거트(Aggregate)를 참조할 것인가?'입니다. 많은 DDD 전문가들이 작성한 책과 글을 보다 보면 "애그리거트 내부에서는 다른 애그리거트를 ID를 통해 참조하라"라고 조언합니다. 여기서 드는 궁금증은 왜 객체 직접 참조가 아닌 ID 참조를 권장하는 걸까요? 지금부터 그 이유를 명확한 경계 설정과 트랜잭션 관리라는 두 가지 핵심 축을 중심으로 알아봅시다.

 

 

애그리거트와 그 경계의 중요성


먼저 애그리거트가 무엇인지 간단히 짚고 넘어가겠습니다.

 

애그리거트(Aggregate)는 쉽게 말해 관련된 데이터와 기능을 하나로 묶은 '덩어리'라고 생각할 수 있습니다.

예를 들어, 온라인 쇼핑몰에서 사용될 '주문'이라는 애그리거트(묶음)를 상상해 봅시다. 이 '주문'이라는 묶음 안에는 '주문 정보 자체, 주문한 상품 목록, 배송지 정보' 등이 포함될 수 있을 것입니다. 이렇게 서로 밀접하게 관련된 데이터와 그 데이터를 처리하는 로직(비즈니스 규칙)들이 하나의 단위로 관리되는 것이 바로 애그리거트입니다. 핵심은 데이터 변경 시 일관성을 유지하는 기본 단위라는 점입니다.

 

애그리거트에는 '대표'라는 개념이 존재하며 이것을 우리는 애그리거트 루트(Aggregate Root)라고 부릅니다.

'주문' 애그리거트에서는 '주문'이라는 정보 자체가 루트가 될 수 있습니다. 중요한 점은, 이 '주문' 애그리거트 내부의 다른 요소들(예: 주문 상품 목록)에 접근하거나 변경하고 싶을 때는 반드시 이 '주문'이라는 '루트'를 통해서만 해야 한다는 것입니다. 즉, 애그리거트 루트는 외부에서 함부로 내부 구성원을 건드리지 못하게 하는 문지기 역할이라고 볼 수 있습니다.

 

애그리거트 경계(Aggregate Boundary)는 말 그대로 애그리거트가 책임지는 데이터와 로직의 범위를 나타내는 선입니다.

'주문' 애그리거트의 경계 안에는 주문 정보, 주문 상품 목록, 배송지 주소 등이 포함될 것입니다. 이 경계 안에서는 데이터가 항상 정확하고 일관된 상태, 즉 불변성(Invariant)을 유지해야 합니다. 여기서 말하는 불변성(Invariant)은 애그리거트가 항상 만족해야 하는 비즈니스 규칙이나 조건을 의미합니다. 예를 들어, '주문' 애그리거트에는 다음과 같은 불변성이 있을 수 있습니다.

  • '주문의 총액은 항상 각 주문 상품 항목(OrderItem) 가격의 합계와 같아야 한다.'
  • '주문에는 최소 하나 이상의 상품이 포함되어야 한다.'
  • '배송이 시작된 주문의 상품은 변경할 수 없다.'

이러한 규칙들은 '주문' 애그리거트의 경계 내에서 항상 지켜져야 하는 약속과 같습니다.

 

애그리거트의 중요한 특징 중 하나는 트랜잭셔널 일관성(Transactional Consistency)입니다.

이는 애그리거트에 대한 변경 작업이 하나의 트랜잭션, 즉 하나의 묶음으로 처리되어야 한다는 의미입니다. 주문에 상품을 추가하는 경우를 생각해 봅시다. 주문하려는 상품 목록에 새로운 상품이 추가되면 당연히 주문의 총액도 그에 맞게 변경되어야 합니다. 이 두 가지 작업은 반드시 함께 성공하거나, 하나라도 실패하면 모든 변경 사항이 원래대로 돌아가야 합니다(원자성). 이렇게 함으로써 애그리거트의 불변성은 해당 트랜잭션이 완료될 때 보장됩니다.

 

결국 애그리거트의 핵심 목표는 '자신의 경계 내에서 데이터 일관성을 스스로 책임지고 보호하는 것'입니다.

이렇게 각 애그리거트가 자신의 데이터를 책임지게 되면, 시스템 전체의 복잡도는 낮아지고 데이터 무결성은 높아집니다. 개발자는 특정 애그리거트의 경계 안에서만 비즈니스 규칙과 데이터 일관성에 집중하면 되므로, 더욱 견고하고 이해하기 쉬운 코드를 작성할 수 있게 됩니다.

 

 

외부 애그리거트를 객체로 직접 참조할 때 발생하는 문제점들


지금까지 알아본 애그리거트는 데이터 일관성을 지키는 핵심 단위입니다. 그런데 만약 한 애그리거트가 다른 애그리거트를 직접 들고 있다면(예: Order 객체가 Product 객체를 필드로 직접 참조), 어떤 골치 아픈 문제들이 생길 수 있을까요?

 

1. 흔들리는 경계, 깨지는 규칙

애그리거트 A가 애그리거트 B 객체를 직접 가지고 있다면(참조하면), A는 B가 공개한 기능(메서드)을 사용해서 B의 상태를 바꿀 수 있게 됩니다. 진짜 문제는, 이렇게 상태를 바꾸는 게 B가 스스로 자기 규칙(불변성)을 지키려고 만든 상황(맥락)과 관계없이, A가 자기 마음대로 진행할 수 있다는 점입니다. 그래서 B가 꼭 지켜야 할 중요한 비즈니스 규칙이 자신도 모르게 깨질 위험이 생깁니다.

 

예를 들어 Order(주문) 애그리거트가 Product(상품) 애그리거트 객체를 직접 가지고 있다고 가정해 봅시다. Order(주문)는 자신이 소유한 로직 안에서 Product(상품)의 decreaseStock()라는 공개된 메서드를 호출해서 상품 재고를 줄일 수 있습니다. 이때, Product(상품)의 decreaseStock() 기능 자체는 단순히 '재고를 줄이는 일'만 할 수 있습니다. 하지만 Product(상품) 애그리거트에게는 더 큰 그림의 규칙들, 예를 들어 "최소 재고는 항상 5개 이상 유지!" 라거나 "재고가 특정 숫자 아래로 내려가면 담당자에게 꼭 알려야 함!" 같은 '전체적인 약속(불변성)'들이 있을 것입니다. 이런 약속들은 보통 Product(상품) 애그리거트의 '총괄 책임자'(루트)가 여러 상황과 규칙들을 종합해서 관리합니다.

 

그런데 만약 Order(주문) 애그리거트가 Product(상품) 애그리거트의 이런 전체적인 약속은 고려하지 않고, 그냥 decreaseStock() 기능만 호출했다면 그 결과로 Product의 '최소 재고 유지' 약속이 깨지거나, 담당자에게 갔어야 할 '알림'이 누락될 수도 있습니다. 이건 Product(상품)가 스스로 지켜야 할 약속을 Order(주문)가 깨버린 상황이 되는 것입니다. 왜냐하면 Order(주문)는 Product(상품)의 전체적인 관리 상황이나 큰 규칙들을 잘 모르거나 신경 쓰지 않고, 자기 로직 흐름에 따라 Product(상품)의 일부 기능만 골라 썼기 때문입니다. 한마디로, Order(주문)가 Product(상품) 애그리거트의 영역을 넘어서서, Product(상품)가 책임져야 할 규칙까지 건드린 셈이 되는 겁니다.

 

또한 한 애그리거트가 다른 애그리거트 객체를 직접 품고 있으면, 두 애그리거트의 생명주기가 불필요하게 엮이게 됩니다. 예를 들어, Product(상품) 정보가 변경되거나 삭제될 때, 이를 직접 참조하고 있는 Order(주문) 관련 로직이 예상치 못한 오류를 뿜어내거나 잘못된 데이터를 참조할 수 있습니다.

 

2. 비대해지는 트랜잭션, 불안한 일관성

애그리거트 A가 B를 직접 참조하면, A 관련 작업을 할 때 B의 데이터까지 한꺼번에 불러와서 하나의 트랜잭션으로 묶어 처리하고 싶은 유혹에 빠지기 쉽습니다. 이는 각 애그리거트가 가져야 할 독립적인 트랜잭션 경계를 무너뜨립니다. DDD에서는 각 애그리거트는 자신만의 트랜잭션 안에서 데이터 일관성을 지키는 것을 원칙으로 합니다.

 

예를 들어, Order(주문) 변경과 Product(상품) 변경은 원칙적으로 별개의 트랜잭션으로 다뤄지고, 필요하다면 결과적 일관성(Eventual Consistency)을 통해 동기화하는 것이 일반적입니다.

또한 여러 애그리거트를 하나의 트랜잭션으로 묶게 되면, 데이터베이스 잠금(lock)의 범위가 넓어지고 잠금 유지 시간도 길어집니다. 이는 시스템 전체의 동시 처리 성능을 떨어뜨리고, 최악의 경우 여러 작업이 서로를 기다리며 멈춰버리는 데드락 상황을 야기할 가능성을 높입니다.

3. 느려지는 성능, 막히는 확장길

애그리거트 A를 불러올 때마다 A가 직접 참조하는 애그리거트 B의 모든 데이터를 항상 같이 로딩해야 한다면 (일명 '즉시 로딩' 또는 Eager Loading), 시스템은 심각한 성능 저하를 겪을 수 있습니다. 특히 B가 방대한 데이터를 가지고 있거나, 참조 관계가 여러 단계로 깊게 얽혀있다면 문제는 걷잡을 수 없이 커집니다. (N+1 문제 발생 가능성)

오늘날 많은 시스템이 채택하는 마이크로서비스 아키텍처(MSA)처럼 애그리거트들이 물리적으로 서로 다른 서비스(다른 서버, 다른 데이터베이스)에 나뉘어 존재한다면, 한 서비스의 메모리 공간에서 다른 서비스의 객체를 직접 참조하는 것은 기술적으로 거의 불가능합니다. 각 서비스는 독립적으로 배포되고 운영되기 때문입니다.

 

이처럼 외부 애그리거트를 객체로 직접 참조하는 방식은 애그리거트의 경계를 모호하게 만들고, 트랜잭션 관리를 복잡하게 하며, 성능과 확장성을 저해하는 등 다양한 문제를 야기할 수 있습니다. 그래서 DDD에서는 이러한 직접 참조 대신, ID를 통한 간접 참조 방식을 권장하는 것입니다.

 

 

애그리거트를 ID로 참조하면 얻는 강력한 이점들


그렇다면 외부 애그리거트를 ID로 참조할 때 어떤 이점들을 얻을 수 있을까요?

 

1. 명확한 경계 설정과 애그리거트 자율성 보장

ID 참조는 "이것은 내 애그리거트 소속이 아니며, 저쪽 애그리거트에 속한 것이다"라는 사실을 코드 수준에서 명확히 드러냅니다. 그렇기 때문에 각 애그리거트는 자신의 경계 내 데이터와 로직에만 집중할 수 있으며, 외부 애그리거트의 내부 상태나 구현에 대해 알 필요가 없습니다.

2. 느슨한 결합(Loose Coupling) 유지

애그리거트 간의 결합도가 낮아집니다. 한 애그리거트의 내부의 변경이 다른 애그리거트에 미치는 영향을 최소화하여 시스템의 유연성과 유지보수성을 향상시킵니다. 만약 다른 애그리거트와 상호작용이 필요할 때는 ID를 사용하여 해당 애그리거트의 루트에 명령을 전달하거나 조회를 요청하는 방식으로 소통합니다.

3. 독립적인 트랜잭션과 결과적 일관성 실현

"한 트랜잭션에는 하나의 애그리거트만 변경한다"는 DDD의 핵심 원칙을 지키기 용이해집니다. ID 참조를 한다면 한 애그리거트의 변경은 해당 애그리거트의 트랜잭션 내에서만 원자적으로 처리됩니다. 만약 여러 애그리거트에 걸친 일관성이 필요할 경우에는 도메인 이벤트(Domain Event)를 발행하고 이를 구독하는 다른 애그리거트가 비동기적으로 처리하여 결과적 일관성(Eventual Consistency)을 달성하도록 구현할 수 있습니다. 이 도메인 이벤트에는 관련된 애그리거트의 ID가 포함됩니다.

4. 성능 최적화 및 분산 시스템 친화성

외부 애그리거트의 ID만 가지고 있다가, 실제로 해당 애그리거트의 정보가 필요한 시점에 리포지토리(Repository)를 통해 조회할 수 있습니다. 이는 불필요한 데이터 로딩을 방지하여 성능을 개선하며, 필요할 때만 데이터를 불러오는 지연 로딩(Lazy Loading)의 이점을 살릴 수 있게 합니다. 또한 ID는 네트워크를 통해 쉽게 전달될 수 있는 데이터 형태이므로, 애그리거트가 서로 다른 서비스에 분산되어 있는 마이크로서비스(MSA) 환경에서 자연스럽게 다른 애그리거트를 참조하고 상호작용할 수 있게 해 줍니다.

 

 

그렇다면 ID 참조는 어떻게 구현해야 할까요?


외부 애그리거트의 ID를 사용하여 상호작용하는 일반적인 방법은 다음과 같습니다.

 

리포지토리를 통한 조회

애플리케이션 서비스(Application Service)에서 한 애그리거트의 작업을 수행하기 전에, 필요한 다른 애그리거트의 정보를 얻기 위해 ID를 사용하여 해당 애그리거트의 리포지토리에서 조회합니다.

// 예시: 주문 처리 시 상품 정보 조회
public class OrderService {

    private OrderRepository orderRepository;
    private ProductRepository productRepository; // 상품 리포지토리

    public void placeOrder(CreateOrderRequest request) {
        Product product = productRepository.findById(request.getProductId()); // ID로 상품 조회
        if (product == null || !product.isInStock(request.getQuantity())) {
            throw new ProductOutOfStockException();
        }
        // 상품 가격 등 필요한 정보를 사용하여 주문 생성 로직 수행
        Order order = Order.create(request.getCustomerId(), product.getId(), product.getPrice(), request.getQuantity());
        orderRepository.save(order);
    }
    
}

명령(Command) 전달 시 ID 사용

한 애그리거트가 다른 애그리거트에게 어떤 작업을 수행하도록 요청(명령)할 때, 대상 애그리거트의 ID와 필요한 데이터를 명령 객체에 담아 전달합니다.

// 상품 ID를 포함하는 주문 항목 추가 명령
public class AddOrderItemCommand {

    private final OrderId orderId;
    private final ProductId productId; // 상품 ID
    private final int quantity;
    private final Money price; // 주문 시점의 상품 가격 (Product에서 가져온)

    // 생성자, getter
    
}

도메인 이벤트를 통한 비동기 상호작용

한 애그리거트에서 중요한 상태 변경이 발생하면 도메인 이벤트를 발행합니다. 이 이벤트에는 해당 애그리거트의 ID와 관련 컨텍스트 정보(필요시 다른 애그리거트 ID 포함)가 담깁니다. 이벤트를 구독하는 다른 애그리거트나 서비스는 이 ID를 사용하여 필요한 후속 조치를 취합니다.

// 주문 완료 이벤트 (주문 ID, 사용자 ID 등 포함)
public class OrderCompletedEvent {

    private final OrderId orderId;
    private final UserId userId;
    // 생성자, getter

    public OrderCompletedEvent(OrderId orderId, UserId userId) {
        this.orderId = orderId;
        this.userId = userId;
    }
    
}

// 이벤트 핸들러 (예: 알림 서비스)
public class NotificationService {

    @EventListener
    public void handleOrderCompleted(OrderCompletedEvent event) {
        // event.getUserId()를 사용하여 사용자 정보를 조회하고 알림을 보낸다.
        sendEmailNotification(event.getUserId(), "주문이 완료되었습니다. 주문번호: " + event.getOrderId());
    }
    
}

 

 

현실 세계에 비유해보기: "회사 부서 간의 협업"


이해를 돕기 위해 간단한 비유를 들어보겠습니다. 어떤 회사에 마케팅 부서(애그리거트 A)와 개발 부서(애그리거트 B)가 있다고 상상해 봅시다. 각 부서는 각자의 목표, 업무 프로세스, 그리고 지켜야 할 내부 규정(불변성)이 있고, 부서장(애그리거트 루트)이 이를 총괄합니다.

 

객체 직접 참조 (바람직하지 않은 방식 👎)

마케팅 부서의 A팀장이 신제품 홍보 자료에 필요한 기술 정보가 급하게 필요해졌습니다. 그래서 A팀장이 개발 부서장의 허락이나 공식 요청 절차 없이, 평소 안면이 있던 개발 부서의 B엔지니어에게 바로 찾아가 다짜고짜 말을 겁니다. "B 씨, 지금 하고 있는 일 잠깐만 멈추고, 우리 신제품 기술 스펙 좀 빨리 정리해 줘요. 한 시간이면 되죠?"

 

여기서 발생하는 문제점은 다음과 같습니다.

  1. 경계 침범 및 규칙 위반: B엔지니어는 현재 개발 부서장의 지시에 따라 중요한 버그 수정 작업(개발 부서의 불변성/규칙)을 진행 중일 수 있습니다. A팀장의 갑작스러운 요청은 개발 부서의 업무 우선순위를 무시하고, B엔지니어의 핵심 업무를 방해하며, 개발 부서 전체의 일정과 내부 규정을 어지럽힐 수 있습니다. (다른 애그리거트의 불변성 침해)

  2. 책임 모호: 만약 B엔지니어가 A팀장의 요청을 들어주다가 원래 하던 버그 수정에 문제가 생기면, 그 책임은 누구에게 있을까요? 개발 부서장은 B엔지니어가 다른 일을 하고 있었는지조차 모를 수 있습니다. (애그리거트 루트 통제력 상실)

  3. 비효율 및 과도한 정보 노출: A팀장은 단순히 기술 스펙 몇 줄이 필요했을 뿐인데, B엔지니어의 자리로 직접 찾아가 이것저것 물어보면서 B엔지니어의 모든 업무 상황이나 개발팀 내부의 민감한 정보까지 불필요하게 알게 될 수 있습니다. 또한, B엔지니어의 전체 업무 시간을 빼앗는 것은 매우 비효율적입니다. (전체 객체 로딩 및 불필요한 데이터 노출과 유사)

이는 마치 마케팅 애그리거트가 개발 애그리거트 내부의 특정 엔티티(B엔지니어)를 직접 객체 참조하여, 개발 애그리거트 루트의 통제를 벗어나 상태를 변경하거나 데이터를 가져오려는 시도와 같습니다.

 

ID 참조 (바람직한 방식 👍)

마케팅 부서의 A팀장은 신제품 홍보 자료에 필요한 기술 정보가 필요할 때, 다음과 같이 행동합니다. A팀장은 개발 부서에 공식적으로 "신규 프로모션을 위한 제품 X의 기술 사양 문의 (요청 ID: MKT-REQ-001)"라는 제목으로 요청서(명령 또는 조회 요청)를 보냅니다. 이 요청서에는 어떤 정보가 필요한지 명시되어 있고, 필요하다면 관련 개발 프로젝트의 식별자(ID)를 포함할 수 있습니다. 개발 부서장(애그리거트 루트)은 이 요청을 접수하고, 부서의 현재 상황과 업무 우선순위, 내부 규정(불변성)을 고려합니다.

 

이런 방식의 요청은 다음과 같은 장점을 가집니다.

  1. 명확한 경계 및 규칙 준수: 개발 부서장은 해당 요청을 처리할 적임자(예: B엔지니어 또는 C엔지니어)에게 업무를 할당하거나, 표준 기술 문서를 전달할 수 있습니다. 모든 과정은 개발 부서의 업무 절차와 규정 안에서 이루어집니다. (애그리거트 루트를 통한 통제 및 불변성 유지)

  2. 명확한 책임: 업무 지시는 개발 부서장을 통해 이루어지므로 책임 소재가 분명합니다.

  3. 효율적인 정보 교환: 마케팅 부서는 필요한 정보만 정확히 얻을 수 있고, 개발 부서는 자신들의 업무 흐름을 유지하면서 요청에 효율적으로 대응할 수 있습니다. (필요한 데이터만 ID를 통해 요청하고 응답받는 것과 유사)

이는 마케팅 애그리거트가 개발 애그리거트를 참조해야 할 때, 개발 애그리거트의 고유 ID나 개발 애그리거트 루트에게 전달할 명확한 요청 ID를 사용하여 소통하는 방식과 같습니다. 개발 애그리거트는 자신의 규칙과 경계를 지키면서 필요한 서비스나 정보를 제공합니다.

 

 

결론: 명확한 경계, 독립적 트랜잭션, 그리고 ID 참조


외부 애그리거트를 ID로 참조하는 것은 DDD의 핵심 원칙인 경계 설정, 불변성 유지, 트랜잭션 관리, 느슨한 결합을 강화하는 매우 중요한 실천 방법입니다. 이는 각 애그리거트가 자신의 책임을 명확히 하고, 독립적인 트랜잭션 단위를 유지하며, 시스템 전체의 복잡성을 낮추고 유연성과 확장성을 높이는 데 크게 기여합니다.

 

물론 저도 처음에는 이런 개념들을 모르고 애그리거트 내부에 다른 애그리거트를 그대로 참조해서 사용하도록 구성하고 비즈니스를 만들곤 했습니다. 그러다 보니 엄청 큰 뭉탱이가 되면서 Super 애그리거트가 된다는 이상한 느낌을 받았는데 왜 ID로 참조해야 하는지 그 개념을 익히고 나서부터는 장기적으로 유지보수 가능하고 확장성 있는 시스템을 구축하기 위해서는 이러한 원칙을 이해하고 적용하는 것이 필수적이라는 생각이 들었습니다.

 

지금 내 코드는 과연 잘 구성된 Aggregate일까요? 지금 다시 살펴봅시다!

반응형

'DDD' 카테고리의 다른 글

[DDD] 도메인 주도 설계: 전략적 설계 (Strategic Design)  (1) 2025.06.08
DDD와 헥사고날 아키텍처는 왜 완벽한 조합일까?  (0) 2025.06.01
[DDD] 값 객체(VO)를 활용한 유비쿼터스 언어 기반의 명확한 도메인 표현법  (0) 2025.05.30
[DDD] 단일 테이블 기반 다중 애그리거트(Aggregate) 모델링 전략  (1) 2025.05.06
코드에 비즈니스의 가치를 담자  (0) 2025.05.03
'DDD' 카테고리의 다른 글
  • [DDD] 도메인 주도 설계: 전략적 설계 (Strategic Design)
  • DDD와 헥사고날 아키텍처는 왜 완벽한 조합일까?
  • [DDD] 값 객체(VO)를 활용한 유비쿼터스 언어 기반의 명확한 도메인 표현법
  • [DDD] 단일 테이블 기반 다중 애그리거트(Aggregate) 모델링 전략
Stark97
Stark97
문의사항 또는 커피챗 요청은 링크드인 메신저를 보내주세요! : https://www.linkedin.com/in/writedev/
  • Stark97
    오늘도 개발중입니다
    Stark97
  • 전체
    오늘
    어제
    • 분류 전체보기 (249) N
      • 개발지식 (20)
        • 스레드(Thread) (8)
        • WEB, DB, GIT (3)
        • 디자인패턴 (8)
      • JAVA (21)
      • Spring (88)
        • Spring 기초 지식 (35)
        • Spring 설정 (6)
        • JPA (7)
        • Spring Security (17)
        • Spring에서 Java 활용하기 (8)
        • 테스트 코드 (15)
      • 아키텍처 (6)
      • MSA (15)
      • DDD (12)
      • gRPC (9)
      • Apache Kafka (18)
      • DevOps (23)
        • nGrinder (4)
        • Docker (1)
        • k8s (1)
        • 테라폼(Terraform) (12)
      • AWS (32)
        • ECS, ECR (14)
        • EC2 (2)
        • CodePipeline, CICD (8)
        • SNS, SQS (5)
        • RDS (2)
      • notion&obsidian (3)
      • 채팅 서비스 (1) N
      • AI 탐험대 (1)
      • 팀 Pulse (0)
  • 링크

    • notion기록
    • 깃허브
    • 링크드인
  • hELLO· Designed By정상우.v4.10.0
Stark97
[DDD] 왜 외부 애그리거트는 ID로 참조하는것이 좋을까?
상단으로

티스토리툴바