Study/도메인 주도 개발 시작하기

chapter 01. 도메인 모델 패턴-2

hongeeii 2024. 2. 13. 22:47
728x90
반응형

도메인

도메인 모델 도출

도메인을 모델링할 때 기본이 되는 작업은 모델을 구성하는 핵심 구성요소, 규칙, 기능을 찾는 것.
주문 도메인과 관련된 몇 가지 요구사항을 보자.

  1. 최소 한 종류 이상의 상품을 주문해야 한다.
  2. 한 상품을 한 개 이상 주문할 수 있다.
  3. 총 주문 금액은 각 상품의 구매 가격 합을 모두 더한 금액이다.
  4. 각 상품의 구매 가격 합은 상품 가격에 구매 개수를 곱한 값이다.
  5. 주문할 때 배송지 정보를 반드시 지정해야 한다.
  6. 배송지 정보는 받는 사람 이름, 전화번호, 주소로 구성된다.
  7. 출고를 하면 배송지를 변경할 수 없다.
  8. 출고 전에 주문을 취소할 수 있다.
  9. 고객이 결제를 완료하기 전에는 상품을 준비하지 않는다.

아직 상세 구현까지 할 수 있는 수준은 아니지만 Order에 관련 메서드를 추가할 수 있다.

class Order {
    fun changeShipped() {}
    fun changeShippingInfo(newShippingInfo: ShippingInfo){}
    fun cancel(){}
    fun completePayment(){}
}

2, 4 번 요구사항에 따르면 주문항목을 표현하는 OrderLine은 적어도 주문할 상품, 상품의 가격, 구매 개수를 포함해야한다.
추가로 각 구매 항목의 구매 가격도 제공해야 한다.

class OrderLine(
    private val product: Product,
    private val price: Int,
    private val quantity: Int,
    private val amounts: Int
) {
    fun calculateAmounts() = price * quantity
    
    fun getAmounts(): Int {}
}

orderLine은 한 상품(product)를 얼마에 몇 개 살지를 담고 있고 구매 가격을 구하는 로직을 구현하고 있다.

1, 3번 요구사항은 Order와 OrderLine의 관계를 보여준다.
한 종류 이상의 상품을 주문할 수 있으므로 Order는 최소 한 개 이상의 OrderLine을 포함해야한다.
또한 총 주문 금액은 OrderLine에서 구할 수 있다.
두 요구사항은 Order에 다음과 같이 구현할 수 있다.

class Order(


) {
    private var orderLines: List<OrderLine> = listOf()
    private lateinit var totalAmounts: Money

    private fun setOrderLines(orderLines: List<OrderLine>) {
        verifyAtLeastOneOrMoreOrderLines(orderLines)
        this.orderLines = orderLines
    }

    private fun verifyAtLeastOneOrMoreOrderLines(orderLines: List<OrderLine>): Unit =
        orderLines.takeIf { it.isEmpty() }.run { throw IllegalArgumentException("no OrderLine") }


    private fun calculateTotalAmounts() {
        val sum = orderLines.map { it.getAmounts() }.sum()
        totalAmounts = Money(sum)
    }

주문과 관련된 요구사항에서 도메인 모델을 점진적으로 만들어 나갔다.
일부는 구현수준까지 만들었고, 일부는 이름 정도만 결정했다.

엔티티와 밸류

도출한 모델은 크게 엔티티와 밸류로 구분할 수 있다.
스크린샷 2024-02-09 오후 2 17 11

엔티티

엔티티의 가장 큰 특징은 식별자를 가진다.
주문 도메인 모델에서 주문에 해당하는 클래스가 Order이므로 Order가 엔티티가 되며 주문 번호를 식별자로 갖는다.
식벽자는 엔티티를 생성하고 속성을 바꾸고 삭제할 때까지 유지된다.

엔티티의 식별자는 바뀌지 않고 고유하기 때문에 두 엔티티 객체의 식별자가 같으면 두 엔티티는 같다고 판단한다.
따라서 equals와 hashcode를 구현할 때도 식별자만을 이용해서 구현한다.

엔티티의 식별자 생성

엔티티의 식별자를 생성하는 시점은 도메인의 특징과 사용하는 기술에 따라 달라진다.
흔히 다음 중 한 가지 방식으로 생성한다.

  • 특정 규칙에 따라 생성
  • UUID나 Nano ID와 같은 고유 식별자 생성기 사용
  • 값을 직접 입력
  • 인련번호 사용(시퀀스나 auto_increment)
  • 현재 시간과 다른 값 조합

주의할 점은 식별자는 유일해야된다는 것.

밸류 타입

data class ShippingInfo (
    // 받는 사람
    private val receiverName: String,
    private val receiverPhoneNumber: String,
    // 주소
    private val shuppingAddress1: String,
    private val shuppingAddress2: String,
    private val shuppingZipcode: String,
)

ShippingInfo 클래스는 받는 사람과 주소에 대한 데이터를 가지고 있다.
receiverName와 receiverPhoneNumber는 서로 다른 두 데이터를 담고 있지만, 두 필드는 개념적으로 받는 사람을 의미한다.
즉 두 필드는 실제로 하나의 개념을 표현한다.
밸류 타입은 개념적으로 완전한 하나를 표현할 때 사용한다.

따라서 위 클래스는 다음과 같이 만들 수 있다.

data class ShippingInfo (
    // 받는 사람
    private val receiver: Receiver,
    // 주소
    private val address: Address
){
    data class Receiver (
        private val name: String,
        private val phoneNumber: String
    )
    
    data class Address (
        private val adress1: String,
        private val adress2: String,
        private val zipcode: String,
    )
}

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

class OrderLine(
    private val product: Product,
    private val price: Money,
    private val quantity: Int,
    
    ) {
    private var amounts: Money? = null
        private set

    fun calculateAmounts() = price.multiple(quantity)

    fun getAmounts(): Money {
        return Money(2)
    }
}

class Money(
    private val value: Int
) {
    fun add(money: Money): Money = Money(value + money.value)

    fun multiple(multiplier: Int) = Money(value * multiplier)
}

price와 amounts를 int->Money으로 변경했다.
명확하게 Money를 사용하는 코드는 정수타입연산이 아니라 돈 계산이라는 의미로 코드를 작성할 수 있다.

밸류 타입을 불변으로 구현하는 여러 이유가 있는데, 가장 중요한 이유는 안전한 코드를 작성할 수 있는데에 있다.

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

도메인 모델에 get/set 메서드를 무조건 추가하는 것은 좋지 않은 버릇이다.
특히 set 메서드는 도메인의 핵심 개념이나 의도를 코드에서 사라지게 한다.

class Order {
  fun changeShippingInfo(newShippingInfo: ShippingInfo) {}
  fun setShippingInfo(newShipping: ShippingInfo){}
}

chageShippingInfo가 배송지 정보를 새로 변경한다는 의미를 가졌다면 setShippingInfo는 단순히 배송지 값을 설정한다는 것을 믜미한다.

set메서드의 또 다른 문제는 도메인 객체를 생성할 때 온전하지 않은 상태가 될 수 있다.

Order order = Order()
// set 메서드로 필요한 모든 값을 전달해야 함
order.orderLines(lines)
order.shippingInfo(shippingInfo)

// 주문자(Orderer)를 설정하지 않은 상태에서 주문 완료 처리
order.setState(OrderState.PREPARING)

위처럼 도메인 객체가 불완전한 상태로 사용되는 것을 막으려면 새엇ㅇ 시점에 필요한 것을 전달해 주어야 한다.
즉 생성자를 통해 필요한 데이터를 모두 받아야 한다.

class Order(
    private val orderer: Orderer,
    private var orderLines: List<OrderLine>,
    private val shippingInfo: ShippingInfo,
    private val orderState: OrderState

) {
    init {
        verifyAtLeastOneOrMoreOrderLines(orderLines)
    }
}

set 메서드가 아닌 직접 값을 할당 할 수 있는 메서드를 사용하자.

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

코드를 작성할 때 도메인에서 사용하는 용어는 매우 중요하다.
도메인에서 사용하는 용어를 코드에 반영하지 않으면 그 코드는 개발자에게 코드의 의미를 해석해야 하는 부담을 준다.

enum class OrderState {
  STEP1, STEP2, STEP3, STEP4, STEP5
}

이렇게 구현했다고 가정한다면 코드를 해석하기 매우 어려울 것.

도메인 주도 설게에서 언어의 중요함을 강조하기위해 유비쿼터스 언어라는 용어를 사용했다.
전문가, 관계자, 개발자가 도메인과 관련된 공통의 언어를 만들고 이를 대화, 문서, 도메인 모델, 코드, 테스트 등 모든 곳에서 같은 용어를 사용해야된다.

728x90
반응형