[DDD 설계의 필수 개념] 엔티티와 값 객체 차이 알아보기

저번 시간에는 엔티티에 대해 알아보았는데요. 이번 포스팅에는 엔티티와 값 객체에 대해 작성하려고 합니다. 포스팅은 도서 <단위 테스트="">의 저자 블라디미르 코리코프의 "Entity vs Value Object: the ultimate list of differences" 포스팅을 재구성하여 작성하였습니다.

먼저, 용어를 명확히 하고자 합니다. 값 객체는 영어로 “Value Object”라 불리며, 약어로 “VO”라고도 합니다. 그러나 본문에서는 통일성을 위해 값 객체라는 명칭을 사용하겠습니다.

1. 엔티티 대 값 객체: 같다는 기준 (Entity vs Value Object: types of equality)

엔티티와 값 객체가 첫 번째로 차이를 보이는 곳은 같다고 비교하는 것에서 차이를 보인다.

동등성이 영어로 equality인데, 뒤에 나오겠지만 Reference Equlity도 equlity로 표현하므로 조금 더 포괄적인 의미로 받아드려 ‘같음’이라 번역하였습니다.

또한 이전 시간에 동등성과 동일성 자바로 이해하기 란 포스팅에서 동일성과 동등성에 대해 자세하게 다뤘으니 참고하면 좋습니다.

이를 비교하기 전에 ‘같음’을 비교하는 것에는 세 가지 구분이 존재한다.

참조가 같음(Reference equality)

서로 다른 두 객체가 같은 참조를 바라볼 때 같다는 것이다.

서로 다른 두 객체가 같은 참조를 바라보고 있는 이미지

1
2
3
var green = new Color(0, 255, 0); // r: 0, g: 255, b: 0
var anotherGreen = green;
green == anotherGreen; // true

이러한 경우 동일성이라고도 부른다.

식별자가 같음(Identifier equality)

식별자 같음의 기준은 서로 다른 두 클래스가 Id와 같은 고유 식별자가 있을 때 식별자가 동일하면 같다고 하는 것이다.

서로 다른 두 객체의 Id가 같은 사진

구조적인 같음(Structural equality)

두 객체의 모든 멤버가 같을 때 구조적으로 같다고 한다. 이를 동등성이라고도 표현한다.

서로 다른 두 객체의 필드가 모두 같은 사진

엔티티와 값 객체는 객체를 비교하는 방식에서 중요한 차이를 보인다.

  • 엔티티는 식별자가 같으면 같다고 판단한다.
  • 값 객체는 구조적으로 같으면 같다고 판단한다.

A가 소유한 ‘5천 원’과 B가 소유한 ‘5천 원’은 서로 가치자 같다. 또 A와 B의 5천이 서로 교환하더라도 가치가 동일하기 때문에 상호 교환이 가능하다.

하지만 엔티티는 사뭇 다르다. 서로 다른 A와 B가 이름도 같고 성별도 같고 심지어 나이까지 같더라도 Id가 다르면 다른 엔티티로 간주한다.

여담: Spring Data JPA는 엔티티의 ‘동일성’을 보장한다.

기본적으로 JPA는 영속성 컨텍스트의 1차 캐시를 통해 엔티티를 관리한다.

특히 엔티티를 다룰 때, 처음 로드된 엔티티가 프록시(Proxy)인지 아닌지에 따라 이후 로드된 동일 엔티티의 동작이 달라진다.

  1. 초기 로드된 엔티티가 프록시인 경우 이후 동일한 엔티티를 로드할 때도 프록시로 반환한다.
  2. 초기 로드된 엔티티가 프록시가 아닌 경우 이후 동일 엔티티도 프록시가 아닌 실제 엔티티로 반환한다. (fetchJoin 등을 하지 않았어도)

영속성 엔티티의 동일성 보장 이미지

김영한님의 <자바 ORM 표준 JPA 프로그래밍 - 기본편> 참조하였습니다.

결과적으로, JPA는 같은 엔티티를 항상 동일한 참조(Reference)를 보장하므로 == 비교를 통해도 비교할 수 있다.

반면 값 객체는 구조적으로 모든 멤버가 같아야 같음을 보장해야 한다. 즉 모든 멤버가 동일하면 equals 메소드 등을 통해 비교할 때 같아야 한다. 따라서 값 객체를 설계할 때는 최소한 롬복의 @EqualsAndHashCode 어노테이션을 사용하거나 직접 Equals와 Hashcode를 구현해야 한다고 생각한다.

2. 엔티티 대 값 객체: 생명주기(Entity vs Value Object: lifespan)

두 번째 차이점은 생명 주기이다. 엔티티는 연속적인 상태로 존재하며저장하지 않더라도 히스토리와 생명주기를 갖는다.

반면, 값 객체는 별도의 생명 주기를 가지지 않는다. 쉽게 생성하고 파괴할 수 있으며, 필요할 경우 기존 객체를 대체해 새로 생성하는 것이 일반적이다.

값 객체는 혼자서는 존재할 수 없다. 항상 하나 또는 여러 개의 엔티티에 속해야 한다. 엔티티가 값 객체를 참조할 때 비로소 값 객체는 의미를 갖는다.

또한 값 객체는 별도로 저장하지 않는다. 값 객체를 저장하는 유일한 방법은 엔티티에 첨부하여 저장하는 것이다.

“가진 돈” 이란 문장은 적절한 맥락을 전달하지 못하기 때문에 의미가 없다. 하지만 “A가 가진 돈”이란 문장은 완벽하게 의미를 가진다. ‘돈’은 값 객체이고, A라는 엔티티가 존재해야만 의미를 가질 수 있다.

3. 엔티티 대 값 객체: 불변성(Entity vs Value Object: immutability)

세 번째 차이점은 불변성이다. 값 객체는 불변해야 한다. 엔티티는 변경할 수 있다. 만약 값 객체를 수정해야 한다면, 기존 객체를 변경하는 대신 새로운 인스턴스를 생성해 대체해야 한다. 값 객체는 불변해야 하기 때문이다. 반면, 엔티티는 변경이 가능하다.

값 객체의 수정 가능성과 불변성은 연관성이 깊다. 만약 값 객체를 수정할 수 있으면, 스스로 고유한 생명 주기를 가지게 된다. 이는 값 객체가 고유한 정체성(identity)을 가지고 있다는 의미이며 이는 DDD 개념 정의와 모순된다. 값 객체가 불변이 아니라면 그것은 값 객체가 아니다.

4. 도메인 모델에서 값 객체를 인식하는 방법은 무엇일까? (How to recognize a value object in your domain model?)

도메인 모델이 엔티티인지 값 객체인지는 딱 무엇이라 정의 내리기 힘들다. 도메인에 따라 동일한 개념이 다르게 해석될 수 있기 때문이다.

도메인 모델에 대해 포스팅 가장 상단의 ‘저번 시간’ 포스팅을 통해 자세히 다뤘습니다.

예를 들어, “돈”은 대부분의 도메인에서 값 객체로 볼 수 있지만, 돈의 흐름을 추적해야 하는 어플리케이션이라면 엔티티로 볼 수 있다.

값 객체를 구분할 수 있는 몇 가지 기준이 있다. 만약 객체가 동일한 속성을 가진 다른 클래스로 안전하게 대체할 수 있다면 해당 도메인 모델이 값 객체라는 좋은 신호다.

더 쉬운 기준은 값 객체를 ‘정수;라고 생각하는 것이다. 우리는 정수 5를 사용할 때 다른 메소드에서 사용한 정수 5와 지금 사용하고 있는 정수 5가 동일한지 고민하지 않는다. 어플리케이션 안에서 모든 정수 5는 동일하다. 따라서 본질적으로 정수는 값 객체다. 도메인 중에서 정수처럼 보이는 도메인이 있다면 그 도메인은 값 객체이다.

5. 값 객체의 데이터베이스 저장 방식 (How to store value objects in the database?)

값 객체를 데이터베이스에 저장할 때는, 별도의 테이블을 생성하지 않는다. 만약 사람(Person)과 주소(Address) 도메인 모델이 있다고 가정하자.

1
2
3
4
5
6
7
8
9
10
11
12
// 엔티티
class Person {
    Long id;
    String name;
    Address address;
}

// 값 객체
class Address {
    String city;
    String zipCode;
}

데이터베이스 구조를 어떻게 가져가면 좋을까? 한 가지 방법은 각자 각자만의 테이블을 만드는 것이다.

Person 테이블이 Address 테이블을 참조하고 있는 이미지

이런 설계는 DB 관점에서 유효하지만 두 가지 문제가 발생한다.

  1. Address 테이블에 고유 식별자가 존재한다. 이는 값 객체에 별도로 Id 필드를 도입해야 한다는 것이다. 즉, Address 클래스에 고유한 정체성(Identity)을 부여한다는 것이며 이는 곧 값 객체 정의를 위반하는 것이다.
  2. Address가 Person과 독립적으로 존재할 수 있어 값 객체의 수명 주기 규칙을 위반한다. 만약 Person 테이블의 로우를 삭제하더라도 Address 로우는 삭제하지 않았기 때문에 그 자체로 존재할 수 있게 된다. 이는 값 객체의 수명이 본인을 감싸고 있는 엔티티에 전적으로 의존해야 한다는 규칙을 위반하는 것이다.

좋은 해결책은 Address 테이블의 필드를 Person 테이블에 삽입하는 것이다.

Person에 Address 테이블을 합쳐진 이미지

이제 모든 문제가 해결되었다. 주소는 ID를 가지지 않고, 주소의 수명은 완벽히 Person에 의존한다.

이런 설계는 값 객체를 정수로 대체하는 방법과도 연관된다. 우리가 도메인 모델에서 정수를 사용할 때 별도의 테이블을 생성하는지 생각해보자. 나이와 같은 정수는 원하는 테이블에 컬럼으로 추가할 뿐 별도의 테이블로 분리하지 않는다. 값 객체도 동일하다. 값 객체를 별도의 테이블을 빼지 않고 상위 엔티티의 테이블의 로우로 삽입하자.

6. 엔티티 보다는 값 객체 선호하기 (Prefer value objects over entities)

엔티티와 값 객체를 고민할 때 중요한 것은 엔티티 보다는 값 객체를 선호해야 한다는 것이다. 엔티티와 달리 값 객체는 불변하다. 그리고 엔티티보다 가볍다. 따라서 작업하기 수월하다. 따라서 비즈니스 로직에서는 값 객체를 우선적으로 사용하고 엔티티는 값 객체를 감싸는 래퍼(wrapper) 역할을 하도록 설계하는 것이 이상적이다.

처음에는 엔티티로 보이던 것이 본질적으로 값 객체일 수도 있다. 예를 들어 Address를 살펴 보자. ‘주소’란 개념은 처음에 엔티티로 도입될 수 있다. 자체적으로 Id를 만들고, DB에 별도의 테이블로 저장하는 것이다.

하지만 다시 보면 도메인 모델에서 ‘주소’란 개념은 ID 없이 서로 바꿔서 사용할 수 있다는 것을 알 수 있다. 정수로도 치환 가능하다. 이런 경우에는 과감히 도메인 모델을 리팩토링하여 엔티티를 값 개체로 전환하자.

7. 요약

엔티티와 값 객체에 대해 요약하면 다음과 같다.

  • 엔티티는 고유 식별자를 가지지만 값 객체는 가지지 않는다.
  • 식별자로 같음을 구분하는 것은 엔티티, 구조적으로 모든 멤버가 같아야 같다고 구분하는 것(동등성) 은 값 객체, 바라보는 참조가 같을 때 같은 개념(동일성)은 이 모두에게 해당한다.
  • 엔티티는 히스토리와 수명이 있지만, 값 객체는 없다.
  • 값 객체는 항상 하나 또는 여러 개의 엔티티에 속해야 하고 단독으로 존재할 수 없다.
  • 값 객체는 불변이어야 하지만 엔티티는 항상 변경 가능하다.
  • 도메인 모델에서 값 객체를 인식하는 방법은 도메인을 ‘정수’로 생각하는 것이다.
  • 값 객체는 데이터베이스에 자체적인 테이블이 있어서는 안 된다.
  • 도메인 모델에서는 항상 엔티티보다는 값 객체를 우선해야 한다.