도메인을 모델에 담기 (도메인 모델링)

kimdohun0104
9 min readNov 13, 2022

--

  • title, description은 최대 글자 수 제한이 있나요?
  • 유료 이벤트(isPaidEvent)가 아니라면 가격(price)은 어떤 값을 가지나요?
  • price는 원(₩‎)인가요? 달러($)인가요?

5줄 남짓한 짧은 코드이지만, Event 모델을 이해하기 위해선 많은 질문이 필요합니다.

저는 이 글을 통해서 방대한 스펙 문서 없이도, 문제를 해결하는 방법을 제시하려고 합니다.

도메인

도메인이란 ‘특정 지식이나 경험의 영역`을 뜻합니다.

예를 들면 토스는 금융 도메인,
핀다도 동일하게 금융이지만, 대출 도메인에 더 집중하는 서비스입니다.
물론 하나의 서비스가 여러 도메인과 연관되어있을 수도 있습니다.

개발자는 개발을 통해 특정 영역의 문제를 해결하는 포지션인 것이죠.

도메인 모델링

이처럼 개발자가 작성하는 모든 코드는 도메인과 밀접하게 연관되어있는 경우가 많습니다. 그래서 보편적인 방법으로 위와 같이 domain 모듈/계층을 구현하는 경우가 많아졌습니다.

실질적인 비즈니스 로직을 수행하는 것은 Service이지만,
최대 글자 수, 통화 기준 등등 데이터가 가지는 논리적인 제약, 관계 등을 구현하는 것은 도메인 모델입니다.

도메인 계층에서 효과적으로 모델링하기 위한 몇 가지 개념을 소개하려고 합니다.

Primitive Obsession 피하기

Primitvie Obsession은 기본형을 과도하게 사용한 경우를 말합니다. 비유하자면 기본형은 너무 다양한 데이터를 담을 수 있는 그릇이라, 데이터의 의미를 표현하기 어려워집니다.

가장 중요한 것은 데이터의 제약과 관계를 나타내는 것이기 때문에, 광범위한 기본형은 어울리지 않습니다.

엄밀히 말하면 String은 기본형이 아니지만, 그릇이 큰 편이기에 해당 글에서는 기피 요소입니다.

typealias로 접근해보기

typealias는 기존 타입에 이름을 부여하는 kotlin의 문법입니다.
위 코드에서는 기존 그릇에 Title, Description이라는 이름을 부여하여 primitive obsession을 피하려고 시도했습니다.

하지만 Title이라는 이름이 있다고 해서, 다른 값이 못 들어가도록 제약이 있는 것은 아닙니다.
오히려 그냥 기본형을 쓰는 것 보다 많아진 타입 이름에 혼란이 생길 수도 있습니다.

논리적인 제약 구현하기

위 코드는 유효한 값이라면 객체를 생성해주고, 그렇지 않을 땐 null을 반환하는 코드입니다.
기본 생성자를 private 제한을 두고 invoke 연산자 오버로딩을 통해 생성자처럼 보이도록 일종의 트릭을 사용하여 구현되었습니다.

이 방법은 typealias와 다르게 그릇의 이름과 함께 데이터에 논리적인 제약을 줄 수 있습니다. 하지만 null을 반환한다는 점에서 약간 아쉬운 것 같습니다.
그리고 ‘eventTitle != null && eventDescription != null’ 같은 보기 안 좋은 보일러플레이트 코드도 함께 늘어납니다.

Null 대신 Optional

Null pointer는 10억 달러짜리 실수였다 — 토니 호어

Null pointer 개념을 처음 제시한 토니 호어는 끔찍한 실수라 회상합니다.
null이 무서운 이유는 프로그램 전체 로직에 영향을 줄 정도로 강력하지만, 에러의 전파를 막을 방법이 딱히 없기 때문입니다.

sealed class Optional<out T> {

data class Some<T>(val value: T) : Optional<T>()
object None : Optional<Nothing>()
}

Optional은 위처럼 간단한 타입이지만 개발자는 에러의 범위를 강력하게 제한하고 처리할 수 있습니다.

왼쪽은 일반적으로 작성하는 자바 코드입니다. 만약 childFunction 에서 null pointer exception이 발생한다면, parentFunction 을 호출하는 부분까지 쉽게 에러가 전파됩니다.
사소한 실수로 발생할 수 있는 에러이지만, 전파를 막아내기는 쉽지 않습니다.

반면 Optional을 사용하는 예제는 직관적입니다.
개발자는 Optional에 들어있는 값이 None일 수 있다는 점을 쉽게 인지할 수 있습니다.

개발자는 데이터를 사용하기 위해 결국 None을 무조건 처리해야 하고, 에러는 Optional 타입을 사용하는 곳까지만 제한됩니다.

Optional로 마이그레이션 하기

operator fun invoke(value: String): Optional<EventTitle> {
val isValid = value.isNotEmpty() && value.length < 24
return if (isValid) {
EventTitle(value).some()
} else {
none()
}
}

// 활용
val title = EventTitle("Hello")
val description = EventDescription("World")

val event = title.zip(description) { title, description ->
Event(title, description)
}

zip 메소드는 Optional 타입 데이터를 쉽게 가공하기 위해서 구현한 메소드입니다.
None이 포함된 경우는 None을, 모두 정상적인 값인 경우엔 다른 Optional 타입으로 반환할 수 있는 람다가 실행됩니다.

zip(Some, None) => None
zip(Some, Some) => (some1, some2) -> Optional<T>

Boolean 의심하기

data class Event(
val isPaidEvent: Boolean,
val price: Won
)

Primitive obsession을 해결했다면, 이번엔 boolean을 한번 의심해봐야 합니다.

위 모델만 봤을 때, paid event(유료 이벤트)가 아니면 price가 어떤 값으로 오는지 알 수 없습니다.
그리고 도메인 관점에서 유료 이벤트가 아니라면, 가격이 없는 것이 당연하다고 느껴집니다.

type 분리하기

// Kotlin
sealed class Event(
val id: EventId,
...
) {

data class Free(...) : Event(...)
data class Paid(
val price: Won, // Paid 타입에만 존재하는 값
...
) : Event(...)
}

이런 경우엔 간단하게 타입을 분리해주는 것만으로 쉽게 개선할 수 있습니다.

Kotlin sealed class를 활용한 상속을, F#은 choice type을, TS는 union type을 활용할 수 있습니다. 언어의 환경에 따라 알맞은 방법을 찾아 구현하면 됩니다.

이제 price는 Paid 타입의 이벤트에서만 접근할 수 있는 값이 되었고, isPaidEvent 변수도 필요하지 않습니다.

연산자 오버로딩 활용하기

data class Won private constructor(val price: Int) {
...
}

operator fun Optional<Won>.minus(won: Optional<Won>): Optional<Won> =
zip(won) { won1, won2 -> Won(won1.price - won2.price) }

// 사용 예시
val total = Won(30)
val cost = Won(20)
val balance: Optional<Won> = total - cost

각 모델은 어떤 행위를 가지고 있는 경우가 많습니다. 다른 모델과의 상호작용도 빈번합니다. 이런 경우엔 연산자 오버로딩을 활용하는 것을 고려해볼 수 있습니다.

연산자 오버로딩은 메소드의 이름에서 얻을 수 있는 의미와 기대 결과를 파악하기 힘들다는 단점이 존재합니다.
하지만 도메인 모델링을 하는 경우엔 오히려 더 효과적으로 의미를 전달할 수 있습니다.

좋은 예시는 위 minus 연산자 오버로딩입니다.
Won은 본질적인 의미로 봤을 땐 ‘계산’이라는 행위를 자연스럽게 할 수 있어야 하지만 개발 관점에서는 객체이기 때문에 ‘-’를 할 수 없습니다.
연산자 오버로딩은 이런 한계를 해결해줍니다.

현실 프로젝트 구조 생각해보기

현실 프로젝트에서 데이터는 어떻게 흐를까요?

네트워킹이나 데이터베이스를 통해 데이터를 가져올 때 도메인 모델을 사용하는 것은 무리가 있을 것입니다.
일반적인 방식대로 DTO객체를 통해 데이터를 가져온 후, 도메인 레이어로 값을 전달할 때 Mapper를 통해서 변환해줍니다.

위 그림에서는 나타나지 않지만, presentation layer로 데이터를 넘길 때도 mapping과정이 필요할 수 있습니다. 모델의 유효성에 따라 사용자에게 친숙한 메세지나 UX를 제공해야 하기 때문입니다.

마무리

이번 글에선 평범한 모델에 도메인을 어떻게 담을 수 있을지, 차례대로 개선하는 방식으로 풀어보았습니다.

위 내용들은 더 잘 표현하는 방법을 프로그래밍 관점으로 접근했지만, 사실 가장 중요한 것은 커뮤니케이션이 아닐까 생각합니다.
어떤 제약이나 규칙을 세우는 것은 개발자가 혼자 해결할 수 있는 영역이 아닌, 서비스를 만드는 모든 사람의 약속이기 때문입니다.

다음에 더 좋은 글로 돌아오겠습니다.

--

--