불변 도메인 모델 설계

객체지향 방식의 엔티티는 상태를 직접 변경한다.

// ❌ Mutable Entity 방식
@Entity
class Order {
    @Id var id: Long = 0
    var status: OrderStatus = OrderStatus.PENDING
 
    fun confirm() { this.status = OrderStatus.CONFIRMED }
    fun cancel() { this.status = OrderStatus.CANCELLED }
}

confirm()을 호출한 뒤 order.status가 변경됐는지 추적해야 한다. 멀티스레드 환경에서는 더 위험하다.

FP에서는 새 객체를 반환하는 방식으로 상태 변경을 표현한다.


도메인 모델과 JPA 엔티티 분리

핵심 전략: 순수 Kotlin data class로 도메인 모델을 만들고, JPA 엔티티는 별도로 둔다.

도메인 레이어      영속성 레이어
─────────────    ─────────────
Order (data)  ↔  OrderEntity (@Entity)
User  (data)  ↔  UserEntity  (@Entity)
// 도메인 모델 — 순수 Kotlin, JPA 의존성 없음
data class Order(
    val id: OrderId,
    val userId: UserId,
    val status: OrderStatus,
    val items: List<OrderItem>,
    val totalAmount: Money,
    val createdAt: Instant,
    val updatedAt: Instant,
)
 
data class OrderItem(
    val productId: ProductId,
    val productName: String,
    val quantity: Int,
    val unitPrice: Money,
) {
    val subtotal: Money get() = unitPrice * quantity
}
 
// JPA 엔티티 — 영속성 관심사만
@Entity
@Table(name = "orders")
class OrderEntity(
    @Id @GeneratedValue(strategy = GenerationType.IDENTITY)
    val id: Long = 0,
 
    @Column(nullable = false)
    val userId: Long,
 
    @Enumerated(EnumType.STRING)
    var status: OrderStatusEntity = OrderStatusEntity.PENDING,
 
    @OneToMany(mappedBy = "order", cascade = [CascadeType.ALL])
    val items: MutableList<OrderItemEntity> = mutableListOf(),
 
    @Column(nullable = false)
    val totalAmount: BigDecimal,
 
    @CreationTimestamp
    val createdAt: Instant = Instant.now(),
 
    @UpdateTimestamp
    var updatedAt: Instant = Instant.now(),
)

변환 함수

// 엔티티 → 도메인 모델
fun OrderEntity.toDomain(): Order = Order(
    id = OrderId(id),
    userId = UserId(userId),
    status = status.toDomain(),
    items = items.map { it.toDomain() },
    totalAmount = Money(totalAmount, "KRW"),
    createdAt = createdAt,
    updatedAt = updatedAt,
)
 
// 도메인 모델 → 엔티티
fun Order.toEntity(): OrderEntity = OrderEntity(
    id = id.value,
    userId = userId.value,
    status = status.toEntity(),
    totalAmount = totalAmount.amount,
)

값 객체(Value Object)로 원시 타입 포장

// ❌ 원시 타입 사용 — userId와 orderId가 바뀌어도 컴파일 에러 없음
fun findOrdersByUser(userId: Long, orderId: Long): List<Order>
 
// ✅ 값 객체 — 타입이 다르면 컴파일 에러
fun findOrdersByUser(userId: UserId, orderId: OrderId): List<Order>
@JvmInline
value class UserId(val value: Long)
 
@JvmInline
value class OrderId(val value: Long)
 
@JvmInline
value class ProductId(val value: Long)
 
// 인라인 클래스 — 런타임에 Long과 동일, 래핑 비용 없음
val userId = UserId(1L)
val orderId = OrderId(1L)
// findOrdersByUser(orderId, userId)  // ← 컴파일 에러!

Money 값 객체

data class Money(val amount: BigDecimal, val currency: String = "KRW") {
 
    companion object {
        val ZERO = Money(BigDecimal.ZERO)
        fun of(amount: Long) = Money(amount.toBigDecimal())
        fun of(amount: Int) = Money(amount.toBigDecimal())
    }
 
    operator fun plus(other: Money): Money {
        require(currency == other.currency) { "통화 불일치: $currency vs ${other.currency}" }
        return copy(amount = amount + other.amount)
    }
 
    operator fun minus(other: Money): Money {
        require(currency == other.currency) { "통화 불일치" }
        return copy(amount = amount - other.amount)
    }
 
    operator fun times(quantity: Int): Money =
        copy(amount = amount * quantity.toBigDecimal())
 
    fun isPositive() = amount > BigDecimal.ZERO
    fun isZero() = amount.compareTo(BigDecimal.ZERO) == 0
}

copy()로 상태 전이 표현

도메인 모델에 상태 변경 메서드를 추가하되, 내부 상태를 변경하는 대신 새 객체를 반환한다.

data class Order(
    val id: OrderId,
    val status: OrderStatus,
    val items: List<OrderItem>,
    val totalAmount: Money,
    val shippingAddress: Address?,
    val cancelledAt: Instant?,
) {
    companion object {
        fun create(command: CreateOrderCommand): Order = Order(
            id = OrderId(0),
            status = OrderStatus.Pending,
            items = command.items.map { OrderItem.from(it) },
            totalAmount = command.items.sumOf { it.unitPrice * it.quantity },
            shippingAddress = null,
            cancelledAt = null,
        )
    }
 
    // 상태 전이 — 새 객체 반환
    fun confirm(): Order {
        require(status == OrderStatus.Pending) { "확인은 대기 상태에서만 가능합니다" }
        return copy(status = OrderStatus.Confirmed)
    }
 
    fun ship(trackingNumber: String): Order {
        require(status == OrderStatus.Confirmed) { "배송은 확인 상태에서만 가능합니다" }
        return copy(status = OrderStatus.Shipped(trackingNumber))
    }
 
    fun cancel(reason: String): Order {
        require(status !is OrderStatus.Cancelled) { "이미 취소된 주문입니다" }
        return copy(
            status = OrderStatus.Cancelled(reason),
            cancelledAt = Instant.now(),
        )
    }
 
    fun addItem(item: OrderItem): Order =
        copy(
            items = items + item,
            totalAmount = totalAmount + item.subtotal,
        )
}

불변 도메인 모델과 JPA의 긴장

JPA는 변경 감지(Dirty Checking)를 위해 엔티티를 mutable하게 유지하길 원한다. 불변 도메인 모델과 충돌이 생긴다.

해결책: JPA 엔티티는 영속성 레이어에서만 mutable하게 유지하고, 도메인 레이어는 완전히 불변으로 분리한다.

// Repository — 도메인 모델을 받아 엔티티로 변환 후 저장
@Repository
class OrderRepository(private val jpaRepository: OrderJpaRepository) {
 
    fun findById(id: OrderId): Order? =
        jpaRepository.findById(id.value)
            .map { it.toDomain() }
            .orElse(null)
 
    fun save(order: Order): Order {
        val entity = if (order.id.value == 0L) {
            order.toEntity()  // 신규 — 새 엔티티 생성
        } else {
            jpaRepository.findById(order.id.value)
                .orElseThrow()
                .apply { updateFrom(order) }  // 기존 — 엔티티 상태 업데이트
        }
        return jpaRepository.save(entity).toDomain()
    }
}
 
// 엔티티 — 도메인 모델로부터 자신을 업데이트하는 메서드
fun OrderEntity.updateFrom(order: Order) {
    this.status = order.status.toEntity()
    this.updatedAt = Instant.now()
    // items 동기화 등...
}

정리

Mutable Entity불변 도메인 모델
상태 변경직접 변경 (order.status = ...)새 객체 반환 (order.copy(...))
JPA 연동직접 사용엔티티 별도 분리
테스트상태 추적 필요입력/출력만 검증
동시성공유 상태 변경 위험안전
코드량적음변환 함수 추가 필요

불변 도메인 모델은 초기 설정 비용이 있지만, 도메인 로직의 예측 가능성과 테스트 용이성이 크게 향상된다. 특히 복잡한 비즈니스 규칙이 있는 도메인에서 효과가 두드러진다.