Skip to content
ioob.dev
Go back

DDD 4편 — Entity와 Value Object

· 9분 읽기
DDD 시리즈 (4/7)
  1. DDD 1편 — 도메인 중심 설계가 필요한 이유
  2. DDD 2편 — 유비쿼터스 언어와 Bounded Context
  3. DDD 3편 — Context Mapping
  4. DDD 4편 — Entity와 Value Object
  5. DDD 5편 — Aggregate와 Repository
  6. DDD 6편 — Domain Service와 Application Service
  7. DDD 7편 — Domain Event와 Anti-Corruption Layer
Table of contents

Table of contents

두 개의 만 원

지갑에 만 원짜리 지폐가 두 장 있다. 하나는 편의점에서 거스름돈으로 받은 것이고, 다른 하나는 ATM에서 뽑은 것이다. 이 두 지폐는 같은가, 다른가?

일상에서는 같다. 어떤 만 원을 내든 커피 한 잔 값을 치를 수 있다. 지폐의 출처나 일련번호는 신경 쓰지 않는다. 중요한 건 만 원이라는 값 자체다.

그런데 은행 입장에서는 다르다. 각 지폐에는 고유한 일련번호가 있고, 위조 여부를 추적해야 할 때 그 번호가 결정적이다. 같은 만 원이라도 일련번호가 다르면 다른 지폐다.

DDD의 핵심 빌딩 블록인 엔티티(Entity)와 값 객체(Value Object)의 구분이 정확히 이 차이다. 식별자(identity)로 구분하느냐, 값(value)으로 비교하느냐.

flowchart LR
    subgraph entity["Entity"]
        E1["주문 A<br/>id = 1001<br/>금액: 50,000원"]
        E2["주문 B<br/>id = 1002<br/>금액: 50,000원"]
        E1 -.-|"금액이 같아도<br/>다른 주문"| E2
    end

    subgraph vo["Value Object"]
        V1["Money<br/>50,000원"]
        V2["Money<br/>50,000원"]
        V1 -.-|"값이 같으면<br/>같은 것"| V2
    end

Entity — 식별자로 구분되는 객체

엔티티(Entity)는 고유한 식별자(identity)를 가지며, 그 식별자로 동일성이 판단되는 객체다. 속성이 모두 바뀌어도 식별자가 같으면 같은 엔티티이고, 속성이 모두 같아도 식별자가 다르면 다른 엔티티다.

사람을 생각해보면 직관적으로 이해된다. 이름을 개명하고, 주소를 옮기고, 외모가 바뀌어도 주민등록번호가 같으면 같은 사람이다. 반대로, 이름과 생년월일이 같은 동명이인도 서로 다른 사람이다.

주문(Order) 엔티티를 코드로 살펴보자.

// Entity: 식별자로 구분된다
class Order(
    val id: OrderId,                        // 식별자 — 이것으로 동일성을 판단
    val customerId: CustomerId,
    private var status: OrderStatus,
    private val lines: MutableList<OrderLine>,
    private var shippingAddress: Address,
) {
    fun changeShippingAddress(newAddress: Address) {
        require(status == OrderStatus.DRAFT) { "초안 상태에서만 배송지를 변경할 수 있다" }
        shippingAddress = newAddress
    }

    fun confirm() {
        require(lines.isNotEmpty()) { "주문 항목이 비어있으면 확정할 수 없다" }
        status = OrderStatus.CONFIRMED
    }

    // 엔티티의 동등성: 식별자로만 비교
    override fun equals(other: Any?): Boolean {
        if (this === other) return true
        if (other !is Order) return false
        return id == other.id
    }

    override fun hashCode(): Int = id.hashCode()
}

@JvmInline
value class OrderId(val value: Long)

equals()를 보면, id만 비교한다는 것을 알 수 있다. 배송지가 바뀌고, 상태가 바뀌어도, id가 같으면 같은 주문이다. 이것이 엔티티의 본질이다.

엔티티의 핵심 특성을 정리하면 이렇다.

식별자가 있다. 데이터베이스의 PK(Primary Key)가 될 수도 있고, UUID나 도메인 규칙으로 생성한 번호가 될 수도 있다. 중요한 건 유일하다는 것이다.

생명주기가 있다. 엔티티는 생성되고, 변경되고, 때로는 삭제된다. 주문은 초안 → 확정 → 결제 완료 → 배송 → 완료의 생명주기를 가진다. 각 상태 전이마다 비즈니스 규칙이 적용된다.

가변(mutable)이다. 엔티티의 속성은 시간에 따라 바뀔 수 있다. 물론 아무 속성이나 마음대로 바꾸는 게 아니라, 도메인 규칙에 따라 통제된 방식으로 변경한다. changeShippingAddress()처럼 메서드를 통해서만 상태를 바꾸는 것이 핵심이다.

Value Object — 값으로만 비교되는 객체

값 객체(Value Object, 이하 VO)는 식별자가 없으며, 속성 값의 조합으로 동등성이 판단되는 객체다. 모든 속성이 같으면 같은 것이고, 하나라도 다르면 다른 것이다.

돈(Money)이 대표적이다. 50,000원50,000원은 같다. 어떤 경로로 만들어졌든, 값이 같으면 같은 돈이다.

// Value Object: 값으로만 비교된다
data class Money(
    val amount: BigDecimal,
    val currency: Currency = Currency.KRW,
) {
    init {
        require(amount >= BigDecimal.ZERO) { "금액은 0 이상이어야 한다" }
    }

    operator fun plus(other: Money): Money {
        require(currency == other.currency) { "통화가 같아야 덧셈이 가능하다" }
        return Money(amount + other.amount, currency)
    }

    operator fun times(multiplier: Int): Money =
        Money(amount * multiplier.toBigDecimal(), currency)

    companion object {
        val ZERO = Money(BigDecimal.ZERO)
    }
}

enum class Currency { KRW, USD, JPY }

Kotlin의 data class와 VO는 궁합이 좋다. data class는 모든 프로퍼티를 기준으로 equals()hashCode()를 자동 생성해주기 때문에, VO의 값으로 비교 특성을 별도 코드 없이 얻을 수 있다. Java였다면 equals(), hashCode(), toString()을 직접 구현해야 했을 것이다.

주소(Address)도 전형적인 VO다.

// Value Object: 주소
data class Address(
    val city: String,
    val street: String,
    val zipCode: String,
) {
    init {
        require(city.isNotBlank()) { "도시는 비어있을 수 없다" }
        require(zipCode.matches(Regex("\\d{5}"))) { "우편번호는 5자리 숫자여야 한다" }
    }
}

서울시 강남구 역삼동 123-45라는 주소가 두 개 있으면, 그 둘은 같은 주소다. 어느 주문에 붙어 있든, 값이 같으면 같다.

VO의 핵심 특성을 정리하면 이렇다.

식별자가 없다. 값 자체가 곧 정체성이다. Money(50000)에는 ID가 필요 없다.

불변(immutable)이다. VO는 한 번 생성되면 바뀌지 않는다. 50,000원이 갑자기 30,000원으로 변하면 안 된다. 값을 바꾸고 싶으면 새로운 VO를 만든다(replace).

// VO는 수정하지 않고 새로 만든다
val price = Money(BigDecimal(50000))
val discounted = Money(BigDecimal(45000))  // 새 객체 생성
// price는 여전히 50,000원

자가 검증(self-validation)한다. init 블록에서 생성 시점에 유효성을 검증한다. Money에 음수가 들어올 수 없고, Address의 우편번호는 5자리 숫자여야 한다. VO가 존재하는 것 자체가 유효하다는 보장이 된다.

교체 가능(replaceable)하다. 엔티티의 속성을 바꿀 때, VO는 수정되는 게 아니라 통째로 교체된다. 주문의 배송지를 바꾸면, 기존 Address를 수정하는 게 아니라 새 Address로 교체한다.

Entity 안의 Value Object

엔티티는 VO를 품는다. 실제 도메인 모델에서 엔티티의 속성 대부분이 VO인 경우가 많다. 아래는 주문 도메인의 전체 모델이다.

classDiagram
    class Order {
        +OrderId id
        +CustomerId customerId
        -OrderStatus status
        -List~OrderLine~ lines
        -Address shippingAddress
        +confirm()
        +changeShippingAddress(Address)
        +totalAmount() Money
    }

    class OrderLine {
        +ProductId productId
        +String productName
        +Money unitPrice
        +int quantity
        +subtotal() Money
    }

    class OrderId {
        +Long value
    }

    class CustomerId {
        +Long value
    }

    class Money {
        +BigDecimal amount
        +Currency currency
        +plus(Money) Money
        +times(int) Money
    }

    class Address {
        +String city
        +String street
        +String zipCode
    }

    Order *-- OrderLine : lines
    Order *-- OrderId : id
    Order *-- CustomerId : customerId
    Order *-- Address : shippingAddress
    OrderLine *-- Money : unitPrice
    OrderLine *-- ProductId : productId

Order가 엔티티이고, OrderId, CustomerId, Money, Address가 전부 VO다. OrderLine도 VO로 설계할 수 있다. 주문 항목 자체에 식별자가 필요 없고, 상품ID·수량·단가의 조합으로 동등성을 판단할 수 있기 때문이다.

코드로 보면 이런 구조가 된다.

// Entity(Order) 안에 여러 VO가 포함된 풍부한 도메인 모델
class Order(
    val id: OrderId,
    val customerId: CustomerId,
    private var status: OrderStatus,
    private val lines: MutableList<OrderLine>,
    private var shippingAddress: Address,
) {
    fun addLine(productId: ProductId, name: String, unitPrice: Money, quantity: Int) {
        require(status == OrderStatus.DRAFT) { "초안 상태에서만 항목 추가가 가능하다" }
        require(quantity > 0) { "수량은 1 이상이어야 한다" }
        lines.add(OrderLine(productId, name, unitPrice, quantity))
    }

    fun changeShippingAddress(newAddress: Address) {
        require(status == OrderStatus.DRAFT) { "초안 상태에서만 배송지 변경이 가능하다" }
        // Address VO를 통째로 교체한다 (수정이 아닌 교체)
        shippingAddress = newAddress
    }

    fun confirm() {
        require(lines.isNotEmpty()) { "주문 항목이 비어있으면 확정할 수 없다" }
        require(status == OrderStatus.DRAFT) { "초안 상태에서만 확정이 가능하다" }
        status = OrderStatus.CONFIRMED
    }

    fun totalAmount(): Money =
        lines.fold(Money.ZERO) { acc, line -> acc + line.subtotal() }
}

// Value Object: 주문 항목
data class OrderLine(
    val productId: ProductId,
    val productName: String,
    val unitPrice: Money,
    val quantity: Int,
) {
    init {
        require(quantity > 0) { "수량은 1 이상이어야 한다" }
    }

    fun subtotal(): Money = unitPrice * quantity
}

이 코드에서 비즈니스 규칙이 엔티티 안에 살아 있다는 점에 주목하자. 초안 상태에서만 항목을 추가할 수 있다, 빈 주문은 확정할 수 없다 — 이런 규칙이 OrderService가 아니라 Order 자체에 있다.

빈약한 도메인 모델 비판

DDD에서 자주 비판하는 패턴이 빈약한 도메인 모델(Anemic Domain Model)이다. Martin Fowler가 명명한 이 안티패턴은, 도메인 객체가 데이터만 담고 로직은 전부 서비스 레이어에 있는 구조를 말한다.

빈약한 모델은 이렇게 생겼다.

// Anemic Domain Model: 도메인 객체가 데이터 주머니에 불과하다
public class Order {
    private Long id;
    private Long customerId;
    private String status;
    private List<OrderLineDto> lines;
    private String city;
    private String street;
    private String zipCode;
    private BigDecimal totalAmount;

    // getter, setter만 수십 개...
}

비즈니스 로직은 서비스에 있다.

// 빈약한 모델의 서비스: 모든 로직이 여기에 몰린다
public class OrderService {
    public void confirmOrder(Long orderId) {
        Order order = orderRepository.findById(orderId);
        if (order.getLines().isEmpty()) {
            throw new IllegalStateException("주문 항목이 비어있다");
        }
        if (!order.getStatus().equals("DRAFT")) {
            throw new IllegalStateException("초안 상태가 아니다");
        }
        order.setStatus("CONFIRMED");

        BigDecimal total = BigDecimal.ZERO;
        for (OrderLineDto line : order.getLines()) {
            total = total.add(line.getPrice().multiply(
                BigDecimal.valueOf(line.getQuantity())));
        }
        order.setTotalAmount(total);

        orderRepository.save(order);
    }
}

이 구조의 문제점은 여러 가지다.

규칙이 흩어진다. 초안 상태에서만 확정 가능이라는 규칙이 OrderService에 있다. 다른 서비스에서도 주문 상태를 바꿀 수 있기 때문에, 규칙을 우회하는 코드가 생길 위험이 있다. order.setStatus(CONFIRMED)를 아무 데서나 호출할 수 있으니, 규칙은 보호받지 못한다.

캡슐화가 깨진다. setStatus()로 상태를 아무 값이나 넣을 수 있고, setTotalAmount()로 금액을 임의로 바꿀 수 있다. 객체가 자기 일관성을 스스로 지킬 수 없다.

중복이 생긴다. 주문 항목이 비어있으면 안 된다는 검증이 confirmOrder()에도 있고, addLine()에도 있고, changeShippingAddress()에도 있을 수 있다. 같은 규칙이 여러 서비스 메서드에 반복된다.

풍부한 도메인 모델(Rich Domain Model)은 이 문제를 해결한다. 도메인 로직이 엔티티 안에 있으므로, 규칙을 우회할 방법이 없다. Order.confirm()을 호출하면 내부에서 상태 검증과 전이가 모두 이뤄진다. 서비스는 도메인 객체에게 일을 위임하는 얇은 계층이 된다.

// 풍부한 모델의 서비스: 도메인 객체에 위임한다
class OrderService(
    private val orderRepository: OrderRepository,
    private val eventPublisher: DomainEventPublisher,
) {
    fun confirmOrder(orderId: OrderId) {
        val order = orderRepository.findById(orderId)
        order.confirm()  // 검증과 상태 전이가 Order 내부에서 일어난다
        orderRepository.save(order)
        eventPublisher.publish(OrderConfirmedEvent(order.id))
    }
}

서비스가 짧아진 것이 보이는가? 비즈니스 로직은 Order.confirm() 안에 있고, 서비스는 객체를 꺼내고(find), 행위를 호출하고(confirm), 저장하고(save), 이벤트를 발행하는(publish) 조율만 담당한다.

Entity와 VO를 구분하는 기준

실무에서 “이건 Entity인가, VO인가?”를 판단할 때 도움이 되는 질문이 있다.

“이 객체를 추적해야 하는가?” 주문 건이 생성되고, 상태가 변하고, 이력을 봐야 한다면 엔티티다. 주문 금액은 추적할 필요 없다. 50,000원이 50,000원이면 그만이다. VO다.

“속성이 모두 같은 두 인스턴스가 같은 것인가?” 이름이 김철수이고 서울에 사는 회원이 두 명 있다면, 그 둘은 다른 회원이다. 엔티티다. 반면 서울시 강남구 역삼동이라는 주소 두 개는 같은 주소다. VO다.

“이 객체가 바뀌는가, 교체되는가?” 주문의 상태는 DRAFT에서 CONFIRMED로 바뀐다. 같은 주문 객체가 변한다. 엔티티다. 배송지를 바꿀 때는 기존 주소를 수정하지 않고 새 주소로 교체한다. VO다.

구분EntityValue Object
동일성 판단식별자모든 속성 값
가변성가변불변
생명주기있다없다
변경 방식내부 상태 변경통째로 교체
예시Order, User, ProductMoney, Address, DateRange

VO를 적극적으로 사용하라

흔히 보이는 문제 중 하나가 VO를 쓸 수 있는데 원시 타입을 쓰는 것이다.

// 원시 타입 남용: 금액이 int이고, 주소가 String이다
public class Order {
    private Long id;
    private int totalAmount;       // 통화는? 음수 허용?
    private String shippingCity;   // 유효성 검증은 어디서?
    private String shippingStreet;
    private String shippingZip;
}

totalAmountint면, 음수가 들어와도 컴파일러가 잡아주지 못한다. 원화와 달러를 더해도 에러가 나지 않는다. 주소 필드가 개별 String이면, 도시+거리+우편번호의 조합이 유효한지 검증하는 로직이 이 객체 바깥 어딘가에 흩어져야 한다.

VO로 감싸면 이 문제가 사라진다.

// VO로 감싸면 타입 시스템이 비즈니스 규칙을 강제한다
data class Money(val amount: BigDecimal, val currency: Currency) {
    init {
        require(amount >= BigDecimal.ZERO) { "금액은 0 이상이어야 한다" }
    }

    operator fun plus(other: Money): Money {
        require(currency == other.currency) { "통화가 같아야 한다" }
        return Money(amount + other.amount, currency)
    }
}

data class Address(val city: String, val street: String, val zipCode: String) {
    init {
        require(city.isNotBlank()) { "도시는 필수다" }
        require(zipCode.matches(Regex("\\d{5}"))) { "우편번호 형식이 맞지 않는다" }
    }
}

Money끼리 통화가 다르면 더할 수 없고, Address는 생성 시점에 유효성이 보장된다. 이런 VO가 쌓이면 타입 시스템이 비즈니스 규칙을 강제하는 상태에 도달한다. 런타임 에러가 줄고, 코드의 의도가 명확해진다.

ID 타입도 VO로 만드는 것이 좋다. Long 대신 OrderId, CustomerId를 쓰면 컴파일 타임에 주문 ID를 고객 ID 자리에 넣는 실수를 잡을 수 있다.

// ID도 VO로: 타입으로 실수를 방지한다
@JvmInline
value class OrderId(val value: Long)

@JvmInline
value class CustomerId(val value: Long)

// 이제 OrderId와 CustomerId를 혼동할 수 없다
fun findOrder(orderId: OrderId): Order = ...
fun findCustomer(customerId: CustomerId): Customer = ...

Kotlin의 value class(인라인 클래스)는 런타임 오버헤드 없이 타입 안전성을 제공하므로, ID 타입을 VO로 만드는 데 적합하다.

핵심 정리

이 편에서 다룬 내용을 정리하면 이렇다.


다음 편에서는 애그리게이트(Aggregate)와 리포지토리(Repository)를 다룬다. 여러 Entity와 VO가 모여 일관성의 경계를 이루는 구조, 그리고 그 경계 단위로 저장과 조회를 담당하는 리포지토리의 설계를 살펴보자.

5편: Aggregate와 Repository — 일관성의 경계와 저장


Related Posts

Share this post on:

Comments

Loading comments...


Previous Post
DDD 3편 — Context Mapping
Next Post
DDD 5편 — Aggregate와 Repository