본문으로 건너뛰기

Kotlin으로 도메인 모델링하기

· 약 14분
Jake Son

Kotlin용 함수형 라이브러리 Arrow의 공식문서에 있는 Domain modeling 블로그 글을 번역한 내용입니다.

함수형 도메인 모델링의 목적은 비즈니스 도메인을 최대한 정확하기 기술하여 타입 안정성을 높이고, 도메인에서 컴파일러 사용을 극대화하여 버그를 방지하고 단위 테스트를 줄이는 것입니다. 추가로, 도메인은 실제 세계와의 접점이므로 도메인에 대한 커뮤니케이션이 더 쉬워집니다.

Kotlin은 함수형 도메인 모델링에 적합합니다. data class, sealed class, enum class, value class 등을 제공하기 때문입니다. 추가로 EitherIor와 같은 몇 가지 흥미로운 제네릭 타입을 제공하는 Arrow 라이브러리도 사용할 수 있습니다.

행사(Event)를 다루는 도메인 모델을 예로 살펴보겠습니다. 다음과 같은 원시 타입 기반 구현을 생각해 볼 수 있습니다.

data class Event(
val id: Long
val title: String,
val organizer: String,
val description: String,
val date: LocalDate
)

여기서 사용한 타입들은 의미가 거의 없는데 이는 title, organizer, description이 전부 같은 타입이기 때문입니다. 이는 title을 요구하는 곳에서 description을 사용하는 미묘한 버그가 발생하기 쉽고, 컴파일러가 도움을 줄 수 없습니다. 컴파일러가 도움이 되지 않아 잘못 사용하는 예제를 살펴봅시다.

Event(
0L,
"Simon Vergauwen",
"In this blogpost we dive into functional DDD...",
"Functional Domain Modeling",
LocalDate.now()
)

여기서는 organizerdescription를 잘못 사용했지만, 컴파일은 성공하며 Event 객체가 생성됩니다. 이 함정에 빠질 수 있는 경우는 더 많은데, 예를 들어 구조분해(destructuring)할 때입니다.

그렇다면 이를 어떻게 방지할 수 있을까요? 또는 어떻게 도메인 모델을 더 잘 타입화할 수 있을까요? Kotiln의 기능인 이미 존재하는 타입을 새 이름으로 위장하는 value class를 사용할 수 있습니다. 이렇게 하면 런타임에 value class가 제거되므로 추가 오버헤드가 발생하지 않습니다. 글 작성 시점에는, value class를 사용하려면 @JvmInline 어노테이션을 추가해야 합니다.

@JvmInline value class EventId(val value: Long)
@JvmInline value class Organizer(val value: String)
@JvmInline value class Title(val value: String)
@JvmInline value class Description(val value: String)

data class Event(
val id: EventId
val title: Title,
val organizer: Organizer,
val description: Description,
val date: LocalDate
)

이전 예제로 돌아가보면, 이제 Title을 요구하는 곳에서 Organizer를, Organizer를 요구하는 곳에서 Description을 제공했기에 컴파일 에러가 발생합니다.

Event(
EventId(0L),
Title("Simon Vergauwen"),
Organizer("In this blogpost we dive into functional DDD..."),
Description("Functional Domain Modeling"),
LocalDate.now()
)

함수형 프로그래밍에서는 이러한 데이터 구성을 product type 또는 record라고 하며, 이는 and 관계를 모델링하는 데 사용합니다. 따라서 EventEventId and Title and Organizer and Description and LocalDate로 구성된다고 말할 수 있으며, 이는 Long and String and String and String and LocalDate로 구성된다고 말하는 것보다 훨씬 더 많은 것을 알려줍니다.

연령 제한을 추적하기 위해 Event 모델을 개선해야 한다고 가정해 봅시다. 이를 다시 String으로 모델링할 수도 있지만, 그렇게 하면 기존 문제가 더 악화될 뿐입니다. 따라서 다섯 가지 값을 가진 MPAA 영화 등급을 따른다고 가정해 보겠습니다. 이 유형은 확실하게 고정된 집합 또는 열거형이므로, enum class를 사용합니다.

enum class AgeRestriction(val description: String) {
General("All ages admitted. Nothing that would offend parents for viewing by children."),
PG("Some material may not be suitable for children. Parents urged to give \"parental guidance\""),
PG13("Some material may be inappropriate for children under 13. Parents are urged to be cautious."),
Restricted("Under 17 requires accompanying parent or adult guardian. Contains some adult material."),
NC17("No One 17 and Under Admitted. Clearly adult.")
}

enum class를 사용하는 것은 위에서 설명한 문제 외에도 여러 가지 이유로 String보다 훨씬 강력합니다. String은 가능한 값이 무한대이지만, 이제 우리는 다섯 개의 값만 사용할 수 있습니다. 따라서 String로 추론하고 작업하는 것보다 AgeRestriction에 대해 추론하는 것이 훨씬 쉽습니다.

함수형 프로그래밍에서는 이러한 데이터 구성을 sum type이라고 하며, 이는 or 관계를 모델링하는 데 사용합니다. 따라서 AgeRestrictionGeneral or PG or PG13 or Restricted or NC17로 구성된다고 말할 수 있습니다. 이는 단순이 String이라고 말하는 것보다 훨씬 더 많은 것을 알려줍니다. String은 무한한 값을 가질 수 있지만, enum class로 모델링한 AgeRestriction은 다섯 가지 값만 가질 수 있습니다. 따라서 sum type을 사용하면 타입의 복잡성을 크게 줄일 수 있습니다.

이제 온라인 행사 대한 대응이 필요하다고 가정해 보겠습니다. 이러한 유형의 행사는 Address 대신 특정 URL을 가집니다. 따라서 Event가 어떠한 유형인가에 따라, 내부 데이터가 약간 달라지게 됩니다. 이를 간단하게 구현하면 다음과 같습니다.

@JvmInline value class Url(val value: String)

@JvmInline value class City(val value: String)
@JvmInline value class Street(val value: String)
data class Address(val city: City, val street: Street)

data class Event(
val id: EventId
val title: Title,
val organizer: Organizer,
val description: Description,
val date: LocalDate,
val ageRestriction: AgeRestriction,
val isOnline: Boolean,
val url: Url?,
val address: Address?
)

이는 일반적인 접근 방식이나, 꽤 문제가 될 수 있습니다. isOnlinetrue이면 urlnull이 아니어야 하고, 아니라면 addressnull이 아니어야 합니다. 그러나 isOnline을 확인한 이후에도, url, address 둘 다 null일 수 있습니다. 그래서 결국 다음과 같은 코드를 작성하게 됩니다.

fun printLocation(event: Event): Unit =
if(event.isOnline) {
event.url?.value?.let(::println)
} else {
event.address?.let(::println)
}

하지만 더 심각한 문제는 다음 예제처럼 의도한 계약을 쉽게 위반할 수 있다는 점입니다.

Event(
Id(0L),
Title("Functional Domain Modeling"),
Organizer("47 Degrees"),
Description("Building software with functional DDD..."),
LocalDate.now(),
AgeRestriction.General,
true,
null,
null
)

컴파일러는 이 코드를 허용하지만, 우리가 의도한 계약에 따르면 isOnlinetrue이면 urlnull이 아니어야 합니다. 이러한 문제는 sealed class를 사용해 Event.OnlineEvent.AtAddress를 타입방식으로 조합해 해결할 수 있습니다.

sealed class Event {
abstract val id: EventId
abstract val title: Title
abstract val organizer: Organizer
abstract val description: Description
abstract val ageRestriction: AgeRestriction
abstract val date: LocalDate

data class Online(
override val id: EventId,
override val title: Title,
override val organizer: Organizer,
override val description: Description,
override val ageRestriction: AgeRestriction,
override val date: LocalDate,
val url: Url
) : Event()

data class AtAddress(
override val id: EventId,
override val title: Title,
override val organizer: Organizer,
override val description: Description,
override val ageRestriction: AgeRestriction,
override val date: LocalDate,
val address: Address
) : Event()
}

이렇게 하면 Url 없이 온라인 Event를 생성할 수 있는 이전 문제가 해결되고, 데이터를 더 훌륭하게 다를 수 있는 방법이 제공됩니다. 이제 if(event.isOnline) 대신 when를 사용하여 Event에 대해 패턴 매칭을 사용할 수 있으며, Kotlin의 스마트 캐스팅 덕분에 Event.Online인 경우 안전하게 Url에 접근할 수 있습니다.

fun printLocation(event: Event): Unit =
when(event) {
is Online -> println(event.url.value)
is AtAddress -> println("${event.address.city}: ${event.address.street}")
}

이러한 유형의 데이터 구성 또한 sum type이며, 이는 or 관계를 모델링하는 데 사용됩니다. 하지만 sealed classenum class보다 더 강력한 기능을 제공합니다. sealed classobject, data class 또는 다른 sealed class케이스로 사용할 수 있습니다. 반면 enum class는 다른 클래스를 확장할 수 없으므로, sealed class케이스가 될 수 없습니다.

예제에서 sealed classOnline 또는 AtAddress Event 두 가지 케이스 중 하나로 구성되며, 여기서 OnlineAtAddress는 다른 여러 타입의 product type입니다. Kotlin의 경험 법칙에 따르면 케이스에 데이터가 포함되지 않은 경우, 즉 모든 케이스를 object로 모델링할 수 있는 경우에는 enum class를 사용하는 것이 좋습니다.

위 예제에서 살펴본 것처럼 도메인을 정확하게 모델링하면 많은 이점이 있습니다. 데이터를 잘못 생성하는 것과 같은 특정 버그를 방지할 수 있습니다. 유효하지 않은 값을 제거하여 코드/모델을 더 쉽게 추론할 수 있으며, 모델에 의존하는 코드를 개선할 수 있습니다.

이제 Arrow의 데이터 타입을 사용하여 코드의 도메인 문제를 더 명확히 하는 방법을 살펴봅시다. 다음과 같은 EventId를 기반으로 예정된 이벤트를 가져올 수 있는 EventService가 있습니다.

interface EventService {
suspend fun fetchUpcomingEvent(id: EventId): Event
}

EventService는 발생할 수 있는 다양한 종류의 오류 시나리오를 모델링하지 않습니다. 다만 suspend를 사용하여 Throwable을 던질 수 있다는 것을 알려줄 뿐입니다. 따라서 오류 도메인을 명시적으로 모델링하려면 위에서 이미 살펴본 기법 중 하나를 사용할 수 있습니다.

다음과 같이, 두 개의 에러 케이스를 모델링할 수 있습니다.

  1. 행사가 존재하지 않는 경우
  2. 행사가 이미 종료되어 더 이상 예정된 행사가 아닌 경우
sealed class Error {
data class EventNotFound(val id: EventId): Error()
data class EventPassed(val event: Event): Error()
}

Arrow CoreEither를 사용하여 ErrorEvent 두 개별 도메인을 구성할 수 있습니다. 이는 or 관계를 모델링 할 수 있도록 해줍니다. 즉 fetchUpcomingEventEvent or Error를 반환하지만, 둘 다 반환하지는 않습니다. 따라서 Eithergeneral sum type으로, 일반적으로 두 개의 개별 도메인을 or 관계로 구성할 수 있습니다.

이제 다음과 같이 EventService를 수정할 수 있습니다.

interface EventService {
suspend fun fetchUpcomingEvent(id: EventId): Either<Error, Event>
}

EitherArrow Core에서 sealed class로 정의되어 있기에, 기존에 사용했던 방법인 when을 사용하여 Error 또는 Event를 안전하게 꺼낼 수 있습니다.

이번 게시글에서는, 도메인을 개선하는 방법에 대해 살펴보았습니다.

  • 도메인 정의 시 원시 타입을 사용하지 않고, 런타임 오버해드가 없는 value class를 사용
  • Event 유형에 따라 달라지는 데이터를 모델링하기 위해 enum classsealed class를 사용
  • 두 개의 도메인을 or 관계로 구성하기 위해 Arrow의 Either를 활용