"도메인 객체는 그냥 데이터를 담는 그릇일까?", "비즈니스 규칙은 누가 지켜야 할까?", "Value Object는 왜 필요할까?", "상태 전이는 어떻게 제어할까?"라는 질문들과 씨름했다. Entity와 VO에 책임을 주고, 도메인 규칙을 코드로 표현하며, 객체가 스스로 무결성을 지키게 만드는 과정을 기록한 글이다.
3주차 과제는 도메인 모델링이었다. 지난주에 ERD와 다이어그램을 그렸으니 이제 코드로 옮기면 되는 거라 생각했다.
근데 첫 줄부터 막혔다.
class Product {
val price: BigDecimal // 이게 도메인 모델인가?
}
"Product는 가격을 가진다"는 건 알겠는데, 어떻게 가져야 할까? 그냥 BigDecimal 필드 하나면 끝일까?
설계 문서는 "무엇"만 말해줬다. "어떻게"는 코드를 작성하는 순간 결정해야 했다.
가장 먼저 고민한 건 금액 표현이었다. 상품 가격, 주문 총액, 포인트 잔액... 전부 금액인데 어떻게 다루지?
class Product(val price: BigDecimal)
class Order(val totalAmount: BigDecimal)
class Point(val balance: BigDecimal)
처음엔 이게 답인 줄 알았다. 근데 곧바로 문제가 보였다.
val price = BigDecimal("-1000") // 음수 가격?
val product1 = BigDecimal("10000") // 원화?
val product2 = BigDecimal("100") // 달러?
val total = product1 + product2 // 이게 맞나?
비즈니스 규칙이 코드에 없다. 가격은 0 이상이어야 하고, 통화가 같아야 더할 수 있다는 규칙이 어디에도 없다. 이걸 매번 Service에서 검증해야 할까?
찾아보니 이럴 때 Value Object(VO)를 쓴다고 했다. 중요한 건 "누구"가 아니라 "값"이 무엇인지.
Price VO를 만들었다:
@Embeddable
data class Price private constructor(
val amount: BigDecimal,
@Enumerated(EnumType.STRING)
val currency: Currency = Currency.KRW,
) : Comparable<Price> {
init {
if (amount < BigDecimal.ZERO) {
throw CoreException(ErrorType.BAD_REQUEST, "금액은 0 이상이어야 합니다.")
}
}
operator fun plus(other: Price): Price {
if (this.currency != other.currency) {
throw CoreException(ErrorType.BAD_REQUEST, "통화가 다른 가격은 더할 수 없습니다.")
}
return Price(this.amount + other.amount, this.currency)
}
operator fun times(multiplier: Int): Price {
if (multiplier < 0) {
throw CoreException(ErrorType.BAD_REQUEST, "수량은 0 이상이어야 합니다.")
}
return Price(this.amount * BigDecimal(multiplier), this.currency)
}
}
이제 불가능한 가격은 존재 자체가 불가능하다:
val price = Price(BigDecimal("-100")) // ❌ 생성 시점에 예외
val krw = Price(BigDecimal("10000"), Currency.KRW)
val usd = Price(BigDecimal("100"), Currency.USD)
val sum = krw + usd // ❌ 연산 시점에 예외
VO를 만들고 나니 테스트가 명확해졌다:
class PriceTest {
@Test
fun `가격은 0 이상이어야 한다`() {
assertThatThrownBy {
Price(amount = BigDecimal("-1"), currency = Currency.KRW)
}.isInstanceOf(CoreException::class.java)
.hasMessageContaining("0 이상")
}
@Test
fun `통화가 다른 가격끼리 더하면 예외가 발생한다`() {
val krw = Price(amount = BigDecimal("10000"), currency = Currency.KRW)
val usd = Price(amount = BigDecimal("100"), currency = Currency.USD)
assertThatThrownBy {
krw + usd
}.isInstanceOf(CoreException::class.java)
.hasMessageContaining("통화")
}
@Test
fun `Value Object이므로 값이 같으면 동일하다`() {
val price1 = Price(amount = BigDecimal("10000"), currency = Currency.KRW)
val price2 = Price(amount = BigDecimal("10000"), currency = Currency.KRW)
assertThat(price1).isEqualTo(price2)
assertThat(price1.hashCode()).isEqualTo(price2.hashCode())
}
}
테스트가 도메인 규칙을 말해준다. "가격은 음수일 수 없다", "다른 통화는 더할 수 없다", "값이 같으면 동일하다". 코드가 곧 규칙이다.
VO를 도입하기 전에는 검증 로직이 Service에 흩어져 있었다. "이 가격이 유효한가?"를 매번 체크해야 했다.
VO를 도입하고 나니 불가능한 상태가 아예 존재하지 않는다. Price 타입이 있다는 것 자체가 "유효한 가격"이라는 증명이다.
그리고 연산자 오버로딩 덕분에 코드가 의도를 드러낸다:
val itemTotal = price * quantity // "가격 × 수량"
val orderTotal = item1Total + item2Total // "항목들의 합"
BigDecimal로 했으면 price.multiply(quantity) 같은 코드가 됐을 거다. 도메인 언어로 말하는 코드가 더 읽기 쉽다.
재고 차감 로직을 작성할 때, 처음엔 당연히 Service에 뒀다:
@Service
class StockService(
private val stockRepository: StockRepository
) {
fun decreaseStock(productId: Long, quantity: Int) {
val stock = stockRepository.findByProductId(productId) ?: throw ...
if (stock.quantity < quantity) { // Service가 검증
throw CoreException(...)
}
stock.quantity -= quantity // Service가 직접 차감
stockRepository.save(stock)
}
}
class Stock(
val productId: Long,
var quantity: Int // 그냥 public var
)
근데 뭔가 이상했다. Stock이 그냥 데이터 컨테이너다. 누구나 stock.quantity = -100 같은 걸 할 수 있다.
"재고를 관리하는 건 Stock의 책임 아닐까?"
Stock에게 책임을 줬다:
@Entity
class Stock(
@Id val productId: Long,
quantity: Int,
) {
@Column(nullable = false)
var quantity: Int = quantity
protected set // 외부에서 직접 수정 불가
init {
if (quantity < 0) {
throw CoreException(ErrorType.BAD_REQUEST, "재고는 0 이상이어야 합니다.")
}
}
fun decrease(amount: Int) {
if (amount <= 0) {
throw CoreException(ErrorType.BAD_REQUEST, "감소량은 0보다 커야 합니다.")
}
if (this.quantity < amount) {
throw CoreException(
ErrorType.BAD_REQUEST,
"재고 부족: 현재 재고 $quantity, 요청 수량 $amount"
)
}
this.quantity -= amount
}
fun increase(amount: Int) {
if (amount <= 0) {
throw CoreException(ErrorType.BAD_REQUEST, "증가량은 0보다 커야 합니다.")
}
this.quantity += amount
}
fun isAvailable(amount: Int): Boolean = this.quantity >= amount
}
이제 Stock에게 "해줘"라고 말한다:
stock.decrease(orderQuantity) // ✅ Stock이 알아서 검증하고 차감
stock.increase(cancelledQuantity) // ✅ Stock이 알아서 검증하고 증가
묻지 않는다. "너 재고 몇 개야?" "부족하지 않아?" 같은 질문을 하지 않고, 그냥 "10개 차감해줘"라고 말한다. Stock이 스스로 판단한다.
class StockTest {
@Test
fun `재고를 감소시킬 수 있다`() {
val stock = Stock(productId = 1L, quantity = 100)
stock.decrease(30)
assertThat(stock.quantity).isEqualTo(70)
}
@Test
fun `재고보다 많이 감소시키면 예외가 발생한다`() {
val stock = Stock(productId = 1L, quantity = 100)
assertThatThrownBy {
stock.decrease(101)
}.isInstanceOf(CoreException::class.java)
.hasMessageContaining("재고 부족")
}
@Test
fun `재고는 생성 시점에 0 이상이어야 한다`() {
assertThatThrownBy {
Stock(productId = 1L, quantity = -1)
}.isInstanceOf(CoreException::class.java)
.hasMessageContaining("재고")
}
}
Service를 띄우지 않아도, DB 없이도 Stock의 규칙을 검증할 수 있다. 도메인 로직이 도메인 객체에 있으니까.
처음엔 "Service에 로직을 두는 게 당연한 거 아냐?"라고 생각했다. 근데 그러면 Stock을 쓰는 모든 곳에서 동일한 검증을 반복해야 한다.
도메인 객체에 책임을 주니:
stock.decrease(5) vs stock.quantity -= 5)주문을 구현하면서 또 다른 고민이 생겼다. 주문의 상태를 어떻게 관리할까?
enum class OrderStatus {
PENDING, // 생성됨
CONFIRMED, // 확정됨
CANCELLED // 취소됨
}
처음엔 그냥 상태만 있으면 되는 줄 알았다. 근데 "언제 어떤 상태로 전이할 수 있는가?"가 비즈니스 규칙이었다.
이 규칙을 어디에 둘까?
Order Entity가 스스로 상태 전이를 제어하게 했다:
@Entity
class Order(
val userId: Long,
items: List<OrderItem>,
) : BaseEntity() {
@Enumerated(EnumType.STRING)
var status: OrderStatus = OrderStatus.PENDING
protected set
fun cancel() {
if (status == OrderStatus.CONFIRMED) {
throw CoreException(ErrorType.BAD_REQUEST, "이미 확정된 주문은 취소할 수 없습니다.")
}
if (status == OrderStatus.CANCELLED) {
throw CoreException(ErrorType.BAD_REQUEST, "이미 취소된 주문입니다.")
}
this.status = OrderStatus.CANCELLED
}
fun confirm() {
if (status == OrderStatus.CANCELLED) {
throw CoreException(ErrorType.BAD_REQUEST, "취소된 주문은 확정할 수 없습니다.")
}
if (status == OrderStatus.CONFIRMED) {
throw CoreException(ErrorType.BAD_REQUEST, "이미 확정된 주문입니다.")
}
this.status = OrderStatus.CONFIRMED
}
}
이제 불가능한 상태 전이는 컴파일 타임에는 막을 수 없지만, 런타임에 확실히 막힌다:
val order = Order(userId = 1L, items = items)
order.confirm()
order.cancel() // ❌ "이미 확정된 주문은 취소할 수 없습니다"
class OrderTest {
@Test
fun `확정된 주문은 취소할 수 없다`() {
val order = createOrder()
order.confirm()
assertThatThrownBy {
order.cancel()
}.isInstanceOf(CoreException::class.java)
.hasMessageContaining("확정된 주문은 취소할 수 없습니다")
}
@Test
fun `취소된 주문은 확정할 수 없다`() {
val order = createOrder()
order.cancel()
assertThatThrownBy {
order.confirm()
}.isInstanceOf(CoreException::class.java)
.hasMessageContaining("취소된 주문은 확정할 수 없습니다")
}
@Test
fun `이미 취소된 주문은 다시 취소할 수 없다`() {
val order = createOrder()
order.cancel()
assertThatThrownBy {
order.cancel()
}.isInstanceOf(CoreException::class.java)
.hasMessageContaining("이미 취소된 주문입니다")
}
}
테스트가 상태 전이 규칙을 문서화한다. 주석이나 문서 없이도 테스트만 봐도 "어떤 전이가 가능한지"를 알 수 있다.
처음엔 Service에서 상태를 검증하려고 했다:
fun cancelOrder(orderId: Long) {
val order = orderRepository.findById(orderId)
if (order.status == OrderStatus.CONFIRMED) { // Service가 검증
throw ...
}
order.status = OrderStatus.CANCELLED
}
근데 이러면:
Order에 cancel() 메서드를 주니:
포인트 차감 로직을 작성할 때도 비슷한 고민이 있었다.
@Entity
class Point(
@Id val userId: Long,
balance: Money,
) {
var balance: Money = balance
protected set
fun deduct(amount: Money) {
if (amount.amount <= BigDecimal.ZERO) {
throw CoreException(ErrorType.BAD_REQUEST, "차감 금액은 0보다 커야 합니다.")
}
if (!canDeduct(amount)) {
throw CoreException(
ErrorType.BAD_REQUEST,
"포인트 부족: 현재 잔액 ${balance.amount}, 차감 요청 ${amount.amount}"
)
}
this.balance = this.balance - amount
}
fun charge(amount: Money) {
if (amount.amount <= BigDecimal.ZERO) {
throw CoreException(ErrorType.BAD_REQUEST, "충전 금액은 0보다 커야 합니다.")
}
this.balance = this.balance + amount
}
fun canDeduct(amount: Money): Boolean = this.balance.isGreaterThanOrEqual(amount)
}
Point Entity가:
Service는 그냥 명령만 내린다:
@Service
class PointService(
private val pointRepository: PointRepository,
) {
fun deductPoint(userId: Long, totalAmount: Money): Point {
val lockedPoint = pointRepository.findByUserIdWithLock(userId) ?: throw ...
lockedPoint.deduct(totalAmount) // Point에게 위임
return pointRepository.save(lockedPoint)
}
}
코드를 작성하면서 계속 고민했다. "이건 Entity? VO?"
Entity는:
@Entity
class Product(
name: String,
price: Price,
brand: Brand,
) : BaseEntity() { // ID를 상속받음
var name: String = name
protected set
var price: Price = price
protected set
fun updatePrice(newPrice: Price) { // 상태 변화
this.price = newPrice
}
}
Product #123은 가격이 바뀌어도 Product #123이다.
Value Object는:
@Embeddable
data class Price(
val amount: BigDecimal,
val currency: Currency = Currency.KRW,
) {
// 연산은 새 객체를 반환
operator fun plus(other: Price): Price =
Price(this.amount + other.amount, this.currency)
}
10,000원은 언제 어디서나 10,000원이다.
헷갈렸던 건 Like였다. 이건 Entity? VO?
@Entity
class Like(
@Column val userId: Long,
@Column val productId: Long,
) : BaseEntity() // ID를 가짐
Like를 Entity로 만든 이유:
(userId, productId) 조합이 고유하다VO로 만들 수도 있었지만, 도메인 요구사항을 고려하면 Entity가 맞다고 판단했다.
좋은 도메인 모델은 테스트하기 쉽다.
반대로 테스트하기 어려운 코드는:
테스트를 작성하면서 설계를 개선하는 과정 자체가 큰 배움이었다.
주문 항목에 주문 시점의 정보를 저장했다:
private fun createOrderItemSnapshot(
product: Product,
quantity: Int,
): OrderItem = OrderItem(
productId = product.id,
productName = product.name,
brandId = product.brand.id,
brandName = product.brand.name,
priceAtOrder = product.price, // 주문 시점 가격
)
데이터 중복이지만 도메인 관점에서는 맞다. 주문은 "그 시점의 기록"이니까. 상품 가격이 바뀌어도 주문 이력은 불변이어야 한다.
정규화와 도메인 무결성 사이에서 도메인을 선택했다.
Price와 Money를 따로 만든 이유:
구조는 비슷하지만 도메인 의미가 다르다. 근데 이게 오버 엔지니어링일 수도 있다. 통합해도 되지 않을까?
아직도 확신은 없지만, 타입으로 도메인 의미를 표현하는 게 더 명확한 것 같다.
Order에 calculateTotalAmount()를 둘까, 별도의 OrderCalculator를 만들까?
지금은 Entity에 뒀다. Order가 자신의 총액을 계산하는 게 자연스럽다고 판단했다. 하지만 계산 로직이 복잡해지면 분리가 필요할 수도 있다.
이번 구현 과정에서 가장 많이 한 질문:
정답은 없었다. 대신 "이 선택이 도메인을 더 잘 표현하는가?"를 계속 물었다.
완벽한 설계는 없다. 다만 코드로 도메인을 표현하고, 테스트로 규칙을 검증할 수 있다면, 그게 좋은 시작이 아닐까?