
왜 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 함수로 상태 변경이 일어나기 때문에, 검증할 수가 없게 된다.
'Backend' 카테고리의 다른 글
| Kafka DLQ 동작 원리와 실패한 event 처리하기 (0) | 2026.05.31 |
|---|---|
| 이벤트 기반의 데이터 마이그레이션을 위한 Kafka Outbox Pattern 적용기 (0) | 2026.04.30 |
| Keycloak SSO 연동 과정에서 이해한 인증과 인가 (0) | 2026.03.28 |
| 멱등성을 보장하는 시스템 개발하기 (6) | 2024.10.13 |
| [sqlalchemy] Entity.metadata.create_all() 자동으로 테이블 생성하기 (0) | 2024.08.08 |