[DDD Start!] Domain (2) - 도메인 모델 도출, 엔티티와 밸류

16504 단어 DDDDDD

도메인 모델 도출

도메인을 모델링할 때 기본이 되는 작업은 모델을 구성하는 핵심 요소, 규칙, 기능을 파악하는 것이다. 이 과정은 요구사항에서 시작된다.

주문 도메인과 관련된 요구사항이다.

주문 항목을 표현하는 OrderLine은 적어도 주문할 상품, 상품의 가격, 구매 개수, 구매 항목의 구매 가격을 제공해야 한다.

data class OrderLine(
    val product: Product,
    val price: Int,
    val quantity: Int,
    val amounts: Int
)

이렇게 만든 모델은 요구사항 정련을 위해 다른 개발자들과 논의하는 과정에서 공유하기도 한다. 모델을 공유할 떄는 화이트보드나 위키와 같은 도구를 사용해서 누구나 쉽게 접근할 수 있도록 하면 좋다.

문서화

문서화를 하는 주된 이유는 지식을 공유하기 위함이다.
전반적인 기능 목록이나 모듈 구조, 빌드 과정은 코드를 보고 직접 이해하는 것보다 사우이 수준에서 정리한 문서를 참조하는 것이 소프트웨어 전반을 빠르게 이해하는 데 도움이 된다. 전체 구조를 이해하고 더 깊게 이해할 필요가 있는 부분을 코드로 분석해 나가면 된다.
코드를 보면서 도메인을 깊게 이해하게 되므로 코드 자체도 문서화의 대상이 된다. 도메인 지식이 잘 묻어나도록 코드를 작성하지 않으면 코드의 동작 과정은 해석할 수 있어도 도메인 관점에서 왜 코드를 그렇게 작성했는지 이해하는데는 도움이 되지 않는다. 단순히 코드를 가독성 좋게 작성하는 것 뿐만 아니라 도메인 관점에서 코드가 도메인을 잘 표현해야 비로소 코드의 가독성이 높아지며 문서로서 코드가 의미를 갖는다.

엔티티와 밸류

도출한 모델은 크게 엔티티(Entitiy)와 밸류(Value)로 구분할 수 있다.

엔티티와 밸류를 제대로 구분해야 도메인을 올바르게 설계하고 구현할 수 있다.

엔티티

엔티티의 가장 큰 특징은 식별자를 갖는다는 것이다.
엔티티의 식별자는 바뀌지 않고 고유하기 때문에 (ex. 주문 엔티티의 주문번호) 두 엔티티 객체의 식별자가 같으면 두 엔티티는 같다고 판단할 수 있다.

엔티티의 식별자 생성

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

  • 특정 규칙에 따라 생성
  • UUID 사용
    val uuid: UUID = UUID.randomUUID()
  • 값을 직접 입력
  • 일련번호 사용(시퀀스나 DB의 자동 증가 칼럼 사용)

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

밸류 타입

밸류 타입은 개념적으로 완전한 하나를 표현할 떄 사용한다.
의미를 명확하게 표현하기 위해 밸류 타입을 사용하는 경우도 있다.

data class Money(val value: Int)

data class OrderLine(
    val product: Product,
    val price: Money,
    val quantity: Int,
    val amounts: Money
)

'돈'을 의미하는 Money 타입을 만들어 사용하면 코드를 이해하는데 도움이 된다. Money 타입 덕에 price 나 amounts가 금액을 의미한다는 것을 쉽게 알 수 있다.

밸류 타입을 사용할 떄 또 다른 장점은 밸류 타입을 위한 기능을 추가할 수 있다는 것이다. Money 타입은 돈 계산을 위한 기능을 추가할 수 있다.

data class Money(val value: Int) {
    fun add(money: Money): Money {
        return Money(this.value + money.value)
    }
    fun multiply(multiplier: Int): Money {
        return Money(this.value + multiplier)
    }
}

밸류 객체의 데이터를 변경할 떄는 기존 데이터를 변경하기보다는 변경한 데이터를 갖는 새로운 밸류 객체를 생성하는 방식을 선호한다.

data class Money(val value: Int) {
    fun add(money: Money): Money {
        return Money(this.value + money.value)
    }
}

Money 클래스의 add() 메서드를 보면 Money 를 새로 생성하고 있다.
Money 처럼 데이터 변경 기능을 제공하지 않는 타입을 불변(immutable)이라고 표현한다. 밸류 타입을 불변으로 구현하는 가장 큰 이유는 불변 타입을 사용하면 보다 안전한 코드를 작성할 수 있다는 것이다.
만약 Money가 setValue()와 같은 메서드를 제공해서 값을 변경할 수 있다면 참조 투명성과 관련된 문제가 생길 수 있다.

따라서 이러한 경우 새로운 Money 객체를 생성하도록 코드를 작성해야 한다.


Money가 불변이면 이런 코드(데이터를 복사한 새로운 객체를 생성)를 작성할 필요가 없다. Money의 데이터를 바꿀 수 없기 때문에 파라미터로 전달받은 price를 안전하게 사용할 수 있다.

불변 객체는 참조 투명성과 스레드에 안전한 특징을 갖고 있다.

엔티티 타입의 두 객체가 같은지 비교할 떄 주로 식별자를 사용한다면,
두 밸류 객체가 같은지 비교할 떄는 모든 속성이 같은지 비교해야 한다.

엔티티 식별자와 밸류 타입

엔티티 식별자의 실제 데이터는 String과 같은 문자열로 구성된 경우가 많다.
Money가 단순 숫자가 아닌 도메인의 '돈'을 의미하는 것처럼 이런 식별자는 단순한 문자열이 아니라 도메인에서 특별한 의미를 지니는 경우가 많기 떄문에 식별자를 위한 밸류타입을 사용해서 의미가 잘 드러나도록 할 수 있다.
예를 들어, 주문번호를 표현하기 위해 Order 식별자 타입으로 String 대신 OrderNo 밸류 타입을 사용하면 타입을 통해 해당 필드가 주문번호라는 것을 알 수 있다.

data class Order(
    val id: OrderNo,
    ...
)

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

get/set 메서드는 습관적으로 추가하는데 이는 좋지 않다. 특히 set 메서드는 도메인의 핵심 개념이나 의도를 코드에서 사라지게 한다.

class Order {
    fun setShippingInfo(newShipping: ShippingInfo) {}
    fun setOrderState(state: OrderState) {}
}

setShippingInfo() 메서드는 단순히 배송지 값을 설정한다는 것을 뜻하는데 배송지 정보를 새로 변경한다는 것인지 의미가 모호하다 (changeShippingInfo() 는 배송지 정보를 새로 변경한다는 의미로 명확하다.)
setOrderState() 메서드도 단순히 상태 값만 변경할지 아니면 상태 값에 따라 다른 처리를 위한 코드를 함께 구현할지 애매하다.

set 메서드의 또 다른 문제는 도메인 객체를 생성할 때 완전한 상태가 아닐 수도 있다는 것이다.

위 코드는 주문자를 설정하는 것을 누락하고 있다. orderer가 null인 상황에서 order.setState() 메서드로 상품 준비 중 상태로 변경이 가능해진다.

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

val order = Order(orderer, lines, shippingInfo, OrderState.PREPARING)

생성자 생성 시점에 필요한 것을 모두 받으면, 다음처럼 호출하는 시점에 필요한 데이터가 올바른지 바로 검사가 가능하다.

class Order {
    fun Order(orderer: Orderer, orderLines: List<OrderLine>, shippingInfo: ShippingInfo, state: OrderState) {
        setOrderer(orderer)
        setOrderLines(orderLines)
            ...
    }
    private fun setOrderer(orderer: Orderer) {
        if (orderer == null) throw IllegalArgumentException("no orderer")
        this.orderer = orderer
        calculateTotalAmounts()
    }
    private fun setOrderLines(orderLines: List<OrderLine>) {
        verifyAtLeastOneOrMoreOrderLines(orderLines)
        this.orderLines = orderLines
    }
    private fun verifyAtLeastOneOrMoreOrderLines(orderLines: List<OrderLine>) {
        if (orderLines.isEmpty()) {
            throw IllegalArgumentException("no orderLines")
        }
    }
    private fun calculateTotalAmounts() {
        this.totalAmounts = orderLines.stream().mapToInt{(it.amounts)}.sum()
    }
}

여기서 set 메서드는 접근 범위가 private 이고, 클래스 내부에서 데이터를 변경할 목적으로 사용된다. private 이기 때문에 외부에서 데이터를 변경할 목적으로 set 메서드를 사용할 수 없다.

불변 밸류 타입을 사용하면 자연스럽게 밸류 타입에는 set메서드를 구현하지 않는다.
set 메서드를 구현해야할 특별한 이유가 없다면 불변 타입의 장점을 살리수 있도록 밸류 타입은 불변으로 구현한다.

DTO의 get/set 메서드

오래 전에 사용했던 프래임워크는 요청 파라미터나 DB 칼럼의 값을 설정할때 set 메서드를 필요로 했기 떄문에 어쩔 수 없이 DTO에 get/set 메서드를 구현해야 했다. 하지만 최근 프레임 워크에선 set 메서드가 아닌 private 필드에 직접 값을 할당할 수 있는 기능을 제공하고 있다. 따라서 set 메서드를 제공하지 않아도 된다면 최대한 구현하지 않는 것이 좋다. 이렇게 하면 DTO도 불변 객체가 되어 불변의 장점을 DTO까지 확장할 수 있게 된다.

좋은 웹페이지 즐겨찾기