Theory
중급 2-3시간 이론

DDD 이론 6: Entity와 Value Object 심화

도메인 모델의 핵심 빌딩 블록인 Entity와 Value Object의 본질적 차이, 구현 패턴, 영속성 전략, 그리고 실무 적용 사례를 심층적으로 학습합니다.

EntityValue ObjectIdentityImmutabilityEqualityPrimitive ObsessionRepository
학습 목표
  • • Entity의 식별성(Identity)과 생명주기를 깊이 이해한다
  • • Value Object의 불변성과 값 동등성 원칙을 마스터한다
  • • Entity vs Value Object 선택 기준을 명확히 적용할 수 있다
  • • Primitive Obsession 안티패턴을 식별하고 리팩토링할 수 있다
  • • 다양한 영속성 전략을 이해하고 적용할 수 있다
  • • 실제 도메인에서 Entity와 Value Object를 설계할 수 있다
  • • 테스트 가능한 도메인 모델을 구현할 수 있다

1. Entity의 본질: 식별성(Identity)

"어떤 객체는 속성이 아니라 연속성과 식별성에 의해 정의된다. 이러한 객체를 Entity라고 한다."

— Eric Evans, Domain-Driven Design, Chapter 5

1.1 Entity란 무엇인가?

Entity는 고유한 식별자(Identity)로 구분되는 도메인 객체입니다. 속성이 모두 바뀌어도 같은 식별자를 가지면 같은 Entity입니다.

🎭 철학적 관점: 테세우스의 배

테세우스의 배는 오랜 세월 동안 낡은 널빤지를 하나씩 새것으로 교체했습니다. 결국 원래의 널빤지가 하나도 남지 않았을 때, 이것은 여전히 "테세우스의 배"인가요?

→ DDD에서 Entity는 "예"라고 답합니다. 식별성이 유지되는 한 같은 Entity입니다.

1.2 Entity의 핵심 특성

🔑 고유 식별자

모든 Entity는 유일한 식별자를 가집니다. UUID, 시퀀스, 자연키 등 다양한 형태가 가능합니다.

⏳ 생명주기

Entity는 생성, 변경, 삭제의 생명주기를 가집니다. 시간에 따라 상태가 변합니다.

🔄 가변성

Entity의 속성은 변경될 수 있습니다. 단, 식별자는 불변이어야 합니다.

📊 추적 가능성

Entity는 시스템에서 추적됩니다. 이력 관리, 감사 로그 등이 필요합니다.

1.3 Entity 식별자 전략

전략예시장점단점
UUID550e8400-e29b-41d4-a716-446655440000분산 환경, 충돌 없음길이, 정렬 어려움
ULID01ARZ3NDEKTSV4RRFFQ69G5FAV시간순 정렬, UUID 호환상대적으로 새로운 표준
시퀀스1, 2, 3, ...간단, 정렬 용이분산 환경 어려움
자연키ISBN, 주민번호의미 있는 값변경 가능성, 의존성
Snowflake1541815603606036480분산, 시간순, 컴팩트시계 동기화 필요
💻 식별자 Value Object 구현
// 식별자를 Value Object로 래핑하여 타입 안전성 확보
class OrderId {
  private readonly value: string;

  private constructor(value: string) {
    this.value = value;
  }

  static generate(): OrderId {
    return new OrderId(crypto.randomUUID());
  }

  static from(value: string): OrderId {
    if (!value || value.trim() === '') {
      throw new Error('OrderId cannot be empty');
    }
    return new OrderId(value);
  }

  equals(other: OrderId): boolean {
    return this.value === other.value;
  }

  toString(): string {
    return this.value;
  }
}

// 사용 예시 - 컴파일 타임에 타입 오류 감지
function findOrder(orderId: OrderId): Order { ... }
function findCustomer(customerId: CustomerId): Customer { ... }

// ❌ 컴파일 에러: OrderId와 CustomerId는 다른 타입
findOrder(customerId);

// ✅ 올바른 사용
findOrder(OrderId.from("order-123"));

1.4 Entity 동등성 구현

// TypeScript Entity 기본 클래스
abstract class Entity<T> {
  protected readonly _id: T;

  constructor(id: T) {
    this._id = id;
  }

  get id(): T {
    return this._id;
  }

  equals(other: Entity<T>): boolean {
    if (other === null || other === undefined) {
      return false;
    }
    if (this === other) {
      return true;
    }
    // 식별자 기반 동등성
    return this._id === other._id;
  }
}

// 구체적인 Entity 구현
class Order extends Entity<OrderId> {
  private _status: OrderStatus;
  private _items: OrderItem[];
  private _totalAmount: Money;

  constructor(id: OrderId) {
    super(id);
    this._status = OrderStatus.DRAFT;
    this._items = [];
    this._totalAmount = Money.zero(Currency.KRW);
  }

  // 비즈니스 메서드들...
  addItem(item: OrderItem): void {
    this._items.push(item);
    this.recalculateTotal();
  }

  confirm(): void {
    if (this._items.length === 0) {
      throw new EmptyOrderError();
    }
    this._status = OrderStatus.CONFIRMED;
  }
}

1.5 Entity 예시: 다양한 도메인

🧑 사용자 (User)

이름, 이메일, 비밀번호가 바뀌어도 같은 사용자

ID: userId (UUID)

📦 주문 (Order)

상태, 배송지가 변해도 같은 주문

ID: orderNumber

🏦 계좌 (Account)

잔액이 변해도 같은 계좌

ID: accountNumber

🎫 예약 (Reservation)

날짜, 좌석이 변경되어도 같은 예약

ID: reservationId

📝 게시글 (Post)

제목, 내용이 수정되어도 같은 게시글

ID: postId

🚗 차량 (Vehicle)

색상, 소유자가 바뀌어도 같은 차량

ID: VIN (차대번호)

2. Value Object의 본질: 값 동등성과 불변성

"어떤 객체는 속성만으로 정의된다. 식별성이 없고, 속성 값이 같으면 같은 것으로 취급한다. 이러한 객체를 Value Object라고 한다."

— Eric Evans, Domain-Driven Design, Chapter 5

2.1 Value Object란 무엇인가?

Value Object는 속성의 조합으로 정의되는 객체입니다. 식별자가 없으며, 모든 속성이 같으면 같은 객체로 취급합니다.

💵 현실 세계의 Value Object: 지폐

만원짜리 지폐 두 장이 있습니다. 일련번호는 다르지만, 우리는 이 둘을 "같은 만원"으로 취급합니다.

커피숍에서 결제할 때 어떤 만원짜리를 내든 상관없습니다. "가치"가 같기 때문입니다.

→ 이것이 Value Object의 본질입니다. 값이 같으면 교체 가능합니다.

2.2 Value Object의 7가지 특성

1. 불변성 (Immutability)

생성 후 상태를 변경할 수 없습니다. 변경이 필요하면 새 객체를 생성합니다.

2. 값 동등성 (Value Equality)

모든 속성이 같으면 같은 객체입니다. 참조가 아닌 값으로 비교합니다.

3. 교체 가능성 (Replaceability)

같은 값의 다른 인스턴스로 언제든 교체할 수 있습니다.

4. 자가 검증 (Self-Validation)

생성 시점에 유효성을 검증하여 항상 유효한 상태를 보장합니다.

5. 부작용 없음 (Side-Effect Free)

메서드 호출이 객체 상태를 변경하지 않습니다. 새 객체를 반환합니다.

6. 개념적 완전성 (Conceptual Whole)

관련된 속성들을 하나의 개념으로 묶어 의미 있는 단위를 형성합니다.

7. 도메인 로직 캡슐화

해당 값과 관련된 비즈니스 로직을 객체 내부에 포함합니다.

2.3 Value Object 구현 패턴

💰 Money Value Object
class Money {
  // 불변 속성
  private readonly _amount: number;
  private readonly _currency: Currency;

  // private 생성자 - 팩토리 메서드 사용 강제
  private constructor(amount: number, currency: Currency) {
    this._amount = amount;
    this._currency = currency;
  }

  // 팩토리 메서드 - 유효성 검증 포함
  static of(amount: number, currency: Currency): Money {
    if (amount < 0) {
      throw new NegativeAmountError(amount);
    }
    if (!Number.isFinite(amount)) {
      throw new InvalidAmountError(amount);
    }
    return new Money(amount, currency);
  }

  // 편의 팩토리 메서드
  static zero(currency: Currency): Money {
    return new Money(0, currency);
  }

  static won(amount: number): Money {
    return Money.of(amount, Currency.KRW);
  }

  // Getter (불변성 유지)
  get amount(): number { return this._amount; }
  get currency(): Currency { return this._currency; }

  // 연산 메서드 - 새 객체 반환 (불변성)
  add(other: Money): Money {
    this.ensureSameCurrency(other);
    return Money.of(this._amount + other._amount, this._currency);
  }

  subtract(other: Money): Money {
    this.ensureSameCurrency(other);
    const result = this._amount - other._amount;
    if (result < 0) {
      throw new InsufficientFundsError(this, other);
    }
    return Money.of(result, this._currency);
  }

  multiply(factor: number): Money {
    return Money.of(this._amount * factor, this._currency);
  }

  // 비교 메서드
  isGreaterThan(other: Money): boolean {
    this.ensureSameCurrency(other);
    return this._amount > other._amount;
  }

  isZero(): boolean {
    return this._amount === 0;
  }

  // 값 동등성
  equals(other: Money): boolean {
    if (!other) return false;
    return this._amount === other._amount && 
           this._currency === other._currency;
  }

  // 통화 검증
  private ensureSameCurrency(other: Money): void {
    if (this._currency !== other._currency) {
      throw new CurrencyMismatchError(this._currency, other._currency);
    }
  }

  // 표현
  toString(): string {
    return `${this._currency.symbol}${this._amount.toLocaleString()}`;
  }
}
📍 Address Value Object
class Address {
  private constructor(
    readonly street: string,
    readonly city: string,
    readonly state: string,
    readonly zipCode: string,
    readonly country: string
  ) {}

  static create(props: {
    street: string;
    city: string;
    state: string;
    zipCode: string;
    country: string;
  }): Address {
    // 유효성 검증
    if (!props.street?.trim()) {
      throw new InvalidAddressError('Street is required');
    }
    if (!props.zipCode?.match(/^\d{5}(-\d{4})?$/)) {
      throw new InvalidZipCodeError(props.zipCode);
    }
    
    return new Address(
      props.street.trim(),
      props.city.trim(),
      props.state.trim(),
      props.zipCode.trim(),
      props.country.trim()
    );
  }

  // 한국 주소 팩토리
  static korean(props: {
    city: string;
    district: string;
    street: string;
    detail: string;
    zipCode: string;
  }): Address {
    return Address.create({
      street: `${props.street} ${props.detail}`,
      city: props.city,
      state: props.district,
      zipCode: props.zipCode,
      country: 'South Korea'
    });
  }

  // 값 동등성
  equals(other: Address): boolean {
    if (!other) return false;
    return this.street === other.street &&
           this.city === other.city &&
           this.state === other.state &&
           this.zipCode === other.zipCode &&
           this.country === other.country;
  }

  // 포맷팅
  format(): string {
    return `${this.street}, ${this.city}, ${this.state} ${this.zipCode}, ${this.country}`;
  }

  formatKorean(): string {
    return `(${this.zipCode}) ${this.city} ${this.state} ${this.street}`;
  }
}

2.4 다양한 Value Object 예시

📧 Email

class Email {
  private constructor(
    readonly value: string
  ) {}

  static of(value: string): Email {
    const regex = /^[^@]+@[^@]+\.[^@]+$/;
    if (!regex.test(value)) {
      throw new InvalidEmailError(value);
    }
    return new Email(value.toLowerCase());
  }

  get domain(): string {
    return this.value.split('@')[1];
  }
}

📱 PhoneNumber

class PhoneNumber {
  private constructor(
    readonly countryCode: string,
    readonly number: string
  ) {}

  static korean(number: string): PhoneNumber {
    const cleaned = number.replace(/\D/g, '');
    if (!cleaned.match(/^01[0-9]{8,9}$/)) {
      throw new InvalidPhoneError(number);
    }
    return new PhoneNumber('+82', cleaned);
  }

  format(): string {
    // 010-1234-5678 형식
    return this.number.replace(
      /(\d{3})(\d{4})(\d{4})/,
      '$1-$2-$3'
    );
  }
}

📅 DateRange

class DateRange {
  private constructor(
    readonly start: Date,
    readonly end: Date
  ) {}

  static of(start: Date, end: Date): DateRange {
    if (start > end) {
      throw new InvalidDateRangeError();
    }
    return new DateRange(start, end);
  }

  contains(date: Date): boolean {
    return date >= this.start && 
           date <= this.end;
  }

  overlaps(other: DateRange): boolean {
    return this.start <= other.end && 
           this.end >= other.start;
  }

  get days(): number {
    const diff = this.end.getTime() - 
                 this.start.getTime();
    return Math.ceil(diff / (1000*60*60*24));
  }
}

📊 Percentage

class Percentage {
  private constructor(
    readonly value: number
  ) {}

  static of(value: number): Percentage {
    if (value < 0 || value > 100) {
      throw new InvalidPercentageError(value);
    }
    return new Percentage(value);
  }

  static fromDecimal(decimal: number): Percentage {
    return Percentage.of(decimal * 100);
  }

  toDecimal(): number {
    return this.value / 100;
  }

  applyTo(amount: Money): Money {
    return amount.multiply(this.toDecimal());
  }
}

📏 Quantity

class Quantity {
  private constructor(
    readonly value: number,
    readonly unit: Unit
  ) {}

  static of(value: number, unit: Unit): Quantity {
    if (value < 0) {
      throw new NegativeQuantityError();
    }
    return new Quantity(value, unit);
  }

  add(other: Quantity): Quantity {
    this.ensureSameUnit(other);
    return Quantity.of(
      this.value + other.value, 
      this.unit
    );
  }

  isZero(): boolean {
    return this.value === 0;
  }
}

🎨 Color

class Color {
  private constructor(
    readonly red: number,
    readonly green: number,
    readonly blue: number
  ) {}

  static rgb(r: number, g: number, b: number): Color {
    [r, g, b].forEach(v => {
      if (v < 0 || v > 255) {
        throw new InvalidColorError();
      }
    });
    return new Color(r, g, b);
  }

  static fromHex(hex: string): Color {
    const result = /^#?([a-f\d]{2})([a-f\d]{2})([a-f\d]{2})$/i.exec(hex);
    if (!result) throw new InvalidColorError();
    return Color.rgb(
      parseInt(result[1], 16),
      parseInt(result[2], 16),
      parseInt(result[3], 16)
    );
  }

  toHex(): string {
    return `#${[this.red, this.green, this.blue]
      .map(x => x.toString(16).padStart(2, '0'))
      .join('')}`;
  }
}

3. Entity vs Value Object 선택 기준

"가능하면 Entity보다 Value Object를 선호하라. Value Object는 불변이므로 추론하기 쉽고, 부작용이 없으며, 테스트하기 쉽다."

— Vaughn Vernon, Implementing Domain-Driven Design

3.1 핵심 질문 체크리스트

🔑 "이것을 시간에 따라 추적해야 하는가?"

예: 주문의 상태 변화, 계좌의 잔액 변동 → Entity

아니오: 금액, 주소, 날짜 범위 → Value Object

⚖️ "값이 같으면 같은 것으로 취급해도 되는가?"

예: 10,000원 = 10,000원, 서울시 강남구 = 서울시 강남구 → Value Object

아니오: 같은 이름의 두 고객은 다른 고객 → Entity

🔄 "같은 값의 다른 인스턴스로 교체해도 문제없는가?"

예: 배송 주소를 같은 값의 새 Address 객체로 교체 → Value Object

아니오: 주문을 다른 주문으로 교체할 수 없음 → Entity

📊 "이력 관리나 감사 로그가 필요한가?"

예: 누가 언제 주문을 변경했는지 추적 → Entity

아니오: 금액 계산 결과의 이력은 불필요 → Value Object

3.2 의사결정 플로우차트

                    ┌─────────────────────────┐
                    │   새로운 도메인 개념    │
                    └───────────┬─────────────┘
                                │
                    ┌───────────▼─────────────┐
                    │ 고유 식별자가 필요한가? │
                    └───────────┬─────────────┘
                                │
              ┌─────────────────┼─────────────────┐
              │ Yes             │                 │ No
              ▼                 │                 ▼
    ┌─────────────────┐        │       ┌─────────────────┐
    │ 생명주기 추적이 │        │       │ 속성이 같으면   │
    │   필요한가?     │        │       │ 같은 것인가?    │
    └────────┬────────┘        │       └────────┬────────┘
             │                 │                │
    ┌────────┼────────┐        │       ┌────────┼────────┐
    │ Yes    │        │ No     │       │ Yes    │        │ No
    ▼        │        ▼        │       ▼        │        ▼
┌────────┐   │   ┌────────┐    │   ┌────────┐   │   ┌────────┐
│ Entity │   │   │ 재검토 │    │   │ Value  │   │   │ 재검토 │
│        │   │   │ 필요   │    │   │ Object │   │   │ 필요   │
└────────┘   │   └────────┘    │   └────────┘   │   └────────┘
             │                 │                │
             └─────────────────┴────────────────┘

3.3 컨텍스트에 따른 선택

🎭 같은 개념, 다른 모델링

같은 현실 세계의 개념도 컨텍스트에 따라 Entity 또는 Value Object가 될 수 있습니다.

개념Entity인 경우Value Object인 경우
주소부동산 시스템: 각 주소를 개별 추적
(건물 정보, 소유자 이력 등)
이커머스: 배송지로만 사용
(같은 주소면 같은 배송지)
화폐 수집: 각 지폐/동전 개별 추적
(일련번호, 발행연도, 상태)
결제 시스템: 금액만 중요
(10,000원 = 10,000원)
좌석극장 예매: 각 좌석 개별 관리
(A열 5번 좌석의 예약 상태)
버스 예매: 좌석 번호만 표시
(좌석 위치 정보로만 사용)
색상페인트 제조: 각 배치 추적
(배치별 미세한 색상 차이)
UI 디자인: RGB 값으로 정의
(#FF0000 = #FF0000)

3.4 Value Object 선호 이유

✓ 스레드 안전성

불변 객체는 동시성 문제가 없습니다. 락 없이 안전하게 공유할 수 있습니다.

✓ 추론 용이성

상태가 변하지 않으므로 코드 흐름을 따라가기 쉽습니다.

✓ 테스트 용이성

부작용이 없어 단위 테스트가 간단합니다. 목(Mock)이 거의 필요 없습니다.

✓ 캐싱 가능

불변이므로 안전하게 캐싱하고 재사용할 수 있습니다.

✓ 자가 검증

생성 시점에 유효성을 검증하여 항상 유효한 상태를 보장합니다.

✓ 도메인 로직 응집

관련 로직이 한 곳에 모여 있어 유지보수가 쉽습니다.

3.5 비교 요약표

특성EntityValue Object
식별고유 식별자 (ID)속성 값의 조합
동등성ID가 같으면 동등모든 속성이 같으면 동등
가변성가변 (상태 변경 가능)불변 (새 객체 생성)
생명주기생성 → 변경 → 삭제생성 → 사용 → 폐기
저장별도 테이블/문서Entity에 임베드 또는 별도 저장
공유참조로 공유 (주의 필요)복사로 공유 (안전)
예시User, Order, AccountMoney, Address, Email

4. Primitive Obsession 안티패턴과 리팩토링

4.1 Primitive Obsession이란?

Primitive Obsession은 도메인 개념을 표현할 때 기본 타입(string, number, boolean)을 과도하게 사용하는 코드 스멜입니다.

❌ Primitive Obsession

interface User {
  id: string;           // UUID? 숫자?
  email: string;        // 유효한 이메일?
  phoneNumber: string;  // 어떤 형식?
  balance: number;      // 통화는? 음수 가능?
  birthDate: string;    // 어떤 포맷?
  status: string;       // 어떤 값이 가능?
}

// 문제점
const user: User = {
  id: "",               // 빈 문자열 허용
  email: "not-email",   // 유효하지 않은 이메일
  phoneNumber: "abc",   // 잘못된 전화번호
  balance: -1000,       // 음수 잔액?
  birthDate: "invalid", // 잘못된 날짜
  status: "whatever"    // 정의되지 않은 상태
};

✓ Value Object 사용

interface User {
  id: UserId;
  email: Email;
  phoneNumber: PhoneNumber;
  balance: Money;
  birthDate: BirthDate;
  status: UserStatus;
}

// 컴파일 타임에 오류 감지
const user: User = {
  id: UserId.generate(),
  email: Email.of("user@example.com"),
  phoneNumber: PhoneNumber.korean("010-1234-5678"),
  balance: Money.won(10000),
  birthDate: BirthDate.of(1990, 1, 15),
  status: UserStatus.ACTIVE
};

// 잘못된 값은 생성 자체가 불가능
Email.of("invalid"); // throws InvalidEmailError

4.2 Primitive Obsession의 문제점

1. 유효성 검증 분산

이메일 유효성 검증이 여러 곳에 중복됩니다. 한 곳에서 누락되면 버그가 발생합니다.

2. 타입 안전성 부재

userId와 orderId가 모두 string이면 실수로 바꿔 사용해도 컴파일러가 잡지 못합니다.

3. 도메인 로직 누출

금액 계산, 이메일 파싱 등의 로직이 여러 서비스에 흩어집니다.

4. 의미 전달 실패

string email보다 Email 타입이 의도를 더 명확하게 전달합니다.

4.3 리팩토링 단계별 가이드

Step 1: 후보 식별
// 리팩토링 전: Primitive Obsession이 있는 코드
class OrderService {
  createOrder(
    customerId: string,      // 👈 후보: CustomerId
    productId: string,       // 👈 후보: ProductId
    quantity: number,        // 👈 후보: Quantity
    price: number,           // 👈 후보: Money
    currency: string,        // 👈 후보: Currency (Money에 포함)
    shippingStreet: string,  // 👈 후보: Address
    shippingCity: string,
    shippingZipCode: string,
    discountPercent: number  // 👈 후보: Percentage
  ): string {                // 👈 후보: OrderId
    // 유효성 검증이 여기저기 흩어져 있음
    if (!customerId) throw new Error('Customer ID required');
    if (quantity <= 0) throw new Error('Invalid quantity');
    if (price < 0) throw new Error('Invalid price');
    // ...
  }
}
Step 2: Value Object 생성
// 식별자 Value Objects
class CustomerId {
  private constructor(readonly value: string) {}
  
  static from(value: string): CustomerId {
    if (!value?.trim()) throw new InvalidCustomerIdError();
    return new CustomerId(value);
  }
  
  equals(other: CustomerId): boolean {
    return this.value === other.value;
  }
}

class ProductId {
  private constructor(readonly value: string) {}
  
  static from(value: string): ProductId {
    if (!value?.trim()) throw new InvalidProductIdError();
    return new ProductId(value);
  }
}

// 도메인 Value Objects
class Quantity {
  private constructor(readonly value: number) {}
  
  static of(value: number): Quantity {
    if (!Number.isInteger(value) || value <= 0) {
      throw new InvalidQuantityError(value);
    }
    return new Quantity(value);
  }
  
  add(other: Quantity): Quantity {
    return Quantity.of(this.value + other.value);
  }
}

class Percentage {
  private constructor(readonly value: number) {}
  
  static of(value: number): Percentage {
    if (value < 0 || value > 100) {
      throw new InvalidPercentageError(value);
    }
    return new Percentage(value);
  }
  
  applyTo(money: Money): Money {
    return money.multiply(this.value / 100);
  }
}
Step 3: 서비스 리팩토링
// 리팩토링 후: Value Object를 사용하는 깔끔한 코드
class OrderService {
  createOrder(command: CreateOrderCommand): OrderId {
    // 유효성 검증이 Value Object 생성 시점에 완료됨
    const order = Order.create({
      customerId: command.customerId,
      items: command.items.map(item => 
        OrderItem.create(item.productId, item.quantity, item.unitPrice)
      ),
      shippingAddress: command.shippingAddress,
      discount: command.discount
    });
    
    return this.orderRepository.save(order);
  }
}

// Command 객체도 Value Object로 구성
interface CreateOrderCommand {
  customerId: CustomerId;
  items: Array<{
    productId: ProductId;
    quantity: Quantity;
    unitPrice: Money;
  }>;
  shippingAddress: Address;
  discount?: Percentage;
}

// 사용 예시
const command: CreateOrderCommand = {
  customerId: CustomerId.from("cust-123"),
  items: [{
    productId: ProductId.from("prod-456"),
    quantity: Quantity.of(2),
    unitPrice: Money.won(15000)
  }],
  shippingAddress: Address.korean({
    city: "서울시",
    district: "강남구",
    street: "테헤란로 123",
    detail: "456호",
    zipCode: "06234"
  }),
  discount: Percentage.of(10)
};

4.4 리팩토링 전후 비교

측면Before (Primitive)After (Value Object)
유효성 검증여러 곳에 분산생성자에서 한 번
타입 안전성string끼리 혼동 가능컴파일 타임 검증
도메인 로직서비스에 흩어짐Value Object에 응집
가독성파라미터 의미 불명확타입이 의미 전달
테스트경계값 테스트 누락 쉬움Value Object 단위 테스트

4.5 점진적 리팩토링 전략

1단계: 새 코드에 적용

새로 작성하는 코드부터 Value Object를 사용합니다. 기존 코드는 그대로 둡니다.

2단계: 핵심 도메인 우선

비즈니스에 중요한 개념(Money, OrderId 등)부터 리팩토링합니다.

3단계: 경계에서 변환

API 경계에서 primitive ↔ Value Object 변환 레이어를 둡니다.

4단계: 점진적 확산

테스트와 함께 점진적으로 내부 코드를 Value Object로 전환합니다.

5. 실무 구현 패턴과 영속성

5.1 Value Object 영속성 전략

전략 1: Entity에 임베드 (권장)
// TypeORM 예시: Embedded Value Object
@Entity()
class Order {
  @PrimaryColumn()
  id: string;

  // Money를 컬럼으로 임베드
  @Column('decimal', { precision: 10, scale: 2 })
  totalAmount: number;

  @Column()
  totalCurrency: string;

  // Address를 여러 컬럼으로 임베드
  @Column()
  shippingStreet: string;

  @Column()
  shippingCity: string;

  @Column()
  shippingZipCode: string;

  // 도메인 모델로 변환
  toDomain(): DomainOrder {
    return new DomainOrder(
      OrderId.from(this.id),
      Money.of(this.totalAmount, Currency.from(this.totalCurrency)),
      Address.create({
        street: this.shippingStreet,
        city: this.shippingCity,
        zipCode: this.shippingZipCode
      })
    );
  }
}

// Prisma 예시
model Order {
  id              String   @id
  totalAmount     Decimal
  totalCurrency   String
  shippingStreet  String
  shippingCity    String
  shippingZipCode String
}
전략 2: JSON 컬럼 사용
// 복잡한 Value Object는 JSON으로 저장
@Entity()
class Order {
  @PrimaryColumn()
  id: string;

  @Column('jsonb')
  shippingAddress: {
    street: string;
    city: string;
    state: string;
    zipCode: string;
    country: string;
  };

  @Column('jsonb')
  lineItems: Array<{
    productId: string;
    quantity: number;
    unitPrice: { amount: number; currency: string };
  }>;
}

// 장점: 스키마 유연성
// 단점: 쿼리 복잡성, 인덱싱 제한
전략 3: 별도 테이블 (컬렉션)
// Value Object 컬렉션은 별도 테이블로
@Entity()
class Order {
  @PrimaryColumn()
  id: string;

  @OneToMany(() => OrderLineItemEntity, item => item.order, {
    cascade: true,
    orphanRemoval: true  // 부모 삭제 시 함께 삭제
  })
  lineItems: OrderLineItemEntity[];
}

@Entity()
class OrderLineItemEntity {
  @PrimaryGeneratedColumn()
  id: number;  // 기술적 ID (도메인에서는 무시)

  @ManyToOne(() => Order)
  order: Order;

  @Column()
  productId: string;

  @Column()
  quantity: number;

  @Column('decimal')
  unitPriceAmount: number;

  @Column()
  unitPriceCurrency: string;
}

// 주의: OrderLineItem은 도메인에서 Value Object지만
// 영속성 레이어에서는 기술적 ID를 가질 수 있음

5.2 Entity 생명주기 관리

// Entity 생명주기 패턴
class Order {
  private _id: OrderId;
  private _status: OrderStatus;
  private _items: OrderItem[];
  private _createdAt: Date;
  private _updatedAt: Date;
  private _version: number;  // Optimistic Locking

  // 팩토리 메서드: 새 Entity 생성
  static create(customerId: CustomerId, address: Address): Order {
    const order = new Order();
    order._id = OrderId.generate();
    order._status = OrderStatus.DRAFT;
    order._items = [];
    order._createdAt = new Date();
    order._updatedAt = new Date();
    order._version = 0;
    
    // 도메인 이벤트 발행
    order.addDomainEvent(new OrderCreatedEvent(order._id, customerId));
    
    return order;
  }

  // 재구성: DB에서 로드
  static reconstitute(props: OrderProps): Order {
    const order = new Order();
    order._id = props.id;
    order._status = props.status;
    order._items = props.items;
    order._createdAt = props.createdAt;
    order._updatedAt = props.updatedAt;
    order._version = props.version;
    return order;
  }

  // 상태 변경 메서드
  confirm(): void {
    if (this._status !== OrderStatus.DRAFT) {
      throw new InvalidOrderStateError('Only draft orders can be confirmed');
    }
    if (this._items.length === 0) {
      throw new EmptyOrderError();
    }
    
    this._status = OrderStatus.CONFIRMED;
    this._updatedAt = new Date();
    
    this.addDomainEvent(new OrderConfirmedEvent(this._id));
  }

  cancel(reason: CancellationReason): void {
    if (!this._status.canBeCancelled()) {
      throw new InvalidOrderStateError('Order cannot be cancelled');
    }
    
    this._status = OrderStatus.CANCELLED;
    this._updatedAt = new Date();
    
    this.addDomainEvent(new OrderCancelledEvent(this._id, reason));
  }
}

5.3 Repository 패턴

// Repository 인터페이스 (도메인 레이어)
interface OrderRepository {
  findById(id: OrderId): Promise<Order | null>;
  findByCustomerId(customerId: CustomerId): Promise<Order[]>;
  save(order: Order): Promise<void>;
  delete(order: Order): Promise<void>;
}

// Repository 구현 (인프라 레이어)
class TypeORMOrderRepository implements OrderRepository {
  constructor(
    private readonly dataSource: DataSource,
    private readonly mapper: OrderMapper
  ) {}

  async findById(id: OrderId): Promise<Order | null> {
    const entity = await this.dataSource
      .getRepository(OrderEntity)
      .findOne({
        where: { id: id.value },
        relations: ['lineItems']
      });
    
    return entity ? this.mapper.toDomain(entity) : null;
  }

  async save(order: Order): Promise<void> {
    const entity = this.mapper.toEntity(order);
    await this.dataSource.getRepository(OrderEntity).save(entity);
    
    // 도메인 이벤트 발행
    await this.publishDomainEvents(order);
  }
}

// Mapper: 도메인 ↔ 영속성 변환
class OrderMapper {
  toDomain(entity: OrderEntity): Order {
    return Order.reconstitute({
      id: OrderId.from(entity.id),
      status: OrderStatus.from(entity.status),
      items: entity.lineItems.map(item => 
        OrderItem.create(
          ProductId.from(item.productId),
          Quantity.of(item.quantity),
          Money.of(item.unitPriceAmount, Currency.from(item.unitPriceCurrency))
        )
      ),
      createdAt: entity.createdAt,
      updatedAt: entity.updatedAt,
      version: entity.version
    });
  }

  toEntity(order: Order): OrderEntity {
    const entity = new OrderEntity();
    entity.id = order.id.value;
    entity.status = order.status.value;
    entity.lineItems = order.items.map(item => {
      const lineItem = new OrderLineItemEntity();
      lineItem.productId = item.productId.value;
      lineItem.quantity = item.quantity.value;
      lineItem.unitPriceAmount = item.unitPrice.amount;
      lineItem.unitPriceCurrency = item.unitPrice.currency.code;
      return lineItem;
    });
    return entity;
  }
}

5.4 테스트 전략

Value Object 테스트

describe('Money', () => {
  it('should create valid money', () => {
    const money = Money.won(10000);
    expect(money.amount).toBe(10000);
    expect(money.currency).toBe(Currency.KRW);
  });

  it('should reject negative amount', () => {
    expect(() => Money.won(-100))
      .toThrow(NegativeAmountError);
  });

  it('should add same currency', () => {
    const a = Money.won(1000);
    const b = Money.won(2000);
    expect(a.add(b).amount).toBe(3000);
  });

  it('should reject different currency', () => {
    const won = Money.won(1000);
    const usd = Money.of(10, Currency.USD);
    expect(() => won.add(usd))
      .toThrow(CurrencyMismatchError);
  });

  it('should be equal by value', () => {
    const a = Money.won(1000);
    const b = Money.won(1000);
    expect(a.equals(b)).toBe(true);
  });
});

Entity 테스트

describe('Order', () => {
  it('should create draft order', () => {
    const order = Order.create(
      CustomerId.from('cust-1'),
      Address.korean({...})
    );
    expect(order.status).toBe(OrderStatus.DRAFT);
  });

  it('should add items', () => {
    const order = createTestOrder();
    order.addItem(
      ProductId.from('prod-1'),
      Quantity.of(2),
      Money.won(10000)
    );
    expect(order.items).toHaveLength(1);
    expect(order.totalAmount.amount).toBe(20000);
  });

  it('should confirm with items', () => {
    const order = createOrderWithItems();
    order.confirm();
    expect(order.status).toBe(OrderStatus.CONFIRMED);
  });

  it('should reject confirm empty order', () => {
    const order = createTestOrder();
    expect(() => order.confirm())
      .toThrow(EmptyOrderError);
  });

  it('should be equal by id', () => {
    const id = OrderId.generate();
    const a = createOrderWithId(id);
    const b = createOrderWithId(id);
    expect(a.equals(b)).toBe(true);
  });
});

6. 실제 도메인별 적용 사례

6.1 이커머스 도메인

Entity

  • Order - 주문번호로 추적, 상태 변화
  • Product - SKU로 식별, 재고/가격 변동
  • Customer - 고객ID로 식별, 정보 변경
  • Cart - 세션/고객별 장바구니
  • Review - 리뷰ID로 식별, 수정 가능

Value Object

  • Money - 금액 + 통화
  • Address - 배송지 주소
  • OrderItem - 상품 + 수량 + 가격
  • SKU - 상품 식별 코드
  • Discount - 할인율/할인금액
  • Rating - 별점 (1-5)

6.2 금융 도메인

Entity

  • Account - 계좌번호로 식별, 잔액 변동
  • Transaction - 거래ID, 이력 추적
  • Loan - 대출번호, 상환 상태
  • Customer - 고객 정보 관리
  • Card - 카드번호, 한도/상태

Value Object

  • Money - 금액 + 통화
  • AccountNumber - 계좌번호 형식
  • InterestRate - 이자율
  • DateRange - 거래 기간
  • CreditScore - 신용점수
  • IBAN - 국제 계좌번호

6.3 예약 시스템 도메인

Entity

  • Reservation - 예약번호, 상태 변화
  • Room - 객실번호, 상태 관리
  • Guest - 고객 정보
  • Flight - 항공편 번호

Value Object

  • DateRange - 체크인/체크아웃
  • TimeSlot - 예약 시간대
  • GuestCount - 인원수
  • RoomType - 객실 유형
  • SeatNumber - 좌석 번호

6.4 HR/인사 도메인

Entity

  • Employee - 사원번호, 경력 추적
  • Department - 부서 코드
  • LeaveRequest - 휴가 신청
  • PerformanceReview - 평가 기록

Value Object

  • Salary - 급여 (금액 + 통화)
  • EmploymentPeriod - 재직 기간
  • JobTitle - 직책
  • WorkingHours - 근무 시간
  • SkillSet - 보유 기술

6.5 콘텐츠/미디어 도메인

Entity

  • Article - 게시글 ID, 수정 이력
  • Video - 영상 ID, 조회수 추적
  • User - 사용자 계정
  • Comment - 댓글 ID
  • Playlist - 재생목록

Value Object

  • Duration - 영상 길이
  • Resolution - 해상도 (1080p)
  • Tag - 태그/키워드
  • Thumbnail - 썸네일 URL
  • ViewCount - 조회수 스냅샷

7. 실습: 주문 도메인 모델 설계

🎯 워크샵: 온라인 쇼핑몰 주문 시스템
Entity와 Value Object를 식별하고, 완전한 도메인 모델을 설계합니다.

7.1 요구사항 분석

기능 요구사항

  • • 고객이 상품을 장바구니에 담을 수 있다
  • • 장바구니에서 주문을 생성할 수 있다
  • • 주문에 배송지를 지정할 수 있다
  • • 쿠폰/할인을 적용할 수 있다
  • • 주문 상태를 추적할 수 있다
  • • 주문을 취소할 수 있다

비즈니스 규칙

  • • 최소 주문 금액: 10,000원
  • • 무료 배송: 50,000원 이상
  • • 기본 배송비: 3,000원
  • • 쿠폰은 1개만 적용 가능
  • • 배송 시작 후 취소 불가

7.2 Entity와 Value Object 식별

┌─────────────────────────────────────────────────────────────────┐
│                    주문 도메인 모델                              │
├─────────────────────────────────────────────────────────────────┤
│                                                                  │
│  [Entity]                        [Value Object]                  │
│  ┌─────────────┐                 ┌─────────────┐                │
│  │   Order     │────────────────►│   Money     │                │
│  │  (주문)     │                 │  (금액)     │                │
│  └──────┬──────┘                 └─────────────┘                │
│         │                                                        │
│         │ contains                ┌─────────────┐                │
│         ▼                         │  Address    │                │
│  ┌─────────────┐                 │  (배송지)   │                │
│  │ OrderItem   │◄────────────────└─────────────┘                │
│  │ (주문항목)  │                                                 │
│  │ [VO]        │                 ┌─────────────┐                │
│  └─────────────┘                 │  Quantity   │                │
│                                  │  (수량)     │                │
│  [Entity]                        └─────────────┘                │
│  ┌─────────────┐                                                 │
│  │  Customer   │                 ┌─────────────┐                │
│  │  (고객)     │────────────────►│   Email     │                │
│  └─────────────┘                 └─────────────┘                │
│                                                                  │
│  [Entity]                        ┌─────────────┐                │
│  │  Product    │                 │  Discount   │                │
│  │  (상품)     │                 │  (할인)     │                │
│  └─────────────┘                 └─────────────┘                │
│                                                                  │
└─────────────────────────────────────────────────────────────────┘

7.3 Value Object 구현

핵심 Value Objects
// Money: 금액을 표현하는 Value Object
class Money {
  private constructor(
    readonly amount: number,
    readonly currency: Currency
  ) {}

  static won(amount: number): Money {
    return Money.of(amount, Currency.KRW);
  }

  static of(amount: number, currency: Currency): Money {
    if (amount < 0) throw new NegativeAmountError(amount);
    return new Money(Math.round(amount), currency);
  }

  static zero(currency: Currency = Currency.KRW): Money {
    return new Money(0, currency);
  }

  add(other: Money): Money {
    this.ensureSameCurrency(other);
    return Money.of(this.amount + other.amount, this.currency);
  }

  subtract(other: Money): Money {
    this.ensureSameCurrency(other);
    return Money.of(this.amount - other.amount, this.currency);
  }

  multiply(factor: number): Money {
    return Money.of(this.amount * factor, this.currency);
  }

  isGreaterThanOrEqual(other: Money): boolean {
    this.ensureSameCurrency(other);
    return this.amount >= other.amount;
  }

  isLessThan(other: Money): boolean {
    this.ensureSameCurrency(other);
    return this.amount < other.amount;
  }

  equals(other: Money): boolean {
    return this.amount === other.amount && 
           this.currency === other.currency;
  }

  private ensureSameCurrency(other: Money): void {
    if (this.currency !== other.currency) {
      throw new CurrencyMismatchError(this.currency, other.currency);
    }
  }

  format(): string {
    return new Intl.NumberFormat('ko-KR', {
      style: 'currency',
      currency: this.currency.code
    }).format(this.amount);
  }
}

// Quantity: 수량을 표현하는 Value Object
class Quantity {
  private constructor(readonly value: number) {}

  static of(value: number): Quantity {
    if (!Number.isInteger(value)) {
      throw new InvalidQuantityError('Quantity must be integer');
    }
    if (value <= 0) {
      throw new InvalidQuantityError('Quantity must be positive');
    }
    return new Quantity(value);
  }

  add(other: Quantity): Quantity {
    return Quantity.of(this.value + other.value);
  }

  multiply(factor: number): number {
    return this.value * factor;
  }

  equals(other: Quantity): boolean {
    return this.value === other.value;
  }
}

// OrderItem: 주문 항목 Value Object
class OrderItem {
  private constructor(
    readonly productId: ProductId,
    readonly productName: string,
    readonly quantity: Quantity,
    readonly unitPrice: Money
  ) {}

  static create(
    productId: ProductId,
    productName: string,
    quantity: Quantity,
    unitPrice: Money
  ): OrderItem {
    return new OrderItem(productId, productName, quantity, unitPrice);
  }

  get lineTotal(): Money {
    return this.unitPrice.multiply(this.quantity.value);
  }

  equals(other: OrderItem): boolean {
    return this.productId.equals(other.productId) &&
           this.quantity.equals(other.quantity) &&
           this.unitPrice.equals(other.unitPrice);
  }
}

// Discount: 할인을 표현하는 Value Object
class Discount {
  private constructor(
    readonly type: DiscountType,
    readonly value: number,
    readonly code?: string
  ) {}

  static percentage(percent: number, code?: string): Discount {
    if (percent < 0 || percent > 100) {
      throw new InvalidDiscountError('Percentage must be 0-100');
    }
    return new Discount(DiscountType.PERCENTAGE, percent, code);
  }

  static fixedAmount(amount: number, code?: string): Discount {
    if (amount < 0) {
      throw new InvalidDiscountError('Amount must be positive');
    }
    return new Discount(DiscountType.FIXED_AMOUNT, amount, code);
  }

  applyTo(money: Money): Money {
    switch (this.type) {
      case DiscountType.PERCENTAGE:
        return money.multiply(this.value / 100);
      case DiscountType.FIXED_AMOUNT:
        return Money.won(Math.min(this.value, money.amount));
    }
  }
}

7.4 Order Entity 구현

class Order {
  private _id: OrderId;
  private _customerId: CustomerId;
  private _items: OrderItem[];
  private _shippingAddress: Address;
  private _status: OrderStatus;
  private _discount?: Discount;
  private _createdAt: Date;
  private _updatedAt: Date;

  // 비즈니스 상수
  private static readonly MINIMUM_ORDER_AMOUNT = Money.won(10000);
  private static readonly FREE_SHIPPING_THRESHOLD = Money.won(50000);
  private static readonly SHIPPING_FEE = Money.won(3000);

  private constructor() {}

  // 팩토리 메서드
  static create(customerId: CustomerId, shippingAddress: Address): Order {
    const order = new Order();
    order._id = OrderId.generate();
    order._customerId = customerId;
    order._items = [];
    order._shippingAddress = shippingAddress;
    order._status = OrderStatus.DRAFT;
    order._createdAt = new Date();
    order._updatedAt = new Date();
    return order;
  }

  // 주문 항목 추가
  addItem(item: OrderItem): void {
    this.ensureModifiable();
    
    const existingIndex = this._items.findIndex(
      i => i.productId.equals(item.productId)
    );
    
    if (existingIndex >= 0) {
      // 기존 항목이 있으면 수량 합산
      const existing = this._items[existingIndex];
      this._items[existingIndex] = OrderItem.create(
        existing.productId,
        existing.productName,
        existing.quantity.add(item.quantity),
        existing.unitPrice
      );
    } else {
      this._items.push(item);
    }
    
    this._updatedAt = new Date();
  }

  // 할인 적용
  applyDiscount(discount: Discount): void {
    this.ensureModifiable();
    this._discount = discount;
    this._updatedAt = new Date();
  }

  // 주문 확정
  confirm(): void {
    this.ensureModifiable();
    
    if (this._items.length === 0) {
      throw new EmptyOrderError();
    }
    
    if (this.subtotal.isLessThan(Order.MINIMUM_ORDER_AMOUNT)) {
      throw new MinimumOrderAmountError(Order.MINIMUM_ORDER_AMOUNT);
    }
    
    this._status = OrderStatus.CONFIRMED;
    this._updatedAt = new Date();
  }

  // 주문 취소
  cancel(reason: string): void {
    if (!this._status.canBeCancelled()) {
      throw new OrderCannotBeCancelledError(this._status);
    }
    
    this._status = OrderStatus.CANCELLED;
    this._updatedAt = new Date();
  }

  // 계산된 속성들
  get subtotal(): Money {
    return this._items.reduce(
      (sum, item) => sum.add(item.lineTotal),
      Money.zero()
    );
  }

  get discountAmount(): Money {
    if (!this._discount) return Money.zero();
    return this._discount.applyTo(this.subtotal);
  }

  get shippingFee(): Money {
    const afterDiscount = this.subtotal.subtract(this.discountAmount);
    return afterDiscount.isGreaterThanOrEqual(Order.FREE_SHIPPING_THRESHOLD)
      ? Money.zero()
      : Order.SHIPPING_FEE;
  }

  get totalAmount(): Money {
    return this.subtotal
      .subtract(this.discountAmount)
      .add(this.shippingFee);
  }

  // 상태 검증
  private ensureModifiable(): void {
    if (this._status !== OrderStatus.DRAFT) {
      throw new OrderNotModifiableError(this._status);
    }
  }

  // Getters
  get id(): OrderId { return this._id; }
  get customerId(): CustomerId { return this._customerId; }
  get items(): readonly OrderItem[] { return [...this._items]; }
  get shippingAddress(): Address { return this._shippingAddress; }
  get status(): OrderStatus { return this._status; }
  get discount(): Discount | undefined { return this._discount; }

  // Entity 동등성
  equals(other: Order): boolean {
    return this._id.equals(other._id);
  }
}

7.5 사용 예시

// 주문 생성 및 처리 예시
async function createOrderUseCase(
  customerId: string,
  items: Array<{ productId: string; quantity: number }>,
  shippingAddress: AddressDto,
  couponCode?: string
): Promise<OrderDto> {
  // 1. Value Object 생성 (유효성 검증 포함)
  const customer = CustomerId.from(customerId);
  const address = Address.korean({
    city: shippingAddress.city,
    district: shippingAddress.district,
    street: shippingAddress.street,
    detail: shippingAddress.detail,
    zipCode: shippingAddress.zipCode
  });

  // 2. Order Entity 생성
  const order = Order.create(customer, address);

  // 3. 상품 정보 조회 및 주문 항목 추가
  for (const item of items) {
    const product = await productRepository.findById(
      ProductId.from(item.productId)
    );
    
    if (!product) {
      throw new ProductNotFoundError(item.productId);
    }

    order.addItem(OrderItem.create(
      product.id,
      product.name,
      Quantity.of(item.quantity),
      product.price
    ));
  }

  // 4. 쿠폰 적용 (선택적)
  if (couponCode) {
    const coupon = await couponRepository.findByCode(couponCode);
    if (coupon && coupon.isValid()) {
      order.applyDiscount(coupon.toDiscount());
    }
  }

  // 5. 주문 확정
  order.confirm();

  // 6. 저장
  await orderRepository.save(order);

  // 7. DTO 반환
  return OrderDto.from(order);
}

// 실행 예시
const orderDto = await createOrderUseCase(
  'cust-123',
  [
    { productId: 'prod-1', quantity: 2 },
    { productId: 'prod-2', quantity: 1 }
  ],
  {
    city: '서울시',
    district: '강남구',
    street: '테헤란로 123',
    detail: '456호',
    zipCode: '06234'
  },
  'WELCOME10'
);

console.log(orderDto);
// {
//   id: 'ord-abc123',
//   status: 'CONFIRMED',
//   items: [...],
//   subtotal: '₩45,000',
//   discount: '₩4,500',
//   shippingFee: '₩3,000',
//   total: '₩43,500'
// }

8. 자주 묻는 질문 (FAQ)

Q1: Value Object에 ID를 부여하면 안 되나요?

Value Object는 정의상 식별자가 없습니다. 만약 ID가 필요하다면 그것은 Entity입니다. 단, 영속성 레이어에서 기술적 ID(surrogate key)를 사용하는 것은 괜찮습니다. 도메인 모델에서는 무시하면 됩니다.

Q2: Value Object가 다른 Entity를 참조해도 되나요?

Value Object는 Entity의 ID(식별자)를 포함할 수 있습니다. 예: OrderItem이 ProductId를 가지는 것. 하지만 Entity 객체 자체를 직접 참조하는 것은 피해야 합니다.

Q3: 모든 primitive를 Value Object로 감싸야 하나요?

아니요. 도메인에서 의미 있는 개념만 Value Object로 만드세요. 단순한 플래그나 카운터는 primitive로 충분합니다. 과도한 래핑은 오히려 복잡성을 증가시킵니다.

Q4: Value Object의 equals() 구현 시 주의점은?

모든 속성을 비교해야 합니다. null 체크를 잊지 마세요. 부동소수점 비교 시 오차를 고려하세요. 컬렉션 속성이 있다면 깊은 비교가 필요합니다.

Q5: Entity의 속성 중 일부만 변경하고 싶을 때는?

Entity는 가변이므로 setter나 메서드로 변경할 수 있습니다. 단, 무분별한 setter 대신 의미 있는 비즈니스 메서드(예: changeAddress, updateStatus)를 사용하세요.

Q6: Value Object를 DB에 저장할 때 별도 테이블이 필요한가요?

대부분의 경우 Entity 테이블에 컬럼으로 임베드하는 것이 좋습니다. 컬렉션인 경우에만 별도 테이블을 고려하세요. JSON 컬럼도 좋은 대안입니다.

Q7: 불변 객체인데 어떻게 '변경'하나요?

변경이 아니라 '새 객체 생성'입니다. money.add(other)는 기존 money를 변경하지 않고 새 Money 객체를 반환합니다. 함수형 프로그래밍의 불변성 원칙과 같습니다.

Q8: Entity와 Value Object 중 애매한 경우는?

컨텍스트에 따라 다릅니다. '이것을 추적해야 하는가?'를 기준으로 판단하세요. 확실하지 않다면 Value Object로 시작하고, 필요시 Entity로 전환하세요.

9. 핵심 요약

🔑 Entity의 핵심
  • • 고유 식별자(ID)로 구분
  • • 시간에 따라 상태가 변함
  • • 생명주기를 가짐 (생성→변경→삭제)
  • • ID가 같으면 같은 Entity
  • • 예: User, Order, Account
⚖️ Value Object의 핵심
  • • 속성의 조합으로 정의
  • • 불변 (Immutable)
  • • 값이 같으면 같은 객체
  • • 교체 가능 (Replaceable)
  • • 예: Money, Address, Email
🎯 선택 기준
  • • 추적이 필요한가? → Entity
  • • 값이 같으면 같은가? → Value Object
  • • 교체해도 되는가? → Value Object
  • • 확실하지 않으면 → Value Object 우선
💡 실무 팁
  • • Value Object를 선호하라
  • • Primitive Obsession을 피하라
  • • 식별자도 Value Object로 래핑
  • • 유효성 검증은 생성자에서
"가능하면 Entity보다 Value Object를 선호하라.
Value Object는 불변이므로 추론하기 쉽고, 부작용이 없으며, 테스트하기 쉽다."

— Vaughn Vernon

10. 참고 자료 및 다음 학습

📚 추천 도서
  • Domain-Driven Design

    Eric Evans - Chapter 5: Entity & Value Object

  • Implementing Domain-Driven Design

    Vaughn Vernon - Chapter 5, 6

  • Domain Modeling Made Functional

    Scott Wlaschin - 함수형 관점의 Value Object

🎯 다음 학습 단계

DDD Theory 07: Aggregate 설계

일관성 경계, Aggregate Root, 트랜잭션 범위

DDD Theory 08: Domain Events

이벤트 기반 통합, 최종 일관성

DDD Theory 09: Repository 패턴

영속성 추상화, 컬렉션 인터페이스