[DDD] 도메인 모델 (Domain Model) 이해하기

2025. 7. 7. 00:17·DDD
반응형

도메인 모델이란 무엇인가?


도메인 모델은 소프트웨어가 다뤄야 할 특정 도메인(업무 영역)을 코드로 표현한 객체 모델(클래스)을 의미합니다. 객체 모델은 도메인을 표현해야 하기 때문에 데이터(필드)와 행동(비즈니스 로직: 메서드)을 모두 포함한다는 것이 핵심입니다. 마틴 파울러는 자신의 블로그에서 “도메인 주도 설계(DDD)는 도메인 모델을 코드로 구현하면서 도메인의 프로세스와 규칙을 풍부하게 이해하는 것에 중심을 둔 소프트웨어 개발 접근법”이라고 설명합니다. 간단히 말해, 도메인 모델은 현실 세계 업무의 중요 개념과 규칙을 소프트웨어 객체에 녹여낸 것입니다.

 

도메인 모델은 해당 분야의 전문가(도메인 전문가)와 개발자 간의 공통 이해(Ubiquitous Language)를 형성하는 데에도 큰 역할을 합니다. 도메인 모델을 통해 애플리케이션(서비스)이 다루는 핵심 개념과 그 관계를 한눈에 보여줄 수 있으므로, 모두가 동일한 그림을 보며 의견을 나눌 수 있습니다. 그렇기에 도메인 모델을 구성하는 것은 비즈니스 문제를 소프트웨어로 풀어나가는 과정의 핵심 과정이라고 할 수 있습니다. 특히 기능이 복잡하고 규칙이 많은 복잡한 도메인일수록, 잘 만들어진 도메인 모델이 개발을 수월하게 해 줍니다.

 

정리하자면 도메인 모델은 현실 도메인의 개념, 상태, 행동을 객체 지향적으로 표현한 모델입니다. 비즈니스의 규칙과 로직을 객체 안에 포함하여, 코드 자체가 도메인의 모습을 있는 그대로 나타내도록 합니다. 이것은 단순한 데이터 구조 이상의 의미를 가지며, 도메인 전문가와 개발자가 소통하는 언어이자 복잡한 로직을 관리하는 소프트웨어 설계의 핵심이라고 할 수 있습니다.

 

 

도메인 모델의 구성 요소


도메인 모델은 여러 가지 전술적 설계의 구성요소들로 이루어집니다. 에릭 에반스는 『도메인 주도 설계』라는 책을 통해 도메인 모델을 구성하는 핵심 요소로 엔티티(Entity), 값 객체(Value Object), 도메인 서비스(Domain Service) 등의 개념을 소개했습니다. 또한 관련된 객체들을 하나의 단위로 묶는 애그리거트(Aggregate) 개념과, 시스템 내 사건을 전달하는 도메인 이벤트(Domain Event)도 중요한 구성요소입니다. 각 구성 요소를 간략히 살펴봅시다.

 

1. 엔티티(Entity)

고유한 식별자(ID)를 가지고 지속적인 생명주기를 갖는 객체입니다. 엔티티는 도메인 내에서 개별적인 신원(identity)을 가지며, 상태가 변해도 동일한 개체로 추적됩니다. 예를 들어 전자상거래 도메인에서 주문(Order), 회원(Customer) 등이 엔티티입니다. 엔티티에는 해당 비즈니스 규칙에 따른 행위(메서드)와 상태(필드)가 들어있습니다. (Spring + JPA로 생각해 보면 @Entity를 선언한 객체를 생각해 주시면 됩니다.)

 

2. 값 객체(Value Object)

고유 식별자가 없고 값으로만 비교되는 객체입니다. 값 객체는 주로 어떤 엔티티의 속성이나 특성을 표현하며, 불변(immutable)으로 다루는 것이 일반적입니다. 예를 들어 주소(Address), 금액(Money), 날짜 범위(DateRange) 등이 값 객체입니다. 두 값 객체를 비교할 때는 객체 자체가 아니라 내부 값이 같으면 동일한 것으로 간주합니다. 값 객체는 변경 불가능하게 설계해 사이드 이펙트를 줄이고, 필요할 때 새로 생성하여 교체하는 방식으로 사용합니다.

 

3. 애그리거트(Aggregate)

연관된 여러 객체(엔티티와 값 객체)를 하나의 논리적 단위로 묶은 것입니다. 애그리거트 루트(Root)라고 불리는 엔티티가 그 집합을 대표하며, 외부에서는 이 루트를 통해서만 애그리거트 내부에 접근합니다. 이렇게 함으로써 한 애그리거트 내의 일관성 불변조건(invariant)을 루트가 관리하고, 트랜잭션 범위가 애그리거트 경계를 넘지 않도록 제한합니다. 예를 들어 주문 애그리거트는 주문(Order) 엔티티를 루트로 하고, 그 내부에 다수의 주문 항목(LineItem) 엔티티와 기타 값 객체들을 포함할 수 있습니다. 한 번에 한 주문과 그 항목들을 통째로 불러오거나 저장하는 식으로 애그리거트를 다룹니다. (스프링에서 JPA를 사용하는 경우 프레임워크 종속성을 제거하기 위해 POJO객체(애그리거트)를 따로 선언한 뒤 그 내부를 엔티티와 동일하게 만든 후 mapper를 통해 매핑시켜 주곤 합니다.)

 

4. 도메인 서비스(Domain Service)

도메인 서비스는 특정 엔티티 내부에 선언하기 애매한 도메인 로직(비즈니스)을 선언하는 서비스입니다. 주로 엔티티나 값 객체로 표현하기 어려운 개념상의 행위를 다룹니다. 예를 들어, 여러 애그리거트가 상호작용해야 하는 복잡한 계산이 존재하는 경우나 외부 시스템 연동을 도메인 의미로 표현해야 할 때 도메인 서비스를 사용합니다. 단, 도메인 서비스 역시 비즈니스 로직을 나타내는 역할이므로, 애플리케이션 서비스(응용 서비스)와 명확하게 구분해야 합니다. (애플리케이션 서비스는 사용자 요청을 조율하고 트랜잭션 관리 등을 하지만, 핵심 비즈니스 로직은 도메인 모델에 위임합니다)

 

5. 도메인 이벤트(Domain Event)

도메인 내에서 발생한 사건을 나타내며, 시스템의 다양한 부분이 이 사건에 반응할 수 있도록 구성합니다. 도메인 이벤트는 특정한 일이 발생했음을 나타내고, 이 사건을 통해 서로 다른 애그리거트나 시스템이 느슨하게 결합될 수 있도록 합니다. 예를 들어 주문 완료(OrderCompleted) 이벤트는 주문 처리가 완료됐음을 나타내고, 재고 관리나 결제 처리 같은 다른 시스템 컴포넌트가 이를 감지하여 후속 처리를 수행할 수 있습니다.

 

위 개념들은 상호보완적으로 도메인 모델을 구성합니다. 엔티티를 중심으로 값 객체들이 속성을 이루고, 관련 객체들을 애그리거트로 묶어 일관성을 유지합니다. 엔티티에 넣기 어렵거나 여러 엔티티에 걸치는 로직은 도메인 서비스로 표현하고, 중요한 도메인 이벤트를 발생시켜 모델의 상태 변화를 다른 부분과 연결시킵니다. 이러한 구성요소를 적절히 활용하면, 도메인 모델이 풍부한 도메인 지식을 품은 살아있는 모델이 됩니다.

 

 

도메인 모델? 도메인? 애그리거트?


저는 DDD를 프로젝트에 적용하고 지속적으로 공부하는 과정에서 도메인 모델이라는 말을 굉장히 많이 듣고 사용했습니다. 처음 이 단어를 들었을 때 제가 느낀 점은 이것이 애그리거트를 의미하는 것인지 엔티티를 의미하는 것인지 아니면 도메인 모델 전체를 의미하는 것인지 매우 혼란스러웠습니다. 팀에서 대화할 때는 애그리거트를 도메인이라고 뭉뚱그려 불렀는데 그러다 보니 서로 간의 이해가 달라 개념의 위계가 무너지고 대화의 혼선이 생긴다는 것을 알게 되었습니다. 특히 회의할 때 비즈니스 설명을 해야 하는데 그때마다 애그리거트가 아닌 도메인이라고 얘기하다 보니 이것이 비즈니스 도메인이나 서브 도메인을 말하는 건지 개념이 머릿속에서 섞이면서 많이 혼란스러웠습니다.

 

이것이 어떤 상황인지 예시를 들어보면 이커머스라는 비즈니스 도메인이 있다고 생각해 봅시다. 그 안에는 '주문, 배송' 같은 서브 도메인이 있을 것이고 이 안에는 서브 도메인을 실제로 구현하기 위한 경계인 바운디드 컨텍스트가 존재합니다. 그리고 바운디드 컨텍스트 안에는 도메인 모델이라는 개념이 있고 그 안에 애그리거트가 존재합니다. 무슨 말을 하는지 모르겠죠..? DDD의 개념이 이렇게 복잡하다 보니 애그리거트를 도메인이라고 부른 순간부터 상대방은 이런 개념을 머릿속에서 다 떠올려보면서 맞는지를 확인하는 사고 과정이 추가됩니다.

 

읽어주실 분들의 이해를 돕기 위해 위에서 설명한 개념들을 표로 정리해 봤습니다.

비즈니스 도메인 (Business Domain) 해결하고자 하는 전체 비즈니스 영역을 의미합니다. 예를 들어 '온라인 커머스' 그 자체가 하나의 거대한 비즈니스 도메인입니다.
서브 도메인 (Subdomain) 비즈니스 도메인을 더 작은 단위로 나눈 각 영역입니다. 이는 문제 영역(Problem Space)에 해당하며, 비즈니스 관점에서 도메인을 분석하고 구분한 것입니다. (핵심, 일반, 지원 3가지 존재)

주문 (Core), 상품 (Core), 회원 (Generic), 배송 (Generic), 리뷰 (Supporting)
바운디드 컨텍스트 (Bounded Context) 서브 도메인을 실제로 구현하기 위한 모델의 경계입니다. 이는 해결책 영역(Solution Space)에 해당하며, 특정 도메인 모델이 일관성을 유지하는 범위를 정의합니다. 하나의 바운디드 컨텍스트는 하나의 팀이 맡아 개발하는 것이 이상적입니다.

- 참고로 대부분의 경우 서브 도메인과 바운디드 컨텍스트는 1:1로 대응되지만, 항상 그렇지는 않습니다.
- 팀 구조가 바운디드 컨텍스트와 일치하지 않는다면 이 모델은 무용지물일 수 있습니다. (콘웨이의 법칙: 소프트웨어의 구조는 그것을 개발하는 조직의 커뮤니케이션 구조를 닮는다)


주문 컨텍스트, 상품 컨텍스트, 회원 컨텍스트, 배송 컨텍스트
도메인 모델 (Domain Model) 특정 바운디드 컨텍스트 내에서 비즈니스 규칙과 프로세스를 표현하는 객체, 데이터, 로직의 집합입니다. 같은 '상품'이라도 어느 컨텍스트에 있느냐에 따라 모델의 형태와 역할이 달라집니다.
애그리거트(Aggregate) 도메인 모델 안에서 연관된 엔티티와 값 객체를 하나의 논리적 단위로 묶은 것으로, 상태 일관성과 트랜잭션 경계를 관리하는 최소 단위입니다.

상품 컨텍스트의 Product 애그리거트
- 상품ID, 상품명, 가격, 재고수량, 상세설명, 카테고리 등 판매를 위한 모든 정보가 포함됩니다.

주문 컨텍스트의 Product 애그리거트
- 상품ID, 상품명, 주문수량, 주문시점가격 등 주문을 처리하는 데 필요한 최소한의 정보만 포함됩니다. 재고수량이나 상세설명은 필요 없습니다.

배송 컨텍스트의 Product 애그리거트
- 상품ID, 무게, 부피, 취급주의사항 등 배송에 필요한 정보만 포함될 수 있습니다.

저는 매번 도메인 모델을 얘기할때마다 이것들을 다 사고하며 생각할 자신이 없었습니다. 그래서 조금이라도 더 쉽게 이해하고자 개념에 대한 고민을 해봤습니다. 그러다 낸 결론은 먼저 도메인이라는 단어를 비즈니스 도메인과 서브 도메인이라는 2가지 개념으로 묶는 것으로부터 시작하는 것이었습니다. 이후 이런 도메인을 코드로 구현하기 위한 영역을 바운디드 컨텍스트로 정했습니다. 이후 바운디드 컨텍스 내에서 비즈니스 규칙과 프로세스를 표현하는 상세 컴포넌트의 집합을 도메인 모델이라고 정의했습니다. 마지막으로 도메인 모델 안에는 Entity, VO, 애그리거트가 존재하는 것으로 정의했더니 조금은 나아졌습니다. (사실 큰 차이가 없기는 합니다..)

[도메인] 전자상거래 (e-commerce)
 └── [서브 도메인] 주문 (Order)
      └── [바운디드 컨텍스트] 주문 처리(Order Processing)
           └── [도메인 모델]
                ├── 애그리거트: 주문(Order)
                │      ├─ 엔티티: 주문, 주문항목
                │      └─ 값 객체: 배송주소, 금액
                ├── 도메인 서비스: 주문 검증 서비스
                └── 도메인 이벤트: 주문 완료됨(OrderCompleted)

참고사항: 대부분의 경우 서브 도메인과 바운디드 컨텍스트는 1:1로 대응되지만, 항상 그렇지는 않다고 적어뒀는데 그 이유는 하나의 거대한 핵심 도메인(e.g., 상품)이라도, 팀의 규모나 기술적 복잡도 때문에 '상품 전시 컨텍스트'와 '상품 재고관리 컨텍스트'로 나뉠 수 있습니다. 만약 스타트업 초기 단계라면 '리뷰'와 '상품' 서브 도메인을 각 바운디드 컨텍스트로 굳이 나누지 않고, 하나의 '상품 관리 컨텍스트'에서 함께 다루는 것이 훨씬 효율적일 수 있습니다.

 

 

리치 도메인 모델 vs. 빈약한 도메인 모델


도메인 모델을 설계할 때 유의해야 할 점은 “풍부한 도메인 모델(Rich Domain Model)”을 지향하는 것입니다. 이는 엔티티나 값 객체(VO) 안에 관련 비즈니스 로직을 최대한 담아내어, 객체가 자신과 관련된 행위를 스스로 수행하게 만드는 설계(객체 지향 설계)입니다. 그 반대는 “빈약한(빈혈적인) 도메인 모델(Anemic Domain Model)”이라 불리는데, 겉보기엔 도메인 모델처럼 보이지만 실제로는 객체들에 행동이 거의 없고 데이터 저장소처럼 쓰입니다. 예를 들어, Order 애그리거트 클래스가 있어도 주문을 검증하거나 추가하는 로직은 모두 별도의 OrderService 같은 곳에 있고, 정작 Order 객체 내부에는 getter/setter만 있다면 이는 빈약한 모델입니다.

 

먼저 빈약한 도메인 모델 (Anemic Domain Model)의 예시를 살펴봅시다.

  • AnemicOrder.kt (데이터만 가진 깡통 객체)
import jakarta.persistence.*

@Entity
@Table(name = "orders")
class AnemicOrder(
    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    val id: Long? = null,

    var orderStatus: OrderStatus, // 외부에서 직접 상태를 변경할 수 있음
    var shippingAddress: String,
)

enum class OrderStatus {
    PENDING, PLACED, SHIPPED, CANCELED
}

서비스: AnemicOrderService.kt (모든 비즈니스 로직이 서비스 계층에 위치)

import org.springframework.stereotype.Service
import org.springframework.transaction.annotation.Transactional

@Service
class AnemicOrderService(
    private val orderRepository: OrderRepository // Spring Data JPA Repository
) {
    @Transactional
    fun shipOrder(orderId: Long) {
        val order = orderRepository.findById(orderId).orElseThrow()

        // 비즈니스 규칙이 서비스 계층에 노출되어 있음
        if (order.orderStatus != OrderStatus.PLACED) {
            throw IllegalStateException("주문이 완료된 상태여야 배송을 시작할 수 있습니다.")
        }

        // 서비스가 직접 엔티티의 상태를 변경(set)함
        order.orderStatus = OrderStatus.SHIPPED
        
        // 배송 시작을 위한 외부 시스템 호출 등...
        println("배송 시스템에 배송 시작을 알립니다...")
    }

    @Transactional
    fun cancelOrder(orderId: Long) {
        val order = orderRepository.findById(orderId).orElseThrow()

        // 또 다른 비즈니스 규칙이 서비스 계층에 흩어져 있음
        if (order.orderStatus == OrderStatus.SHIPPED) {
            throw IllegalStateException("이미 배송이 시작된 주문은 취소할 수 없습니다.")
        }
        
        order.orderStatus = OrderStatus.CANCELED
        println("주문이 취소되었습니다.")
    }
}

마틴 파울러는 자신의 블로그에서 빈약한 도메인 모델을 “객체지향 디자인의 기본 아이디어(데이터와 프로세스의 결합)에 어긋나는 절차지향적 설계”라고 강하게 비판했습니다. 빈약한 모델은 객체를 제 역할에 활용하지 못해, 도메인 모델을 사용하는 복잡한 비용만 지고 이점은 얻지 못한다고도 언급합니다. 위 코드처럼 모든 로직이 서비스에 몰리면, 관련 로직을 찾기 위해 여러 서비스를 뒤져야 하고, Order의 상태 변화를 추적하기 어려워져 애플리케이션의 복잡성이 훨씬 커지고 객체지향의 이점을 살리지 못합니다.

 

따라서 엔티티 자체가 해당 비즈니스 규칙과 행위를 책임지도록 설계해야 합니다. 예를 들어 주문 객체 스스로 주문가능 여부를 판단하고 상태를 변경하도록 만드는 것입니다. 이렇게 하면 관련 로직이 한 곳에 모이고 응집도가 높아져 코드를 이해하기 쉬워지고 변경에 유연해집니다. 반면 중요한 로직을 도메인 객체 밖으로 빼기 시작하면 점차 빈약한 모델이 되기 쉽습니다.

 

이제 풍부한 도메인 모델 (Rich Domain Model)의 예시를 살펴봅시다.

  • RichOrder.kt (데이터와 행위를 함께 가지는 객체)
import jakarta.persistence.*

@Entity
@Table(name = "orders")
class RichOrder(
    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    val id: Long? = null,

    @Enumerated(EnumType.STRING)
    var orderStatus: OrderStatus,
        private set, // 외부에서 상태를 직접 변경하지 못하도록 private set으로 보호

    var shippingAddress: String,
        private set,
) {
    // '배송 시작'이라는 비즈니스 행위를 스스로 책임짐
    fun ship() {
        if (this.orderStatus != OrderStatus.PLACED) {
            throw IllegalStateException("주문이 완료된 상태여야 배송을 시작할 수 있습니다.")
        }
        this.orderStatus = OrderStatus.SHIPPED
    }

    // '주문 취소'라는 비즈니스 행위를 스스로 책임짐
    fun cancel() {
        if (this.orderStatus == OrderStatus.SHIPPED) {
            throw IllegalStateException("이미 배송이 시작된 주문은 취소할 수 없습니다.")
        }
        this.orderStatus = OrderStatus.CANCELED
    }
    
    // 주소 변경이라는 행위도 스스로 책임짐
    fun changeShippingAddress(newAddress: String) {
        if (this.orderStatus == OrderStatus.SHIPPED) {
            throw IllegalStateException("이미 배송이 시작되어 주소를 변경할 수 없습니다.")
        }
        this.shippingAddress = newAddress
    }
}

서비스: RichOrderService.kt (도메인 객체에게 메시지를 보내는 역할만 수행)

import org.springframework.stereotype.Service
import org.springframework.transaction.annotation.Transactional

@Service
class RichOrderService(
    private val orderRepository: OrderRepository
) {
    @Transactional
    fun shipOrder(orderId: Long) {
        // 서비스는 단지 엔티티를 찾아, 엔티티에게 메시지(ship())를 보낼 뿐
        val order = orderRepository.findById(orderId).orElseThrow()
        order.ship()
        
        // 배송 시작을 위한 외부 시스템 호출 등...
        println("배송 시스템에 배송 시작을 알립니다...")
    }

    @Transactional
    fun cancelOrder(orderId: Long) {
        val order = orderRepository.findById(orderId).orElseThrow()
        order.cancel()
        println("주문이 취소되었습니다.")
    }
}

이렇게 하면 Order와 관련된 모든 비즈니스 규칙과 상태 변경 로직이 RichOrder 클래스 한 곳에 모여 응집도가 높아집니다. 코드를 이해하기 쉬워지고, 주문 관련 정책이 변경될 때 RichOrder 클래스만 수정하면 되므로 변경에 유연해집니다.

 

Tip: 레이어드 아키텍처와의 진정한 조화

많은 개발자들이 '로직은 서비스 계층에, 엔티티는 데이터 전달용으로만'이라는 생각에 익숙하기 때문에, 풍부한 도메인 모델이 계층형 아키텍처의 원칙을 깨뜨리는 것이 아닌지 혼란스러워합니다. 결론부터 말하면, 풍부한 도메인 모델은 계층을 파괴하는 것이 아니라, 오히려 각 계층의 역할을 더욱 명확하게 만드는 접근법입니다. 핵심은 '어떤 로직이 어디에 있어야 하는가'를 올바르게 이해하는 것입니다.

구분 어플리케이션 서비스 (Application Service) 도메인 모델 (Domain Model)
주요 책임 조정 (Orchestration), 흐름 제어 비즈니스 규칙 실행, 상태 관리
관심사 트랜잭션, 영속성, 외부 연동, 보안 순수한 업무 로직, 데이터 일관성
비유 프로젝트 매니저, 오케스트라 지휘자 각 분야의 전문가, 전문 연주자
상태 관리 도메인 객체의 상태를 직접 변경하지 않음 스스로의 상태를 직접 변경하고 책임짐
주요 질문 "어떤 객체에게 어떤 작업을 시킬 것인가?" "이 작업을 수행하기 위한 규칙은 무엇인가?"

 

 

리치 도메인 모델의 혼동은 위험하다: '갓 객체(God Object)'의 함정


"엔티티 안에 관련 비즈니스 로직을 최대한 담아내라"는 '풍부한 도메인 모델'의 원칙은 강력하지만, 그 의미를 오해하면 모든 책임을 하나의 객체에 떠넘기는 '갓 객체(God Object)' 안티패턴에 빠지기 쉽습니다. 갓 객체는 너무 많은 역할을 떠안아 비대해지고, 결국 시스템 전체의 유연성과 유지보수성을 크게 해칩니다. 갓 객체가 탄생하는 대표적인 두 가지 잘못된 길과 그 해결책을 알아봅시다.

 

1. 다른 애그리거트를 직접 제어하는 객체

  • 가장 교묘한 실수는 한 애그리거트가 다른 애그리거트의 상태를 직접 제어하도록 만드는 것입니다. 이는 애그리거트의 가장 중요한 규칙, 즉 '자신의 경계 안에서 일관성을 스스로 지킨다'는 원칙을 위반합니다.
@Entity
class Order(/*...*/) {
    // '주문'이라는 하나의 행위에 다른 애그리거트의 상태 변경 책임까지 포함
    fun place(product: Product, coupon: Coupon) {
        // 1. 재고 차감 (Product 애그리거트의 상태를 Order가 직접 변경)
        // 'Product'의 전문가 행세를 하며 상태를 제어하고 있다.
        product.decreaseStock(1)

        // 2. 쿠폰 상태 변경 (Coupon 애그리거트의 상태를 Order가 직접 변경)
        // 'Order'가 'Coupon'의 상태까지 관리하려고 한다.
        coupon.use()

        // 3. 주문 상태 변경 (이것만이 Order의 순수한 책임)
        this.orderStatus = OrderStatus.PLACED
    }
}
  • 이 코드에서 Order는 자신의 상태뿐만 아니라 Product, Coupon의 상태까지 침범합니다. 이는 애그리거트 간의 경계를 허물고 강한 결합을 만들어, 향후 Product의 재고 정책이나 Coupon의 사용 정책 변경이 Order에까지 영향을 미치게 만듭니다.

 

해결책: 도메인 서비스로 여러 애그리거트 조정하기

  • 여러 애그리거트가 협력해야 하는 복잡한 비즈니스 로직은 도메인 서비스가 담당합니다. 도메인 서비스는 각 애그리거트에게 각자의 책임을 수행하도록 조정(Orchestrate) 하는 지휘자 역할을 합니다.
@Service
class OrderPlacementService(
    private val productRepository: ProductRepository,
    private val couponRepository: CouponRepository,
    private val orderRepository: OrderRepository
) {
    @Transactional
    fun placeOrder(request: OrderRequest) {
        // 1. 각 애그리거트를 조회한다.
        val product = productRepository.findById(request.productId).orElseThrow()
        val coupon = couponRepository.findById(request.couponId).orElseThrow()
        
        // 2. 각 애그리거트에게 자신의 책임을 수행하도록 '위임'한다.
        product.decreaseStock(request.quantity)
        coupon.use()

        // 3. 이 트랜잭션의 주 목적인 Order를 '생성'한다.
        val newOrder = Order.createFrom(request)
        orderRepository.save(newOrder)
    }
}
  • 이제 각 애그리거트(Order, Product, Coupon)는 자신의 상태와 규칙에만 집중하는 진정한 리치 모델이 됩니다. 복잡한 흐름 제어의 책임은 도메인 서비스로 명확하게 분리되었습니다.

2. 인프라스트럭처에 직접 의존하는 객체

  • 더 나쁜 경우는 도메인 모델이 '어떻게'에 해당하는 인프라스트럭처 계층에 직접 의존하는 것입니다. 예를 들어 '알림을 보낸다'는 요구사항을 도메인 객체가 직접 처리하는 것입니다.
@Entity
class Order(
    // ... 필드
    var status: OrderStatus
) {
    fun confirmPayment(notificationRepository: NotificationRepository) {
        // 1. 자신의 상태를 변경 (이것은 Order의 올바른 책임)
        this.status = OrderStatus.PLACED
        println("주문 상태를 PLACED로 변경합니다.")

        // 2. 인프라스트럭처 직접 호출 (잘못된 책임)
        // '어떻게' 알림을 보낼지(채널, 메시지 형식 등)를 도메인 객체가 알고 있다.
        notificationRepository.send("EMAIL", "고객님의 주문이 완료되었습니다.")
    }
}
  • 도메인 모델은 순수하게 '무엇을' 할 것인가(비즈니스 규칙) 에만 집중해야 합니다. '어떻게' 알림을 보낼지(이메일, SMS, 푸시 등)는 도메인의 관심사가 아닙니다. 위의 코드에서처럼 도메인이 인프라스트럭처에 의존하는 순간, 테스트는 어려워지고 기술이 바뀔 때마다 도메인 코드까지 변경해야 하는 재앙이 발생합니다.

해결책: 도메인 이벤트로 인프라스트럭처와 분리하기

  • 인프라스트럭처와의 통신은 도메인 이벤트를 통해 완전히 분리합니다. 애플리케이션 서비스는 단지 "주문이 완료되었다"는 사실만 발행(Publish) 하고, 실제 알림 발송은 이 이벤트를 구독(Subscribe) 하는 별도의 핸들러가 처리합니다.
@Service
class OrderAppService(
    private val orderPlacementService: OrderPlacementService, // 도메인 서비스 사용
    private val eventPublisher: ApplicationEventPublisher      // 이벤트 발행기
) {
    fun placeOrder(request: OrderRequest) {
        // 도메인 서비스를 통해 핵심 비즈니스 로직 처리
        val newOrder = orderPlacementService.placeOrder(request)

        // '알림을 보내라'가 아닌, '주문이 완료됐다'고 알린다.
        val event = OrderPlacedEvent(newOrder.id!!)
        eventPublisher.publishEvent(event)
    }
}

이벤트 핸들러: NotificationEventHandler.kt (알림 책임자)

@Component
class NotificationEventHandler(
    private val notificationRepository: NotificationRepository // 인프라 의존성은 여기에만!
) {
    @TransactionalEventListener
    fun onOrderPlaced(event: OrderPlacedEvent) {
        notificationRepository.send("주문 완료!", "주문 ID: ${event.orderId}")
    }
}
  • 이제 도메인과 애플리케이션 계층은 알림 발송 기술로부터 완벽하게 자유로워졌습니다. 유연성과 확장성이 극대화되고, 각 컴포넌트는 자신의 역할에만 충실하게 됩니다.

 

 

도메인 이벤트와 모델 간 통합


도메인 이벤트(Domain Event)는 도메인 모델 내에서 일어난 중요한 사건을 객체로 캡처한 것입니다. 도메인 이벤트를 발행하면 시스템의 다른 부분에 해당 사건을 알릴 수 있고, 이를 통해 여러 애그리거트나 하위 도메인이 느슨하게 연결되어 상호작용할 수 있습니다. 다시 말해, 이벤트를 매개로 도메인 모델 간 통합이나 외부 시스템과의 연계를 구현할 수 있습니다.

 

예를 들어 우리가 주문 애그리거트에서 OrderPlacedEvent를 발행했다면, 이를 수신하는 별도의 도메인 이벤트 핸들러나 리스너에서 재고 차감, 배송 처리, 사용자 알림 이메일 전송 등을 수행할 수 있습니다. “주문이 완료되었다”는 사건을 한 곳에서 객체로 만들어 퍼뜨리고, 관련된 여러 후속 처리는 각각의 컴포넌트가 맡아 수행하는 것입니다. 이러한 패턴 덕분에 주문 도메인 로직은 자신의 책임(주문 처리)에 집중하고, 부수 효과(재고 시스템 업데이트 등)는 이벤트 구독자가 처리하게 되어 결합도를 낮출 수 있습니다. (핵심!!)

 

도메인 이벤트를 어떻게 구현할지에 대해서는 몇 가지 모범 사례가 있습니다. 이벤트 객체는 불변(immutable)으로 만들어서 발생 시점 이후 내용이 변하지 않게 해야 하며, 이름은 과거 시제로 명확한 사건명을 짓는 것이 일반적입니다 (예: OrderPlaced, PaymentProcessed, ItemAddedToCart 등). 이벤트 클래스 내부에는 사건과 관련된 필수 정보(식별자, 타임스탬프 등)만 포함하고 너무 큰 데이터를 담지 않도록 주의해야 합니다. 왜냐하면 도메인 이벤트는 '무슨 일이 일어났는지'를 알리는 알림(Notification)이지, 데이터를 전달하는 운송 수단(Data Carrier) 이 아니기 때문입니다.

 

정리하자면, 도메인 이벤트는 도메인 모델의 맥락(Context) 내에서 일어난 의미 있는 변화를 나타내며, 이를 시스템 내 다른 부분과 연결해 주는 메시지입니다. DDD에서는 이러한 이벤트를 잘 활용하여 도메인 간 통합이나 사이드 이펙트 처리를 깔끔하게 분리합니다.

 

 

예시: 전자상거래 도메인 모델 설계


또다시 우리에게 익숙한 전자상거래(E-Commerce) 도메인을 예로 들어 도메인 모델을 한 번 그려보겠습니다. 온라인 쇼핑몰을 생각해 보면 주요한 도메인 개념으로 '고객, 상품, 주문, 주문 항목, 배송지 주소, 배송 방법' 등이 떠오릅니다. 이들을 객체로 모델링하면 대략 다음과 같은 관계도가 나옵니다.

전자상거래 UML
전자상거래 UML

위의 UML에서 볼 수 있듯이, Customer(고객) 엔티티는 여러 Order(주문) 엔티티와 연결될 수 있습니다 (1:N 관계). Order 엔티티는 LineItem(주문 항목)이라는 엔티티 목록을 포함하고 있으며, 이는 Order와 생명주기를 함께하는 합성 관계입니다(채워진 마름모 표시). Order와 LineItem은 함께 하나의 애그리거트를 이루며, Order가 애그리거트 루트로서 LineItem들의 일관성을 관리합니다.

 

예를 들어 Order를 통해서만 LineItem을 추가하거나 변경하게 모델링하고, Order를 삭제하면 속한 LineItem들도 함께 삭제되도록 한 것입니다. 또한 Order에는 ShippingAddress(배송지 주소)라는 값 객체(VO)가 포함되어 있고, 각 LineItem은 Product(상품)에 대한 참조를 가지고 있습니다. ShippingMode는 배송 방법을 나타내는 값 객체나 열거형으로 볼 수 있습니다.

 

이러한 도메인 모델을 통해 “한 고객이 여러 주문을 하고, 각 주문은 여러 상품 항목을 가진다”라는 비즈니스 상황을 자연스럽게 객체로 표현할 수 있습니다. 도메인 모델 다이어그램에는 비즈니스에 중요한 제약도 일부 드러나는데요. 예를 들어 “주문은 비어 있을 수 없다”, “주문 합계 금액은 각 주문 항목 금액의 합으로 계산된다” 등의 규칙은 Order 엔티티의 행동으로 구현되겠지만, 위 모델에서도 Order와 LineItem의 관계 및 속성으로 그 힌트를 볼 수 있습니다.

 

이렇듯 도메인 모델은 데이터베이스 ERD와 비슷해 보일 수 있지만, 핵심은 데이터 관계뿐만 아니라 행위와 제약(불변식)까지 함께 고려한다는 점입니다. 도메인 모델을 설계할 때부터 “어떤 메서드가 필요하고, 어떤 불변 조건이 지켜져야 하는가”를 생각해야 합니다.

 

 

도메인 모델 구현 예시 (Kotlin + Spring Boot)


앞서 설계한 전자상거래 도메인 모델 중 주문(Order) 애그리거트를 간략화하여 Kotlin 코드로 구현해 보겠습니다. 이 예시는 Spring Boot JPA와 헥사고날 아키텍처(ports & adapters) 패턴을 사용한 경우를 가정합니다. 도메인 계층의 코드는 최대한 순수하게 비즈니스 로직을 표현하고, 인프라세부 사항(JPA 등)은 어댑터 계층으로 위임합니다. 코드에서는 이해를 돕기 위해 핵심적인 필드와 메서드만 포함했습니다.

 

먼저 엔티티를 선언합니다. 저는 POJO형태의 도메인 객체를 따로 선언하지 않고 JPA 엔티티 내부에서 비즈니스를 가지는 방식으로 예시를 작성해 봤습니다. (이렇게 선언했을 때는 애그리거트가 Jpa 라이브러리에 대한 의존성을 가진다는 단점이 있으니 실무에서는 많은 고민을 해보시고 사용해 주세요.)

import org.springframework.data.jpa.repository.JpaRepository
import org.springframework.stereotype.Service
import org.springframework.context.ApplicationEventPublisher
import javax.persistence.*

@Entity
class Order(
    @Id @GeneratedValue(strategy = GenerationType.IDENTITY)
    val id: Long? = null,
    val customerId: Long,
    @Embedded // 값 객체 임베딩
    val shippingAddress: PostalAddress
) {
    @OneToMany(cascade = [CascadeType.ALL], orphanRemoval = true)
    @JoinColumn(name = "order_id")  // LineItem의 외래키
    private val lineItems: MutableList<LineItem> = mutableListOf()

    var status: OrderStatus = OrderStatus.PENDING
        private set

    fun addItem(productId: Long, quantity: Int) {
        lineItems.add(LineItem(productId = productId, quantity = quantity))
    }

    fun placeOrder(): OrderPlacedEvent {
        require(lineItems.isNotEmpty()) { "주문 항목이 비어있을 수 없습니다" }
        this.status = OrderStatus.PLACED
        // 결제 처리 등 비즈니스 로직 수행 (생략)
        return OrderPlacedEvent(orderId = this.id!!, itemsCount = lineItems.size)
    }
}

@Entity
class LineItem(
    @Id @GeneratedValue(strategy = GenerationType.IDENTITY)
    val id: Long? = null,
    val productId: Long,
    val quantity: Int
    /* 주문ID는 외래키로 order_id 컬럼에 매핑 (Order에서 JoinColumn으로 관리) */
)

@Embeddable
data class PostalAddress(
    val city: String,
    val street: String,
    val zip: String
    /* 값 객체: equals/hashCode로 동일성 비교, 불변 객체로 사용 */
)

enum class OrderStatus { PENDING, PLACED, SHIPPED, DELIVERED }

data class OrderPlacedEvent(val orderId: Long, val itemsCount: Int, val occurredAt: java.time.Instant = java.time.Instant.now())

위 코드에서 Order 엔티티는 주문의 핵심 데이터를 가지고 있으며 addItem(...) 메서드로 내부에 LineItem을 추가합니다. placeOrder() 메서드는 주문을 확정하면서 비즈니스 규칙을 적용합니다. 여기서는 간단히 “주문 항목이 하나 이상 있어야 주문할 수 있다”는 검증 (require)과 상태 변경을 구현했습니다. 그리고 중요한 도메인 이벤트로 OrderPlacedEvent를 생성하여 반환합니다. OrderPlacedEvent는 주문 번호와 상품 개수 등을 담고 있는데, 도메인에서 의미 있는 사건인 “주문이 확정됨”을 객체로 표현한 것입니다.

 

다음으로 응용 서비스를 살펴봅시다. (헥사고날)

// Application Service (응용 서비스)
@Transactional
@Service
class OrderService(
    private val orderPort: OrderPort,
    private val eventPublisher: ApplicationEventPublisher
): OrderUsecase {

    override fun placeOrder(orderId: Long) {
        val order = orderPort.findById(orderId) 
          ?: throw NoSuchElementException("Order not found")
        val event = order.placeOrder()      // 도메인 로직 실행 (주문 확정)
        orderPort.save(order)               // 애그리거트 상태 영속화
        eventPublisher.publishEvent(event)  // 도메인 이벤트 발행
    }
    
}

코드를 살펴보면 OrderService(애플리케이션 계층 서비스)는 도메인 모델을 통해 실제 비즈니스를 수행합니다. order.placeOrder()를 호출하면 도메인 모델 내부에서 비즈니스 로직이 수행되고 이벤트 객체가 반환됩니다. 그런 다음 orderPort.save(order)를 통해 변경된 주문을 저장하고, eventPublisher.publishEvent(event)를 통해 도메인 이벤트를 시스템에 발행합니다.

 

마지막으로 DB와의 통신을 위한 Adapter입니다.

// Out Port
interface OrderPort {
    fun findById(id: Long): Order?
    fun save(order: Order): Order
}

// Infrastructure Adapter
interface OrderPersistenceAdapter(
    private val orderJpaRepository: OrderJpaRepository,
    private val orderMapper: OrderMapper
): OrderPort {

    override fun findById(orderId: Long): Order {
        val orderEntity = orderJpaRepository.findById(orderId)
        return orderEntity
        // 만약 POJO 애그리거트 객체를 사용한다면 mapper를 활용
        // return orderMapper.toDomain(orderEntity)
    }
    
    override fun save(order: Order): Order {
        // 만약 POJO 애그리거트 객체를 사용한다면 mapper를 활용
        // val order = orderMapper.toEntity(order)
        return orderJpaRepository.save(order)
    }

}

추가 설명

위 예시에서 Order와 LineItem은 한 애그리거트로 묶여 함께 저장되고 삭제됩니다. @OneToMany와 cascade = ALL 설정으로 Order를 저장할 때 LineItem 목록도 같이 저장되도록 했습니다. Order가 애그리거트 루트이므로, Order 외부에서는 LineItem에 직접 접근하지 않고 Order.addItem(...) 등의 메서드를 통해서만 다루게 됩니다. 이런 구현을 통해 애그리거트의 일관성 규칙(예: 주문 확정 시 주문 항목은 비어있지 않아야 함 등)을 한 곳(Order 엔티티)에서 관리합니다.

 

 

결론


이번 글을 정리하고 돌아보니, 저 역시 처음에는 수많은 DDD 용어들 앞에서 혼란스러웠던 기억이 생생히 떠오릅니다. 바운디드 컨텍스트, 애그리거트, 도메인 이벤트… 이 모든 것들이 그저 따라야 할 규칙이나 기술 패턴처럼 느껴지기도 했습니다. 하지만 이 개념들을 하나씩 파고들고, 잘못된 설계를 바로잡는 과정을 수없이 거치면서 깨달은 점이 있습니다. 결국 DDD는 단순히 '이벤트'나 '서비스' 같은 기술을 적용하는 행위가 아니라는 것입니다. 그것은 우리가 만들려는 비즈니스 도메인의 복잡성을 정면으로 마주하고, 그 안에 명확한 질서와 경계를 부여하려는 끊임없는 노력에 더 가깝습니다.

 

DDD를 처음 접해본 사람의 입장에선 이 글에서 소개된 풍부한 도메인 모델, 헥사고날 아키텍처, 도메인 이벤트 등의 패턴들이 어쩌면 코드를 더 복잡하게 만드는 것처럼 보일 수도 있습니다. 하지만 이는 소프트웨어가 본질적으로 마주해야 할 '혼란스러운 복잡성'을 다루기 위한 '정제된 복잡성'입니다. 우리는 더 큰 혼돈을 피하기 위해, 이런 기준을 가진 명확한 설계를 받아들이는 것입니다.

 

처음부터 모든 것을 한 번에 완벽하게 적용하려 하기보다는, 먼저 내 앞에 있는 작은 도메인 객체 하나라도 "왜 이 비즈니스 행위(메서드)는 서비스에서 처리하는 것이 아닌 이 객체 스스로가 책임져야 할까?"라고 질문을 던져보는 것부터 시작하면 굉장히 좋을 것입니다. 이 질문에 대한 답을 찾아 코드를 옮겨보는 작은 시도 하나하나가, 결국 도메인의 본질을 더욱 명확하게 드러내고, 우리를 더 나은 설계로 이끌어 줄 것이라 믿습니다. (실제로 저는 그랬습니다!)

 

 

출처 모음


https://martinfowler.com/bliki/DomainDrivenDesign.html#:~:text=Domain,logic%20needs%20to%20be%20organized

https://martinfowler.com/bliki/AnemicDomainModel.html#:~:text=The%20key%20point%20here%20is,point%20in%20his%20service%20pattern

https://martinfowler.com/bliki/ValueObject.html#:~:text=As%20I%20do%20this%2C%20I,coordinates%2C%20are%20called%20value%20objects

https://martinfowler.com/bliki/DDD_Aggregate.html#:~:text=Aggregates%20are%20the%20basic%20element,should%20not%20cross%20aggregate%20boundaries

https://socadk.github.io/design-practice-repository/artifact-templates/DPR-DomainModel.html#:~:text=,all%20required%20relationships%20are%20present

https://martinfowler.com/bliki/PresentationDomainDataLayering.html#:~:text=The%20dependencies%20generally%20run%20from,to%20as%20a%20Hexagonal%20Architecture

https://ddd-practitioners.com/2023/03/09/how-to-succeed-with-domain-events/#:~:text=In%20DDD%2C%20domain%20events%20are,than%20directly%20through%20function%20calls

https://www.kamilgrzybek.com/blog/posts/how-to-publish-handle-domain-events#:~:text=Domain%20Event%20is%20one%20of,parts%20potentially%20can%20react%20to

https://martinfowler.com/eaaDev/DomainEvent.html#:~:text=Domain%20Event%20is%20particularly%20important,are%20made%20through%20Domain%20Event

반응형

'DDD' 카테고리의 다른 글

[DDD] 도메인 주도 설계: 전략적 설계 (Strategic Design)  (1) 2025.06.08
DDD와 헥사고날 아키텍처는 왜 완벽한 조합일까?  (0) 2025.06.01
[DDD] 왜 외부 애그리거트는 ID로 참조하는것이 좋을까?  (0) 2025.05.31
[DDD] 값 객체(VO)를 활용한 유비쿼터스 언어 기반의 명확한 도메인 표현법  (0) 2025.05.30
[DDD] 단일 테이블 기반 다중 애그리거트(Aggregate) 모델링 전략  (1) 2025.05.06
'DDD' 카테고리의 다른 글
  • [DDD] 도메인 주도 설계: 전략적 설계 (Strategic Design)
  • DDD와 헥사고날 아키텍처는 왜 완벽한 조합일까?
  • [DDD] 왜 외부 애그리거트는 ID로 참조하는것이 좋을까?
  • [DDD] 값 객체(VO)를 활용한 유비쿼터스 언어 기반의 명확한 도메인 표현법
Stark97
Stark97
소통 및 문의: dig04059@gmail.com (편하게 연락주세요!) 링크드인 소통이나 커피챗도 환영합니다!
  • Stark97
    오늘도 개발중입니다
    Stark97
  • 전체
    오늘
    어제
    • 분류 전체보기 (251) N
      • 개발지식 (20)
        • 스레드(Thread) (8)
        • WEB, DB, GIT (3)
        • 디자인패턴 (8)
      • AI (2) N
      • 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 (19)
      • 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)
      • 팀 Pulse (0)
  • 링크

    • notion기록
    • 깃허브
    • 링크드인
  • hELLO· Designed By정상우.v4.10.0
Stark97
[DDD] 도메인 모델 (Domain Model) 이해하기
상단으로

티스토리툴바