DDD
Domain-Driven Design
DDD(Domain-Driven Design)는 복잡한 비즈니스 도메인을 소프트웨어 설계의 중심에 두는 방법론입니다. Eric Evans가 제안한 이 접근법은 Ubiquitous Language, Bounded Context, Aggregate 등의 개념을 통해 도메인 전문가와 개발자 간의 소통을 개선합니다.
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"에서 소개한 소프트웨어 설계 방법론입니다. 핵심 철학은 "소프트웨어의 복잡성은 기술이 아니라 도메인에서 온다"입니다.
개발팀과 도메인 전문가가 공유하는 공통 언어. 코드, 문서, 대화 모두에서 동일한 용어 사용
동일한 용어가 다른 의미를 가질 수 있는 경계. "상품"이 쇼핑과 재고관리에서 다른 모델
여러 Bounded Context 간의 관계를 시각화. 팀 간 협업과 통합 방식 정의
Core(핵심), Supporting(지원), Generic(범용) 서브도메인으로 비즈니스 영역 분류
고유 식별자(ID)로 구분되는 객체. 생명주기 동안 상태가 변해도 동일한 객체로 인식
식별자 없이 값으로만 구분되는 불변 객체. Money, Address, Email 등
일관성 경계를 형성하는 엔티티/값객체 묶음. Aggregate Root를 통해서만 접근
Aggregate의 영속성 관리. 컬렉션처럼 보이는 인터페이스로 저장소 추상화
특정 엔티티에 속하지 않는 도메인 로직. 여러 Aggregate가 협력하는 비즈니스 규칙
도메인에서 발생한 중요한 사건을 표현. Bounded Context 간 느슨한 결합
// 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');
}
}
}
// 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);
}
}
// 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/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/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를 함께 발견하세요.