본문 바로가기

NestJS와 함께하는 DDD

유비쿼터스 언어와 경계 컨텍스트를 바탕으로 DDD의 책임을 나누고, 작은 주문 애그리게이트의 규칙·상태 전이·저장 경계를 살펴봅니다.

지난 절에서는 NestJS에서 CQRS (Command Query Responsibility Segregation) 패턴 구현 방법을 알아봤습니다.

이제 12장 네 번째 절에서는 복잡한 비즈니스 도메인을 모델링하고 관리하기 위한 접근 방식인 도메인 주도 설계(Domain-Driven Design, DDD)를 다루고, 이를 NestJS 프로젝트에 적용하는 방법을 살펴보겠습니다.

소프트웨어는 결국 현실 세계의 복잡한 비즈니스 문제를 해결하기 위해 존재합니다.

DDD는 이러한 비즈니스 도메인을 깊이 이해하고, 그 지식을 소프트웨어 모델에 반영함으로써, 복잡성을 관리하고 유연하며 확장 가능한 시스템을 구축하는 데 중점을 둡니다.


도메인 주도 설계(DDD)란?

도메인 주도 설계(DDD)는 소프트웨어 개발에서 복잡한 도메인(비즈니스 영역)의 모델링에 집중하는 접근 방식입니다.

에릭 에반스(Eric Evans)가 2003년 저서 "Domain-Driven Design: Tackling Complexity in the Heart of Software"에서 처음 제시했습니다.

DDD의 핵심 목표
  • 비즈니스 복잡성 관리: 복잡한 비즈니스 로직과 규칙을 명확하게 이해하고 소프트웨어 모델에 정확히 반영합니다.
  • 도메인 전문가와의 협업: 개발자와 도메인 전문가(비즈니스 관계자) 간의 효과적인 의사소통을 통해 도메인 지식을 공유하고 모델을 정교화합니다.
  • 유연하고 확장 가능한 시스템 구축: 비즈니스 요구사항의 변화에 유연하게 대응하고, 장기적으로 유지보수 및 확장이 용이한 시스템을 만듭니다.
DDD의 핵심 개념

유비쿼터스 언어

  • 정의: 개발자, 도메인 전문가, 테스터 등 프로젝트에 참여하는 모든 이해관계자가 공통적으로 사용하는 언어입니다. 이 언어는 코드, 문서, 대화 등 프로젝트의 모든 측면에 일관되게 반영됩니다.
  • 중요성: 의사소통의 오해를 줄이고, 도메인 모델이 비즈니스 현실을 정확히 반영하도록 돕습니다. 예를 들어, 주문이라는 용어가 비즈니스에서는 고객이 상품을 구매하는 행위이지만 개발에서는 DB의 Order 테이블로 다르게 이해되는 것을 방지합니다.

도메인 모델

  • 정의: 특정 도메인의 핵심 개념, 데이터, 행위 및 관계를 추상화하여 표현한 것입니다. 코드에서는 규칙, 상태, 불변 조건을 담는 모델로 구현됩니다.
  • 중요성: 비즈니스 규칙이 모델 내부에 캡슐화되어 복잡성을 관리하고, 변경에 강한 코드를 만듭니다.

바운디드 컨텍스트

  • 정의: 특정 도메인 모델이 유효하고 일관되게 적용되는 명확한 경계입니다. 각 바운디드 컨텍스트는 자체적인 유비쿼터스 언어와 도메인 모델을 가집니다.
  • 중요성: 대규모 시스템을 여러 개의 작은, 독립적인 부분으로 나누어 복잡성을 줄입니다. 각 바운디드 컨텍스트는 마이크로서비스의 자연스러운 경계가 될 수 있습니다.
  • 예시: 주문 관리 컨텍스트, 재고 관리 컨텍스트, 결제 컨텍스트 등. 각 컨텍스트에서 '상품'의 정의나 속성은 다를 수 있습니다.

엔티티 (Entity)

  • 정의: 고유한 식별자를 가지며, 시간의 흐름에 따라 상태가 변경될 수 있는 객체입니다. 동일한 속성을 가지더라도 식별자가 다르면 다른 엔티티로 간주됩니다.
  • 예시: User (사용자 ID), Order (주문 ID), Product (상품 ID).

값 객체 (Value Object)

  • 정의: 고유한 식별자가 없으며, 자신의 속성 값으로만 정의되는 객체입니다. 불변(Immutable)이며, 속성 값이 같으면 동일한 값 객체로 간주됩니다.
  • 예시: Address (거리, 도시, 우편번호), Money (금액, 통화). 값 객체는 엔티티의 속성으로 사용되어 도메인 모델의 표현력을 높입니다.

애그리게이트 (Aggregate)

  • 정의: 하나 이상의 엔티티와 값 객체를 논리적으로 묶어 하나의 단위로 다루는 클러스터입니다. 애그리게이트는 하나의 루트 엔티티(Aggregate Root)를 가지며, 이 루트 엔티티를 통해서만 애그리게이트 내부의 다른 객체에 접근하고 변경할 수 있습니다.
  • 중요성: 데이터 일관성(Consistency)을 유지하기 위한 트랜잭션 경계를 정의합니다. 애그리게이트는 항상 유효한 상태를 유지해야 합니다.
  • 예시: Order 애그리게이트는 Order 엔티티(루트)와 여러 개의 OrderItem 값 객체(또는 엔티티)를 포함할 수 있습니다. OrderItem은 독립적으로 존재하지 않고 Order에 종속됩니다.

도메인 서비스 (Domain Service)

  • 정의: 특정 엔티티나 값 객체에 속하기 어려운, 여러 도메인 객체에 걸친 비즈니스 로직이나 행위를 캡슐화한 서비스입니다.
  • 예시: 상품 주문, 계좌 이체와 같이 여러 애그리게이트나 도메인 객체를 조작하는 비즈니스 프로세스.

리포지토리 (Repository)

  • 정의: 도메인 모델의 영속성(Persistence)을 담당하는 인터페이스입니다. 애그리게이트 루트를 저장하고 조회하는 작업을 추상화합니다.
  • 중요성: 도메인 로직이 데이터베이스 접근 로직과 섞이는 것을 방지하여 도메인 모델을 순수하게 유지합니다. 데이터베이스 기술 변경 시에도 도메인 계층에 미치는 영향을 최소화합니다.

팩토리 (Factory)

  • 정의: 복잡한 도메인 객체(엔티티, 애그리게이트)의 생성 로직을 캡슐화하는 객체입니다.
  • 중요성: 객체 생성의 복잡성을 외부에서 숨기고, 항상 유효한 상태의 객체가 생성되도록 보장합니다.

작은 주문으로 애그리게이트 규칙 살펴보기

주문 항목을 배열에 넣는 것만으로는 애그리게이트의 역할이 잘 드러나지 않습니다. 주문을 확정할 때 지켜야 할 규칙을 정하고, 그 규칙을 Order가 직접 판단하게 만들어 보겠습니다. 여기서 불변 조건(invariant)은 메서드 호출이 끝난 뒤에도 유지되어야 하는 규칙입니다.

이 예제의 주문은 DRAFT(작성 중)에서 시작합니다. 항목의 수량은 양의 안전한 정수여야 하고, 항목이 하나 이상 있어야 CONFIRMED(확정됨)로 바뀔 수 있습니다. 확정한 주문에는 항목을 더 넣거나 다시 확정할 수 없습니다. 재확정은 성공으로 취급하지 않고 상태 오류로 거부합니다.

아래 코드는 NestJS나 데이터베이스를 사용하지 않는 순수 TypeScript 모델입니다. 뒤의 User 계층 예제와 별도의 주문 모델로 읽으세요.

src/order/domain/order.ts
export type OrderStatus = 'DRAFT' | 'CONFIRMED';
export type OrderItem = Readonly<{
  productId: string;
  quantity: number;
}>;

export class OrderRuleViolation extends Error {}

export class Order {
  readonly #id: string;
  #status: OrderStatus = 'DRAFT';
  readonly #items: OrderItem[] = [];

  constructor(id: string) {
    this.#id = id;
  }

  get id(): string {
    return this.#id;
  }

  get status(): OrderStatus {
    return this.#status;
  }

  get items(): readonly OrderItem[] {
    return this.#items.map(item => ({ ...item }));
  }

  addItem(productId: string, quantity: number): void {
    this.requireDraft();
    if (!Number.isSafeInteger(quantity) || quantity < 1) {
      throw new OrderRuleViolation('INVALID_QUANTITY');
    }
    this.#items.push({ productId, quantity });
  }

  confirm(): void {
    this.requireDraft();
    if (this.#items.length === 0) {
      throw new OrderRuleViolation('EMPTY_ORDER');
    }
    this.#status = 'CONFIRMED';
  }

  private requireDraft(): void {
    if (this.#status !== 'DRAFT') {
      throw new OrderRuleViolation('ORDER_ALREADY_CONFIRMED');
    }
  }
}

Number.isSafeInteger()는 소수나 NaN, 무한대, 안전한 정수 범위를 벗어난 수를 거부합니다. quantity < 1 검사까지 있어 0과 음수도 들어갈 수 없습니다. 검사는 배열에 넣거나 상태를 바꾸기 전에 끝나므로 거부된 호출은 기존 주문을 그대로 남깁니다. 확정한 주문에서는 수량 검사에 앞서 상태 검사에서 거부됩니다.

외부에서는 #items에 직접 접근할 수 없습니다. items를 읽을 때 배열과 각 항목 객체를 새로 복사하므로, 받은 배열이나 항목을 바꾸더라도 주문 내부의 내용은 바뀌지 않습니다. Readonly는 TypeScript에서 변경을 막는 타입이고, 실제 내부 참조를 숨기는 역할은 이 복사가 맡습니다.

다음 순서로 호출하면 실패 뒤에 올바른 입력을 넣어도 주문을 계속 작성할 수 있는지 살펴볼 수 있습니다. observe()는 도메인 규칙 오류만 기록하며, 예상하지 못한 오류는 그대로 전달합니다.

src/order/order.example.ts
import { Order, OrderRuleViolation } from './domain/order';

const order = new Order('order-1');

function observe(label: string, action: () => void): void {
  let result = 'OK';
  try {
    action();
  } catch (error) {
    if (!(error instanceof OrderRuleViolation)) throw error;
    result = error.message;
  }
  console.log(label, result, order.status, order.items);
}

observe('빈 주문 확정', () => order.confirm());
observe('수량 0 추가', () => order.addItem('book-1', 0));
observe('수량 2 추가', () => order.addItem('book-1', 2));
observe('주문 확정', () => order.confirm());
observe('확정 뒤 추가', () => order.addItem('book-2', 1));
observe('다시 확정', () => order.confirm());

소스의 검사 순서에 따른 예상 결과는 다음과 같습니다. 마지막 행의 두 호출은 각각 거부됩니다.

순서와 행동결과호출 뒤 주문
빈 주문에서 confirm()EMPTY_ORDERDRAFT, 항목 0개
addItem('book-1', 0)INVALID_QUANTITYDRAFT, 항목 0개
addItem('book-1', 2)OKDRAFT, 항목 1개·수량 2
항목이 있는 주문에서 confirm()OKCONFIRMED, 항목 1개·수량 2
확정 뒤 addItem() 또는 confirm()ORDER_ALREADY_CONFIRMEDCONFIRMED, 항목 1개·수량 2

결과가 다르면 requireDraft()가 두 변경 메서드의 처음에 있는지, 수량 검사와 빈 주문 검사가 실제 변경보다 먼저 끝나는지 확인하세요. 이 단계에서는 DB 저장보다 Order 내부의 판단을 먼저 따라가면 됩니다.

예제는 수량과 확정 규칙만 다룹니다. 주문·상품 ID의 형식과 존재 여부, 가격, 재고, 결제, 취소는 검사하지 않습니다. 같은 상품을 두 번 추가하면 별도 항목으로 남으며 수량을 합치지 않습니다. 따라서 상품별 합계나 그 합계의 안전한 정수 범위를 보장하는 모델은 아닙니다.

도메인 메서드에서 저장 포트로 연결하기

주문을 확정해 달라는 요청을 받으면 애플리케이션 계층은 주문을 조회하고, confirm()을 호출한 뒤 저장합니다. 확정할 수 있는지 판단하는 코드는 계속 Order 안에 둡니다. 저장 포트(repository port)는 애플리케이션이 필요한 조회·저장 작업을 인터페이스로 표현한 것으로, 실제 DB 호출은 이를 구현한 어댑터가 맡습니다.

src/order/domain/order.repository.ts
import type { Order } from './order';

export interface OrderRepository {
  findById(id: string): Promise<Order | null>;
  save(order: Order): Promise<void>;
}
src/order/application/confirm-order.ts
import type { OrderRepository } from '../domain/order.repository';

export class ConfirmOrder {
  constructor(private readonly orders: OrderRepository) {}

  async execute(id: string): Promise<void> {
    const order = await this.orders.findById(id);
    if (order === null) throw new Error('ORDER_NOT_FOUND');

    order.confirm();
    await this.orders.save(order);
  }
}

주문이 없으면 confirm()과 save()를 호출하지 않습니다. 주문 규칙에 어긋나 confirm()이 예외를 던져도 save()까지 가지 않습니다. 확정에 성공하면 그 주문 객체를 save()에 전달합니다. 이 순서에서 애플리케이션은 조회와 저장을 조율하고, 도메인 모델은 상태 변경의 허용 여부를 결정합니다.

다만 confirm()에 성공한 뒤 save()가 실패하면, 메모리의 Order는 이미 CONFIRMED입니다. 이 코드는 저장 실패 시 상태 복구나 재시도를 구현하지 않으며, 실제 DB 트랜잭션의 원자성도 보장하지 않습니다. 동시에 같은 주문을 바꾸는 요청의 충돌, 저장된 주문의 복원, HTTP 오류 응답 변환도 별도 구현이 필요합니다. 위 인터페이스와 일반 생성자만으로 NestJS DI 등록이나 DB 연결이 만들어지지는 않습니다.

아래 그림에서 상태 변경의 허용·거부와 저장 경계를 함께 확인할 수 있습니다. 항목 유무는 같은 DRAFT 상태 안의 조건이며 별도의 상태 이름은 아닙니다.

주문 애그리게이트의 확정 규칙과 저장 경계

DRAFT 항목 추가·confirm 조건·CONFIRMED·application save 성공/실패에서도 이미 바뀐 메모리 상태가 중요하다.

주문 애그리게이트의 확정 규칙과 저장 경계

Nest · Order aggregate

Order가 상태를 결정하고 Application이 저장을 요청합니다

항목이 하나 이상인 DRAFT만 CONFIRMED로 바뀝니다. 거부된 호출은 항목과 상태를 그대로 남깁니다. confirm() 뒤의 save()는 별도 저장 단계이며, 실패해도 메모리 상태가 자동으로 되돌아가지는 않습니다.

Order 상태와 저장 경계의 모바일 재배치두 실제 상태DRAFT/CONFIRMED와 네 설명 노드이다. addItem은 DRAFT 자기 전이, confirm은 항목이 있을 때 CONFIRMED로 전이한다. 점선 두 연결은 상태별 거부 설명이며 새 상태 전이가 아니다. 별도Application 저장 요청 뒤 실패해도 메모리Order는 이미 확정 상태다.addItem()양의 안전한 정수confirm()항목 ≥ 1DRAFT · 작성 중항목 0개 이상추가 가능빈 주문 → EMPTY_ORDER잘못된 수량 거부DRAFT·항목 유지CONFIRMED · 확정항목 1개 이상추가·재확정 거부확정 뒤 추가·재확정ORDER_ALREADY_CONFIRMED기존 상태·항목 유지Application → Repo Port별도 저장 단계조회 → confirm()성공한 주문만 save()없거나 규칙 위반이면save() 미호출save()save() 실패 전달메모리 Order는 이미CONFIRMED복구·재시도·DB 원자성동시성은 미구현실선: 허용 전이/저장 요청점선: 상태별 거부 설명
Order 확정 규칙과 저장 경계 DRAFT 주문은 양의 안전한 정수 수량을 추가할 수 있고 항목이 있어야 CONFIRMED로 바뀌며, 규칙 위반은 변경 없이 거부하고 확정 이후 저장 실패의 복구는 별도 구현해야 합니다. addItem() · 양의 안전한 정수 confirm() · 항목 ≥ 1 save() DRAFT · 작성 중 new Order()의 시작 상태 항목 0개 이상 · 추가 가능 CONFIRMED · 확정됨 항목 1개 이상 추가·재확정 거부 빈 주문 확정 → EMPTY_ORDER 잘못된 수량 → INVALID_QUANTITY 거부 뒤에도 기존 DRAFT·항목 유지 확정 뒤 addItem()·confirm() → ORDER_ALREADY_CONFIRMED 거부 뒤에도 기존 CONFIRMED·항목 유지 APPLICATION → REPOSITORY PORT · 별도 저장 단계 조회 → order.confirm() 성공한 주문만 save()에 전달 없거나 규칙 위반이면 save() 미호출 save() 실패가 전달되어도 메모리 Order는 이미 CONFIRMED 복구·재시도·DB 원자성·동시성 미구현 실선: 허용 전이·저장 요청 / 점선: 상태별 거부 설명 · HTTP/DB 완료를 뜻하지 않음
Order 확정 규칙과 저장 경계 DRAFT 주문은 양의 안전한 정수 수량을 추가할 수 있고 항목이 있어야 CONFIRMED로 바뀌며, 규칙 위반은 변경 없이 거부하고 확정 이후 저장 실패의 복구는 별도 구현해야 합니다. addItem() · 양의 안전한 정수 confirm() · 항목 ≥ 1 save() DRAFT · 작성 중 new Order()의 시작 상태 항목 0개 이상 · 추가 가능 CONFIRMED · 확정됨 항목 1개 이상 추가·재확정 거부 빈 주문 확정 → EMPTY_ORDER 잘못된 수량 → INVALID_QUANTITY 거부 뒤에도 기존 DRAFT·항목 유지 확정 뒤 addItem()·confirm() → ORDER_ALREADY_CONFIRMED 거부 뒤에도 기존 CONFIRMED·항목 유지 APPLICATION → REPOSITORY PORT · 별도 저장 단계 조회 → order.confirm() 성공한 주문만 save()에 전달 없거나 규칙 위반이면 save() 미호출 save() 실패가 전달되어도 메모리 Order는 이미 CONFIRMED 복구·재시도·DB 원자성·동시성 미구현 실선: 허용 전이·저장 요청 / 점선: 상태별 거부 설명 · HTTP/DB 완료를 뜻하지 않음
  1. DRAFT에서 작성

    new Order()는 빈 DRAFT로 시작합니다. 양의 안전한 정수 수량만 추가할 수 있습니다. 잘못된 수량은 INVALID_QUANTITY로 거부되고 기존 항목은 유지됩니다.

  2. 항목이 있어야 확정

    빈 주문의 confirm()은 EMPTY_ORDER입니다. 항목이 하나 이상이면 CONFIRMED가 됩니다. 항목 유무는 DRAFT 안의 조건이며 별도 상태가 아닙니다.

  3. 확정 뒤 변경 거부

    addItem()과 confirm()은 각각 ORDER_ALREADY_CONFIRMED를 던집니다. 재확정은 성공으로 취급하지 않으며 상태와 항목은 그대로입니다.

  4. Application에서 저장 요청

    주문을 조회하고 confirm()에 성공하면 save()를 호출합니다. 주문이 없거나 규칙 위반이면 save()까지 가지 않습니다.

  5. 저장 실패의 경계

    save()가 실패해도 메모리 Order는 이미 CONFIRMED입니다. 상태 복구·재시도·DB 트랜잭션 원자성·동시성 처리는 이 순수 모델 예제에 없습니다.

상태 이름은 DRAFT와 CONFIRMED 두 개입니다. Order가 변경을 허용할지 결정하며, repository port는 저장을 추상화합니다. 순수 모델의 상태 전이와 실제 영속 저장의 성공을 구분하세요.

이 예의 조건

  • 두 박스는 같은 실행의 진행 정보와 자료·결과를 관찰하는 고정 역할입니다. 박스의 좌우·위아래 위치를 별도 호출 순서로 해석하지 않습니다.
  • 조건별 종료는 대안입니다. 실패 뒤 정상 후속 단계를 실행하거나 독립 비교 사례를 같은 요청으로 연결하지 않습니다.
  • 두 가지 실제 상태인 DRAFT/CONFIRMED와 네 개의 설명 노드가 있다. addItem은 DRAFT 자기 전이, confirm은 항목이 있을 때 CONFIRMED로 전이한다. 점선 두 연결은 상태별 거부 설명이며 새 상태 전이가 아니다. 별도 Application 저장 요청 뒤 실패해도 메모리 Order는 이미 확정 상태다.
  • DRAFT 주문은 양의 안전한 정수 수량을 추가할 수 있고 항목이 있어야 CONFIRMED로 바뀌며, 규칙 위반은 변경 없이 거부하고 확정 이후 저장 실패의 복구는 별도 구현해야 합니다.
관계도의 보충 설명
원래 전체 그림의 표시 문구·조건·주석
순번원문 표시
6두 가지 실제 상태인 DRAFT/CONFIRMED와 네 개의 설명 노드가 있다. addItem은 DRAFT 자기 전이, confirm은 항목이 있을 때 CONFIRMED로 전이한다. 점선 두 연결은 상태별 거부 설명이며 새 상태 전이가 아니다. 별도 Application 저장 요청 뒤 실패해도 메모리 Order는 이미 확정 상태다.
단계별 상태·값과 전환 이유
장면관찰 값전환 이유
DRAFT에서 항목 추가

addItem은 DRAFT를 유지하며 항목을 늘립니다.

상태: DRAFT · 동작: addItem 자기 전이항목이 바뀌지만 주문 상태는 유지됩니다.
항목이 있는 주문 확정

항목이 있을 때 confirm이 CONFIRMED로 전이합니다.

전 상태: DRAFT · 후 상태: CONFIRMED · 조건: 항목 있음메모리 Order의 상태가 확정으로 바뀝니다.
확정 뒤 저장 요청

별도 Application이 save를 요청하며 저장 실패도 이미 바뀐 메모리 상태를 자동으로 되돌리지 않습니다.

메모리: CONFIRMED · 저장: 별도 Application 요청 · 실패: 메모리 확정 유지도메인 전이와 저장 완료의 경계를 구분합니다.

주문 애그리게이트의 확정 규칙과 저장 경계

DRAFT 항목 추가·confirm 조건·CONFIRMED·application save 성공/실패에서도 이미 바뀐 메모리 상태가 중요하다.

주문 애그리게이트의 확정 규칙과 저장 경계

Nest · Order aggregate

Order가 상태를 결정하고 Application이 저장을 요청합니다

항목이 하나 이상인 DRAFT만 CONFIRMED로 바뀝니다. 거부된 호출은 항목과 상태를 그대로 남깁니다. confirm() 뒤의 save()는 별도 저장 단계이며, 실패해도 메모리 상태가 자동으로 되돌아가지는 않습니다.

Order 상태와 저장 경계의 모바일 재배치두 실제 상태DRAFT/CONFIRMED와 네 설명 노드이다. addItem은 DRAFT 자기 전이, confirm은 항목이 있을 때 CONFIRMED로 전이한다. 점선 두 연결은 상태별 거부 설명이며 새 상태 전이가 아니다. 별도Application 저장 요청 뒤 실패해도 메모리Order는 이미 확정 상태다.addItem()양의 안전한 정수confirm()항목 ≥ 1DRAFT · 작성 중항목 0개 이상추가 가능빈 주문 → EMPTY_ORDER잘못된 수량 거부DRAFT·항목 유지CONFIRMED · 확정항목 1개 이상추가·재확정 거부확정 뒤 추가·재확정ORDER_ALREADY_CONFIRMED기존 상태·항목 유지Application → Repo Port별도 저장 단계조회 → confirm()성공한 주문만 save()없거나 규칙 위반이면save() 미호출save()save() 실패 전달메모리 Order는 이미CONFIRMED복구·재시도·DB 원자성동시성은 미구현실선: 허용 전이/저장 요청점선: 상태별 거부 설명
Order 확정 규칙과 저장 경계 DRAFT 주문은 양의 안전한 정수 수량을 추가할 수 있고 항목이 있어야 CONFIRMED로 바뀌며, 규칙 위반은 변경 없이 거부하고 확정 이후 저장 실패의 복구는 별도 구현해야 합니다. addItem() · 양의 안전한 정수 confirm() · 항목 ≥ 1 save() DRAFT · 작성 중 new Order()의 시작 상태 항목 0개 이상 · 추가 가능 CONFIRMED · 확정됨 항목 1개 이상 추가·재확정 거부 빈 주문 확정 → EMPTY_ORDER 잘못된 수량 → INVALID_QUANTITY 거부 뒤에도 기존 DRAFT·항목 유지 확정 뒤 addItem()·confirm() → ORDER_ALREADY_CONFIRMED 거부 뒤에도 기존 CONFIRMED·항목 유지 APPLICATION → REPOSITORY PORT · 별도 저장 단계 조회 → order.confirm() 성공한 주문만 save()에 전달 없거나 규칙 위반이면 save() 미호출 save() 실패가 전달되어도 메모리 Order는 이미 CONFIRMED 복구·재시도·DB 원자성·동시성 미구현 실선: 허용 전이·저장 요청 / 점선: 상태별 거부 설명 · HTTP/DB 완료를 뜻하지 않음
Order 확정 규칙과 저장 경계 DRAFT 주문은 양의 안전한 정수 수량을 추가할 수 있고 항목이 있어야 CONFIRMED로 바뀌며, 규칙 위반은 변경 없이 거부하고 확정 이후 저장 실패의 복구는 별도 구현해야 합니다. addItem() · 양의 안전한 정수 confirm() · 항목 ≥ 1 save() DRAFT · 작성 중 new Order()의 시작 상태 항목 0개 이상 · 추가 가능 CONFIRMED · 확정됨 항목 1개 이상 추가·재확정 거부 빈 주문 확정 → EMPTY_ORDER 잘못된 수량 → INVALID_QUANTITY 거부 뒤에도 기존 DRAFT·항목 유지 확정 뒤 addItem()·confirm() → ORDER_ALREADY_CONFIRMED 거부 뒤에도 기존 CONFIRMED·항목 유지 APPLICATION → REPOSITORY PORT · 별도 저장 단계 조회 → order.confirm() 성공한 주문만 save()에 전달 없거나 규칙 위반이면 save() 미호출 save() 실패가 전달되어도 메모리 Order는 이미 CONFIRMED 복구·재시도·DB 원자성·동시성 미구현 실선: 허용 전이·저장 요청 / 점선: 상태별 거부 설명 · HTTP/DB 완료를 뜻하지 않음
  1. DRAFT에서 작성

    new Order()는 빈 DRAFT로 시작합니다. 양의 안전한 정수 수량만 추가할 수 있습니다. 잘못된 수량은 INVALID_QUANTITY로 거부되고 기존 항목은 유지됩니다.

  2. 항목이 있어야 확정

    빈 주문의 confirm()은 EMPTY_ORDER입니다. 항목이 하나 이상이면 CONFIRMED가 됩니다. 항목 유무는 DRAFT 안의 조건이며 별도 상태가 아닙니다.

  3. 확정 뒤 변경 거부

    addItem()과 confirm()은 각각 ORDER_ALREADY_CONFIRMED를 던집니다. 재확정은 성공으로 취급하지 않으며 상태와 항목은 그대로입니다.

  4. Application에서 저장 요청

    주문을 조회하고 confirm()에 성공하면 save()를 호출합니다. 주문이 없거나 규칙 위반이면 save()까지 가지 않습니다.

  5. 저장 실패의 경계

    save()가 실패해도 메모리 Order는 이미 CONFIRMED입니다. 상태 복구·재시도·DB 트랜잭션 원자성·동시성 처리는 이 순수 모델 예제에 없습니다.

상태 이름은 DRAFT와 CONFIRMED 두 개입니다. Order가 변경을 허용할지 결정하며, repository port는 저장을 추상화합니다. 순수 모델의 상태 전이와 실제 영속 저장의 성공을 구분하세요.

이 예의 조건

  • 두 박스는 같은 실행의 진행 정보와 자료·결과를 관찰하는 고정 역할입니다. 박스의 좌우·위아래 위치를 별도 호출 순서로 해석하지 않습니다.
  • 조건별 종료는 대안입니다. 실패 뒤 정상 후속 단계를 실행하거나 독립 비교 사례를 같은 요청으로 연결하지 않습니다.
  • 두 가지 실제 상태인 DRAFT/CONFIRMED와 네 개의 설명 노드가 있다. addItem은 DRAFT 자기 전이, confirm은 항목이 있을 때 CONFIRMED로 전이한다. 점선 두 연결은 상태별 거부 설명이며 새 상태 전이가 아니다. 별도 Application 저장 요청 뒤 실패해도 메모리 Order는 이미 확정 상태다.
  • DRAFT 주문은 양의 안전한 정수 수량을 추가할 수 있고 항목이 있어야 CONFIRMED로 바뀌며, 규칙 위반은 변경 없이 거부하고 확정 이후 저장 실패의 복구는 별도 구현해야 합니다.

관계도의 보충 설명

원래 전체 그림의 표시 문구·조건·주석
순번원문 표시
6두 가지 실제 상태인 DRAFT/CONFIRMED와 네 개의 설명 노드가 있다. addItem은 DRAFT 자기 전이, confirm은 항목이 있을 때 CONFIRMED로 전이한다. 점선 두 연결은 상태별 거부 설명이며 새 상태 전이가 아니다. 별도 Application 저장 요청 뒤 실패해도 메모리 Order는 이미 확정 상태다.
단계별 상태·값과 전환 이유
장면관찰 값전환 이유
DRAFT에서 항목 추가

addItem은 DRAFT를 유지하며 항목을 늘립니다.

상태: DRAFT · 동작: addItem 자기 전이항목이 바뀌지만 주문 상태는 유지됩니다.
항목이 있는 주문 확정

항목이 있을 때 confirm이 CONFIRMED로 전이합니다.

전 상태: DRAFT · 후 상태: CONFIRMED · 조건: 항목 있음메모리 Order의 상태가 확정으로 바뀝니다.
확정 뒤 저장 요청

별도 Application이 save를 요청하며 저장 실패도 이미 바뀐 메모리 상태를 자동으로 되돌리지 않습니다.

메모리: CONFIRMED · 저장: 별도 Application 요청 · 실패: 메모리 확정 유지도메인 전이와 저장 완료의 경계를 구분합니다.

독립 확인: 빈 DRAFT 주문에 수량 1.5를 추가하고, 거부된 뒤 수량 2를 추가합니다. items getter로 받은 배열의 첫 객체 수량을 99로 바꾼 다음 주문을 확정합니다. 확정 뒤 수량 0의 추가를 시도하면 어떤 오류가 먼저 나나요? 같은 주문을 사용하는 ConfirmOrder에서 저장이 실패했을 때와 아직 DRAFT인 별도 주문에서 저장이 실패했을 때를 구별해 설명하세요.

풀이 확인

1.5는 안전한 정수가 아니므로 INVALID_QUANTITY이며 항목은 추가되지 않습니다. 다음 수량 2는 허용됩니다. getter는 배열과 각 항목 객체를 새로 만들어 반환하므로, 반환된 객체를 99로 바꿔도 내부 수량은 2입니다. 확정 뒤의 추가는 requireDraft()가 수량 검사보다 먼저 실행되어 ORDER_ALREADY_CONFIRMED입니다. 이미 확정한 같은 주문을 ConfirmOrder로 다시 처리하면 confirm()이 먼저 거부해 save()에 도달하지 않습니다. 별도 DRAFT 주문에 유효 항목이 있어서 confirm()이 성공한 다음 save()가 실패했다면, 그 메모리 주문은 이미 CONFIRMED입니다. 이 코드에는 실패 뒤 상태 복원이나 DB transaction이 없습니다. 항목별 수량 검사만으로 여러 항목의 합계까지 안전한 정수임이 보장되는 것도 아니므로, 별도의 합계 규칙이 필요하다는 원 설명을 유지합니다.

NestJS에서 DDD 적용

NestJS에서 DDD를 적용할 때 bounded context, aggregate, repository contract, 계층 책임을 나눠 봅니다.

NestJS는 모듈화, DI(Dependency Injection), 계층형 아키텍처를 제공하므로 DDD의 계층을 코드 구조로 나눠 표현할 수 있습니다.

다만 NestJS 모듈 경계와 도메인 경계가 항상 일치하는 것은 아니므로 의존성 방향을 별도로 확인해야 합니다.

NestJS와 DDD 계층 매핑
  • 표현 계층 (Presentation Layer): 클라이언트 요청을 처리하고 응답을 반환합니다.
    • NestJS: Controllers
  • 애플리케이션 계층 (Application Layer): 사용자 요청을 수신하고, 도메인 계층의 객체를 사용하여 비즈니스 로직을 조정하고, 영속성 계층과 상호작용합니다. 도메인 로직 자체를 포함하기보다, 도메인 객체를 오케스트레이션합니다.
    • NestJS: Services (또는 CommandHandlers, QueryHandlers - CQRS와 함께)
  • 도메인 계층 (Domain Layer): 비즈니스 규칙과 상태 전이를 표현하며, 도메인 모델(엔티티, 값 객체, 애그리게이트, 도메인 서비스)이 위치합니다.
    • NestJS: 별도의 디렉토리(예: src/domain)에 User (엔티티), Address (값 객체), OrderAggregate (애그리게이트), OrderService (도메인 서비스) 등의 클래스로 구현.
  • 인프라 계층 (Infrastructure Layer): 도메인 계층의 요구사항을 충족시키기 위한 기술적인 세부 사항(데이터베이스 접근, 외부 서비스 연동, 메시징)을 구현합니다. 도메인 계층은 인프라 계층에 의존하지 않아야 합니다(의존성 역전 원칙).
    • NestJS: Repositories 구현체, 데이터베이스 모듈(TypeOrmModule, MongooseModule), 외부 API 클라이언트.
NestJS에서 DDD 구현 가이드라인

바운디드 컨텍스트 정의: 가장 먼저 시스템을 논리적인 바운디드 컨텍스트로 분리합니다.

각 컨텍스트는 NestJS의 독립적인 모듈 또는 마이크로서비스가 될 수 있습니다. (예: UserModule, OrderModule, ProductModule).

도메인 계층 설계
  • 각 모듈(컨텍스트) 내부에 domain 디렉토리를 생성하고, 그 안에 엔티티, 값 객체, 애그리게이트 루트, 도메인 서비스 등을 정의합니다.
  • 애그리게이트 루트: 강한 일관성(Strong Consistency)이 필요한 도메인 규칙을 애그리게이트 루트 내부에 캡슐화합니다. (예: Order 엔티티 내부에 addOrderItem, cancel 등 비즈니스 메서드 포함).
  • 값 객체: 불변 객체로 정의하고, 동등성(Equality)을 값으로 판단하도록 구현합니다. (예: Address 클래스).
  • 도메인 서비스: 여러 애그리게이트에 걸친 비즈니스 로직을 처리합니다.
리포지토리 추상화
  • 도메인 계층에서는 리포지토리의 인터페이스(추상 클래스)만 정의합니다. (예: IUserRepository).
  • 인프라 계층에서 이 인터페이스를 구현하는 실제 데이터베이스 리포지토리(예: TypeOrmUserRepository)를 만듭니다.
  • NestJS의 DI를 사용하여 애플리케이션 계층에서 인터페이스를 주입받아 사용합니다.

아래 User 코드는 계층 연결을 보여 주는 발췌입니다. User.create(), User.fromPersistence(), UserEntity.fromDomain()의 구현과 컨트롤러·DTO 정의는 생략되어 있습니다. 앞 절의 CRUD용 User를 그대로 가져오면 이 메서드들이 생기지 않으므로, 별도의 도메인 모델과 영속성 모델을 준비해야 합니다. DB 연결 설정과 루트 모듈 등록까지 완성한 프로젝트 예제로 읽지는 마세요.

src/user/domain/repositories/user.repository.interface.ts
import { User } from '../entities/user.entity';
export const USER_REPOSITORY = Symbol('USER_REPOSITORY');

export interface IUserRepository {
  findById(id: string): Promise<User | null>;
  save(user: User): Promise<User>;
  // ... 기타 CRUD 메서드
}
src/user/infrastructure/repositories/typeorm-user.repository.ts
import { Injectable } from '@nestjs/common';
import { Repository } from 'typeorm';
import { InjectRepository } from '@nestjs/typeorm';
import { UserEntity } from '../entities/user.entity'; // TypeORM 엔티티
import { IUserRepository } from '../../domain/repositories/user.repository.interface';
import { User } from '../../domain/entities/user.entity'; // 도메인 엔티티
@Injectable()
export class TypeOrmUserRepository implements IUserRepository {
  constructor(
    @InjectRepository(UserEntity)
    private typeOrmRepository: Repository<UserEntity>,
  ) {}
  async findById(id: string): Promise<User | null> {
    const entity = await this.typeOrmRepository.findOne({ where: { id } });
    return entity ? User.fromPersistence(entity) : null; // DB 엔티티를 도메인 엔티티로 변환
  }
  async save(user: User): Promise<User> {
    const entity = UserEntity.fromDomain(user); // 도메인 엔티티를 DB 엔티티로 변환
    const savedEntity = await this.typeOrmRepository.save(entity);
    return User.fromPersistence(savedEntity);
  }
}

TypeScript interface는 실행 시 사라집니다. 모듈의 providers와 @Inject()에는 동일한 USER_REPOSITORY Symbol을 쓰고, IUserRepository는 정적 타입으로 유지합니다.

src/user/user.module.ts
import { Module } from '@nestjs/common';
import { TypeOrmModule } from '@nestjs/typeorm';
import { UserEntity } from './infrastructure/entities/user.entity';
import { UserController } from './presentation/user.controller';
import { UserService } from './application/user.service'; // 애플리케이션 서비스
import { TypeOrmUserRepository } from './infrastructure/repositories/typeorm-user.repository';
import { USER_REPOSITORY } from './domain/repositories/user.repository.interface';
@Module({
  imports: [TypeOrmModule.forFeature([UserEntity])],
  controllers: [UserController],
  providers: [
    UserService,
    {
      provide: USER_REPOSITORY, // 런타임 Symbol을 토큰으로 사용
      useClass: TypeOrmUserRepository, // 실제 구현체
    },
  ],
  exports: [USER_REPOSITORY], // 다른 모듈에서 리포지토리 사용 시
})
export class UserModule {}

애플리케이션 서비스 구현: UserService와 같은 애플리케이션 서비스는 컨트롤러의 요청을 받아 도메인 계층의 객체(User 엔티티, IUserRepository 등)를 호출하고, 트랜잭션을 관리하며, 필요한 경우 DTO(Data Transfer Object)를 도메인 객체로 변환하거나 그 반대로 변환하는 역할을 합니다.

src/user/application/user.service.ts
import { Inject, Injectable } from '@nestjs/common';
import { USER_REPOSITORY, type IUserRepository } from '../domain/repositories/user.repository.interface';
import { User } from '../domain/entities/user.entity';
import { CreateUserDto } from '../presentation/dtos/create-user.dto';

@Injectable()
export class UserService {
  constructor(@Inject(USER_REPOSITORY) private readonly userRepository: IUserRepository) {}

  async createUser(createUserDto: CreateUserDto): Promise<User> {
    // DTO를 도메인 엔티티로 변환 (또는 도메인 팩토리 사용)
    const newUser = User.create(createUserDto.name, createUserDto.email);
    return this.userRepository.save(newUser);
  }

  async findUserById(id: string): Promise<User | null> {
    return this.userRepository.findById(id);
  }
}

이 발췌는 표현 계층의 CreateUserDto를 직접 받고 도메인 User를 반환합니다. 애플리케이션용 입력과 외부 응답 DTO를 분리하는 설계까지 구현한 코드는 아니며, 트랜잭션을 여는 코드도 없습니다. 앞의 주문 예제처럼 도메인 호출과 저장의 순서를 조율하는 역할을 먼저 구분하고, 필요한 트랜잭션과 경계 변환을 추가하세요.

CQRS와 DDD의 조합: CQRS는 DDD와 매우 잘 어울립니다.

  • 명령(Command) 계층: 쓰기 모델은 DDD의 애그리게이트와 리포지토리 패턴을 사용하여 비즈니스 규칙과 일관성을 엄격하게 유지합니다.
  • 조회(Query) 계층: 읽기 모델은 단순하고 평탄화된 데이터 구조로, 효율적인 조회를 위해 최적화됩니다. 도메인 모델의 복잡한 비즈니스 로직을 포함하지 않습니다.

DDD 구조를 코드로 옮길 때 핵심은 "어느 계층이 결정을 내리는가"를 분명히 하는 것입니다.

컨트롤러와 애플리케이션 서비스는 흐름을 조율하고, 실제 비즈니스 규칙은 애그리게이트와 도메인 모델 안에 남겨야 합니다.


DDD 적용 시 고려사항

  • 복잡성: DDD는 복잡한 도메인에 효과적이지만, 단순한 CRUD 애플리케이션에 적용하면 불필요한 오버헤드와 복잡성만 증가시킬 수 있습니다. 프로젝트의 규모와 도메인 복잡성을 신중하게 평가해야 합니다.
  • 도메인 전문가와의 협업: DDD의 성공은 개발자가 도메인 전문가와 긴밀하게 협력하여 유비쿼터스 언어를 개발하고 도메인 모델을 정교화하는 능력에 달려 있습니다.
  • 점진적 적용: DDD의 모든 개념을 한 번에 적용하기보다는, 핵심적인 바운디드 컨텍스트부터 점진적으로 적용해 나가는 것이 현실적입니다.
  • 학습 곡선: DDD는 기존의 레이어드 아키텍처나 CRUD 방식과는 다른 사고방식을 요구하므로, 팀원들의 학습과 적응 시간이 필요합니다.
  • 이벤트 소싱: CQRS와 마찬가지로, DDD는 종종 이벤트 소싱과 함께 사용되어 비즈니스 이벤트의 변경 이력을 보존하고 읽기 모델을 재생성하는 데 활용됩니다.

DDD 적용 여부는 폴더 구조가 아니라 도메인 판단을 어디에 둘지로 점검할 수 있습니다.

도메인 주도 설계는 복잡한 비즈니스 규칙을 모델, 언어, 경계로 나누어 코드에 반영하는 접근 방식입니다.

NestJS의 견고한 아키텍처와 DI 시스템은 DDD 원칙을 적용해 응집도가 높고 유지보수하기 쉬운 애플리케이션을 구축하는 기반을 제공합니다.

프로젝트 규모와 도메인 복잡성을 고려해 DDD를 적용해야 하며, 단순 CRUD 시스템에서는 모델 분리 비용이 이점보다 클 수 있습니다.

주문 상태도는 애그리게이트 내부의 규칙을 보여 줍니다. 다음 계층 그림은 그 모델을 NestJS에 연결할 때의 의존성 방향과, DTO 분리·트랜잭션 조율을 추가한 설계를 보여 줍니다.

bounded context 안에서 domain으로 향하는 DDD 의존성

Nest · DDD dependency design

DDD 경계와 의존성 방향

화살표는 코드 의존성을 나타냅니다. 작은 Order 예제는 Domain의 확정 규칙과 Application의 조회·저장 순서를 보여 줍니다. 본문 User 발췌의 Symbol DI·mapper 연결에 application input·응답 DTO 분리와 transaction 조율을 추가할 때의 설계이며, 이 기능들이 발췌에 모두 구현된 것은 아닙니다.

DDD 영역과 코드 의존성의 모바일 재배치Bounded context 안에 Core, Core 안에 Domain이 중첩된다. Controller→Application, Application→Aggregate와RepoPort, Infrastructure→RepoPort, RepoPort→Aggregate의 다섯 코드 의존성이다. 실행 순서 화살표가 아니며 모델링 피드백은 별도 설명 노드이다.BOUNDED CONTEXTCORE ·USE CASEDOMAINMODELport 의존implementsOrder 참조ControllerHTTP DTO ↔ input외부 adapterApplicationflow · transactionuse caseAggregate rootEntity + Value Objectnamed behaviorinvariantSymbol Repo Portruntime token도메인 계약Mapper/Repositoryrecord ↔ domaininfrastructureModeling feedback비즈니스 규칙→ 유비쿼터스 언어→ 모델 → 구현→ 피드백Domain은 ORM record나HTTP DTO를 import 안 함
DDD bounded context와 안쪽으로 향하는 의존성 하나의 bounded context 안에서 Controller가 Application use case를 호출하고 Application이 Domain Aggregate를 사용한다. 반대편 infrastructure adapter는 mapper로 persistence record를 번역하며 Symbol repository port를 구현해 의존성이 Domain 쪽으로 향한다. BOUNDED CONTEXT CORE · USE CASE + DOMAIN DOMAIN MODEL ADAPTER Controller HTTP DTO ↔ input USE CASE Application flow transaction AGGREGATE ROOT Entity + Value Object named behavior invariant SYMBOL Repo Port runtime token Mapper + Repository record ↔ domain IMPLEMENTS MODELING FEEDBACK 비즈니스 규칙 → 유비쿼터스 언어 → 모델 → 구현 → 피드백 DOMAIN DOES NOT IMPORT ORM RECORDS OR HTTP DTOs
DDD bounded context와 안쪽으로 향하는 의존성 하나의 bounded context 안에서 Controller가 Application use case를 호출하고 Application이 Domain Aggregate를 사용한다. 반대편 infrastructure adapter는 mapper로 persistence record를 번역하며 Symbol repository port를 구현해 의존성이 Domain 쪽으로 향한다. BOUNDED CONTEXT CORE · USE CASE + DOMAIN DOMAIN MODEL ADAPTER Controller HTTP DTO ↔ input USE CASE Application flow transaction AGGREGATE ROOT Entity + Value Object named behavior invariant SYMBOL Repo Port runtime token Mapper + Repository record ↔ domain IMPLEMENTS MODELING FEEDBACK 비즈니스 규칙 → 유비쿼터스 언어 → 모델 → 구현 → 피드백 DOMAIN DOES NOT IMPORT ORM RECORDS OR HTTP DTOs
  1. 언어와 bounded context

    도메인 전문가와 같은 용어를 쓰고 그 모델이 유효한 경계를 먼저 정합니다. module이나 microservice와 자동으로 같지 않습니다.

  2. Interface adapter

    Controller가 HTTP DTO를 application input으로 번역하며 domain entity를 외부 응답으로 직접 내보내지 않습니다.

  3. Application

    Use case와 transaction 흐름을 조율하지만 실제 업무 invariant는 Aggregate에 맡깁니다.

  4. Domain

    Entity·Value Object·Aggregate의 명명된 행동이 유효 상태를 결정하며 Nest와 ORM을 import하지 않습니다.

  5. Repository port

    compile-time interface와 runtime Symbol token을 분리해 JavaScript에서 사라지는 interface를 DI token으로 쓰지 않습니다.

  6. Infrastructure

    Mapper와 repository adapter가 persistence record를 번역하고 안쪽 port를 구현합니다.

  7. 모델 feedback

    업무 대화, 코드 이름, invariant test가 어긋나면 용어와 context 경계를 다시 다듬습니다.

Order가 확정 가능 여부를 결정하고 Application이 저장을 요청합니다. 별도 주문 상태도에서 허용·거부를 확인한 뒤 이 계층 그림으로 연결하세요. DTO 분리·실제 transaction·persistence mapping은 모델의 규칙과 구분해 구현해야 합니다.

같은 규칙을 지켜도 저장 결과가 충돌하는 경우

이 주문 모델은 한 객체 안에서 수량과 상태 전이를 검사합니다. 저장소에서 같은 주문을 두 번 읽어 서로 다른 객체를 만들었다면 두 객체의 규칙 검사가 서로를 잠그지는 않습니다. 다음은 실제 DB 실행 결과가 아니라, 버전이 7인 주문을 두 요청이 읽었다고 가정한 저장 순서입니다.

순서요청 A의 객체요청 B의 객체조건 없는 저장소의 상태
1상품 1개가 있는 DRAFT를 읽음같은 DRAFT를 별도로 읽음DRAFT, 버전 7
2confirm() 성공이전 객체에 addItem('p2', 2) 성공아직 DRAFT, 버전 7
3확정한 객체를 저장아직 저장하지 않음CONFIRMED
4작업 종료오래된 DRAFT 전체를 덮어씀DRAFT로 되돌아갈 수 있음

B의 객체는 여전히 DRAFT이므로 addItem() 자체는 현재 규칙을 어기지 않습니다. 충돌을 막으려면 저장소 계약에도 읽었을 때의 버전을 전달하고, 현재 저장 버전과 예상 버전이 같은 경우에만 갱신하는 조건을 쓰기와 함께 원자적으로 적용해야 합니다. 버전 7을 조건으로 A가 먼저 저장해 버전 8이 되었다면 B의 조건은 실패해야 합니다. 먼저 조회하고 나중에 조건 없는 save()를 호출하는 두 단계로는 그 사이의 다른 쓰기를 막지 못합니다. 지금의 OrderRepository 인터페이스에는 이 기능이 구현되어 있지 않으므로, 도메인 규칙 검사만으로 동시 저장 안전성을 얻었다고 판단하지 않습니다.

또한 각 quantity가 안전한 정수라는 조건은 모든 항목의 합계가 안전하다는 조건과 다릅니다. quantity를 각각 9007199254740991과 2로 넣는 것은 현재 모델에서 허용됩니다. 두 수의 수학적 합은 9007199254740993이지만 JavaScript Number로 더하면 9007199254740992로 표현됩니다. 합계 제한이 업무 규칙이라면 별도 검사를 설계해야 합니다. 이 모델은 합계를 계산하거나 제한하지 않습니다.

저장 충돌과 토큰을 확인하는 문제

문제 1. DRAFT 주문에 addItem('p1', 2), addItem('p1', 3)을 차례로 호출했습니다. items의 길이와 수량 합은 얼마이며, 같은 상품이 자동으로 합쳐진다고 설명해도 되나요?

풀이. 길이는 2이고 수량 합은 5입니다. addItem()은 검증 후 새 항목을 push()하므로 productId가 같아도 두 항목을 보관합니다. 상품별 합치기나 중복 거절은 현재 구현에 없는 규칙입니다. 합치기가 필요하다면 가격·옵션까지 무엇을 같은 항목으로 볼지 정하고 구현과 테스트를 함께 추가해야 합니다.

문제 2. 위 표에서 A와 B가 모두 예상 버전 7로 저장합니다. 저장소가 성공할 때 버전을 1 증가시키며 버전 조건을 원자적으로 검사한다면 두 저장은 모두 성공하나요?

풀이. A가 먼저 성공하면 저장 버전은 8이 됩니다. B의 예상 버전 7은 현재 8과 다르므로 실패해야 하며, B의 오래된 DRAFT로 덮어쓰지 않아야 합니다. 충돌 후에는 최신 주문을 다시 읽고 업무 의도를 재검토합니다. 이미 확정된 주문에 상품을 추가하려는 요청은 현재 상태 규칙에 따라 거절될 수 있으므로, 오래된 작업을 그대로 자동 재시도하는 것과 구별합니다.

문제 3. 공급자 등록에서 Symbol('USER_REPOSITORY')를 만들고 주입 파일에서도 같은 문자열로 새 Symbol을 만들었습니다. 두 값은 같은 토큰인가요?

풀이. 서로 다른 Symbol입니다. 문자열 설명이 같아도 새로 생성한 Symbol의 식별자는 다릅니다. 앞 코드처럼 공통 파일에서 내보낸 USER_REPOSITORY 값을 공급자와 주입부가 함께 가져와야 합니다. Nest의 공급자 토큰과 공통 상수 사용 방식은 공식 custom providers 문서에서도 설명합니다. 여기서 두 Symbol을 비교하는 실험은 JavaScript 식별자 확인이며, 실제 Nest 컨테이너의 부팅 성공을 입증하는 실행은 아닙니다.