Backend

Domain Layer를 더 잘 표현하기 위한 Kotlin 문법

minjiwoo 2026. 6. 30. 20:53
728x90

실무에서 접하고 있는 DDD와 코틀린 인 액션을 읽고 ...

왜 Domain Layer는 순수해야 하는가?

회사에서 코드 리뷰를 받다가 domain layer 는 특히 더 중요하게 리뷰하고 있다는 조언을 듣게 되었다. Domain Layer의 Domain Model을 만들었다고 생각했는데, 실질적으로 살펴봤을때 데이터베이스의 테이블을 Kotlin Class로 옮겨놓은 것 뿐이었다. 도메인의 행위와 규칙은 없고, 데이터 구조만 남아있었다. 

예를 들어서, 주문 테이블이 있었다고 치면 테이블의 컬럼들은 아래와 같을 것이다.

orders
- id
- user_id
- status
- total_price
- created_at
- updated_at

이걸 그대로 옮기면 Order 라는 도메인 모델처럼 보이지만, 실제로는 orders 테이블을 표현한 데이터 구조가 되어버린다.

data class Order(
    val id: Long,
    val userId: Long,
    val status: String,
    val totalPrice: BigDecimal,
    val createdAt: LocalDateTime,
    val updatedAt: LocalDateTime
)

DDD 에서 Domain 은 단순히 데이터를 담는 객체가 아니라, 비즈니스의 핵심 개념과 비즈니스의 제약 및 규칙을 담아야 하는 영역이다. Domain Layer는 DB, Framework, API, UI, 외부 시스템 같은 세부사항에 의존하지 않아야 한다. Domain Layer 는 어플리케이션에서 가장 오래 살아남아야 하는 핵심 영역이기 때문이다. DB, Framework 등은 바뀔 수 있지만 비즈니스 규칙은 상대적으로 오래 유지된다. 비즈니스 규칙과 기술 세부사항들이 섞여있으면, 기술적인 변경이 일어날 때마다 비즈니스 로직도 같이 흔들리게 된다. 따라서 Domain Layer 는 세부 사항에 대해서 몰라야 하며, 관심사를 분리해야 한다. 

 

그렇다면 Kotlin은 이런 Domain Model을 더 잘 표현하기 위해 어떤 기능을 제공할까?

DDD 에서 표현하고 싶은 것 Kotlin 문법
변경을 최소화하는 객체 val
값 자체가 중요한 객체(Value Object) data class
식별자를 명확한 타입으로 표현 value class
생성 규칙 강제 private constructor
생성 책임 분리 companion object
상태를 안전하게 표현 sealed class

1. val 

val 은 참조를 변경할 수 없다. 예를 들어서, 주문 금액인 totalPrice 가 중간에  order.totalPrice = BigDecimal.ZERO 로 변경되어 버리면, 문제가 될것이다. 

class Order(
    val id: OrderId,
    val totalPrice: Money
)

2. data class 

DDD에서는 Value Object 라는 개념이 있다. 식별자가 아니라, 값 자체로 의미를 가지는 객체이다. 두 객체의 속성이 같다면 같은 객체로 취급 할 수 있다. 예를 들어서, 금액과 통화를 나타내는 Money 있다고 하자. 

enum class Currency {
    KRW,
    USD,
    JPY
}

data class Money (
    val amount: BigDecimal,
    val currency: Currency
)

Money(1000, KRW) 와 Money(1000, KRW) 는 같은 객체로 취급할 수 있다. 

3. value class 

도메인 모델에서 식별자를 단순하게 Long, String 타입으로 두면 비즈니스 적으로는 다른 개념인데도 불구하고 코드상으로는 구분되지 않아서 컴파일러 입장에서는 넘거가게 된다. 

val orderId: Long = 1L
val userId: Long = 1L

cancelOrder(userId) // 실수로 userId를 넘겨도 컴파일 에러가 안 남

Kotlin에서는 @JvmInline value class를 사용해서 식별자를 별도 타입으로 감쌀 수 있다. 

@JvmInline
value class OrderId(val value: Long)

@JvmInline
value class UserId(val value: Long)

@JvmInline
value class ProductId(val value: Long)

Order 에서 단순하게 Long, String 타입 대신 value class 를 통해서 식별자를 표현할 수 있다. 이렇게 되면 컴파일 타임에 잘못된 식별자를 잡을 수 있게 된다. 

data class Order(
    val id: OrderId, // 비즈니스 개념을 포함하게 된 식별자
    val userId: UserId,
    val productId: ProductId
)

 

4. private constructor

객체 생성 시점부터 비즈니스 규칙을 지키게 만드는 게 중요하다. private constrictor 는 도메인 객체가 항상 유효한 상태로만 존재하도록 강제하는 장치이다.

예를 들어서 주문은 최소 하나 이상의 item 을 가져야 한다는 비즈니스 규칙이 있을 수 있다. 

class Order(
    val id: OrderId,
    val items: List<OrderItem>,
    val status: OrderStatus
)

그렇지만 위와 같이 만들어버리면, items 에 emptyList 를 담아버리면 주문한 아이템이 없는데도 주문이 생성되게 되어버릴 것이다. 

val order = Order(
    id = OrderId(1L),
    items = emptyList(),
    status = OrderStatus.CREATED
)

private constrictor 를 통해 외부에서 직접 객체를 생성하는 것을 막고, 반드시 create() 함수를 통해서만 객체를 생성하도록 강제할 수 있다. 

class Order private constructor(
    val id: OrderId,
    val items: List<OrderItem>,
    val status: OrderStatus
) {
    companion object {
        fun create(
            id: OrderId,
            items: List<OrderItem>
        ): Order {
            require(items.isNotEmpty()) {
                "주문 항목이 없는 주문은 생성할 수 없습니다."
            }

            return Order(
                id = id,
                items = items,
                status = OrderStatus.CREATED
            )
        }
    }
}

// create 함수를 통한 Order 객체 생성 예시 

val order = Order.create(
    id = OrderId(1L),
    items = listOf(orderItem)
)

5. companion object 

companion object는 클래스 안에 붙어 있는 공용 객체 이다.

class Nickname private constructor(
    val value: String
) {
    companion object {
        fun create(input: String): Nickname {
            val trimmed = input.trim()

            require(trimmed.isNotBlank()) {
                "닉네임은 비어 있을 수 없습니다."
            }

            return Nickname(trimmed)
        }
    }
}

외부에서 객체를 만들지 않고도 Member.create() 를 호출할 수 있다. 

위의 예시처럼 companion object 안에 객체를 생성하는 메서드를 둘 수 있다.

이 메서드는 정적 팩토리 메서드처럼 사용할 수도 있다. 외부에서 도메인 객체의 필드 값을 마음대로 넣도록 생성자를 열어두는 대신, 팩토리 메서드 내부에서 필요한 값을 조립하고 검증한 뒤 도메인 객체를 생성하게 만들 수 있다. 이렇게 하면 생성 규칙을 한 곳에 모을 수 있고, 외부 계층의 관심사와 도메인 생성 규칙을 분리할 수 있다.

class Order private constructor(
    val id: OrderId,
    val userId: UserId,
    val items: List<OrderItem>,
    val status: OrderStatus,
    val totalPrice: Money
) {
    companion object {
        fun createFrom(
            cart: Cart,
            orderId: OrderId
        ): Order {
            require(!cart.isEmpty()) {
                "장바구니가 비어 있으면 주문을 생성할 수 없습니다."
            }
            // 함수 내에서 값을 조합하여 도메인 객체를 생성한다. 
            val orderItems = cart.items.map { OrderItem.from(it) }

            val totalPrice = orderItems.fold(Money.ZERO) { acc, item ->
                acc + item.totalPrice()
            }

            return Order(
                id = orderId,
                userId = cart.userId,
                items = orderItems,
                status = OrderStatus.Created,
                totalPrice = totalPrice
            )
        }
    }
}

 

6. sealed class

도메인 객체는 보통 상태를 가진다. 주문 상태는 아래와 같은 상태들을 가질 수 있을 것이다. 상태를String으로 표현하면 오타나 잘못된 값이 들어와도 컴파일 시점에 막을 수 없다.

enum class OrderStatus {
    CREATED,
    PAID,
    SHIPPING,
    CANCELLED
}

enum class 을 사용하면 가능한 상태 목록을 제한하여 안전하게 상태를 다룰 수 있다. 위의 경우에는 충분해보이지만, 상태마다 필요한 데이터가 다른 경우에 모든 정보를 담기 부족하다. 

sealed class를 사용하면 가능한 상태를 제한하면서, 각 상태가 가져야 하는 데이터를 타입으로 함께 표현할 수 있다. 예를 들어 결제 완료 상태는 결제 시각을 가지고, 배송 중 상태는 운송장 번호를 가지며, 취소 상태는 취소 사유를 가질 수 있다.

sealed class OrderStatus {
    data object Created : OrderStatus()

    data class Paid(
        val paidAt: LocalDateTime
    ) : OrderStatus()

    data class Shipping(
        val trackingNumber: String
    ) : OrderStatus()

    data class Cancelled(
        val reason: String,
        val cancelledAt: LocalDateTime
    ) : OrderStatus()
}

sealed class 로 상태 관리를 할때, when 절과 함께 쓰게 되면 일부 로직에서 처리를 빼먹는 실수를 방지할 수 있다. 

fun canCancel(status: OrderStatus): Boolean {
    return when (status) {
        is OrderStatus.Created -> true
        is OrderStatus.Paid -> true
        is OrderStatus.Shipping -> false
        // Cancelled 처리가 빠짐. 컴파일 에러 발생!
    }
}

도메인 모델에서는 아래와 같이 활용할 수 있다. 

class Order private constructor(
    val id: OrderId,
    private var status: OrderStatus
) {
    fun pay(paidAt: LocalDateTime) {
        if (status !is OrderStatus.Created) {
            throw IllegalStateException("생성된 주문만 결제할 수 있습니다.")
        }

        status = OrderStatus.Paid(paidAt)
    }

    fun startShipping(trackingNumber: String) {
        if (status !is OrderStatus.Paid) {
            throw IllegalStateException("결제 완료된 주문만 배송을 시작할 수 있습니다.")
        }

        require(trackingNumber.isNotBlank()) {
            "운송장 번호는 비어 있을 수 없습니다."
        }

        status = OrderStatus.Shipping(trackingNumber)
    }

    fun cancel(reason: String, cancelledAt: LocalDateTime) {
        if (!canCancel()) {
            throw IllegalStateException("현재 상태에서는 주문을 취소할 수 없습니다.")
        }

        status = OrderStatus.Cancelled(
            reason = reason,
            cancelledAt = cancelledAt
        )
    }

    private fun canCancel(): Boolean {
        return when (status) {
            is OrderStatus.Created -> true
            is OrderStatus.Paid -> true
            is OrderStatus.Shipping -> false
            is OrderStatus.Cancelled -> false
        }
    }
}

 

Entity 를 Data Class 로 만들지 않는 이유

entity 라는 개념은, 식별자를 가지고 있다. 식별자가 같으면 필드값이 좀 바뀌어도 같은 객체로 취급한다. 

class Member(
    val id: MemberId,
    var nickname: Nickname,
    var status: MemberStatus
)

예를 들어서, 인스타그램의 닉네임을 바꾼다고 해서 그 회원이 달라지지는 않을 것이다. Member(1L, 'freemjstudio', 'active') 와 Member(1L, 'thisisminjiwoo')는 동일한 객체로 취급한다. 

반면에 data class 의 기본 equals() 는 모든 프로퍼티를 기준으로 비교한다. 

val member1 = Member(
    id = MemberId(1L),
    nickname = Nickname("민지"),
    status = MemberStatus.Active
)

val member2 = Member(
    id = MemberId(1L),
    nickname = Nickname("minjee"),
    status = MemberStatus.Active
)

println(member1 == member2) // false

id 는 같은데 닉네임이 다르기 때문에 false 로 리턴된다. 따라서 일반적으로 생각하는 도메인의 개념과 data class 의 equals 개념은 다르기 때문에 data class 로 entity 를 구현하면 어색하다. 

data class 의 copy() 함수도 도메인 Entity 의 개념을 위반할 수 있다. 

data class Order(
    val id: OrderId,
    val status: OrderStatus,
    val totalPrice: Money
)

val paidOrder = order.copy(
    status = OrderStatus.Paid
)

기존 order 를 복사해서, status 만 결제 완료 상태로 바꾼 새로운 Order 객체를 생성하고 있다. 결제 상태로 변경이 되기 위한 다른 비즈니스 규칙들은 모두 건너뛰고 copy 함수로 상태 변경이 일어나기 때문에, 검증할 수가 없게 된다. 

728x90