Theory
고급 2-3시간 이론

DDD 이론 9: Service와 Supple Design

Domain Service와 Application Service의 책임 분리, 그리고 유연하고 표현력 있는 설계를 위한 Supple Design 패턴을 학습합니다.

Domain ServiceApplication ServiceSupple DesignSide-Effect-FreeIntention-RevealingClosure of Operations
학습 목표
  • • 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 ServiceApplication Service
위치도메인 레이어애플리케이션 레이어
책임도메인 로직 수행유스케이스 조율
상태무상태 (Stateless)무상태 (Stateless)
의존성도메인 객체만Repository, Domain Service 등
트랜잭션관여하지 않음트랜잭션 경계 관리
예시TransferService, PricingServiceOrderApplicationService

1.3 Domain Service 구현

예시: 가격 계산 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
// 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 및 핵심 요약

Q1: Domain Service와 Application Service를 어떻게 구분하나요?

Domain Service는 순수한 도메인 로직(가격 계산, 송금 등)을 담당하고, Application Service는 유스케이스 조율(트랜잭션, Repository 호출, 이벤트 발행)을 담당합니다.

Q2: 모든 로직을 Service에 넣어도 되나요?

아니요. 이것은 '빈약한 도메인 모델(Anemic Domain Model)' 안티패턴입니다. Entity와 Value Object에 자연스럽게 속하는 로직은 해당 객체에 두세요.

Q3: Side-Effect-Free Functions를 항상 사용해야 하나요?

Value Object와 쿼리 메서드에서는 필수입니다. Entity의 명령 메서드는 상태를 변경하므로 부작용이 있을 수 있지만, 명확히 문서화해야 합니다.

Q4: Supple Design 패턴을 모두 적용해야 하나요?

상황에 맞게 선택적으로 적용하세요. 가장 중요한 것은 Intention-Revealing Interfaces와 Side-Effect-Free Functions입니다.

4. 핵심 요약

🔧 Service 구분
  • Domain Service: 순수 도메인 로직
  • Application Service: 유스케이스 조율
  • • 둘 다 무상태(Stateless)
  • • Entity에 속하지 않는 로직만
✨ Supple Design
  • 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