DDD(도메인 주도 설계)

원문: https://www.notion.so/1c9bf506e994804aabb2cd958ade1c2c?pvs=1

→ Domain Driven Design

[1] 서론

“도메인(=비즈니스의 핵심 개념과 규칙)을 중심에 두고 시스템을 설계하자” 는 철학이다

  • 클린 아키텍처 : “기술을 분리해서 비즈니스 로직을 보호” 하는 구조

  • DDD : ”비즈니스 자체를 더 잘 이해하고, 코드에 녹여내기”라는 설계 방법


**1) 둘은 **이렇게 이어져 있다.

2) DDD 핵심 개념 미리보기

3) 그래서 왜 확장하는가?

4) 한 줄 요약

클린 아키텍처는 “어떻게 분리할까”를 알려주고 DDD는 “무엇을 설계할까”를 알려준다.


5) DDD 학습 순서


[2] DDD란 무엇인가?

(1) DDD 정의

“비즈니스 도메인을 코드에 그대로 반영하는 방식의 설계 철학”

즉, 개발자가 코드를 짤 때, “비즈니스 용어 그대로 코드에 녹이자”가 핵심이다.

(2) 왜 나왔을까?

  • 시스템이 커질수록 기술적인 복잡함보다 비즈니스 로직이 더 복잡해짐

  • 기존 방식은 기술 위주 구조 → 비즈니스 로직이 흩어지고 숨겨짐

  • 그래서 도메인을 중심으로 설계하고 유지하자는 패러다임이 생긴 것이다.

(3) DDD의 특징

(4) 언제 DDD가 필요하나?

[3] 도메인, 도메인 모델이란?

(1) 도메인(Domain)이란?

”우리가 해결하려는 문제 영역” 즉, 비즈니스 자체를 의미한다.

예를 들어)

  • 쇼핑몰 시스템이라면 : 상품, 장바구니, 주문, 결제, 배송
  • 병원 시스템이라면 : 환자, 예약, 진료, 약 처방

클린 아키텍처의 핵심은 “비즈니스 로직 보호”였다.

→ DDD는 그 “비즈니스”를 도메인이라고 부른다.

(2) 도메인 모델(Domain Model)이란?

도메인을 코드로 표현한 것이 바로 도메인 모델이다.

즉, 도메인 모델은

  • 비즈니스 규칙을 객체로 만든 것
  • 예시) Order, Product, Payment, User 등

1) 도메인 모델의 핵심 특징

2) 예시로 이해하기 (쇼핑몰 도메인)

  • 도메인 : “주문 처리”

  • 도메인 모델 모습

3) 정리 요약

[4] Entity 와 Value Object 의 차이

(1) Entity란?

고유한 식별자(ID)를 가지는 객체

  • 어떤 객체가 시스템 안에서 구분될 수 있어야 할 때 → Entity

  • 보통 DB의 기본키(ID)와 1:1 대응됨

class User {
  constructor(
    public readonly id: string,
    public name: string,
    public email: string,
  ) {}
}
  • 두 User 객체가 같은 name과 email을 가져도 id가 다르면 완전히 다른 사용자로 본다.

(2) Value Object란?

고유 ID는 없고, 값 자체는 중요한 객체

  • 값이 같으면 동일한 객체로 취급한다.

  • 불변으로 설계하는 경우가 많다.

class Address {
  constructor(
    public readonly city: string,
    public readonly street: string,
  ) {}

  equals(other: Address): boolean {
    return this.city === other.city && this.street === other.street;
  }
}
  • 두 Address 객체의 city와 street가 같으면 → 같은 주소로 본다.

  • 고유 ID가 없다.

(3) 핵심 차이 요약

1) 왜 구분이 중요할까?

  • Entity는 시스템에서 “이 사람”, “이 주문”처럼 개별 식별이 필요한 경우

  • Value Object는 그냥 값 일 뿐 → 복사, 비교, 불변으로 설계

⇒ 이 구분이 정확해야 애그리거트, 도메인 서비스 등도 올바르게 설계할 수 있다.

2) 코드로 표현

// order.entity.ts
export class Order {
  constructor(
    public readonly id: string,             // Entity
    public readonly address: Address,       // Value Object
    public readonly total: number,
  ) {}
}

// address.vo.ts
export class Address {
  constructor(
    public readonly city: string,
    public readonly street: string,
  ) {}
}
  • Order는 Entity (id로 식별됨)

  • Address는 Value Object (값이 같으면 같은 객체로 취급)

3) 정리 요약

[5] Aggregate(애그리거트)란?

하나 이상의 엔티티 + 값 객체를 묶은 비즈니스 단위 단, 외부에서는 루트 엔티티(Aggregate Root)를 통해서만 접근 해야한다.

  • 예시

(1) 예시 구조 (쇼핑몰)

```plain text [Order] ← Aggregate Root ├── OrderItem (Entity) └── ShippingAddress (Value Object)


→ Order는 전체 주문의 대표자

→ OrderItem을 직접 수정하지 않고, 항상 Order를 통해 관리해야 한다.

## (2) 왜 이렇게 나누는가?

## (3) 코드 예시

```typescript
// order.entity.ts
export class Order {
  constructor(
    public readonly id: string,
    private items: OrderItem[],               // 내부 Entity
    private address: Address,                 // VO
  ) {}

  addItem(productId: string, quantity: number) {
    this.items.push(new OrderItem(productId, quantity));
  }

  getTotalPrice(): number {
    return this.items.reduce((sum, item) => sum + item.getPrice(), 0);
  }
}
// order-item.entity.ts
export class OrderItem {
  constructor(
    private readonly productId: string,
    private quantity: number,
  ) {}

  getPrice(): number {
    // 예시: 1개당 10000원
    return this.quantity * 10000;
  }
}

→ OrderItem은 외부에서 직접 접근하지 않음

→ Order가 유일한 진입점 = Aggregate Root

(4) 한 줄 요약

Aggregate는 관련 객체들을 하나로 묶고, 그 중심 루트를 통해서만 접근하도록 만든 보호된 비즈니스 단위다.

[6] 도메인 서비스란?

도메인 로직인데, 특정 Entity에 넣기 애매할 때 사용하는 객체

즉,

  • 비즈니스 로직은 맞는데

  • 어떤 Entity에도 소속시키기 애매한 로직

  • 또는 여러 Entity가 함께 관여되는 로직

→ 이걸 모아서 처리하는 것이 바로 “도메인 서비스”이다.

(1) 언제 필요한가?

(2) 예시 (포인트 전송 로직)

// PointTransferService.ts (도메인 서비스)
export class PointTransferService {
  constructor(
    private readonly accountRepo: AccountRepository,
  ) {}

  transfer(senderId: string, receiverId: string, amount: number) {
    const sender = this.accountRepo.findById(senderId);
    const receiver = this.accountRepo.findById(receiverId);

    sender.decreasePoint(amount);
    receiver.increasePoint(amount);

    this.accountRepo.save(sender);
    this.accountRepo.save(receiver);
  }
}
  • 이 로직은 Account 하나 만으로는 해결할 수 없음

  • 여러 Entity(sender, receiver)가 함께 관여됨

  • 그래서 별도 서비스로 추출 = 도메인 서비스

(3) 도메인 서비스의 특징

(4) 도메인 서비스 vs 애플리케이션 서비스 차이

1) 예시로 설명

  • 도메인 서비스

  • 애플리케이션 서비스 (또는 인프라계층)

  • 표로 정리

2) 그래서 이렇게 나눠야 한다.

(5) 한 줄 요약

도메인 서비스는 Entity에 넣기 애매한 “순수한 도메인 규칙”을 담는 stateless 객체다.

[7] Repository & Factory란?

(1) Repository란?

도메인 객체(Entity, Aggregate)를 저장하고 조회하는 인터페이스 즉, DB나 외부 저장소와의 연결을 도메인 계층 밖에서 담당하게 해주는 패턴이다.

1) 왜 필요한가?

  • 도메인 객체는 DB를 직접 몰라야 한다.(→ DIP, 의존성 규칙)

  • 하지만 저장/조회는 필요하니까 → 중간에 Repository를 둬서 분리

2) 예시

// domain/order.repository.ts
export interface OrderRepository {
  save(order: Order): void;
  findById(orderId: string): Order;
}
// infrastructure/mysql-order.repository.ts
@Injectable()
export class MySQLOrderRepository implements OrderRepository {
  save(order: Order) {
    // 실제 DB 저장 로직 (TypeORM, Prisma 등)
  }

  findById(orderId: string) {
    // 실제 조회 로직
  }
}

→ 도메인 서비스나 UseCase는 OrderRepository 인터페이스만 알고 있다.

→ 구현체는 바깥(infrastructure 계층)에서 주입

(2) Factory란?

복잡한 도메인 객체 생성을 담당하는 객체 단순한 new Order(…)로 만들기 힘들 때 사용한다.

1) 언제 필요한가?

  • Entity 생성 시 내부 규칙이 많을 때 (예: 할인, 상태 조건, 여러 값 조합 등)

  • 외부에서 “생성 로직”을 통일하고 싶은 경우

2) 예시

// domain/order.factory.ts
export class OrderFactory {
  static createFromCart(cart: Cart): Order {
    const items = cart.getItems();
    const total = items.reduce((sum, item) => sum + item.getPrice(), 0);
    return new Order('generated-id', items, total);
  }
}

→ Order 생성에 필요한 조건, 계산 등을 Factory가 알아서 처리


(3) 둘의 역할 비교

Repository는 “어디서 가져오고, 어떻게 저장할까”를 담당 Factory는 “어떻게 만들까?”를 담당

[8] 유비쿼터스 언어란?

개발자, 기획자, 비즈니스 담당자가 모두 공유하는 공통언어를 뜻한다.

즉,

  • 도메인 전문가(비즈니스 담당자)와

  • 개발자가

→ 같은 말로 대화하고, 같은 용어로 모델을 설계하자는 개념이다.

(1) 왜 필요한가?

(2) 유비쿼터스 언어를 쓰면

  • 코드 = 대화 = 문서 = 모델 → 전부 같은 말

  • 기획서에 있는 용어가 그대로 클래스/메서드/변수 이름이 됨

  • 비즈니스 용어가 코드에 녹아 들어감

(3) 예시

1) 나쁜 코드 (기술 중심)

if (user.role === 'admin') {
  // ...
}

→ 나중에 “운영자”, “슈퍼관리자”도 추가되면 의미가 불명확해짐

2) 좋은 코드 (도메인 언어 중심)

if (user.canApproveOrders()) {
  // 주문 승인 로직
}
  • canApproveOrders()는 비즈니스 용어

  • “누가 주문을 승인할 수 있나?”라는 질문에 그대로 대응 가능

  • 유지보수, 소통, 테스트 모두 쉬움

(4) 실제 프로젝트 적용법

(5) 요약

유비쿼터스 언어는 “모두가 같은 용어로 시스템을 이해하고 설계하게 해주는 DDD의 핵심 소통 도구이다.

[9] NestJS에 DDD + 클린 아키텍처를 적용한 예시

(1) 전체 구조 예시

src/
├── domain/
│   └── order/
│       ├── order.entity.ts             # Entity
│       ├── order-item.entity.ts        # Entity
│       ├── address.vo.ts               # Value Object
│       ├── order.repository.ts         # Repository Interface
│       └── order.factory.ts            # Factory
│
├── application/
│   └── use-cases/
│       └── create-order.use-case.ts    # Application Service (UseCase)
│
├── infrastructure/
│   └── repositories/
│       └── mysql-order.repository.ts   # Repository 구현체 (DB 기술)
│
├── interfaces/
│   └── controllers/
│       └── order.controller.ts         # HTTP 요청 처리
│   └── dtos/
│       └── create-order.dto.ts         # 요청/응답 DTO
│
├── config/
│   └── app.module.ts                   # 의존성 주입 설정

(2) 주문 생성하기

1) [인터페이스 계층] HTTP 요청

// interfaces/controllers/order.controller.ts
@Post()
createOrder(@Body() dto: CreateOrderDto) {
  return this.createOrderUseCase.execute(dto);
}

2) [애플리케이션 계층] UseCase 실행

// application/use-cases/create-order.use-case.ts
export class CreateOrderUseCase {
  constructor(
    private readonly repo: OrderRepository,
    private readonly factory: OrderFactory,
  ) {}

  execute(dto: CreateOrderDto) {
    const order = this.factory.createFromDto(dto);         // 생성 책임은 Factory
    this.repo.save(order);                                 // 저장은 Repository
    return order;
  }
}

3) [도메인 계층] 도메인 모델 정의

// domain/order/order.entity.ts
export class Order {
  constructor(
    private readonly id: string,
    private readonly items: OrderItem[],
    private readonly address: Address,
  ) {}

  getTotalAmount(): number {
    return this.items.reduce((sum, item) => sum + item.getPrice(), 0);
  }
}

4) [인프라 계층] 실제 저장소 구현

// infrastructure/repositories/mysql-order.repository.ts
@Injectable()
export class MySQLOrderRepository implements OrderRepository {
  save(order: Order) {
    // TypeORM, Prisma 등으로 DB 저장
  }

  findById(id: string): Order {
    // 조회 로직
  }
}

5) [config] DI 설정

// config/app.module.ts
@Module({
  controllers: [OrderController],
  providers: [
    CreateOrderUseCase,
    OrderFactory,
    {
      provide: OrderRepository,
      useClass: MySQLOrderRepository,
    },
  ],
})
export class AppModule {}

(3) 각 기술이 어디에 위치하는 지 요약

(4) 장점

(5) 클린 아키텍처 vs DDD

1) 실무에선 어떻게 되냐면

  • 클린 아키텍처 구조 위에 DDD를 얹는다.

  • 즉, 계층 구조는 클린 아키텍처 따라가고,

  • 그 안에 들어가는 도메인 모델은 DDD 철학을 따른다.

plain text [ Order (Entity) ] ← DDD [ OrderService (UseCase)] ← 클린 아키텍처 [OrderController / Repo ] ← 클린 아키텍처

(6) 요약

클린 아키텍처 + DDD 조합은 복잡한 시스템을 “비즈니스 중심 + 기술 유연성” 구조로 설계할 수 있게 해준다.

즉,

클린 아키텍처는 “틀”이고, DDD는 그 틀 안에 채워 넣는 “내용과 언어”다.

Comments