Entity와 Value Object

개요

Evans는 모델 요소를 Entity, Value Object, Service로 분류했다. Fowler는 이를 Evans Classification이라고 부르며, 프로그래밍 언어와 다이어그램 표기법 어느 쪽도 채우지 못했던 사고의 공백을 메웠다고 평가한다.

Entity와 Value Object를 가르는 기준은 동일성(identity) 이다. 필드가 몇 개인지, 얼마나 복잡한지는 기준이 아니다.


Entity

정의

많은 객체가 속성이 변하더라도 생애주기를 관통하는 연속성과 동일성을 나타낸다. 어떤 객체는 주로 속성으로 정의되지 않는다. 이들은 시간을 관통하고 종종 서로 다른 표현을 가로지르는 동일성의 실을 나타낸다.

Evans의 문제 서술은 다음과 같다.

때로는 속성이 다르더라도 어떤 객체가 다른 객체와 같은 것으로 매칭되어야 한다. 반대로 속성이 같더라도 다른 객체와 구별되어야 한다. 동일성을 잘못 판단하면 데이터가 오염될 수 있다.

처방은 이렇다.

객체가 속성이 아니라 동일성으로 구별될 때, 그것을 모델에서 그 객체 정의의 일차적 요소로 삼아라. 클래스 정의를 단순하게 유지하고 생애주기 연속성과 동일성에 집중시켜라.

형태나 이력에 관계없이 각 객체를 구별할 수단을 정의하라. 속성으로 객체를 매칭하려는 요구사항을 경계하라. 각 객체마다 고유한 결과를 산출하도록 보장된 연산을 정의하라.

모델은 무엇이 같은 것인지를 정의해야 한다.

Entity는 Reference Object라고도 불린다.

식별자 전략

Microsoft의 가이드는 식별자 선택을 의식적으로 하라고 조언한다.

방식성격
자연 키(natural key)주문번호, 사업자등록번호처럼 비즈니스 의미를 담고 시스템 간에 인식된다
대리 키(surrogate key)UUID처럼 비즈니스 의미가 없지만 외부 시스템과의 결합을 피한다

식별자가 사용자에게 직접 노출될 필요는 없다. 마이크로서비스 환경에서는 다른 서비스가 식별자로 Entity를 참조하므로 식별자가 서비스 경계를 넘어 안정적이고 유의미하게 유지되어야 한다. 하나의 식별자가 여러 Bounded Context에 걸칠 수 있고, 애플리케이션의 수명보다 오래 지속될 수도 있다.

행위를 가져야 한다

Entity는 데이터만 나르는 것이 아니라 행위를 캡슐화해야 한다. 비즈니스 로직이 Entity 밖 서비스 클래스에 있으면 Anemic Domain Model이 된다. 검증, 상태 전이, 비즈니스 규칙을 Entity 안에 둔다.

// 규칙이 Entity 안에
showSeat.hold(reservationId, now.plusMinutes(7))

// 규칙이 밖에 (빈혈)
showSeat.setStatus("HELD")
showSeat.setHeldUntil(now.plusMinutes(7))
showSeat.setHeldByReservationId(reservationId)

Value Object

정의

어떤 객체는 사물의 특성을 기술하거나 계산한다. 많은 객체는 개념적 동일성을 갖지 않는다.

Evans의 문제 서술은 이렇다.

Entity의 동일성을 추적하는 것은 필수적이지만, 다른 객체에까지 동일성을 부여하면 시스템 성능을 해치고, 분석 작업을 늘리며, 모든 객체가 똑같아 보이게 만들어 모델을 흐린다. 소프트웨어 설계는 복잡성과의 끊임없는 싸움이므로 특별한 처리는 필요한 곳에만 적용되도록 구분해야 한다.

그렇다고 이 범주의 객체를 단지 “동일성의 부재"로만 생각하면 얻는 것이 없다. 사실 이 객체들은 자기 나름의 특성과 모델에 대한 자기 나름의 의의를 가진다. 이들은 사물을 기술하는 객체다.

처방은 이렇다.

모델 요소의 속성과 로직에만 관심이 있다면 그것을 Value Object로 분류하라. 그것이 전달하는 속성의 의미를 표현하게 하고 관련 기능을 부여하라.

Value Object를 불변으로 다뤄라. 모든 연산을 가변 상태에 의존하지 않는 부작용 없는 함수로 만들어라. Value Object에 동일성을 부여하지 말고, Entity 유지에 필요한 설계 복잡성을 피하라.

실무 지침

  • Value Object는 불변이다. 갱신하려면 새 인스턴스를 만들어 기존 것을 교체한다. 불변 객체는 스레드 간 공유가 가능하고, 방어적 복사 없이 캐시할 수 있으며, 분산 시스템에서 추론하기 쉽다
  • Value Object가 도메인 로직을 캡슐화하는 메서드를 가질 수 있지만, 그 메서드는 부작용을 만들지 않고 새 Value Object를 반환한다
  • 기본 선택지를 Value Object로 삼는다. 시간에 따라 동일성을 추적해야 할 때만 Entity로 승격시킨다

주소가 좋은 예다. 도로명·시·우편번호가 같은 두 주소는 서로 교환 가능하므로 보통 Value Object다. 그러나 감사 목적 등으로 특정 주소 레코드를 시간에 따라 추적해야 하는 도메인이라면 Entity가 된다.

sealed 타입으로 표현 불가능한 상태 막기

상태를 Value Object로 모델링할 때, 상태마다 필요한 데이터가 다르면 열거형으로는 부족하다.

sealed interface SeatStatus {
    data object Available : SeatStatus
    data class Held(val reservationId: ReservationId, val heldUntil: Instant) : SeatStatus
    data class Sold(val reservationId: ReservationId) : SeatStatus
}

HeldheldUntil을 가진다. 열거형으로 만들면 heldUntil을 nullable 필드로 밖에 둘 수 없고, 그러면 “AVAILABLE인데 heldUntil이 들어있는” 표현 불가능한 상태가 컴파일된다. sealed 계층은 그것을 타입 수준에서 막고, when이 exhaustive해지므로 상태를 추가하면 누락 지점을 컴파일러가 잡아준다.

Held(reservationId, t) 두 개는 완전히 같은 것이고, 상태를 바꿀 때는 필드를 수정하는 대신 새 인스턴스로 교체한다. Value Object의 정의에 정확히 부합한다.


Entity와 Value Object 비교

EntityValue Object
동일성식별자로 구별. 속성이 달라도 같은 것일 수 있다값으로 동등성 판단. 값이 다르면 다른 것
가변성상태가 변한다불변. 교체한다
추적생애주기를 추적한다추적하지 않는다
저장별도 추적 저장이 필요한 경우가 많다Root와 함께 직렬화될 수 있다
판단 질문“이것을 시간에 따라 추적해야 하는가”“값이 같으면 같은 것인가”

Vernon은 Aggregate 설계에서 Value Object를 선호하라고 권한다. 포함할 부분이 스스로 변해야 하는지, 아니면 변경이 필요할 때 완전히 교체될 수 있는지 물어보면 상당수가 Value Object로 리팩터링된다. Value Object는 더 작고, 버그가 적고, 불변성 덕분에 단위 테스트로 정확성을 증명하기 쉽다.


JPA에서의 주의점

Kotlin에서 data class는 Value Object에 정답이지만 JPA Entity에 쓰면 안 된다.

  • equals/hashCode가 모든 필드 기반으로 생성되어 지연 로딩 프록시 및 영속성 컨텍스트와 충돌한다
  • copy()가 캡슐화를 뚫는다

Entity는 class + 식별자 기반 동등성으로, Value Object는 data class로 만든다.


Domain Event

Evans가 2004년 원서 이후 추가한 패턴이다. 도메인 전문가가 관심을 갖는 무언가가 일어났다는 것을 표현한다.

Entity는 자신의 상태와 생애주기를 규율하는 규칙을 추적할 책임을 진다. 그러나 상태 변경의 실제 원인을 알아야 한다면 그것은 보통 명시적이지 않고, 시스템이 왜 지금 상태가 되었는지 설명하기 어려워진다. 감사 로그는 추적을 허용하지만 프로그램 자체의 로직에 쓰기에는 적합하지 않은 경우가 많다.

분산 시스템에서는 별개의 문제가 추가된다. 분산 시스템의 상태는 항상 완전히 일관될 수 없다. Aggregate는 내부적으로 항상 일관되게 유지하면서 다른 변경은 비동기로 처리한다.

처방은 다음과 같다.

도메인 활동에 대한 정보를 일련의 이산적 이벤트로 모델링하고, 각 이벤트를 도메인 객체로 표현하라. 이것은 소프트웨어 자체의 활동을 반영하는 시스템 이벤트와 구별된다.

무관한 도메인 활동은 무시하고, 도메인 전문가가 추적하거나 통지받기를 원하는 이벤트, 또는 다른 모델 객체의 상태 변경과 연관된 이벤트를 명시적으로 드러내라.

특징은 다음과 같다.

  • Domain Event는 과거의 기록이므로 통상 불변이다
  • 이벤트 기술 외에 이벤트가 발생한 시각관련된 Entity의 식별자를 담는 것이 일반적이다
  • 시스템에 입력된 시각과 입력한 사람의 식별자를 별도로 담는 경우도 많다
  • 이 속성들의 조합으로 이벤트 식별자를 만들면, 같은 이벤트의 두 인스턴스가 한 노드에 도착했을 때 동일한 것으로 인식할 수 있다

분산 시스템에서는 특정 노드가 현재 알고 있는 Domain Event들로부터 Entity의 상태를 추론할 수 있어, 시스템 전체 정보가 없어도 일관된 모델을 유지할 수 있다.

내부 이벤트와 통합 이벤트

마이크로서비스 환경에서는 이 구분이 중요하다.

  • 내부 Domain Event는 Bounded Context 안에 머문다
  • 통합 이벤트(integration event) 는 그것을 발생시킨 트랜잭션이 커밋된 뒤 메시지 브로커를 통해 비동기로 발행된다

레코드가 테이블에 삽입되었다는 것은 Domain Event가 아니다. 배송이 취소되었다는 것이 Domain Event다.

Domain Event는 처음부터 넣기보다 Aggregate 간 결과적 일관성이 실제로 필요해지는 시점에 도입하는 편이 낫다. 그때 자연스럽게 필요해진다.


관련 개념


참고 자료