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)를 가진다.
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)되지 않는다.
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 객체를 만들어 엔티티의 참조를 교체하는 것이다.
이 원칙이 값 객체를 안전하고 예측 가능하게 만드는 핵심이다.
⚠️
공유 참조 위험: 두 엔티티가 같은 Address 인스턴스를 가리킬 때, 하나가 수정하면 양쪽 모두 변경됨
⚠️
버그 추적 어려움: 값이 어디서 바뀌었는지 추적하기 위해 전체 코드를 추적해야 함
⚠️
스레드 안전성: 뮤터블 공유 상태는 동시성 환경에서 race condition 발생 가능
⚠️
히스토리 손실: 이전 값을 알 수 없음. 감사 로그 구현이 어려워짐
✅
공유 안전: 불변 객체는 공유해도 안전. 한쪽이 바꾸면 새 인스턴스이므로 다른 쪽에 영향 없음
✅
추론 용이: 생성 후 값이 절대 안 바뀜. 함수에 넘겨도 원본이 안전
✅
스레드 안전: 불변이므로 동기화 없이 여러 스레드에서 안전하게 읽기 가능
✅
이벤트 소싱 친화: 이전 값을 보존하기 쉬워 도메인 이벤트와 결합이 자연스러움
@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)
idOrderId (VO)
customer_idCustomerId (VO)
statusOrderStatus (Enum)
shipping_addressAddress (VO)
itemsList[OrderItem (Entity)]
total_amountMoney (VO)
created_atdatetime
idOrderItemId (VO)
product_idProductId (VO)
quantityQuantity (VO)
unit_priceMoney (VO)
discountPercentage (VO)
값 객체 (Value Objects)
amountDecimal
currencystr
+ add() → Money
+ multiply() → Money
streetstr
citystr
countrystr
불변 — 새 배송지는
새 Address 객체
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).
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)로 강제.
셋째, 도메인 로직 내포 — 값 객체 생성자에서 도메인 규칙을 강제해 "항상 유효한 객체"를 보장.
그리고 컨텍스트 의존성 — 같은 개념도 추적 필요 여부에 따라 다르게 모델링한다.
댓글