Search
📘

도메인 주도 설계 철저 입문 - 나루세 마사노부

1. 학습 성과

DDD 관점에서 기존 프로젝트를 재설계하여 구현했다. 본서 읽고 재설계한 코드: https://github.com/MJbae/order-system 본서 읽기 전 코드: https://github.com/MJbae/order-system/tree/v0.5

가. 요구사항에 따른 개발 기능 정리

1) 상품 주문 기능
재고량 미만으로 상품 주문 시 주문은 실패해야 한다.
상품 주문 시 재고량을 고려한 동시성 제어가 필요하다.
상품 구입액의 합이 5만원 미만이라면 배송비가 추가되어야 한다.

나. 도메인 객체 설계 및 구현

1) Entity and VO
Order: 주문에 대한 비지니스 규칙 및 생애주기를 관리한다. 주문에 대한 전반적인 비지니스 규칙을 관리한다.
@Entity @Table(name = "order_table") class Order( @Id @Column(name = "order_id") @GeneratedValue(strategy = GenerationType.IDENTITY) var id: Long?, @Embedded var price: OrderPrice ) { fun placeOrder(orderCommand: OrderCommand): OrderResult { this.price = price.calculatePriceTotal(orderCommand) val item = orderCommand.item ?: return OrderResult(false, price) item.decreaseStock(orderCommand.orderQuantity) this.price = price.applyDeliveryCharge() return OrderResult(true, price) } }
Kotlin
복사
세부 구현: https://github.com/MJbae/order-system/blob/main/src/main/kotlin/com/order/domain/Order.kt
OrderPrice: 주문 금액에 대한 비지니스 규칙을 담당한다. 배송비 추가에 대한 비지니스 규칙이 정의되어 있다.
@Embeddable @Access(AccessType.FIELD) data class OrderPrice( @Column(name = "order_price") val value: BigDecimal, @Transient private val freeDeliveryThreshold: BigDecimal, @Transient private val deliveryCharge: BigDecimal ) { fun applyDeliveryCharge(): OrderPrice { if (isDeliveryChargeRequired(this.value)) { return OrderPrice(this.value + deliveryCharge, this.freeDeliveryThreshold, this.deliveryCharge) } return OrderPrice(this.value, this.freeDeliveryThreshold, this.deliveryCharge) } }
Kotlin
복사
세부 구현: https://github.com/MJbae/order-system/blob/main/src/main/kotlin/com/order/domain/OrderPrice.kt
Item: 주문 대상인 상품에 대한 비지니스 규칙과 생애주기를 관리한다.
@Entity @Table(name = "item") class Item( @Id @Column(name = "item_id") @GeneratedValue(strategy = GenerationType.IDENTITY) private var id: Long?, val price: BigDecimal, @Column(name = "item_name") val name: String, @Embedded var stock: Stock, @Version private var version: Int? ) { fun decreaseStock(orderQuantity: Int) { this.stock = stock.decrease(orderQuantity) } }
Kotlin
복사
세부 구현: https://github.com/MJbae/order-system/blob/main/src/main/kotlin/com/order/domain/Item.kt
Stock: 주문 수량에 대한 비지니스 규칙을 담당한다. 재고 부족에 대한 비지니스 규칙이 정의되어 있다.
@Embeddable @Access(AccessType.FIELD) data class Stock( @Column(name = "stock_quantity") private val quantity: Int ) { fun decrease(toMinus: Int): Stock { if (toMinus > quantity) { throw SoldOutException("주문한 상품의 수가 재고량 보다 많습니다.") } return Stock(this.quantity - toMinus) } }
Kotlin
복사
세부 구현: https://github.com/MJbae/order-system/blob/main/src/main/kotlin/com/order/domain/Stock.kt
2) Aggregation 활용
Stock.quantity에 대한 수정은 Aggregation Root인 ItemdecreaseStock 을 통해서만 가능하다. Item, Stock은 하나의 Aggregation에 해당하고 Aggregation Root는 Item 객체이기 때문이다.
3) Domain Service
도메인 객체의 행동으로 표현하기 어려운 것이 없으므로 Domain Service는 별도로 정의하지 않는다.
4) Repository and Factory
OrderRepository: 세부 구현 참고
ItemRepository: 세부 구현 참고
OrderFactory: 세부 구현 참고

다. 유스케이스 설계 및 구현

1) Application Service
OrderService: 주문에 대한 UseCase에 따라 도메인 객체를 호출하고 이를 조합한다.
@Service @Transactional class OrderService( private val orderRepository: OrderRepository, private val itemRepository: ItemRepository, private val orderFactory: OrderFactory, ) { fun order(orderCommand: OrderCommand): OrderResult { val item: Item = itemRepository.findByIdInLock(orderCommand.itemId) val newCommand = OrderCommand(orderCommand.itemId, orderCommand.orderQuantity, item) val order = orderFactory.create( freeDeliveryThreshold = BigDecimal(50000), deliveryCharge = BigDecimal(2500) ) val result = order.placeOrder(newCommand) itemRepository.save(item) orderRepository.save(order) return result } }
Kotlin
복사
세부 구현: https://github.com/MJbae/order-system/blob/main/src/main/kotlin/com/order/application/OrderService.kt
2) Command Object
OrderCommand: OrderService::order의 매개변수로 전달된다. Command 객체를 사용함으로써 해당 메소드 매개변수 부분의 변경을 최소화할 수 있다. 더불어 Command 객체를 VO 형태로 정의함으로써 변경 가능성을 최소화했다.
data class OrderCommand( val itemId: Long, val orderQuantity: Int, val item: Item? ) { constructor(itemId: Long, orderQuantity: Int) : this( itemId = itemId, orderQuantity = orderQuantity, item = null ) }
Kotlin
복사
세부 구현: https://github.com/MJbae/order-system/blob/main/src/main/kotlin/com/order/application/OrderCommand.kt

2. 주요 개념 정리

가. 도메인 주도 설계

1) 도메인이란?
소프트웨어가 사용되는 분야 or 소프트웨어가 해결하려는 문제
2) 도메인 주도 설계란?
도메인 지식에 초점을 맞춘 소프트웨어 설계 기법이다. 다시 말해, 소프트웨어가 사용되는 분야에 대한 깊은 이해를 바탕으로, 유용한 지식을 뽑아내서 이를 바탕으로 소프트웨어를 설계하는 기법이다.
3) 도메인 주도 설계 목적은?
지속 가능한 소프트웨어 개발이다. 도메인 지식 중심으로 모델링 과정을 거친다면 훨씬 적은 비용으로 소프트웨어에 대한 유지보수가 가능하다.
도메인을 중심으로 설계된다면 코드의 서술성이 강화되어 코드 가독성의 향상으로 이어지고, 코드 변경 시 도메인 중심으로 연관 코드에 대한 작업이 가능하므로 개발 편의성이 향상된다. 구체적으로 말하자면 도메인의 유용한 지식과 주요 규칙이 도메인 객체에 집중적으로 반영되면 도메인 객체에 대한 이해만으로 소프트웨어 전체에 대해 쉽게 이해할 수 있다. 더불어 소프트웨어의 주요 상태나 행위가 도메인 객체에 집약되어 있다면 기능 변경의 대상을 쉽게 파악할 수 있고, 낮은 비용을 변경 작업을 할 수 있다.

나. 도메인 모델 vs 도메인 객체

1) 공통점
도메인 지식이 반영된 결과물이다.
2) 차이점
도메인 모델은 도메인 지식을 추상화한 형태의 개념이지만, 도메인 객체는 소프트웨어의 모듈로 구현된 결과이다.
3) 도메인 모델과 도메인 객체의 상호작용
도메인에 대한 이해를 바탕으로 필요한 지식을 뽑아내어 모델링한 결과가 도메인 모델이다. 도메인 객체는 이러한 도메인 모델을 바탕으로 프로그램이 이해할 수 있는 규칙에 따라 구조화하여 탄생한다. 반대로 도메인 객체를 생성하는 과정에서 유용한 개념을 도메인 모델에 반영할 수도 있다.

3. VO vs Entity

가. 정의 기준

1) VO
특정 타입이 도메인 모델로 간주된다면 우선적으로 VO로 정의한다. 도메인 모델이 아니여도 규칙이 존재하고 낱개 처리가 된다면 VO로 정의할 수 있다. 만약 도메인 모델이 아니지만 VO로 정의됐다면 정말 도메인 모델로 분류할 수 업는 지 한 번 더 확인할 필요가 있다.
참고로 VO에도 도메인 모델의 행위가 들어갈 수 있다. Entity와 VO의 차이점은 행위나 속성의 유무가 아니다.
2) Entity
도메일 모델 중 생애주기가 존재하고 일회성이 아니라 연속적으로 사용되는 개념이 있다면 엔티티로 정의할 수 있다. 특정 도메인 모델은 VO이면서 Entity로 정의될 수 있는 모호함이 있다. 이런 경우에는 소프트웨어의 관점에서 어떤 도메인 객체에 더 적합한지 다시 한 번 확인할 필요가 있다. 본서의 예제와 같이 타이어는 차 입장에서 VO이지만 타이어 공장에서는 Entity로 다뤄지는 것처럼 말이다.

나. 특징 차이

1) 비교
VO는 특정 도메인 상태의 동일함으로 객체의 동일함을 표현한다. 반면 Entity는 도메인 상태와 별개로 객체 자체 Identity로 객체의 동일함을 표현한다.
2) 가변성
VO는 가변셩을 허용하지 않는다. Entity는 가변성을 허용하지만 피치못할 상황에서만 가변성을 허용한다.
3) 객체 수정
VO는 새로운 VO 인스턴스를 생성하여 변수에 대입하여 객체를 수정한다. 반면 Entity는 객체의 행동으로 내부의 속성을 수정한다.

4. 도메인 서비스 vs 어플리케이션 서비스

가. 정의 기준

1) 도메인 서비스
도메인 서비스는 도메인 객체의 행동으로 정의하기 어색한 행동에 대해 별도의 객체로 분리하여 정의한 객체이다. 다시 말해, 도메인 서비스는 도메인 객체의 특정 행위가 전체적인 코드 흐름에서 이상하거나 다른 개발자에게 오해를 일으킬 만한 여지가 있는 경우에만 사용한다. 만약 도메인 서비스에 도메인 주요 규칙이 정의되기 시작하면 정작 도메인 객체는 자율성이 사라진 절차지향 프로그래밍의 자료구조로 전락할 것이다.
같은 맥락에서 도메인 서비스 객체에는 내부 로직에 영향을 줄 수 있는 상태를 정의하지 않는다. 왜냐하면 도메인 서비스만을 위한 행위가 정의될 가능성이 있기 때문이다. 도메인 서비스는 도메인 객체를 보조하는 역할로만 머물러야 하므로 위와 같은 제약을 두는 것이 적절한다.
2) 어플리케이션 서비스
어플리케이션 서비스는 도메인 객체의 행위를 묶어서 클라이언트의 필요를 만족시키기 위한 기능을 제공하는 것이다. 어플리케이션 서비스에서 모든 도메인 객체의 행위를 호출한다.

나. 서비스 로직 설계 노하우

1) 무상태 지향
도메인 서비스와 어플리케이션 서비스 모두 무상태를 지향해야 한다. 무상태가 되면 서비스 내부 로직의 변화에 신경쓰지 않아도 됨으로 유지보수면에서 편의성이 높아진다.
2) 코드의 응집도를 고려하여 설계
서비스 내 세 개의 외부 의존성이 있지만 두 개의 메소드 중 하나에서는 오직 두 개의 외부 의존성을 사용한다면, 해당 서비스는 응집도가 낮다고 판단할 수 있다. 이런 경우, 각각의 메소드에 따라 두 개의 서비스로 분리하는 것이 응집도면에서 바람직하다.
참고로 응집도가 높으면 코드의 재사용성, 해당 모듈의 견고성, 가독성 면에서 일반적으로 낫다.

다. 어플리케이션 서비스 활용법

1) 클라이언트  어플리케이션 서비스  
클라이언트, 어플리케이션 서비스 간 반환값과 매개변수로 도메인 객체 자체를 사용하지 말고 DTO를 활용할 것을 권장한다. 도메인 객체 변경 시 클라이언트  어플리케이션 서비스 상호작용에 부작용 발생 가능하다.
2) DTO  도메인 객체
DTO의 인자로 도메인 객체의 속성을 일일이 전달하는 것보다, 도메인 객체 전체를 넘겨줌으로써 캡슐화를 가능케하는 것이 낫다. 도메인 객체를 인자로 넘길 경우, DTO의 인자로 전달할 변수의 변경에 자유롭다.
3) 도메인 규칙 유출 주의
도메인 규칙이 어플리케이션 서비스에 유출되면 도메인 규칙이 흩어지는 결과로 이어질 가능성이 높다. 도메인 규칙은 도메인 모델에 모으고 어플리케이션 서비스에서는 도메인 의사결정 결과만 받아서 처리하도록 해야 한다.
4) Command 객체 활용
어플리케이션 서비스의 매개변수로 Command 객체를 전달하면 메소드 시그니처를 수정하지 않아도 된다.

5. 애그리게이트 활용

가. 에그리게이트란

1) 정의
에그리게이트란 상태의 변경에 대해 하나의 단위로 묶인 객체의 집합이다. 에그리게이트로 묶여 있다면 객체의 상태 변경은 오직 에그리게이트 루트 객체를 통해서만 가능하다.
User를 필두로한 Aggregate 경계와 Circle을 필두로한 Aggregate 경계에서 각기 다른 Aggregate 내 도메인 객체에 대한 상태 변경은 각 Aggregate Root 객체를 통해서만 발생해야 한다.
2) 목적
복잡도 높은 시스템에서 변경 포인트를 최소화함으로써 데이터 무결성을 비교적 쉽게 유지할 수 있다. 더불어 비지니스 규칙에 따른 상태 변경 제약을 에그리게이트 루트에 집약적으로 적용할 수 있다.

나. 에그리게이트 적용

1) 데메테르의 법칙 준수
데메테르 법칙에 따라 객체 내부의 속성에 대해 직접 접근하기 보다 공개된 메소드를 통해 간접 조작하는 방향으로 객체의 행동을 정의해야 한다. 같은 맥락에서 getter는 가급적 사용하면 안된다. 객체 내부의 속성을 외부에 공개함으로써 해당 속성과 관련된 비지니스 규칙이 도메인이 아닌 외부에 정의될 가능성을 열어주기 때문이다. 단, 식별자는 예외이다.
참고) 테메트르는 그리스 신화의 농업의 신으로, 땅과 물과 햇빛과 같이 풍작의 내부 요소에 직접 관여하지 않고도 풍작을 가져온다. 이와 같이 객체의 내부 요소에 관여하지 않고 오직 객체의 식별 가능한 동작 또는 내부 요소를 조작하는 공개 메소드를 통해 객체의 내부 요소에 대해 조작하는 것을 데메테르 법칙이라고 한다.
2) 에그리게이트 경계 지정
에그리게이트는 상태 변경에 대해 하나의 단위로 묶였기 때문에 ‘변경의 단위’로써 경계를 지정할 수 있다. 변경의 관점에서 레파지토리는 하나의 에그리게이트에 하나만 정의하는 것이 적절하다.
3) 에그리게이트의 크기
변경의 단위이기 때문에 트랜잭션과 잠금 관점에서 너무 큰 에그리게이트는 성능 상 문제가 된다. 가능하면 에그리게이트의 단위를 작게 유지하는 것이 바람직하다.

6. 명세 활용

가. 명세란

명세란 도메인 객체에 대해 특정 조건 만족여부를 확인(이하 평가)하기 위해 별도로 정의한 객체이다.
도메인 객체에 대한 평가는 도메인 규칙이므로 도메인 객체 내 정의되어야 하지만 외부 의존성과의 피치못할 결합 등으로 인해 어플리케이션 서비스 내 정의되는 경우가 있다. 해당 평가를 별도의 객체로 분리하여 도메인 규칙을 어플리케이션 서비스와 분리하는 것이 명세 객체의 목적이다.

나. 명세 적용

1) 레파지토리와의 결합이 필요한 평가
레파지토리와의 결합이 요구되는 평가에 대해 별도의 명세 객체를 정의하고, 이를 어플리케이션 서비스에서 의존하는 방향으로 적용할 수 있다.
2) 메소드명만으로 의도가 들어나지 않는 평가
하나의 도메인 객체에 무수히 많은 평가가 정의된다면 평가를 정의한 메소드명만으로 평가에 대한 의도가 잘 드러나지 않을 수 있다. 이러한 경우, 도메인 객체에 대한 가독성을 높이기 위해 해당 평가 메소드를 평가에 대한 행위만을 모아둔 명세 객체로 이전하는 것이 코드 가독성면에서 바람직하다.
3) 성능 이슈
레파지토리에 명세 객체를 결합할 경우, 구체적인 쿼리 조건을 명세로 대체할 수 있다. 단, 어플리케이션 단계에서 명세 객체의 조건에 따른 데이터 필터가 들어갈 경우, 지나친 메모리 자원 소모에 따라 성능 이슈가 발생할 수 있다.
성능 상 이슈가 있다면 명세 객체를 사용하면 안된다.

7. 기타

가. 레파지토리의 책임

레파지토리 객체의 책임은 오직 영속성 객체 관리로 제한된다. 만약 레파지토리 객체에서 비지니스 규칙을 다루게 된다면, 레파지토리 내부 구현이 변경될 때마다 비지니스 규칙도 함께 변경해야 한다. 적절한 책임 분리에 따라 코드 유지보수성을 높이기 위해 레파지토리 내 비지니스 규칙을 제거해야 한다.

나. Return Error or Exception

에러를 반환한다면 결과 객체를 전달한다고? 결과 객체를 받고 그 다음의 행동은 클라이언트의 판단에 맡기는 것?
예외를 발생시키면 예외를 받는 입장에서 반드시 처리하도록 강제함

다. 트랜잭션 무결성 vs 결과 무결성

트랜잭션 무결성이란 트랜잭션 기능에 따라 항상 데이터의 무결성이 보장되는 것이다. 반면 결과 무결성이란 특정 시점에는 데이터 모순이 허용되지만 무결성을 지켜주는 특정 작업에 따라 해당 모순이 곧 제거되어 지켜지는 무결성이다. 예를 들어, 특정 시간에 맞춰 실행되는 크론잡에 의해 데이터의 무결성이 결과적으로 지켜질 수 있다.
항상 데이터의 무결성이 보장되어야 하는지, 특정 시점에는 모순을 허용해도 되는 지를 고려하여 무결성을 지킨다면 성능을 가져갈 수 있다.

라. Software Design vs Software Architecture

Software Architecture는 Software의 전체적인 구성에 대한 설계이다. 주로 Software의 주요 구성요소 간 관계를 다룬다.
참고) Software Architecture in UML
UML Component Diagram: +----------+ +-------------+ +----------+ | Client | <--> | Gateway | <--> | Server | +----------+ +-------------+ +----------+
Plain Text
복사
Software Design은 Software의 주요 구성요소의 세부사항에 대한 설계이다. 주로 주요 구성요소의 속성과 행위의 구현에 대한 지침을 다룬다.
참고) Software Design in UML
UML Class Diagram: +----------+---- | Client | +----------+---- | - request() | | - response() | +----------+---- +-------------+ | Gateway | +-------------+ | - request() | | - response()| +-------------+ +----------+-- | Server | +----------+-- | - request() | | - response()| +----------+---
Plain Text
복사

참고 자료

나루세 마사노부, 도메인 주도 설계 철저 입문