Communitygithub.com

도메인 주도 설계 — 패턴 및 모범 사례

복잡한 비즈니스 로직을 위한 도메인 주도 설계(DDD) 패턴과 실무 지침을 제공합니다.

도메인 주도 설계 — 패턴 및 모범 사례란 무엇인가요?

이 스킬은 AI 어시스턴트가 DDD를 기반으로 한 소프트웨어 설계를 할 때 따라야 할 패턴과 규칙을 정의합니다. 전략적 설계(Bounded Context, Context Map)와 전술적 설계(Entity, Value Object, Aggregate, Domain Event, Repository)를 모두 다루며, 복잡한 비즈니스 로직을 다루는 엔터프라이즈 애플리케이션에 적합합니다. 특히 Aggregate Root를 통한 불변성 보호, 도메인 이벤트를 통한 일관성 관리, 그리고 Clean Architecture 의존성 규칙(인프라가 도메인에 의존하지 않음)을 강조합니다. TypeScript 예제와 함께 디렉터리 구조, 안티 패턴, 문제 해결 방법도 포함되어 있어 실무에서 바로 적용할 수 있습니다. Claude Code, Cursor, Codex 등 모든 AI 코딩 어시스턴트에서 사용할 수 있습니다.

지원 대상✓Claude Code~Codex CLI~Cursor
npx skills add kok-o/koko-contextos-agents

즐겨 사용하는 AI에게 물어보기

이 에이전트 스킬이 미리 로드된 새 채팅을 엽니다.

문서

도메인 주도 설계 — 패턴 및 모범 사례은(는) 무엇을 하나요?

DDD를 사용해야 하는 경우

사용해야 하는 경우:

  • CRUD를 넘어서는 복잡한 비즈니스 로직
  • 서로 다른 용어를 사용하는 여러 도메인 전문가
  • 도메인 모델이 경쟁 우위인 경우
  • 엔터프라이즈급 애플리케이션

사용하지 말아야 하는 경우:

  • 단순 CRUD 애플리케이션
  • 해커톤/MVP (과잉)
  • 도메인 전문가가 없는 경우

전략적 설계

Bounded Context(제한된 컨텍스트)

DDD에서 가장 중요한 개념입니다. Bounded Context는 특정 모델이 정의되고 적용되는 경계입니다.

예시 — 전자상거래:

[주문 컨텍스트]          [결제 컨텍스트]       [배송 컨텍스트]
  - 주문                  - 결제               - 배송
  - 주문항목              - 거래                - 추적번호
  - 고객 (참조)           - 환불                - 주소
  - 주소 (값 객체)        - 청구서              - 운송사

고객은 각 컨텍스트에서 다른 의미를 가집니다:

  • 주문 컨텍스트: 이름, 이메일, 배송 선호도
  • 결제 컨텍스트: 청구 정보, 결제 방법
  • 지원 컨텍스트: 티켓 내역, 만족도 점수

컨텍스트 맵

[주문] ←→ [결제]     # 파트너십
[주문] → [배송]     # 고객-공급자
[주문] → [레거시 CRM]   # 반부식 계층(Anti-Corruption Layer)

전술적 설계

엔티티(Entities)

식별자를 가진 객체입니다. 동일한 속성을 가져도 ID가 다르면 다른 엔티티입니다.

class User {
  readonly id: UserId;
  name: string;
  email: Email;  // 값 객체
}

값 객체(Value Objects)

속성에 의해 정의되며 식별자가 없는 객체입니다. 불변(immutable)입니다.

class Email {
  constructor(readonly value: string) {
    if (!isValidEmail(value)) throw new InvalidEmailError(value);
  }
  equals(other: Email): boolean {
    return this.value === other.value;
  }
}

애그리게이트(Aggregates)

단일 루트 엔티티(Aggregate Root)를 가진 엔티티와 값 객체의 클러스터입니다. 모든 접근은 루트를 통해 이루어집니다.

class Order {  // Aggregate Root
  private items: OrderItem[] = [];
  
  addItem(product: ProductRef, quantity: number): void {
    // 비즈니스 로직은 서비스가 아닌 여기에 작성
    if (quantity <= 0) throw new InvalidQuantityError();
    this.items.push(new OrderItem(product, quantity));
  }
  
  get total(): Money {
    return this.items.reduce((sum, item) => sum.add(item.subtotal), Money.zero());
  }
}

애그리게이트 규칙:

  1. 다른 애그리게이트는 ID로만 참조
  2. 트랜잭션당 하나의 애그리게이트
  3. 애그리게이트 간 최종 일관성(Eventual Consistency)

도메인 이벤트(Domain Events)

도메인 전문가가 관심을 가지는 도메인 내에서 발생한 사건입니다.

class OrderPlaced implements DomainEvent {
  constructor(
    readonly orderId: OrderId,
    readonly customerId: CustomerId,
    readonly total: Money,
    readonly occurredAt: Date
  ) {}
}

도메인 서비스(Domain Services)

엔티티나 값 객체에 자연스럽게 속하지 않는 비즈니스 로직입니다.

class PricingService {
  calculatePrice(order: Order, customer: Customer, promotions: Promotion[]): Money {
    // 여러 애그리게이트를 포함하는 복잡한 가격 계산 로직
  }
}

리포지토리(Repositories)

데이터 접근에 대한 추상화입니다. 애그리게이트 루트당 하나의 리포지토리입니다.

interface OrderRepository {
  findById(id: OrderId): Promise<Order | null>;
  save(order: Order): Promise<void>;
  delete(id: OrderId): Promise<void>;
}

디렉터리 구조 (DDD)

src/
├── modules/
│   └── orders/                    # Bounded Context
│       ├── domain/
│       │   ├── entities/
│       │   │   └── order.ts       # Aggregate Root
│       │   ├── value-objects/
│       │   │   └── money.ts
│       │   ├── events/
│       │   │   └── order-placed.ts
│       │   ├── services/
│       │   │   └── pricing.ts
│       │   └── repositories/
│       │       └── order.repository.ts  # 인터페이스
│       ├── application/
│       │   ├── commands/
│       │   │   └── place-order.ts
│       │   ├── queries/
│       │   │   └── get-order.ts
│       │   └── handlers/
│       │       └── place-order.handler.ts
│       └── infrastructure/
│           ├── persistence/
│           │   └── order.repository.impl.ts  # 구현체
│           └── api/
│               └── orders.controller.ts

클린 아키텍처 의존성 규칙

DDD에서 의존성은 반드시 안쪽으로 향해야 합니다:

[ 프레임워크 & 드라이버 (Web, DB, UI) ]
      └──▶ [ 인터페이스 어댑터 (컨트롤러, 게이트웨이) ]
            └──▶ [ 애플리케이션 (유스 케이스, CQRS 핸들러) ]
                  └──▶ [ 도메인 (엔티티, 값 객체) ]
  • 도메인 계층은 ORM(Prisma, TypeORM), HTTP 프레임워크(Express, NestJS), 외부 SDK에 대해 전혀 의존성이 없어야 합니다.
  • 리포지토리는 도메인/애플리케이션 계층에서 인터페이스로 정의되고, 인프라 계층에서 구현됩니다.

도메인 이벤트 vs 통합 이벤트(Integration Events)

  1. 도메인 이벤트: 단일 Bounded Context 내부의 상태 변경을 나타냅니다.
    • Aggregate Root 내에서 직접 발생시킵니다 (order.addItem(...)가 OrderItemAdded 발생).
    • 트랜잭션 커밋 전에 인-프로세스로 전달됩니다.
  2. 통합 이벤트: Bounded Context 경계를 넘어 다른 서비스와 통신하기 위해 발행됩니다.
    • 트랜잭셔널 아웃박스(Transactional Outbox) 패턴을 통해 메시지 브로커로 전달됩니다.
    • 버전 관리를 포함한 하위 호환 가능한 스키마를 사용해야 합니다.

반부식 계층(Anti-Corruption Layer, ACL)

외부 Bounded Context나 타사 벤더 API(예: Stripe, Salesforce)의 데이터를 소비할 때:

  • 외부 도메인 모델을 절대 도메인으로 직접 가져오지 마세요.
  • 인프라 계층에 ACL 변환기/어댑터를 만들어 외부 DTO를 자체 값 객체와 엔티티로 변환하세요.

안티 패턴

  • [실패] 빈약한 도메인 모델(Anemic domain model) — getter/setter만 있는 엔티티, 모든 로직이 서비스에 있음
  • [실패] 거대 애그리게이트(Big aggregate) — 애그리게이트는 작고 불변성에 집중해야 함
  • [실패] 애그리게이트 간 트랜잭션 — 최종 일관성 사용
  • [실패] DDD를 모든 곳에 적용 — 복잡성이 정당화되는 곳에만 DDD 사용
  • [실패] ORM 엔티티가 도메인으로 유출 — 도메인 엔티티는 @Entity() 또는 ORM 데코레이터에 의존해서는 안 됨

코드 예제

자세한 코드 예제는 EXAMPLES.md를 참조하세요.

검증 체크리스트

작업 완료 전 검토 단계에서 확인할 사항입니다.

일반적인 실수

명시적으로 피해야 할 안티 패턴 및 사항입니다. TROUBLESHOOTING.md를 참조하세요.

통합 노트

이 스킬이 다른 스킬과 어떻게 상호작용하는지 설명합니다.

관련 스킬