도메인 주도 개발 시작하기 (1) - 도메인 모델 시작하기

Jaewoo Ha·2022년 11월 28일
1

1.1 도메인이란?

책을 사기 위한 온라인 서점을 구현해야될 소프트웨어라고 생각을 해본다. 온라인 서점은 상품 조회, 구매, 결제, 배송 추적 등의 기능을 제공한다. 이때 온라인 서점은 소프트웨어로 해겨라고자 하는 문제 영역, 즉 도메인에 해당한다.

1.1.1 하위 도메인 구분

한 모데인은 다시 하위 도메인으로 나눌 수 있다. 예시로 든 온라인 서점 도메인을 하위 도메인으로 나눌 수 있다.

  1. 카탈로드
    • 구매 가능 상품 목록 제공
  2. 주문
    • 고객 주문 처리
  3. 혜택
    • 쿠폰 서비스 제공
    • 특별 할인 서비스 제공
  4. 배송
    • 구매한 상품 전달 과정 처리

한 하위 도메인은 다른 하위 도메인과 연동하여 완전한 기능 제공한다.

특정 도메인을 위한 소프트웨어라고 해서 도메인이 제공해야 할 모든 기능을 직접 구현하는 것은 아니다. 하기 그림처럼 외부 업체를 사용하여 구현을 할 수 있다.

도메인마다 고정된 하위 도메인이 존재하는 것은 아니다. 하위 도메인을 어떻게 구성할지 여부는 상황에 따라 달라진다. B2B와 같은 경우는 카탈로그를 제공하고 주문서를 받는 정도만 필요할 것이다. B2C와 같은 경우는 카탈로그, 주문, 리뷰, 결제, 배송, 회원 기능 등이 필요하다.

1.2 도메인 전문가와 개발자 간 지식 공유

전문가는 해당 도메인에 대한 지식과 경험을 바탕으로 원하는 기능 개발을 요구한다. 개발자는 요구사항 분석을 하고 설계하여 코드를 작성하며 테스트하고 배포한다. 요구사항은 첫 단추와 같다. 잘못 개발한 코드를 수정해서 올바르게 고치려면 많은 노력이 든다. 코딩에 앞서 요구사항을 올바르게 이해하는 것이 중요한 이유이다.

요구사항을 제대로 이해하지 않으면 유용함이 떨어지는 시스템을 만들기 때문이다. 제품을 만드는데 실패하거나 일정이 크게 밀리기도 한다. 아쉽게도 자주 발생하는 문제이다.

개발자오 전문가의 직접적인 대화로 요구사항을 올바르게 이해할 수 있다. 내용을 전파하는 전달자가 많으면 많을수록 정보가 왜곡되고 손실이 발생하게 되며, 개발자는 최초에 전문가가 요구한 것과는 다른 무언가를 만들게 된다.

도메인 전문가만큼은 아니지만 이해관계자와 개발자도 도메인 지식을 갖춰야된다. 도메인 전문가, 관계자, 개발자가 같은 지식을 공유할 수록 도메인 전문가가 원하는 제품을 만들 가능성이 높아진다.

1.3 도메인 모델

기본적인 도메인 모델의 정의는 특정 도메인을 개념적으로 표현한 것이다. 예시를 들었단 주문 도메인을 객체 모델로 구성하면 하기 그럼처럼 만들 수 있다.

도메인의 모든 내용을 담고 있지는 않지만 주문(Order)은 주문(orderNumber)와 지불 금액(totalAmounts)이 있고, 배송정보(ShoppingInfo)를 변경(changeShipping) 할 수 있다. 도메인 모델을 사용하면 여러 관계자들이 동일한 모습으로 도메인을 이해하고 도메인 지식을 공유하는데 도움이 된다.

객체 모델은 제공하는 기능과 주요 데이터 구성을 파악하는데 있어 모델링 하기에 적합하다. 하기처럼 다이어그램으로 주문의 상태 전이를 모델링할 수 있다.

도메인 모델을 표현할 떄 클래스 다이어그램이나 상태 다이어그램과 같은 UML 표기법만 사용해야 하는 것은 아니다. 관계가 중요한 도메인이라면 그래프를 이용해서 도메인을 모델링할 수 있다. 도메인을 이해하는 데 도움이 된다면 표현 방식은 중요하지 않다.

도메인 모델은 기본적으로 도메인을 이해하기 위한 개념 모델이다. 개념 모델을 이용해서 바로 코드를 작성할 수 있는 것은 아니기에 구현 기술에 맞는 구현 모델이 따로 필요하다. 구현 모델과개념 모델은 서로 다른 것이지만 구현 모델이 개념 모델을 최대한 따르도록 할 수 있다.

1.4 도메인 모델 패턴

일반적인 애플리케이션 아키텍처 영역을 표시했다. 이와 같은 표현은 도메인 모델 패턴을 의미한다. 도메인 모델은 아키텍처상의 도메인 계층을 객체 지향 기법으로 구현하는 패턴이다.

  • user interface 또한 presentation : 클라이언트의 요청을 처리하고 클라이언트에게 정보를 보여준다. 클라이언트는 user일 수도 외부 시스템일 수도 있다.
  • application : 클라이언트가 요청한 기능을 실행한다. 업무 로직을 직접 구현하지 않으며 도메인 계층을 조합해서 기능을 실행한다.
  • domain : 시스템이 제공할 도메인 규칙을 구현한다.
  • infrastructure : 데이터베이스나 메시징 시스템과 같은 외부 시스템과의 연동을 처리한다.

도메인 계층은 모데인의 핵심 규칙을 구현한다. 도메인 규칙을 객체 지향 기법으로 구현하는 패턴이 도메인 모델 패턴이다.

OrderState

enum class OrderState {
    PAYMENT_WAITING {
        override fun isShippingChangeable(): Boolean {
            return true;
        }
    },
    PREPARING {
        override fun isShippingChangeable(): Boolean {
            return true;
        }
    },
    SHIPPED,
    DELIVERING,
    DELIVERY_COMPLETED;

    open fun isShippingChangeable(): Boolean {
        return false;
    }
}

Order

class Order() {
    val state: OrderState = OrderState.SHIPPED;
    val shippingInfo: ShippingInfo;

    fun changeShippingInfo(newShippingIngo: ShippingInfo) {
        if (state.isShippingChangeable().not()) {
            throw IllegalStateException("can't change shipping in " + state)
        }
        this.shippingInfo = newShippingIngo
    }
}

OrderState는 배송지를 변경할 수 있는지를 검사할 수 있는 isShippingChangeable() 메서드를 제공하고 있다. PAYMENT_WATING 상태와 PREPARING 상태의 isShippingChangeable() 메서드는 true를 리턴한다. OrderState는 주문 대기 중이거나 상품 준비 중에는 배송지를 변경할 수 있다는 도메인 규칙을 구현하고 있다.

실제 배송지 정보를 변경하는 Order 클래스의 changeShippingInfo() 메서드는 OrderStateisShippingChangeable() 메서드를 이용해서 변경 가능한 경우에만 배송지를 변경한다.

OrderState

enum class OrderState {
    PAYMENT_WAITING,
    PREPARING,
    SHIPPED,
    DELIVERING,
    DELIVERY_COMPLETED;
}

Order

class Order() {
    val state: OrderState = OrderState.SHIPPED;
    val shippingInfo: ShippingInfo;

    fun changeShippingInfo(newShippingIngo: ShippingInfo) {
        if (isShippingChangeable().not()) {
            throw IllegalStateException("can't change shipping in " + state)
        }
        this.shippingInfo = newShippingIngo
    }

    private fun isShippingChangeable(): Boolean {
        return state == OrderState.PAYMENT_WAITING || state == OrderState.PREPARING
    }
}

OrderStateOrder에 속한 데이터이므로 배송지 정보 변경 가능 여부를 판단하는 코드를 Order로 이동할 수 있다. 배송지 변경이 가능한지를 판단할 규칙이 주문 상태와 다른 정보를 함께 사용한다면 OrderState만으로는 배송지 변경 가능 여부를 판단할 수 없으므로 Order에서 로직을 구현해야 한다.

하위 도메인 모델에서 도메인의 기능을 구현해야된다. 도메인 기능에 맞는 기능을 구현해야된다.
핵심 규칙을 구현한 코드는 도메인 모델에만 위치하기 때문에 규칙이 바뀌거나 규칙을 확장해야 할 때 다른 코드에 영향을 덜 주고 변경 내역을 모델에 반영할 수 있게 된다.

1.5 도메인 모델 도출

개발을 하기 위해선 도메인에 대한 이해가 있어야 코딩을 시작할 수 있다. 기획서, usecase, user story와 같은 요구 사항과, 관련자와의 대화를 통해 도메인을 이해해야된다. 또한, 도메인 모델 초안을 만들어야 코드를 작성할 수 있다.

class Order() {
    val state: OrderState = OrderState.SHIPPED;
    val shippingInfo: ShippingInfo;

    fun changeShipped() { ... }

    fun changeShippingInfo(newShippingIngo: ShippingInfo) { ... }

    fun cancel() { ... }

    fun completePayment() { ... }
}

구현해야될 요구 사항

  • 한 상품을 한 개 이상 주문할 수 있다.
  • 각 상품의 구매 가격합은 상품 가격에 구매 개수를 곱한 값이다.

요구사항에 따라 주문 항목을 표현하는 OrderLine은 적어도 주문할 상품, 상품의 가격, 구매 개수를 포함해야 된다.

class OrderLine(
    var product: Product,
    var quantity: Int,
    var price: Int,
    var amounts: Int?,
) {

    fun OrderLine(product: Product, price: Int, quantity: Int) {
        this.product = product
        this.price = price
        this.quantity = quantity
        this.amounts = calculateAmount()
    }

    private fun calculateAmount(): Int {
        return price * quantity
    }

    fun getAmounts(): Int { ... }
}

OrderOrderLine과의 관계를 알려준다.

  • 최소 한 종류 이상의 상품을 주문해야 한다.
  • 총 주문 금액은 각 상품의 구매 가격 합을 모두 더한 금액이다.
class Order(
    var state: OrderState,
    var orderLines: MutableList<OrderLine>,
    var totalAmounts: Money,
) {

    fun Order(orderLines: MutableList<OrderLine>) {
        setOrderLines(orderLines)
    }

    @JvmName("setOrderLines1")
    private fun setOrderLines(orderLines: MutableList<OrderLine>) {
        verifyAtLeastOneOrMoreOrderLines(orderLines)
        this.orderLines = orderLines
        calculateTotalAmounts()
    }

    private fun verifyAtLeastOneOrMoreOrderLines(orderLines: MutableList<OrderLine>) {
        if (orderLines.isNullOrEmpty()) {
            throw IllegalArgumentException("no OrderLine")
        }
    }
    
    private fun calculateTotalAmounts() {
        this.totalAmounts = new Money(orderLines.stream()
            .mapToInt(OrderLine::getAmounts)
            .sum())
    }
    
    // ... another methods
}

Order는 한 개 이상의 OrderLiine을 가질 수 있으므로 Order를 생성할 때 OrderLine 목록을 List로 전달한다. setOrderLines()는 생성자에서 호출을 하며 요구사항에 정의한 제약 조건을 검사한다. 최소 한 종류 이상의 상품을 주문해야 하므로 verifyAtLeastOneOrMoreOrderLines() 메서드를 이용해서 OrderLine이 한 개 이상 존재하는지 검사한다. calculateTotalAmounts() 메서드를 이용해서 총 주문 금액을 계산한다.

배송지 정보에 대한 구현은 ShippingInfo 클래스에서 정의한다.

class ShippingInfo(
    val receiverName: String,
    val receiverPhoneNumber: String,
    val shippingAddress1: String,
    val shippingAddress2: String,
    val shippingZipCode: String,
) {}

Order를 생성할 때 OrderLine의 목록뿐만 아니라 ShippingInfo도 함께 전달해야 함을 의미한다. 이를 생성자에 반영한다.

class Order(
	...
    var shippingInfo: ShippingInfo,
) {

	@JvmName("setShippingInfo1")
    private fun setShippingInfo(shippingInfo: ShippingInfo) {
        if (shippingInfo == null) {
            throw IllegalArgumentException("no ShippingInfo")
        }
        this.shippingInfo = shippingInfo
    }

}

setShippingInfo() 메서드는 ShippingInfonull이면 예외로 처리를 함으로써 도메인 규칙을 구현한다.

도메인 규칙에 따른 제약과 규칙은 하기와 같다.

  • 출고를 하면 배송지 정보를 변경할 수 없다.
  • 출고 전에 주문을 취소할 수 있다.

도메인 제약과 규칙은 제약사항을 기술하고 있다. 출고 상태에 따라 주무에 대한 제약을 갖는다. 이 요구사항을 충족하려면 주문은 최소한 출고 상태를 표현할 수 있어야 된다.

  • 고객이 결제를 완료하기 전에는 상품을 준비하지 않는다.
enum class OrderState {
	// ...another state enum value
    CANCELED,
}
class Order(
    private var state: OrderState,
    private var orderLines: MutableList<OrderLine>,
    private var totalAmounts: Money,
    private var shippingInfo: ShippingInfo,
) {

    fun Order(orderLines: MutableList<OrderLine>, shippingInfo: ShippingInfo) {
	    verifyNotYetShipped()
        setOrderLines(orderLines)
        setShippingInfo(shippingInfo)
    }
    
        fun cancel() {
        verifyNotYetShipped()
        this.state = OrderState.CANCELED
    }

    private fun verifyNotYetShipped() {
        if (state != OrderState.PAYMENT_WAITING && state != OrderState.PREPARING) {
            throw IllegalStateException("already shipped")
        }
    }
    
    // ...another methods
}

OrderState에서 취소 상태를 정의를 한 후, 배송지 변경이나 주문 취소 기능은 출고 전에만 가능하다는 제약 규칙을 적용을 한다.

1.6 Entity and Value

도출한 모델은 크게 entity와 value로 구분할 수 있다. 요구사항 분석 과정에서 만든 모델은 하기 그림과 같이 entity와 value가 존재한다.

entity와 value의 차이는 도메인을 올바르게 설계할 수 있도록 하기 때문에 중요하다.

1.6.1 Entity

Entity는 식별자를 가진다. 식별자는 entity 객체마다 고유해서 각 entity는 서로 다른 식별자를 갖는다. 예를 들어 주문번호는 주문마다 다르기 때문에 주문번호가 식별자가 된다. 주문 도메인 모델에서 주문에 해당하는 클래스가 Order이르모 Order가 entity가 되며 주문번호를 속성으로 갖게 된다.

배송지 주소나 상태가 바뀌더라도 주문번호가 변경이 되지 않는 것처럼 식별자는 변화하지 않는다. Entity 속성을 바꾸고 삭제할 때까지 식별자는 유지된다.

식별자는 바뀌지 않고 고유하기 떄문에 두 entity 객체의 식별자가 같으면 두 entity는 같다고 판단할 수 있다.

class Order(
    private val orderNumber: String,
) {

    override fun equals(other: Any?): Boolean {
        if (this === other) return true
        if (javaClass != other?.javaClass) return false

        other as Order

        if (orderNumber != other.orderNumber) return false

        return true
    }

    override fun hashCode(): Int {
        val prime = 31
        var result = 1
        result = prime * result + getOrderNumber()
        return result
    }

    private fun getOrderNumber(): Int {
        return when {
            orderNumber.isEmpty() -> 0
            else -> orderNumber.hashCode()
        }
    }
}

1.6.2 Entity의 식별자 생성

규칙 생성에 대한 방식

  • 특정 규칙에 따라 생성
  • UUID나 Nano ID와 같은 고유 식별자 생성기 사용
  • 값을 직접 입력
  • 일련번호 사용(시퀀스나 DB의 자동 증가 칼럼 사용)

주문번호, 운송장번호, 카드번호와 같은 식별자는 특정 규칙에 따라 생성한다. 이 규칙은 도메인에 따라 그리고 회사마다 다른다. 주의할 점은 같은 시간에 동시에 식별자를 생성해도 같은 식별자가 만들어지면 안 된다는 것이다.

UUID를 이용해서 식별자를 생성하는 것은 마땅한 규칙이 없다면 UUID를 식별자로 사용해도 된다.

회원 아이디나 이메일과 같은 식별자는 값을 직접 입력한다. 사용자가 직접 입력하는 값이기 때문에 식별자를 중복해서 입력하지 않도록 사전에 방지하는 것이 중요하다.

일련번호 방식은 주로 데이터베이스가 제공하는 자동 증가 기능을 사용한다.

자동 증가 칼럼을 제외한 다른 방식은 식별자를 먼저 만들고 entity 객체를 생성할 때 식별자를 전달한다. 자동 증가 칼럼은 DB 테이블에 데이터를 삽입해야 비로소 값을 알 수 있기 때문에 데이터를 추가하기 전에는 식별자를 알 수 없다. entity 객체를 생성할 때 식별자를 전달할 수 없음을 의미한다.

1.6.3 Value 타입

ShippingInfo 클래스의 receiverName 필드와 receiverPhoneNumber 필드는 서로 다른 두 데이터를 담고 있지만 두 필드는 받는 사람이라는 하나의 개념을 표현하고 있다. 비슷하게 shippingAddress1, shippingAddress2, shippingZipcode 필드는 주소라는 하나의 개념을 표현하고 있다.

Value 타입은 개념적으로 완전한 하나를 표현할 때 사용한다. 예를 들어 받는 사람을 위한 value 타입인 Receiver를 다음과 같이 작성할 수 있다.

class Receiver(
    private var name: String,
    private var phoneNumber: String,
) {

    fun Receiver(name: String, phoneNumber: String) {
        this.name = name
        this.phoneNumber = phoneNumber
    }

    fun getName(): String {
        return name
    }

    fun getPhoneNumber(): String {
        return phoneNumber
    }
}

ShippingInfo의 주소 관련 데이터도 Address value 타입으로 사용해서 보다 명확하게 표현할 수 있다.

class Address(
    private var address1: String,
    private var address2: String,
    private var zipCode: String,
) {
    
    fun Address(address1: String, address2: String, zipCode: String) {
        this.address1 = address1
        this.address2 = address2
        this.zipCode = zipCode
    }
    
    fun getAddress1(): String {
        return address1
    }
    
    fun getAddress2(): String {
        return address2
    }
    
    fun getZipCode(): String {
        return zipCode
    }
}

value 타입을 선언함으로써 ShippingInfo는 다음과 같이 변경이 될 수 있다.

class ShippingInfo(
    val receiver: Receiver,
    val address: Address
) {}

Value 타입이 꼭 두 개 이상의 데이터를 가져야 하는 것은 아니다. OrderLine 처럼 의미를 명확하게 표현하기 위해 value 타입을 사용하는 경우도 있다.

Money 라는 value 타입을 만들어 OrderLine을 다음과 같이 변경할 수 있다.

Money

class Money(
    private var value: Int,
) {

    fun Money(value: Int) {
        this.value = value
    }

    fun getValue(): Int {
        return value
    }
}

OrderLine

class OrderLine(
    var product: Product,
    var quantity: Money,
    var price: Int,
    var amounts: Money?,
) {

    fun OrderLine(product: Product, price: Int, quantity: Money) {
        this.product = product
        this.price = price
        this.quantity = quantity
        this.amounts = Money(calculateAmount())
    }

    private fun calculateAmount(): Int {
        return price * quantity.getValue()
    }

    fun getAmounts(): Int {
        return calculateAmount()
    }
}

value 타입의 장점은 기능을 추가할 수 있다는 것이다. Money 에 기능을 추가한 코드는 하기와 같다.

class Money(
    private var value: Int,
) {

    fun add(money: Money) {
        return Money(value + money.getValue())
    }

    fun multiply(multiplier: Int) {
        return Money(value * multiplier)
    }
}

value 타입은 코드의 의미를 더 잘 이해할 수 있도록 하는 도움을 준다. Money처럼 데이터 변경 기능을 제공하지 않는 타입을 불변이라고 표현한다. value 타입을 불변으로 구현하는 이유는 안전한 코드를 작성할 수 있다는 데 있다.

1.6.5 도메인 모델에 set 메서드 넣지 않기

set 메서드는 도메인의 핵심 개념이나 의도를 코드에서 사라지게 하는 경우가 있어 set 메서드를 추가하지 않는다. 습관적으로 작성한 set 메서드로 인해 상태 변경과 관련된 도메인 지식이 코드에서 사라지게 된다. set 메서드의 또 다른 문제는 도메인 객체를 생성할 때 온전하지 않은 상태가 될 수 있다.

set 메서드를 사용하면 요구사항 규칙을 누락할 수 있다. 도메인 객체가 불완전한 상태로 사용되는 것을 막으려면 생성 시점에서 필요한 것을 전달해 주어야 한다.

생성자로 필요한 것을 모두 받으므로 생성자를 호출하는 시점에 필요한 데이터가 올바른지 검사할 수 있다.

1.7 도메인 용어와 유비쿼터스 언어

코드를 작성할 때 도메인에서 사용하는 용어는 매우 중요하다. 도메인에서 사용하는 용어를 코드에 반영하지 않으면 그 코드는 개발자에게 코드의 의미를 해석해야 하는 부담을 준다. 도메인에서 사용하는 용어로 해석해야 되는 코드는 불필요한 변환 과정을 보여준다.

도메인 용어가 반영된 코드는 가독성을 높여 코드를 분석하고 이해하는 시간을 줄여준다. 도메인 용어를 사용해서 도메인 규칙을 코드로 작성하게 되므로 버그도 줄어든다.

에릭 에반스는 도메인 주도 설계에서 언어의 중요함을 강조하기 위해 유비쿼터스 언어라는 용어를 사용했다. 전문가, 관계자, 개발자가 도메인과 관련된 공통의 언어를 만들고 모든 곳에서 같은 용어를 사용한다면 용어의 모호함을 줄일 수 있고 개발자는 도메인과 코드 사이에서 불필요한 해석 과정을 줄일 수 있다.

시간이 지날수록 도메인에 대한 이해가 높아지는데 새롭게 이해한 내용을 잘 표현할 수 있는 용어를 찾아내고 이를 다시 공통의 언어로 만들어 다 같이 사용한다. 적당한 단어를 찾는 노력을 하지 않고 도메인에 어울리지 않은 단어를 사용하면 코드는 도메인과 점점 멀어지게 된다. 도메인 용어에 알맞은 단어를 찾는 시간을 아까워하지 말자.

profile
내일의 코드는 더 안전하고 깔끔하게

0개의 댓글