1. 객체지향에서의 본질적인 구분
1-1. 왜 두 개념이 필요한가?
소프트웨어 설계에서 모든 객체를 동일하게 취급하면, 코드가 복잡해지고 유지보수성이 떨어진다. 그래서 객체의 ‘역할’에 따라 개념적으로 구분하는 것이 중요하다. Value Object와 Entity의 구분은 바로 이 설계 명확성을 높이기 위한 핵심적인 전략이다. Value Object는 그 값 자체가 중요한 객체이고, Entity는 고유한 정체성을 가진 객체다. 동일한 데이터를 가진 두 객체라도, Value Object라면 하나로 취급되지만, Entity라면 각각 별개의 존재다. 이 구분이 없다면 동일성, 식별, 변경 추적 등의 이슈가 설계 전반에 영향을 미치게 된다.
이러한 구분은 단지 철학적 개념이 아니라, 모델링의 의도와 코딩 구현 방식 모두에 영향을 주는 핵심 설계 기준이다. 어떤 개념을 Value Object로 표현할 것인가, 어떤 건 Entity로 표현할 것인가에 따라 도메인 로직의 구조가 완전히 달라질 수 있다.
1-2. 구별 기준은 ‘정체성’의 유무
Value Object와 Entity를 구분하는 가장 근본적인 기준은 정체성(identity) 의 유무다. 정체성이란 "이 객체는 무엇인가?"를 구별하는 기준으로, 객체가 생성 시점과 상태에 관계없이 동일한 식별자로 추적되어야 할 때 Entity로 본다. 반대로, 객체의 내용(값)이 같다면 동일한 것으로 간주되는 객체는 Value Object다.
예를 들어 ‘상품 가격’이나 ‘배송 주소’는 값이 동일하면 두 객체는 같다고 본다. 하지만 ‘주문’이나 ‘회원’은 같은 이름이나 주소를 갖고 있더라도, 서로 다른 존재로 인식된다. 이러한 구분을 통해 설계자는 모델의 책임을 명확히 나누고, 추적 가능한 정보와 단순 비교 가능한 정보를 체계적으로 구분할 수 있다. 이는 도메인 복잡도가 높을수록 더 큰 효과를 발휘한다.
1-3. 불변성과 변경 가능성의 구도
Value Object는 기본적으로 불변 객체(immutable) 로 설계한다. 값이 바뀌면 기존 객체를 수정하지 않고, 새로운 객체를 만들어 교체하는 방식이다. 이는 동시성 문제, 상태 추적, 복잡한 비교 로직에서 많은 이점을 준다.
반면, Entity는 상태 변경이 전제된 객체다. 사용자의 주소가 바뀌거나, 주문의 상태가 변경되는 것처럼, 시간에 따라 내부 값이 달라지더라도 그 객체의 정체성은 유지된다. 이러한 특성은 도메인 이벤트 처리나 변경 로그 추적 등에서 Entity가 반드시 필요한 이유다. 이처럼 두 객체의 생명주기 관리 전략이 완전히 다르기 때문에, 객체 유형을 명확히 구분하는 것이 설계 안정성을 높이는 첫걸음이다.
1-4. 실무에서 가장 흔한 오해
실제 프로젝트에서 가장 흔하게 벌어지는 실수는, 식별자 없이도 충분한 객체를 Entity로 설계하거나, 상태 변경이 필요한 객체를 Value Object로 고정하는 것이다. 이러한 오해는 결국 도메인 모델을 복잡하게 만들고, 객체 간 책임 분리를 흐리게 한다.
예를 들어 ‘주소’는 많은 개발자가 Address Entity를 만들고 id를 붙여놓지만, 실제로는 식별보다 값이 더 중요하고, 변경 시 추적할 필요도 없다면 명백한 Value Object다. 반대로 '할인 쿠폰'처럼 코드가 같다고 해도 유효 기간, 사용 여부 등에 따라 추적 관리가 필요하다면 Entity로 보는 것이 옳다. 이처럼 객체의 사용 목적과 정체성 여부를 중심으로 판단하는 사고 방식이 필요하다. 데이터 형태나 테이블 구조에 얽매이지 않고, 설계 의도를 기준으로 객체의 본질을 정의해야 한다.
2. Value Object의 특징과 활용 전략
2-1. 불변성: 변경이 아닌 교체로 설계하라
Value Object는 기본적으로 불변(immutable) 한 객체로 설계하는 것이 이상적이다. 불변 객체는 생성 후 내부 상태가 바뀌지 않기 때문에, 동시성 문제가 줄어들고, 로직의 예측 가능성이 높아진다. 이러한 특성은 특히 멀티스레드 환경이나 이벤트 기반 시스템에서 중요한 이점을 제공한다.
예를 들어 사용자 주소(Address)를 Value Object로 설계했다면, 주소가 바뀌었을 때 기존 객체를 수정하지 않는다.
대신 새로운 Address 객체를 생성하고, 엔터티에 덮어씌운다. 이 방식은 "값이 바뀐 객체는 다른 객체"라는 설계 철학을 반영하며, 시스템 상태를 추적하고 테스트하기 쉽게 만든다. 이러한 불변성은 Java의 record, Kotlin의 data class, C#의 struct 같은 언어적 지원 구조와도 잘 맞는다. Value Object는 가능하면 생성자에서 모든 값을 설정하고, 이후에는 상태 변경을 막는 구조로 설계하는 것이 가장 안전하다.
2-2. 값 기반 동일성: equals()와 hashCode() 재정의
Value Object는 값이 같으면 동일한 객체로 판단하기 때문에, 반드시 equals()와 hashCode()를 제대로 정의해야 한다. 객체의 ID가 아닌 속성 값 자체로 동일성을 비교하기 때문에, 기본 객체 비교(==)를 사용하는 것은 위험하다. 예를 들어 두 Address 객체가 city, street, zipcode 필드 모두 같다면 두 객체는 같은 것으로 간주해야 한다. 이를 위해 equals 메서드는 해당 필드들을 기반으로 비교하고, hashCode도 일관되게 구현해야 한다. 그렇지 않으면 Set, Map 등의 컬렉션에서 정상적으로 작동하지 않거나, 이상한 동작이 발생할 수 있다. Kotlin의 data class, Java 16 이상의 record, Lombok의 @EqualsAndHashCode 등은 이러한 반복 코드를 자동화해주는 도구로 유용하다. 핵심은 식별자가 아닌 값 그 자체가 객체 동일성의 기준이라는 점을 시스템 전반에 걸쳐 반영하는 것이다.
2-3. 설계 상의 응집성과 캡슐화
Value Object는 하나의 의미 단위를 표현하기 때문에, 응집도가 매우 높고 캡슐화하기 좋은 단위다. 여러 개의 필드를 하나의 객체로 묶음으로써, 도메인 모델이 더 명확하고 읽기 쉬워진다. 예를 들어 Price 객체는 amount와 currency를 하나로 묶어 표현할 수 있다. 이는 단순한 숫자 하나로 가격을 나타내는 것보다 훨씬 풍부한 의미를 가지며, 다른 객체가 이 정보를 조작하거나 계산할 때 더 정확하고 명확한 메서드(plus(), multiply(), discount())를 제공할 수 있다.
이처럼 Value Object는 도메인 의미를 코드에 자연스럽게 반영할 수 있는 구조이기 때문에, 비즈니스 모델을 풍부하고 의도적으로 표현할 때 큰 역할을 한다. 무의미한 Primitive 값 대신 Value Object를 활용하는 습관은 도메인 중심 설계의 첫걸음이다.
2-4. 실무 활용 시의 주의점과 팁
Value Object를 실제 코드에 적용할 때는 몇 가지 실무 팁을 유념해야 한다. 첫째, 불변 객체의 변경은 ‘교체’로 구현되어야 하므로, 엔터티가 이를 덮어쓰는 메서드를 제공해야 한다. 예: order.changeShippingAddress(new Address(...))
둘째, JPA와 함께 사용할 경우 @Embeddable 을 이용하면 테이블을 따로 나누지 않고도 Value Object를 쉽게 관리할 수 있다. 단, 이 경우 equals/hashCode를 반드시 구현해야 하며, 내부 필드의 null 처리나 기본 생성자 요구사항을 유의해야 한다.
셋째, 중복 방지를 위해 의미 있는 단위로 분리해야 한다. 예: PhoneNumber, EmailAddress, Period, Money, DiscountPolicy 등은 도메인마다 재사용 가능한 Value Object 후보군이다. 이러한 구조는 도메인을 문서처럼 표현할 수 있도록 해주며, 설계 의도를 그대로 코드에 드러내는 강력한 도구가 된다.
3. Entity의 특성과 관리 전략
3-1. 정체성을 중심으로 관리하는 객체
Entity는 시스템 내에서 고유한 정체성(identity) 을 기준으로 구별되는 객체다. 이는 단순히 값이 아니라, 존재 자체의 연속성과 추적 가능성을 유지하는 데 목적이 있다. 예를 들어 고객, 주문, 계약, 게시글 등은 생성 시점부터 종료 시점까지 고유 식별자(ID)로 관리되며, 시간이 지나며 상태가 바뀌더라도 동일한 객체로 간주된다.
이러한 식별자는 보통 UUID, Long id, 또는 복합 키로 구현되며, 데이터베이스에서도 기본 키(Primary Key)로 활용된다.
중요한 건 이 ID가 객체 생애 주기의 전 과정을 관통해 객체의 유일성을 보장한다는 점이다. 즉, 값이 같더라도 ID가 다르면 다른 객체, 값이 달라도 ID가 같으면 같은 객체로 간주된다. 정체성을 가진 Entity는 변경 가능한 속성을 갖고 있으며, 그 속성의 변화는 도메인 상태의 변화로 이어진다. 따라서 Entity는 단순한 데이터 컨테이너가 아니라, 시간에 따라 상태를 가지며 진화하는 객체로 다뤄져야 한다.
3-2. 생명주기와 상태 변화의 흐름
Entity는 도메인 내에서 시간에 따라 상태가 변화하는 객체이며, 이 흐름을 관리하는 것이 중요하다. 예를 들어 주문(Order)은 “생성됨 → 결제됨 → 배송 중 → 배송 완료”라는 일련의 상태 전환을 거친다. 이러한 상태 전이 과정은 단순 필드 변경이 아닌, 도메인 규칙과 행위에 따라 이뤄져야 한다. 따라서 Entity는 자신의 상태를 변경하는 행위를 스스로의 책임으로 수행하도록 설계하는 것이 바람직하다. order.markAsShipped(), user.changePassword()처럼 의미 있는 메서드로 상태 전이를 표현해야 한다. 이 방식은 코드의 의도를 명확히 전달하고, 상태 변경에 대한 유효성 검증을 하나의 책임 아래 묶어줄 수 있다.
또한 Entity의 생명주기에는 생성, 저장, 조회, 갱신, 삭제 등의 흐름이 포함되며, 이 중 비즈니스 의미를 담아야 하는 단계에는 명확한 도메인 메서드가 존재해야 한다. 그렇지 않으면 단순 CRUD 레벨에서 로직이 분산되고, 시스템이 비즈니스 중심성을 잃게 된다.
3-3. 동등성 비교: ID 기반의 equals/hashCode
Entity는 고유 식별자(ID)로 동일성을 판단하므로, equals()와 hashCode()도 반드시 ID 중심으로 정의해야 한다.
이는 특히 컬렉션(Set, Map 등)에서 Entity를 사용할 때 필수적인 규칙이다. 값이 같더라도 ID가 다르면 다른 객체로 인식되어야 하며, 이는 Value Object와의 가장 큰 차이점 중 하나다. 예를 들어 다음과 같은 구조가 바람직하다.
@Override
public boolean equals(Object o) {
if (this == o) return true;
if (!(o instanceof Order)) return false;
Order other = (Order) o;
return id != null && id.equals(other.id);
}
@Override
public int hashCode() {
return Objects.hashCode(id);
}
이렇게 설계하면 객체 간 비교는 언제나 ID 중심으로 이루어지고, 불필요한 속성 비교로 인한 성능 저하나 논리 오류를 방지할 수 있다. 단, ID가 할당되지 않은 신규 객체는 equals 비교 시 주의가 필요하다.
3-4. 엔티티 관리 시 주의할 실무 포인트
실무에서는 Entity가 지나치게 많은 책임을 갖지 않도록 하는 것이 중요하다. Entity는 비즈니스 규칙을 표현하는 핵심 객체지만, 인프라 로직, UI 의존, DTO 변환 등은 외부로 분리해야 한다. SRP(Single Responsibility Principle)를 지키지 않으면 도메인 로직이 오염되고 테스트하기 어려운 구조가 된다. 또한 Entity의 생성 시점은 도메인 규칙이 가장 많이 적용되어야 하는 부분이다. new 키워드를 직접 쓰기보다는 정적 팩토리 메서드(Order.createNew(...))를 통해 의도와 유효성 검증을 함께 처리하는 방식이 훨씬 명확하고 안전하다.
마지막으로 Entity 간의 연관 관계(1:N, N:1 등)는 지연 로딩, 순환 참조, 상태 동기화 이슈 등 다양한 문제를 동반하므로,
설계 초기부터 도메인 중심의 방향성과 트랜잭션 범위를 고려한 접근이 필요하다. 복잡한 연관 관계는 Aggregate로 묶거나, 도메인 이벤트를 통한 간접 연결로 구조화하는 것이 유지보수에 유리하다.
4. 구체 예시로 보는 차이점
4-1. Address: 가장 흔한 Value Object의 대표 예시
‘주소(Address)’는 실무에서 자주 등장하는 Value Object 사례다. 서울시 강남구 테헤란로 123이라는 주소는, 누가 사용하든 동일한 의미를 갖는다. 그 자체로 중요한 것은 '주소의 값'이지, '주소라는 객체의 정체성'이 아니다. 따라서 동일한 주소 값이 여러 사용자에 의해 공유되어도 문제가 되지 않으며, 오히려 불변 객체로 다루면 성능, 관리, 이해도 측면에서 모두 이점이 크다.
주소 변경은 기존 Address 인스턴스를 수정하는 것이 아니라, 새로운 Address 객체를 생성해 교체하는 방식으로 처리한다. 이처럼 Address는 equals/hashCode를 값 기준으로 정의해야 하며, JPA에서는 @Embeddable로 매핑하여 별도 테이블 없이 관리할 수 있다. 주소의 각 필드(city, street, zipCode)는 강한 결합을 이루며, 하나의 응집된 의미 단위로 취급된다.
4-2. User: 변경 가능한 Entity의 대표 모델
‘사용자(User)’는 명백한 Entity다. 사용자는 회원 가입 시 생성되며, 고유 ID(email 또는 userId)로 시스템 내에서 식별되고, 이후 비밀번호 변경, 프로필 수정, 로그인 상태 갱신 등 다양한 상태 변화가 일어난다. 이러한 변화가 있어도 그 사용자는 여전히 같은 사용자로 간주되어야 하므로, User는 정체성을 기반으로 관리되는 Entity가 된다. 데이터베이스에서도 User는 항상 Primary Key를 기반으로 저장되고 조회되며, 그 ID는 평생 유지된다.
또한 사용자 간 동일성을 비교할 때는 절대로 이름이나 생일 같은 정보가 아니라, ID를 기반으로 비교해야 한다. 즉, user1.equals(user2)는 user1.getId().equals(user2.getId())를 의미하며, 사용자가 가진 데이터는 변할 수 있어도, 정체성은 변하지 않는다는 설계 원칙을 따르게 된다.
4-3. Money: 복잡한 도메인 계산의 Value Object 활용
‘Money’는 단순히 정수나 실수 값으로 처리할 수도 있지만, 금액을 객체로 추상화하면 더 풍부하고 안전한 도메인 모델이 된다. 예를 들어 Money(amount, currency)처럼 구성하면, 통화 단위가 다를 때 잘못된 계산을 방지할 수 있다. 또한 금액 연산(+, -, 할인, 세금 계산 등)을 add(), subtract(), multiply() 같은 도메인 메서드로 구현함으로써 비즈니스 로직이 도메인 객체 내부에서 응집력을 갖고 유지된다. 외부 코드에서는 더 이상 단순 숫자 연산이 아닌, 의미 있는 금액 연산을 호출하게 되는 것이다. Money는 그 자체로 불변 객체로 관리되며, equals는 값 기준으로 비교한다. 이는 금액이라는 단위를 도메인에서 더 안전하고 명확하게 표현할 수 있도록 해준다.
예: new Money(1000, "KRW").equals(new Money(1000, "KRW")) == true
4-4. Order: 상태 전이와 도메인 이벤트가 수반되는 Entity
‘주문(Order)’ 객체는 매우 전형적인 Entity다. 고유 주문 번호(orderId)를 가지며, 생성 → 결제 대기 → 결제 완료 → 배송 → 완료로 상태가 계속 변한다. 이 상태 전이는 단순 필드 변경이 아닌, 비즈니스 규칙에 따라 제한되고 트리거되어야 한다.
예를 들어 order.markAsShipped()는 단순히 status = SHIPPED가 아니라, ‘배송 가능한 상태인지’, ‘재고가 확보되었는지’, ‘사용자가 유효한지’ 등의 체크를 수행해야 한다.
이러한 도메인 로직은 Order 엔터티 안에 캡슐화되어 있어야 하며, 외부에서 임의로 조작되어서는 안 된다. 또한 주문 생성 후 ‘주문 생성됨’ 이벤트를 발행하거나, 결제 완료 후 알림을 보내는 식으로 도메인 이벤트 기반 확장 구조도 Entity에서 자연스럽게 발생할 수 있다. 이처럼 Order는 식별, 상태 변경, 연관 관계, 도메인 행위를 모두 갖춘 완전한 Entity의 예시다.
5. 설계 시 올바른 선택 기준
5-1. 비즈니스의 요구에 따라 선택하라
Value Object와 Entity는 기능적으로 구현하는 개념이 아니라, 비즈니스 요구에 따라 설계적으로 구분되는 철학이다. 즉, 어떤 객체를 어떤 방식으로 다룰 것인가는, 해당 객체가 도메인 내에서 어떤 역할을 맡는지에 따라 결정되어야 한다. 예를 들어 이메일 주소는 단지 텍스트처럼 보이지만, 사용자 식별에 사용되면 Entity 속성이 필요할 수 있다. 반대로 배송 정보는 객체처럼 보이지만 추적 대상이 아니라면 Value Object로 설계하는 것이 더 낫다. 형태가 아니라 의미와 책임을 기준으로 판단해야 올바른 객체 설계가 된다. 가장 중요한 것은, “이 객체를 시스템 내에서 고유하게 추적해야 하는가?”, 그리고 “변화에 따라 식별을 유지해야 하는가?”를 자문해보는 것이다. 이 기준만 명확히 해도, 대부분의 객체를 Entity와 Value Object 중 어디에 속할지 쉽게 판별할 수 있다.
5-2. 변경과 동일성에 대한 전략을 구분하라
설계 시 반드시 고려해야 할 또 하나의 포인트는 변경 전략과 동일성 판단 방식이다. Value Object는 변경이 불가능하므로, 교체 전략을 사용해야 한다. Entity는 상태 변경을 전제로 설계되므로, 내부 로직으로 변화 과정을 제어해야 한다. 동일성 판단도 다르다. Value Object는 속성 비교로 판단하며, Entity는 ID를 기준으로 판단해야 한다. 이 구분이 모호하면 Set, Map 등 컬렉션의 동작이나 비교 연산에서 예기치 못한 문제가 생길 수 있다.
예를 들어 JPA 엔티티의 equals/hashCode 구현을 ID 기반으로 하지 않으면, 동일한 객체가 여러 번 저장되거나, 컬렉션에서 정상적으로 제거되지 않는 문제가 생긴다. 따라서 설계 시점에 이 두 전략을 명확히 구분하고, 코드로 실현할 수 있어야 한다.
5-3. 작은 Value Object부터 점진적으로 적용하라
처음부터 모든 객체를 Value Object와 Entity로 엄격히 나누는 것은 어렵다. 실제 프로젝트에서는 작고 단순한 Value Object부터 먼저 분리하는 전략이 효과적이다. 예를 들어 Email, PhoneNumber, Address, Money, Percentage, Period 같은 것부터 시작하는 것이다. 이러한 객체들은 단순한 String이나 int로 표현하면 반복 코드가 늘고, 의미도 약해진다.
하지만 도메인 개념 단위로 객체화하면, 그 안에 유효성 검증, 계산, 포맷 등의 책임을 줄 수 있다. 이것만으로도 도메인 로직이 크게 단순화되고, 테스트도 훨씬 쉬워진다. 점점 복잡해지는 도메인 로직을 감싸는 구조로 Value Object를 확장하다 보면, 자연스럽게 핵심 개념은 Entity로, 부수적 의미는 VO로 정착되는 자연스러운 경계 분리가 가능해진다.
5-4. 잘못된 설계 신호를 감지하라
설계가 올바르게 구분되지 않았을 때는 다양한 증상이 코드에서 나타난다. 예를 들어 Entity가 너무 많은 필드를 가지고 있고, 내부 상태를 제어하는 메서드가 없으며, equals/hashCode가 기본 구현이라면 이는 단순한 데이터 덩어리에 불과하다.
또한 VO가 객체로 정의되어 있지만 불변이 아니거나, 내부 필드가 null 상태를 허용하고, 여러 곳에서 외부에서 직접 조작되고 있다면, 이는 VO의 장점을 살리지 못한 설계다.
이런 경우 리팩토링 시점에 반드시 “이 객체가 무엇을 표현해야 하는가”라는 본질로 돌아가야 한다. 궁극적으로 중요한 건 객체가 책임을 가지고 있고, 그 책임이 일관되게 설계되어 있는지다. Entity든 VO든, 그 본질은 “도메인을 더 잘 표현하고 유지보수 가능하게 만드는 것”이라는 점을 잊지 말아야 한다.
'컴퓨터공학' 카테고리의 다른 글
이벤트 소싱과 CQRS (0) | 2025.05.24 |
---|---|
애그리거트와 리포지토리 패턴 (0) | 2025.05.24 |
도메인 주도 설계(DDD) 핵심 (0) | 2025.05.23 |
전략, 옵저버, 커맨드 패턴 (0) | 2025.05.23 |
싱글턴과 DI 컨테이너 구조 (0) | 2025.05.23 |