DDD 이론 9: Service와 Supple Design
Domain Service와 Application Service의 책임 분리, 그리고 유연하고 표현력 있는 설계를 위한 Supple Design 패턴을 학습합니다.
- • Domain Service와 Application Service의 차이를 명확히 이해한다
- • Service를 사용해야 할 때와 사용하지 말아야 할 때를 구분한다
- • Supple Design의 6가지 패턴을 이해하고 적용할 수 있다
- • 의도를 드러내는 인터페이스를 설계할 수 있다
- • 부작용 없는 함수를 작성할 수 있다
1. Domain Service vs Application Service
"도메인의 중요한 프로세스나 변환이 Entity나 Value Object의 자연스러운 책임이 아닐 때, 그 연산을 Service로 선언된 독립적인 인터페이스로 모델에 추가하라."
— Eric Evans, Domain-Driven Design
1.1 Service가 필요한 경우
1. 여러 Aggregate에 걸친 연산
하나의 Entity에 자연스럽게 속하지 않는 도메인 로직
// 송금: Account A → Account B
// 어느 Account에 속해야 할까? → Domain Service
class TransferService {
transfer(from: Account, to: Account, amount: Money): void {
from.withdraw(amount);
to.deposit(amount);
}
}2. 외부 시스템 연동
결제 게이트웨이, 이메일 발송 등 인프라 의존
3. 복잡한 비즈니스 규칙
여러 도메인 객체의 상태를 조합해야 하는 규칙
1.2 Domain Service vs Application Service
| 구분 | Domain Service | Application Service |
|---|---|---|
| 위치 | 도메인 레이어 | 애플리케이션 레이어 |
| 책임 | 도메인 로직 수행 | 유스케이스 조율 |
| 상태 | 무상태 (Stateless) | 무상태 (Stateless) |
| 의존성 | 도메인 객체만 | Repository, Domain Service 등 |
| 트랜잭션 | 관여하지 않음 | 트랜잭션 경계 관리 |
| 예시 | TransferService, PricingService | OrderApplicationService |
1.3 Domain Service 구현
// Domain Service: 순수한 도메인 로직
class PricingService {
calculateOrderTotal(
items: OrderItem[],
customer: Customer,
coupon: Coupon | null
): PricingResult {
// 1. 기본 가격 계산
let subtotal = items.reduce(
(sum, item) => sum.add(item.lineTotal),
Money.zero()
);
// 2. 회원 등급 할인
const memberDiscount = this.calculateMemberDiscount(
subtotal,
customer.membershipLevel
);
// 3. 쿠폰 할인
const couponDiscount = coupon
? this.calculateCouponDiscount(subtotal, coupon)
: Money.zero();
// 4. 배송비 계산
const shippingFee = this.calculateShippingFee(
subtotal.subtract(memberDiscount).subtract(couponDiscount)
);
return new PricingResult(
subtotal,
memberDiscount,
couponDiscount,
shippingFee
);
}
private calculateMemberDiscount(amount: Money, level: MembershipLevel): Money {
const discountRate = {
[MembershipLevel.BRONZE]: 0,
[MembershipLevel.SILVER]: 0.03,
[MembershipLevel.GOLD]: 0.05,
[MembershipLevel.VIP]: 0.10
};
return amount.multiply(discountRate[level]);
}
private calculateShippingFee(amount: Money): Money {
return amount.isGreaterThanOrEqual(Money.won(50000))
? Money.zero()
: Money.won(3000);
}
}1.4 Application Service 구현
// Application Service: 유스케이스 조율
class OrderApplicationService {
constructor(
private readonly orderRepository: OrderRepository,
private readonly customerRepository: CustomerRepository,
private readonly productRepository: ProductRepository,
private readonly pricingService: PricingService,
private readonly eventPublisher: DomainEventPublisher
) {}
@Transactional
async createOrder(command: CreateOrderCommand): Promise<OrderId> {
// 1. 필요한 데이터 조회
const customer = await this.customerRepository.findById(
CustomerId.from(command.customerId)
);
if (!customer) throw new CustomerNotFoundError(command.customerId);
const products = await this.productRepository.findByIds(
command.items.map(i => ProductId.from(i.productId))
);
// 2. 도메인 객체 생성
const order = Order.create(
customer.id,
Address.from(command.shippingAddress)
);
// 3. 주문 항목 추가
for (const item of command.items) {
const product = products.find(p => p.id.value === item.productId);
if (!product) throw new ProductNotFoundError(item.productId);
order.addItem(
product.id,
product.name,
Quantity.of(item.quantity),
product.price
);
}
// 4. 가격 계산 (Domain Service 사용)
const pricing = this.pricingService.calculateOrderTotal(
order.items,
customer,
command.couponCode ? await this.findCoupon(command.couponCode) : null
);
order.applyPricing(pricing);
// 5. 저장
await this.orderRepository.save(order);
// 6. 이벤트 발행
await this.eventPublisher.publishAll(order.domainEvents);
return order.id;
}
}2. Supple Design 패턴
"유연한 설계(Supple Design)는 클라이언트 개발자가 도메인 객체를 자연스럽게 조합하여 의미 있는 표현을 만들 수 있게 한다."
— Eric Evans
2.1 Intention-Revealing Interfaces
메서드 이름만으로 의도를 명확히 전달합니다. 구현을 보지 않아도 무엇을 하는지 알 수 있어야 합니다.
❌ 의도가 불명확
class Order {
process(): void { ... }
handle(): void { ... }
doIt(): void { ... }
update(data: any): void { ... }
}✅ 의도가 명확
class Order {
confirm(): void { ... }
cancel(reason: CancellationReason): void { ... }
ship(trackingNumber: TrackingNumber): void { ... }
addItem(item: OrderItem): void { ... }
}2.2 Side-Effect-Free Functions
가능한 한 부작용 없는 함수를 사용합니다. 같은 입력에 항상 같은 출력을 반환하고, 상태를 변경하지 않습니다.
❌ 부작용 있음
class Money {
add(other: Money): void {
// 자신의 상태를 변경 (부작용)
this.amount += other.amount;
}
}
const a = Money.won(1000);
a.add(Money.won(500));
// a가 변경됨 - 예측하기 어려움✅ 부작용 없음
class Money {
add(other: Money): Money {
// 새 객체 반환 (불변)
return Money.of(
this.amount + other.amount,
this.currency
);
}
}
const a = Money.won(1000);
const b = a.add(Money.won(500));
// a는 그대로, b는 새 객체2.3 Assertions
사전조건, 사후조건, 불변식을 명시적으로 표현합니다.
class Order {
confirm(): void {
// 사전조건 (Precondition)
if (this.status !== OrderStatus.DRAFT) {
throw new InvalidStateError('Order must be in DRAFT status');
}
if (this.items.length === 0) {
throw new EmptyOrderError('Order must have at least one item');
}
this._status = OrderStatus.CONFIRMED;
// 사후조건 (Postcondition) - 개발 중 검증용
this.assertPostCondition();
}
private assertPostCondition(): void {
// 불변식 검증
console.assert(
this.totalAmount.equals(this.calculateTotal()),
'Total amount must match calculated total'
);
}
}
class Money {
static of(amount: number, currency: Currency): Money {
// 사전조건
if (amount < 0) {
throw new NegativeAmountError(`Amount cannot be negative: ${amount}`);
}
if (!Number.isFinite(amount)) {
throw new InvalidAmountError(`Amount must be finite: ${amount}`);
}
return new Money(amount, currency);
}
}2.4 Conceptual Contours
도메인의 자연스러운 경계를 따라 설계합니다. 함께 변경되는 것은 함께, 독립적인 것은 분리합니다.
❌ 부자연스러운 경계
// 너무 세분화
class Street { ... }
class City { ... }
class ZipCode { ... }
class Country { ... }
// 또는 너무 뭉뚱그림
class CustomerData {
name, email, phone,
street, city, zipCode,
cardNumber, cardExpiry...
}✅ 자연스러운 경계
// 개념적으로 완전한 단위
class Address {
street: string;
city: string;
zipCode: string;
country: string;
}
class Customer {
name: PersonName;
email: Email;
phone: PhoneNumber;
address: Address;
paymentMethod: PaymentMethod;
}2.5 Standalone Classes
가능한 한 독립적인 클래스를 만듭니다. 의존성이 적을수록 이해하고 테스트하기 쉽습니다.
// 독립적인 Value Object - 외부 의존성 없음
class Money {
private constructor(
readonly amount: number,
readonly currency: Currency
) {}
// 자체적으로 완전한 기능
add(other: Money): Money { ... }
subtract(other: Money): Money { ... }
multiply(factor: number): Money { ... }
isGreaterThan(other: Money): boolean { ... }
equals(other: Money): boolean { ... }
format(): string { ... }
}
// 독립적인 Domain Service
class TaxCalculator {
// 외부 의존성 없이 순수 계산
calculate(amount: Money, taxRate: TaxRate): Money {
return amount.multiply(taxRate.value);
}
}2.6 Closure of Operations
연산의 반환 타입이 인자와 같은 타입이면 조합이 가능해집니다.
class Money {
// Closure of Operations: Money + Money = Money
add(other: Money): Money {
return Money.of(this.amount + other.amount, this.currency);
}
subtract(other: Money): Money {
return Money.of(this.amount - other.amount, this.currency);
}
}
// 자연스러운 조합 가능
const total = items
.map(item => item.price)
.reduce((sum, price) => sum.add(price), Money.zero());
// 체이닝 가능
const finalPrice = basePrice
.add(shippingFee)
.subtract(discount)
.add(tax);3. FAQ 및 핵심 요약
Domain Service는 순수한 도메인 로직(가격 계산, 송금 등)을 담당하고, Application Service는 유스케이스 조율(트랜잭션, Repository 호출, 이벤트 발행)을 담당합니다.
아니요. 이것은 '빈약한 도메인 모델(Anemic Domain Model)' 안티패턴입니다. Entity와 Value Object에 자연스럽게 속하는 로직은 해당 객체에 두세요.
Value Object와 쿼리 메서드에서는 필수입니다. Entity의 명령 메서드는 상태를 변경하므로 부작용이 있을 수 있지만, 명확히 문서화해야 합니다.
상황에 맞게 선택적으로 적용하세요. 가장 중요한 것은 Intention-Revealing Interfaces와 Side-Effect-Free Functions입니다.
4. 핵심 요약
- • Domain Service: 순수 도메인 로직
- • Application Service: 유스케이스 조율
- • 둘 다 무상태(Stateless)
- • Entity에 속하지 않는 로직만
- • Intention-Revealing: 의도 명확히
- • Side-Effect-Free: 부작용 없는 함수
- • Assertions: 조건 명시
- • Closure of Operations: 조합 가능
"유연한 설계는 클라이언트 개발자가 도메인 객체를
자연스럽게 조합하여 의미 있는 표현을 만들 수 있게 한다."
— Eric Evans
DDD Theory 10
Domain Events
DDD Theory 11
CQRS 패턴
DDD Theory 12
Event Sourcing