본문 바로가기
카테고리 없음

엔티티 & 값 객체 (Entity & Value Object)

by SuldenLion 2026. 3. 6.
반응형
엔티티 & 값 객체 | 도메인 주도 설계 (DDD)
DDD
도메인 주도 설계
Domain-Driven Design

엔티티 &
값 객체

Entity & Value Object — 도메인 모델의 두 근원

도메인 모델을 구성하는 가장 기본적인 두 빌딩 블록. 엔티티는 누구인가로, 값 객체는 무엇인가로 정의된다. 이 둘을 정확히 구분하는 것이 좋은 DDD 설계의 출발점이며, 잘못된 구분은 불필요한 복잡성과 버그의 온상이 된다. 동일성·불변성·사이드이펙트 없는 설계까지 완전히 해부한다.

도메인 주도 설계 시리즈 읽기 시간 약 20분 난이도 입문~중급
01

DDD의 빌딩 블록 — 전체 지도

도메인 주도 설계(Domain-Driven Design)는 복잡한 비즈니스 문제를 소프트웨어 모델로 표현하는 방법론이다. Eric Evans가 2003년 저서 「Domain-Driven Design」에서 체계화했으며, 도메인 전문가의 언어로 코드를 구성하는 것이 핵심이다. DDD의 전술적 패턴은 여러 빌딩 블록으로 이루어지며, 그 가운데 엔티티(Entity)값 객체(Value Object)가 가장 기본적인 두 요소다.

🏷️

엔티티 (Entity)

고유한 식별자(ID)를 가지는 객체. 속성이 바뀌어도 동일한 존재. 생명주기가 있다.

💎

값 객체 (Value Object)

속성의 집합으로 정의되는 객체. ID 없음. 불변. 값이 같으면 같은 것.

🧩

애그리게이트 (Aggregate)

엔티티와 값 객체의 클러스터. 일관성 경계. 루트 엔티티를 통해서만 접근.

🏭

팩토리 (Factory)

복잡한 객체 생성 로직 캡슐화. 생성자가 너무 복잡할 때 도메인 팩토리로 분리.

📦

리포지터리 (Repository)

애그리게이트의 영속성 추상화. 컬렉션처럼 사용. 인프라 세부사항 숨김.

⚙️

도메인 서비스 (Domain Service)

어떤 객체에도 자연스럽게 속하지 않는 도메인 로직. 무상태. 도메인 언어로 명명.

💡 오늘의 초점: 엔티티와 값 객체를 잘못 구분하면 불필요한 ID 관리, 메모리 낭비, 잘못된 동등성 비교로 인한 버그, 과도한 뮤터블 상태라는 문제가 생긴다. 이 둘을 명확히 이해하면 코드가 도메인을 자연스럽게 표현하게 된다.

02

엔티티 (Entity) — 동일성으로 정의되는 객체

엔티티는 고유한 식별자(Identity)를 통해 구분되는 도메인 객체다. 핵심은 시간이 지나고 속성이 바뀌어도 동일한 존재로 추적된다는 점이다. 철수라는 사람은 10년 전과 이름, 주소, 직업이 모두 다를 수 있지만, 여전히 "그 사람"이다. 주민등록번호처럼 불변의 식별자가 존재를 정의한다.

Definition — 엔티티

속성이 아닌 연속성(Continuity)과 식별자(Identity)에 의해 정의되는 객체. 동일한 식별자를 가진 두 객체는 속성이 달라도 같은 엔티티다. 엔티티는 생성되어 변경되고 소멸하는 생명주기(Lifecycle)를 가진다.

Python — 엔티티 구현
from dataclasses import dataclass, field from uuid import UUID, uuid4 from datetime import datetime # 엔티티: 식별자(id)가 동일성의 기준 @dataclass class User: id: UUID = field(default_factory=uuid4) # 불변 식별자 name: str email: str created_at: datetime = field(default_factory=datetime.now) def __eq__(self, other): # 엔티티 동등성: ID만 비교 (속성 무시) if not isinstance(other, User): return False return self.id == other.id # ID만으로 판단! def __hash__(self): return hash(self.id) # set/dict 키로 사용 시 ID 기반 def change_email(self, new_email: str) -> None: # 엔티티는 변경 가능 — 같은 객체의 상태가 바뀜 self.email = new_email # in-place mutation OK # 속성이 달라도 ID가 같으면 동일한 엔티티 user_a = User(id=same_id, name="김철수", email="old@mail.com") user_b = User(id=same_id, name="김철수", email="new@mail.com") assert user_a == user_b # True — 같은 사용자, 이메일만 바뀜
03

값 객체 (Value Object) — 속성으로 정의되는 객체

값 객체는 속성의 집합 그 자체가 정의인 객체다. 식별자가 없다. "10,000원"이라는 금액은 어느 지갑에 있든, 누가 들고 있든 같은 "10,000원"이다. 두 지폐가 같은 금액이면 완전히 교환 가능하다. 값 객체의 가장 중요한 특성은 불변성(Immutability)이다. 변경이 필요할 때는 새 값 객체로 교체한다.

Definition — 값 객체

식별자 없이 속성의 내용(What it is)으로만 정의되는 불변 객체. 모든 속성이 같은 두 값 객체는 완전히 같다. 값 객체는 교체(Replace)될 수 있지만 수정(Mutate)되지 않는다.

Python — 값 객체 구현 (frozen dataclass)
from dataclasses import dataclass from decimal import Decimal # 값 객체: frozen=True → 불변 보장, 자동 __eq__ + __hash__ 생성 @dataclass(frozen=True) class Money: amount: Decimal currency: str # "KRW", "USD", "EUR" def __post_init__(self): # 생성 시 유효성 검증 — 값 객체 내부에 도메인 규칙 포함 if self.amount < 0: raise ValueError("금액은 0 이상이어야 합니다") if self.currency not in ("KRW", "USD", "EUR"): raise ValueError(f"지원하지 않는 통화: {self.currency}") def add(self, other: "Money") -> "Money": # 변경이 아닌 새 값 객체 반환 — 불변성 유지 if self.currency != other.currency: raise ValueError("다른 통화는 더할 수 없습니다") return Money(self.amount + other.amount, self.currency) def multiply(self, factor: Decimal) -> "Money": return Money(self.amount * factor, self.currency) # 모든 속성이 같으면 동일한 값 객체 price_a = Money(Decimal("10000"), "KRW") price_b = Money(Decimal("10000"), "KRW") assert price_a == price_b # True — 다른 인스턴스지만 같은 값 # 변경이 아닌 교체 price_a = price_a.add(Money(Decimal("5000"), "KRW")) # 새 Money 반환 assert price_a.amount == Decimal("15000") # 기존 변수에 재할당
04

동일성 vs. 동등성 — 핵심 차이

엔티티와 값 객체의 가장 근본적인 차이는 어떻게 비교하는가다. 엔티티는 동일성(Identity)으로, 값 객체는 동등성(Equality)으로 비교한다. 이 차이를 시각적으로 보면 다음과 같다.

// 동일성(Entity) vs. 동등성(Value Object) 비교
🏷️ Entity — ID 기반 동일성 비교
id: uuid-001
name: "김철수"
email: "old@mail.com"
==?
id: uuid-001
name: "김철수"
email: "new@mail.com"
✓ True — ID가 같으므로 동일 엔티티 (이메일이 달라도)
엔티티는 속성이 달라져도 ID가 같으면 "같은 존재"다 — 시간의 흐름 속 연속적 동일성
💎 Value Object — 모든 속성 동등성 비교
amount: 10000
currency: "KRW"
==?
amount: 10000
currency: "KRW"
✓ True — 모든 속성이 같으므로 동등 (별개 인스턴스여도)
amount: 10000
currency: "KRW"
==?
amount: 10000
currency: "USD"
✗ False — currency가 다르므로 다른 값
값 객체는 식별자가 없다. 모든 속성이 동일해야만 "같은 것"이다 — 마치 숫자처럼
05

값 객체의 불변성 — 왜 변경이 아니라 교체인가

값 객체는 한 번 생성되면 절대 내부 상태가 바뀌지 않는다. "주소를 변경한다"는 것은 기존 Address 객체를 수정하는 것이 아니라, 새 Address 객체를 만들어 엔티티의 참조를 교체하는 것이다. 이 원칙이 값 객체를 안전하고 예측 가능하게 만드는 핵심이다.

잘못된 접근 — Mutable VO
값 객체를 직접 수정
⚠️
공유 참조 위험: 두 엔티티가 같은 Address 인스턴스를 가리킬 때, 하나가 수정하면 양쪽 모두 변경됨
⚠️
버그 추적 어려움: 값이 어디서 바뀌었는지 추적하기 위해 전체 코드를 추적해야 함
⚠️
스레드 안전성: 뮤터블 공유 상태는 동시성 환경에서 race condition 발생 가능
⚠️
히스토리 손실: 이전 값을 알 수 없음. 감사 로그 구현이 어려워짐
올바른 접근 — Immutable VO
새 인스턴스 생성 후 교체
공유 안전: 불변 객체는 공유해도 안전. 한쪽이 바꾸면 새 인스턴스이므로 다른 쪽에 영향 없음
추론 용이: 생성 후 값이 절대 안 바뀜. 함수에 넘겨도 원본이 안전
스레드 안전: 불변이므로 동기화 없이 여러 스레드에서 안전하게 읽기 가능
이벤트 소싱 친화: 이전 값을 보존하기 쉬워 도메인 이벤트와 결합이 자연스러움
불변 값 객체 — 변경이 아닌 교체
@dataclass(frozen=True) class Address: street: str city: str postal_code: str country: str def relocate_to(self, new_street: str, new_city: str) -> "Address": # 기존 객체를 수정하지 않고, 변경된 새 Address를 반환 return Address( street=new_street, city=new_city, postal_code=self.postal_code, # 기존 값 유지 country=self.country ) # 엔티티(User)에서 주소 변경 class User: # 엔티티 ... def move_to(self, new_street: str, new_city: str): old_address = self.address self.address = self.address.relocate_to(new_street, new_city) # old_address 는 여전히 이전 주소를 가리킴 → 히스토리 보존 가능 self._record_event(UserMoved(old=old_address, new=self.address))
06

값 객체에 도메인 로직 담기

값 객체의 가장 강력한 활용은 관련 도메인 규칙을 값 객체 내부에 캡슐화하는 것이다. 단순히 데이터를 묶는 것을 넘어, 해당 개념에 관한 모든 행위와 제약을 함께 표현한다. 이것이 원시 타입(primitive)으로는 표현할 수 없는 값 객체의 진정한 가치다.

값 객체에 풍부한 도메인 로직 내포
@dataclass(frozen=True) class EmailAddress: value: str def __post_init__(self): # 생성 시점에 도메인 규칙 강제 → 유효하지 않은 이메일은 절대 생성 불가 import re if not re.match(r'^[\w.+-]+@[\w-]+\.[\w.]+$', self.value): raise ValueError(f"잘못된 이메일 형식: {self.value}") @property def domain(self) -> str: return self.value.split("@")[1] @dataclass(frozen=True) class Percentage: value: Decimal # 0 ~ 100 def __post_init__(self): if not (0 <= self.value <= 100): raise ValueError(f"비율은 0~100 이어야 합니다: {self.value}") def apply_to(self, money: Money) -> Money: return money.multiply(self.value / 100) -- 원시 타입 vs. 값 객체 표현력 비교 -- ❌ 원시 타입 (Primitive Obsession 안티패턴) def apply_discount(price: float, discount: float) -> float: return price * (1 - discount) # discount가 0.1인지 10인지 모름! ✅ 값 객체 (의도가 명확) def apply_discount(price: Money, discount: Percentage) -> Money: discount_amount = discount.apply_to(price) return price.subtract(discount_amount) # 타입이 의도를 설명

Primitive Obsession 안티패턴 탈출: 원시 타입(str, float, int)으로 도메인 개념을 표현하면 유효성 검증이 흩어지고, 단위·형식 오류가 런타임에 발생한다. Money, EmailAddress, PhoneNumber, Percentage 같은 값 객체는 생성 시점에 규칙을 강제해 도메인 개념을 타입 수준에서 보장한다.

07

어떤 것이 엔티티이고 어떤 것이 값 객체인가

동일한 개념이 컨텍스트에 따라 엔티티가 될 수도 있고 값 객체가 될 수도 있다. 결정 기준은 항상 "이 개념을 시간의 흐름 속에서 추적해야 하는가?"다.

개념컨텍스트 A분류컨텍스트 B분류
주소 배송 주소 (특정 주문에 고정) 값 객체 지점 관리 시스템 (지점별 추적) 엔티티
상품 가격 (속성) 값 객체 은행 계좌 잔액 (거래 추적 필요) 엔티티
색상 상품 색상 옵션 값 객체 브랜드 색상 팔레트 (버전 관리) 엔티티
사람 소셜 서비스 사용자 (프로필 추적) 엔티티 설문조사 익명 응답자 값 객체
날짜 범위 할인 기간 (시작~종료) 값 객체 휴가 신청 (승인 이력 추적) 엔티티
좌석 항공권 좌석 번호 값 객체 극장 좌석 (예약 이력 추적) 엔티티
🔑

판별 질문 체크리스트

① 이 개념에 고유한 식별자가 필요한가? → YES라면 엔티티
② 속성이 모두 같은 두 인스턴스를 구분해야 하는가? → YES라면 엔티티
③ 이 개념의 변경 이력을 추적해야 하는가? → YES라면 엔티티
④ 속성이 같으면 완전히 교환 가능한가? → YES라면 값 객체
⑤ 다른 개념을 설명하는 속성 묶음인가? → YES라면 값 객체

08

실전 모델링 — 주문 도메인 예제

이커머스 주문 도메인을 통해 엔티티와 값 객체를 어떻게 구분하고 배치하는지 살펴본다. 같은 주문 도메인에서도 Order는 엔티티, Money·Address는 값 객체다.

// Order 애그리게이트 — 엔티티와 값 객체 구성
Aggregate Root — Order (Entity)
Order Entity / Aggregate Root
idOrderId (VO)
customer_idCustomerId (VO)
statusOrderStatus (Enum)
shipping_addressAddress (VO)
itemsList[OrderItem (Entity)]
total_amountMoney (VO)
created_atdatetime
OrderItem Entity (Order 내부)
idOrderItemId (VO)
product_idProductId (VO)
quantityQuantity (VO)
unit_priceMoney (VO)
discountPercentage (VO)
값 객체 (Value Objects)
Money VO
amountDecimal
currencystr
+ add() → Money
+ multiply() → Money
Address VO
streetstr
citystr
countrystr
불변 — 새 배송지는
새 Address 객체
Quantity VO
valueint
규칙: value > 0
+ add() → Quantity
OrderId, CustomerId, ProductId — 식별자도 값 객체로 표현 (Typed ID 패턴)
→ order.id == "string" 같은 타입 오류 컴파일 타임에 차단 / 의미 명확화
주문 도메인 — 완전한 구현 예시
# Typed ID — 식별자도 값 객체로 @dataclass(frozen=True) class OrderId: value: UUID = field(default_factory=uuid4) # Order 엔티티 — 비즈니스 행위 포함 @dataclass class Order: id: OrderId = field(default_factory=OrderId) customer_id: CustomerId status: OrderStatus = OrderStatus.DRAFT shipping_address: Address = None items: list = field(default_factory=list) def add_item(self, product_id: ProductId, qty: Quantity, price: Money): if self.status != OrderStatus.DRAFT: raise DomainError("확정된 주문에는 항목을 추가할 수 없습니다") item = OrderItem(product_id=product_id, quantity=qty, unit_price=price) self.items.append(item) def total_amount(self) -> Money: # 값 객체(Money)의 메서드를 활용한 집계 — 순수 도메인 로직 total = Money(Decimal("0"), "KRW") for item in self.items: total = total.add(item.subtotal()) return total def __eq__(self, other): if not isinstance(other, Order): return False return self.id == other.id # 엔티티: ID 기반 비교
09

엔티티의 생명주기와 Repository

엔티티는 생성되어 변경되고 소멸하는 생명주기를 가진다. 이 생명주기를 관리하는 패턴이 리포지터리(Repository)다. 리포지터리는 엔티티의 영속성 메커니즘을 추상화해, 도메인 레이어가 DB나 ORM의 세부 구현을 몰라도 되게 한다.

엔티티 생명주기 흐름
생성(Create)
Factory / Constructor
활성(Active)
비즈니스 행위 수행
변경(Mutate)
상태 전환, 속성 갱신
보관(Archive)
소프트 삭제
삭제(Delete)
하드 삭제
Repository.save(entity)
DB / 영속성 계층
Repository.find_by_id(id)
리포지터리는 컬렉션처럼 사용 — save(), find_by_id(), find_all(), delete()
값 객체는 리포지터리를 갖지 않음 — 항상 엔티티(애그리게이트)에 속해서 함께 저장
10

값 객체의 퍼시스턴스 — DB에 어떻게 저장하는가

값 객체는 자체 테이블을 갖지 않는다. 소속된 엔티티와 함께 저장된다. ORM에서 이를 구현하는 방식은 두 가지다 — 내장(Embedded)직렬화(Serialization).

값 객체 퍼시스턴스 — SQLAlchemy 예시
from sqlalchemy.orm import DeclarativeBase, MappedColumn, composite ── 방법 1: Embedded (컬럼 직접 매핑) ───────────────────────── DB 테이블: orders(id, amount, currency, street, city, country, ...) class OrderModel(Base): __tablename__ = "orders" id: Mapped[UUID] = mapped_column(primary_key=True) # Money 값 객체 → 두 컬럼으로 분해 amount: Mapped[Decimal] = mapped_column("total_amount") currency: Mapped[str] = mapped_column("total_currency") # Address 값 객체 → 세 컬럼으로 분해 street: Mapped[str] = mapped_column("ship_street") city: Mapped[str] = mapped_column("ship_city") country: Mapped[str] = mapped_column("ship_country") ── 방법 2: JSON 직렬화 (PostgreSQL JSONB) ───────────────────── from sqlalchemy import JSON shipping_address: Mapped[dict] = mapped_column(JSON) # address = {"street": "...", "city": "...", "country": "..."} # 주의: JSON 컬럼은 인덱스 생성이 제한적 ── 도메인 ↔ ORM 변환 (Anti-Corruption Layer) ───────────────── def to_domain(self) -> Order: # ORM 모델 → 도메인 객체 return Order( id=OrderId(self.id), total_amount=Money(self.amount, self.currency), shipping_address=Address(self.street, self.city, self.country) )
11

자주 하는 실수와 안티패턴

흔한 실수 — 안티패턴과 올바른 패턴
── 안티패턴 1: 값 객체에 ID 부여 ───────────────────────────── ❌ 모든 것에 id를 붙이는 습관 (DB ORM 편의 우선 설계) class Address: id: int # ❌ 값 객체에 ID? → 공유 참조 위험, 생명주기 관리 복잡 street: str ✅ 올바른 방법: ID 없는 값 객체, 엔티티 테이블에 내장 @dataclass(frozen=True) class Address: # ID 없음 street: str; city: str; country: str ── 안티패턴 2: 값 객체를 뮤터블로 사용 ────────────────────── order.shipping_address.city = "부산" # ❌ 값 객체 직접 수정 order.shipping_address = Address(..., city="부산", ...) # ✅ 교체 ── 안티패턴 3: Primitive Obsession ─────────────────────────── def create_order(user_id: int, price: float, currency: str): ... # ❌ def create_order(customer: CustomerId, price: Money): ... # ✅ ── 안티패턴 4: 엔티티를 값처럼 복사 비교 ──────────────────── user_a.name == user_b.name and user_a.email == user_b.email # ❌ user_a.id == user_b.id # ✅ 엔티티는 ID로만 비교 ── 안티패턴 5: 유효성 검증을 서비스에 분산 ───────────────── if not is_valid_email(email_str): # ❌ 서비스 레이어에서 검증 email = EmailAddress(email_str) # ✅ 생성 시 예외, 도메인 객체 내부

⚠️ 엔티티와 값 객체를 잘못 구분하면 생기는 현실적 문제: 값 객체에 ID를 붙이면 "이미 저장된 주소가 있는지" 중복 체크 로직이 생기고, 여러 엔티티가 같은 주소 레코드를 공유해 한쪽 변경이 다른 쪽에 영향을 미친다. 반대로 엔티티를 값처럼 사용하면 실수로 새 인스턴스를 만들어 추적해야 할 존재를 잃어버리게 된다.

12

정리 및 핵심 교훈

엔티티와 값 객체의 구분은 DDD 설계의 가장 기본적이면서도 가장 많이 틀리는 부분이다. 결국 질문은 하나다 — "이 개념을 시간의 흐름 속에서 동일한 존재로 추적해야 하는가?"

🏷️

엔티티 = ID + 생명주기

식별자가 존재의 근거. 속성이 바뀌어도 같은 존재. __eq__는 ID만 비교. Repository로 생명주기 관리.

💎

값 객체 = 속성 + 불변성

ID 없음. 모든 속성이 같으면 동일. frozen dataclass로 불변 강제. 변경 대신 새 인스턴스 반환.

🛡️

값 객체에 도메인 규칙 내포

생성자에서 유효성 검증. 도메인 로직을 해당 개념 내부에 캡슐화. Primitive Obsession 탈출.

🔄

컨텍스트가 분류를 결정한다

같은 개념도 도메인 컨텍스트에 따라 엔티티가 될 수도, 값 객체가 될 수도 있다. 추적 필요 여부가 기준.

💾

값 객체는 내장 저장

자체 테이블 없음. 엔티티 테이블에 컬럼으로 내장. 별도 ID 없이 엔티티와 함께 생성·소멸.

🔑

Typed ID 패턴

식별자도 값 객체로 표현. OrderId, CustomerId로 타입 안전성 확보. 컴파일 타임 오류 방지.

💬 인터뷰 핵심 요약: 엔티티와 값 객체를 묻는 면접에서 기대하는 답변은 세 가지다. 첫째, 동일성 기준의 차이 — 엔티티는 ID로, 값 객체는 모든 속성으로 비교한다. 둘째, 불변성 — 값 객체는 수정이 아닌 교체. frozen=True나 레코드(record)로 강제. 셋째, 도메인 로직 내포 — 값 객체 생성자에서 도메인 규칙을 강제해 "항상 유효한 객체"를 보장. 그리고 컨텍스트 의존성 — 같은 개념도 추적 필요 여부에 따라 다르게 모델링한다.

도메인 주도 설계 시리즈  |  엔티티 & 값 객체 완전 분석

반응형

댓글