PART 3 · 강의 3/4

DDD
전술적 설계

Domain-Driven Design의 빌딩 블록 - Entity, Value Object, Aggregate, Repository, Domain Service, Domain Event

01

DDD 빌딩 블록

도메인 모델을 구성하는 핵심 패턴들

🆔

Entity

고유한 식별자를 가지며, 생명주기 동안 연속성을 유지하는 객체

💎

Value Object

식별자 없이 속성 값으로만 구분되는 불변 객체

📦

Aggregate

일관성 경계 안에서 관련 객체들의 클러스터

🗃️

Repository

Aggregate의 영속성을 담당하는 컬렉션 추상화

⚙️

Domain Service

특정 Entity에 속하지 않는 도메인 로직

📣

Domain Event

도메인에서 발생한 중요한 사건을 나타내는 객체

💡 빌딩 블록 선택 기준

객체가 식별자로 구분되어야 하면 Entity, 속성 값으로 구분되면 Value Object입니다. 여러 객체를 하나의 트랜잭션 경계로 묶어야 하면 Aggregate를 사용합니다.

02

Entity vs Value Object

두 핵심 패턴의 차이점 이해하기

🆔 Entity

  • 고유한 식별자(ID)를 가짐
  • 생명주기 동안 연속성 유지
  • 속성이 변해도 같은 객체
  • 동등성은 ID로 판단
  • 가변(Mutable) 가능
예시: User, Order, Product, Account
VS

💎 Value Object

  • 식별자 없음
  • 속성 값으로 동등성 판단
  • 불변(Immutable)
  • 교체 가능(Replaceable)
  • Side-effect 없음
예시: Money, Address, Email, DateRange
Entity 예시: User
class User {
  private readonly id: UserId;
  private name: string;
  private email: Email;

  constructor(id: UserId, name: string, email: Email) {
    this.id = id;
    this.name = name;
    this.email = email;
  }

  // 이름이 바뀌어도 같은 User
  changeName(newName: string): void {
    this.name = newName;
  }

  // 동등성은 ID로 판단
  equals(other: User): boolean {
    return this.id.equals(other.id);
  }
}
Value Object 예시: Money
class Money {
  private readonly amount: number;
  private readonly currency: Currency;

  private constructor(amount: number, currency: Currency) {
    this.amount = amount;
    this.currency = currency;
  }

  static create(amount: number, currency: Currency): Money {
    return new Money(amount, currency);
  }

  // 불변 - 새 객체 반환
  add(other: Money): Money {
    if (!this.currency.equals(other.currency)) {
      throw new Error('Currency mismatch');
    }
    return Money.create(this.amount + other.amount, this.currency);
  }

  // 동등성은 값으로 판단
  equals(other: Money): boolean {
    return this.amount === other.amount &&
           this.currency.equals(other.currency);
  }
}
03

Aggregate (집합체)

일관성 경계와 트랜잭션 단위

Aggregate란?

Aggregate는 데이터 변경의 단위로 취급되는 연관 객체들의 클러스터입니다. 각 Aggregate는 루트 Entity(Aggregate Root)를 가지며, 외부에서는 오직 루트를 통해서만 Aggregate 내부에 접근할 수 있습니다.

Order Aggregate 예시

Order Aggregate
👑
Order
Aggregate Root
OrderLine
Entity
ShippingAddress
Value Object
OrderStatus
Value Object
Money
Value Object
📌 Aggregate 설계 규칙
  • 일관성 경계 — Aggregate 내부는 항상 일관된 상태를 유지해야 함
  • 루트를 통한 접근 — 외부에서는 Aggregate Root를 통해서만 접근
  • 하나의 트랜잭션 — 하나의 트랜잭션에서 하나의 Aggregate만 수정
  • 작게 유지 — Aggregate는 가능한 작게 설계 (성능과 동시성)
  • ID로 참조 — 다른 Aggregate는 ID로만 참조 (직접 객체 참조 X)
Aggregate Root 예시: Order
class Order {
  private readonly id: OrderId;
  private readonly customerId: CustomerId;  // 다른 Aggregate는 ID로 참조
  private lines: OrderLine[] = [];
  private shippingAddress: Address;
  private status: OrderStatus;

  // 외부에서는 Order를 통해서만 OrderLine에 접근
  addLine(productId: ProductId, quantity: number, price: Money): void {
    if (this.status !== OrderStatus.Draft) {
      throw new Error('Cannot modify confirmed order');
    }
    const line = new OrderLine(productId, quantity, price);
    this.lines.push(line);
  }

  removeLine(lineId: OrderLineId): void {
    if (this.status !== OrderStatus.Draft) {
      throw new Error('Cannot modify confirmed order');
    }
    this.lines = this.lines.filter(l => !l.id.equals(lineId));
  }

  confirm(): void {
    if (this.lines.length === 0) {
      throw new Error('Cannot confirm empty order');
    }
    this.status = OrderStatus.Confirmed;
    // Domain Event 발행
    this.addDomainEvent(new OrderConfirmedEvent(this.id));
  }

  get totalAmount(): Money {
    return this.lines.reduce(
      (sum, line) => sum.add(line.subtotal),
      Money.zero()
    );
  }
}
04

Repository (저장소)

Aggregate의 영속성을 추상화하는 패턴

📝
Domain Layer
Use Case, Entity
🗃️
Repository Interface
도메인 레이어에 정의
🗄️
Repository Impl
인프라 레이어에 구현
Repository Interface (Domain Layer)
// 도메인 레이어에 정의
interface OrderRepository {
  // Aggregate 단위로 조회
  findById(id: OrderId): Promise<Order | null>;
  findByCustomerId(customerId: CustomerId): Promise<Order[]>;

  // Aggregate 단위로 저장
  save(order: Order): Promise<void>;

  // Aggregate 단위로 삭제
  delete(order: Order): Promise<void>;

  // 다음 ID 생성 (옵션)
  nextId(): OrderId;
}

// 도메인 로직은 구현 세부사항을 모름
class ConfirmOrderUseCase {
  constructor(private orderRepo: OrderRepository) {}

  async execute(orderId: string): Promise<void> {
    const order = await this.orderRepo.findById(
      new OrderId(orderId)
    );
    if (!order) throw new Error('Order not found');

    order.confirm();
    await this.orderRepo.save(order);
  }
}
Repository Implementation (Infra Layer)
// 인프라 레이어에 구현
class PostgresOrderRepository implements OrderRepository {
  constructor(private db: Database) {}

  async findById(id: OrderId): Promise<Order | null> {
    const row = await this.db.query(
      'SELECT * FROM orders WHERE id = $1',
      [id.value]
    );
    if (!row) return null;

    // DB 모델 → 도메인 모델 변환
    return this.toDomain(row);
  }

  async save(order: Order): Promise<void> {
    // 도메인 모델 → DB 모델 변환
    const data = this.toPersistence(order);

    await this.db.query(
      `INSERT INTO orders (...) VALUES (...)
       ON CONFLICT (id) DO UPDATE SET ...`,
      [data]
    );

    // OrderLine도 함께 저장 (Aggregate 단위)
    await this.saveOrderLines(order);
  }

  private toDomain(row: any): Order { ... }
  private toPersistence(order: Order): any { ... }
}
💡 Repository 핵심 원칙

Repository는 Aggregate Root 단위로 동작합니다. Order Aggregate를 저장하면 OrderLine도 함께 저장됩니다. 인터페이스는 도메인 레이어에, 구현은 인프라 레이어에 위치하여 의존성 역전을 달성합니다.

05

Domain Service

특정 Entity에 속하지 않는 도메인 로직

언제 Domain Service를 사용하나요?

도메인 로직이 여러 Aggregate에 걸쳐있거나, 특정 Entity의 책임으로 보기 어려울 때 사용합니다. 무상태(Stateless)이며, 순수한 도메인 로직만 포함합니다.

Domain Service 예시: 가격 계산
// 여러 Aggregate(Order, Customer, Product)에 걸친 로직
class PricingService {
  calculateOrderPrice(
    order: Order,
    customer: Customer,
    discounts: Discount[]
  ): Money {
    let totalPrice = order.subtotal;

    // 고객 등급에 따른 할인
    if (customer.isVIP()) {
      totalPrice = totalPrice.multiply(0.9);
    }

    // 프로모션 할인 적용
    for (const discount of discounts) {
      if (discount.isApplicableTo(order)) {
        totalPrice = discount.apply(totalPrice);
      }
    }

    // 최소 금액 보장
    return Money.max(totalPrice, Money.zero());
  }
}

// 배송비 계산 - Order도 Address도 아닌 별도 로직
class ShippingCostCalculator {
  calculate(
    address: Address,
    items: OrderLine[],
    shippingMethod: ShippingMethod
  ): Money {
    const weight = items.reduce((sum, item) =>
      sum + item.weight, 0
    );
    const distance = this.calculateDistance(address);

    return shippingMethod.calculateCost(weight, distance);
  }
}
📌 Domain Service vs Application Service
  • Domain Service — 순수한 도메인 로직, 인프라 의존 없음
  • Application Service — Use Case 조율, 트랜잭션, 인프라 호출
  • Domain Service는 비즈니스 규칙을 표현
  • Application Service는 워크플로우를 조율
06

Domain Event

도메인에서 발생한 중요한 사건

OrderCreatedEvent

새로운 주문이 생성되었을 때 발행. 재고 예약, 알림 전송 등을 트리거.

OrderConfirmedEvent

주문이 확정되었을 때 발행. 결제 처리, 배송 준비 시작.

PaymentCompletedEvent

결제가 완료되었을 때 발행. 주문 상태 업데이트, 영수증 발행.

OrderShippedEvent

배송이 시작되었을 때 발행. 고객에게 배송 알림 전송.

Domain Event 정의
// 불변의 이벤트 객체
class OrderConfirmedEvent implements DomainEvent {
  readonly occurredOn: Date;
  readonly orderId: string;
  readonly customerId: string;
  readonly totalAmount: number;

  constructor(order: Order) {
    this.occurredOn = new Date();
    this.orderId = order.id.value;
    this.customerId = order.customerId.value;
    this.totalAmount = order.totalAmount.value;
  }

  get eventType(): string {
    return 'order.confirmed';
  }
}

// Aggregate에서 이벤트 발행
class Order extends AggregateRoot {
  confirm(): void {
    // 비즈니스 규칙 검증
    if (this.lines.length === 0) {
      throw new Error('Cannot confirm empty order');
    }
    this.status = OrderStatus.Confirmed;

    // 도메인 이벤트 등록
    this.addDomainEvent(new OrderConfirmedEvent(this));
  }
}
Event Handler
// 이벤트 핸들러 (Application Layer)
class SendOrderConfirmationEmail
  implements DomainEventHandler<OrderConfirmedEvent> {

  constructor(
    private emailService: EmailService,
    private customerRepo: CustomerRepository
  ) {}

  async handle(event: OrderConfirmedEvent): Promise<void> {
    const customer = await this.customerRepo.findById(
      new CustomerId(event.customerId)
    );
    if (!customer) return;

    await this.emailService.send({
      to: customer.email,
      subject: '주문이 확정되었습니다',
      body: `주문번호: ${event.orderId}`
    });
  }
}

// 다른 Bounded Context로 이벤트 전파
class ReserveInventoryOnOrderConfirmed
  implements DomainEventHandler<OrderConfirmedEvent> {

  constructor(private inventoryService: InventoryService) {}

  async handle(event: OrderConfirmedEvent): Promise<void> {
    await this.inventoryService.reserve(event.orderId);
  }
}
💡 Domain Event의 장점

느슨한 결합 — Aggregate 간 직접 참조 없이 통신 가능
감사 추적 — 시스템에서 발생한 모든 중요 사건 기록
CQRS/ES 기반 — Event Sourcing과 자연스럽게 연결

SUMMARY

핵심 요약

  • Entity — 식별자로 구분되며, 생명주기 동안 연속성을 유지
  • Value Object — 속성 값으로 동등성 판단, 불변 객체
  • Aggregate — 일관성 경계, Root를 통해서만 접근, 하나의 트랜잭션 단위
  • Repository — Aggregate 영속성 추상화, 인터페이스는 도메인에 정의
  • Domain Service — 여러 Aggregate에 걸친 도메인 로직, 무상태
  • Domain Event — 도메인의 중요한 사건, 느슨한 결합과 감사 추적