🏗️ 아키텍처

DDD

Domain-Driven Design

DDD(Domain-Driven Design)는 복잡한 비즈니스 도메인을 소프트웨어 설계의 중심에 두는 방법론입니다. Eric Evans가 제안한 이 접근법은 Ubiquitous Language, Bounded Context, Aggregate 등의 개념을 통해 도메인 전문가와 개발자 간의 소통을 개선합니다.

📖 상세 설명

Domain-Driven Design(DDD)은 2003년 Eric Evans가 그의 저서 "Domain-Driven Design: Tackling Complexity in the Heart of Software"에서 소개한 소프트웨어 설계 방법론입니다. 핵심 철학은 "소프트웨어의 복잡성은 기술이 아니라 도메인에서 온다"입니다.

전략적 설계 (Strategic Design)

Ubiquitous Language (보편 언어)

개발팀과 도메인 전문가가 공유하는 공통 언어. 코드, 문서, 대화 모두에서 동일한 용어 사용

Bounded Context (경계된 컨텍스트)

동일한 용어가 다른 의미를 가질 수 있는 경계. "상품"이 쇼핑과 재고관리에서 다른 모델

Context Map

여러 Bounded Context 간의 관계를 시각화. 팀 간 협업과 통합 방식 정의

Subdomain

Core(핵심), Supporting(지원), Generic(범용) 서브도메인으로 비즈니스 영역 분류

전술적 설계 (Tactical Design)

Entity (엔티티)

고유 식별자(ID)로 구분되는 객체. 생명주기 동안 상태가 변해도 동일한 객체로 인식

Value Object (값 객체)

식별자 없이 값으로만 구분되는 불변 객체. Money, Address, Email 등

Aggregate (애그리거트)

일관성 경계를 형성하는 엔티티/값객체 묶음. Aggregate Root를 통해서만 접근

Repository

Aggregate의 영속성 관리. 컬렉션처럼 보이는 인터페이스로 저장소 추상화

Domain Service

특정 엔티티에 속하지 않는 도메인 로직. 여러 Aggregate가 협력하는 비즈니스 규칙

Domain Event

도메인에서 발생한 중요한 사건을 표현. Bounded Context 간 느슨한 결합

💻 코드 예제

Value Object 구현

// domain/value-objects/Money.ts
export class Money {
  private constructor(
    private readonly _amount: number,
    private readonly _currency: string
  ) {
    if (_amount < 0) {
      throw new Error('Amount cannot be negative');
    }
  }

  static of(amount: number, currency: string): Money {
    return new Money(amount, currency);
  }

  static zero(currency: string): Money {
    return new Money(0, currency);
  }

  get amount(): number { return this._amount; }
  get currency(): string { return this._currency; }

  add(other: Money): Money {
    this.ensureSameCurrency(other);
    return new Money(this._amount + other._amount, this._currency);
  }

  subtract(other: Money): Money {
    this.ensureSameCurrency(other);
    return new Money(this._amount - other._amount, this._currency);
  }

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

  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 Error('Currency mismatch');
    }
  }
}

Entity와 Aggregate Root

// domain/aggregates/Order.ts
import { Money } from '../value-objects/Money';
import { OrderItem } from '../entities/OrderItem';
import { OrderPlaced } from '../events/OrderPlaced';

export type OrderStatus = 'pending' | 'confirmed' | 'shipped' | 'delivered' | 'cancelled';

export class Order {
  private _items: OrderItem[] = [];
  private _status: OrderStatus = 'pending';
  private _domainEvents: DomainEvent[] = [];

  private constructor(
    private readonly _id: string,
    private readonly _customerId: string,
    private _shippingAddress: Address
  ) {}

  // Factory Method
  static create(customerId: string, shippingAddress: Address): Order {
    const order = new Order(
      crypto.randomUUID(),
      customerId,
      shippingAddress
    );

    // Domain Event 발행
    order.addDomainEvent(new OrderPlaced({
      orderId: order._id,
      customerId: customerId,
      occurredAt: new Date(),
    }));

    return order;
  }

  get id(): string { return this._id; }
  get customerId(): string { return this._customerId; }
  get items(): readonly OrderItem[] { return this._items; }
  get status(): OrderStatus { return this._status; }

  // 비즈니스 규칙이 담긴 메서드
  addItem(productId: string, quantity: number, unitPrice: Money): void {
    if (this._status !== 'pending') {
      throw new Error('Cannot modify confirmed order');
    }

    const existingItem = this._items.find(item => item.productId === productId);
    if (existingItem) {
      existingItem.increaseQuantity(quantity);
    } else {
      this._items.push(OrderItem.create(productId, quantity, unitPrice));
    }
  }

  removeItem(productId: string): void {
    if (this._status !== 'pending') {
      throw new Error('Cannot modify confirmed order');
    }
    this._items = this._items.filter(item => item.productId !== productId);
  }

  // Aggregate 내부 계산
  calculateTotal(): Money {
    return this._items.reduce(
      (total, item) => total.add(item.subtotal()),
      Money.zero('KRW')
    );
  }

  confirm(): void {
    if (this._items.length === 0) {
      throw new Error('Cannot confirm empty order');
    }
    if (this._status !== 'pending') {
      throw new Error('Order is not in pending status');
    }
    this._status = 'confirmed';

    this.addDomainEvent(new OrderConfirmed({
      orderId: this._id,
      totalAmount: this.calculateTotal(),
      occurredAt: new Date(),
    }));
  }

  cancel(reason: string): void {
    if (this._status === 'shipped' || this._status === 'delivered') {
      throw new Error('Cannot cancel shipped or delivered order');
    }
    this._status = 'cancelled';
  }

  // Domain Events
  pullDomainEvents(): DomainEvent[] {
    const events = [...this._domainEvents];
    this._domainEvents = [];
    return events;
  }

  private addDomainEvent(event: DomainEvent): void {
    this._domainEvents.push(event);
  }
}

Repository 인터페이스

// domain/repositories/IOrderRepository.ts
import { Order } from '../aggregates/Order';

export interface IOrderRepository {
  findById(id: string): Promise<Order | null>;
  findByCustomerId(customerId: string): Promise<Order[]>;
  save(order: Order): Promise<void>;
  delete(id: string): Promise<void>;
  nextId(): string; // ID 생성 전략
}

Domain Service

// domain/services/OrderPricingService.ts
import { Order } from '../aggregates/Order';
import { Money } from '../value-objects/Money';
import { Customer } from '../aggregates/Customer';

export class OrderPricingService {
  // 여러 Aggregate를 조합하는 비즈니스 로직
  calculateFinalPrice(order: Order, customer: Customer): Money {
    const subtotal = order.calculateTotal();

    // 회원 등급별 할인
    const discount = this.calculateDiscount(subtotal, customer.tier);

    // 배송비 계산
    const shippingFee = this.calculateShippingFee(subtotal);

    return subtotal.subtract(discount).add(shippingFee);
  }

  private calculateDiscount(subtotal: Money, tier: CustomerTier): Money {
    const discountRates: Record<CustomerTier, number> = {
      bronze: 0,
      silver: 0.03,
      gold: 0.05,
      platinum: 0.1,
    };
    return subtotal.multiply(discountRates[tier]);
  }

  private calculateShippingFee(subtotal: Money): Money {
    // 5만원 이상 무료 배송
    if (subtotal.amount >= 50000) {
      return Money.zero(subtotal.currency);
    }
    return Money.of(3000, subtotal.currency);
  }
}

Application Service (Use Case)

// application/usecases/PlaceOrderUseCase.ts
import { IOrderRepository } from '../../domain/repositories/IOrderRepository';
import { IEventPublisher } from '../../domain/events/IEventPublisher';
import { Order } from '../../domain/aggregates/Order';
import { OrderPricingService } from '../../domain/services/OrderPricingService';

export class PlaceOrderUseCase {
  constructor(
    private orderRepository: IOrderRepository,
    private customerRepository: ICustomerRepository,
    private pricingService: OrderPricingService,
    private eventPublisher: IEventPublisher
  ) {}

  async execute(input: PlaceOrderInput): Promise<PlaceOrderOutput> {
    // 고객 조회
    const customer = await this.customerRepository.findById(input.customerId);
    if (!customer) {
      throw new Error('Customer not found');
    }

    // Order Aggregate 생성
    const order = Order.create(input.customerId, input.shippingAddress);

    // 상품 추가
    for (const item of input.items) {
      order.addItem(item.productId, item.quantity, item.unitPrice);
    }

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

    // 최종 가격 계산 (Domain Service)
    const finalPrice = this.pricingService.calculateFinalPrice(order, customer);

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

    // Domain Events 발행
    const events = order.pullDomainEvents();
    for (const event of events) {
      await this.eventPublisher.publish(event);
    }

    return {
      orderId: order.id,
      totalAmount: finalPrice.amount,
      status: order.status,
    };
  }
}

💬 현업 대화 예시

👨‍💼 도메인 전문가

"주문을 '확정'하기 전에는 상품을 추가하거나 뺄 수 있어야 하고, 확정 후에는 변경이 불가능해야 합니다."

👩‍💻 개발자

"그럼 Order Aggregate에서 status가 'pending'일 때만 addItem, removeItem을 허용하고, confirm()이 호출되면 상태가 바뀌면서 수정이 막히도록 비즈니스 규칙을 코드로 표현하겠습니다."

👨‍💻 주니어 개발자

"Order에서 직접 Customer의 등급을 조회해서 할인율을 계산해도 되나요?"

👩‍💻 시니어 개발자

"Order Aggregate가 Customer Aggregate를 직접 참조하면 경계가 무너져요. 그런 로직은 Domain Service에서 처리하세요. OrderPricingService가 두 Aggregate를 받아서 가격을 계산하는 거죠."

👨‍💼 아키텍트

"쇼핑 Context에서의 '상품'과 재고관리 Context에서의 '상품'은 관심사가 달라요. 쇼핑에서는 가격, 이미지, 설명이 중요하고, 재고에서는 수량, 위치, 입출고 이력이 중요하죠. 각각의 Bounded Context로 분리하고 Context Map을 그려봅시다."

⚠️ 주의사항

모든 프로젝트에 DDD가 필요한 건 아닙니다: 단순 CRUD 애플리케이션에 DDD를 적용하면 과도한 복잡성만 추가됩니다. 비즈니스 로직이 복잡한 Core Domain에 집중하세요.

Aggregate 크기 주의: Aggregate가 너무 크면 동시성 문제와 성능 저하가 발생합니다. 트랜잭션 일관성이 필요한 최소 범위로 설계하세요.

Aggregate 간 직접 참조 금지: 다른 Aggregate를 ID로만 참조하세요. 직접 객체 참조는 경계를 무너뜨립니다.

팁: DDD는 기술보다 커뮤니케이션입니다. 도메인 전문가와 Event Storming 세션을 통해 Bounded Context와 Domain Event를 함께 발견하세요.

🔗 관련 용어

📚 더 배우기