NestJS와 함께하는 DDD
유비쿼터스 언어와 경계 컨텍스트를 바탕으로 DDD의 책임을 나누고, 작은 주문 애그리게이트의 규칙·상태 전이·저장 경계를 살펴봅니다.
앞 글에서는 NestJS로 CQRS 패턴을 구현했습니다. 12장의 마지막인 이 글에서는 복잡한 업무 영역을 모델링하고 관리하는 방법인 도메인 주도 설계(Domain-Driven Design, DDD)를 NestJS 프로젝트에 적용하는 방법을 살펴봅니다.
소프트웨어는 결국 현실의 복잡한 업무 문제를 풀기 위해 존재합니다. DDD는 업무 영역을 깊이 이해하고 그 지식을 소프트웨어 모델에 반영해, 복잡성을 관리하고 변화에 유연한 시스템을 만드는 데 초점을 둡니다.
도메인 주도 설계란
도메인 주도 설계는 복잡한 도메인(업무 영역)의 모델링에 집중하는 개발 방식으로, 에릭 에반스(Eric Evans)가 2003년 저서 “Domain-Driven Design: Tackling Complexity in the Heart of Software”에서 처음 제시했습니다. DDD는 복잡한 업무 규칙을 분명히 이해해 모델에 정확히 반영하고, 개발자와 도메인 전문가가 지식을 함께 다듬으며, 요구사항의 변화에 유연하게 대응하는 시스템을 만드는 것을 목표로 합니다.
유비쿼터스 언어
유비쿼터스 언어(ubiquitous language)는 개발자, 도메인 전문가, 테스터처럼 프로젝트에 참여하는 모두가 함께 쓰는 언어이며, 코드·문서·대화에 일관되게 반영됩니다. 예를 들어 “주문”이 업무에서는 고객이 상품을 사는 행위인데 개발에서는 DB의 Order 테이블로만 이해되는 식의 오해를 줄이고, 도메인 모델이 업무 현실을 정확히 반영하게 돕습니다.
도메인 모델
도메인 모델은 업무 영역의 핵심 개념, 데이터, 행위, 관계를 추상화한 것입니다. 코드에서는 규칙, 상태, 불변 조건을 담은 모델로 구현되며, 업무 규칙이 모델 안에 캡슐화되어 있어 변경에 강한 코드가 됩니다.
바운디드 컨텍스트
바운디드 컨텍스트(bounded context)는 특정 도메인 모델이 일관되게 적용되는 범위입니다. 컨텍스트마다 자신의 유비쿼터스 언어와 도메인 모델을 가지며, 큰 시스템을 작고 독립적인 부분으로 나누어 복잡성을 줄입니다. 주문 관리, 재고 관리, 결제가 각각 컨텍스트가 될 수 있고, 각 컨텍스트에서 “상품”의 정의와 속성은 다를 수 있습니다. 바운디드 컨텍스트는 마이크로서비스를 나누는 자연스러운 기준이 되기도 합니다.
엔티티와 값 객체
엔티티(entity)는 고유한 식별자를 가지며 시간이 지나며 상태가 바뀔 수 있는 객체입니다. 속성이 같아도 식별자가 다르면 다른 엔티티이며, 사용자 ID를 가진 User, 주문 ID를 가진 Order가 그 예입니다.
값 객체(value object)는 식별자 없이 자신의 속성 값으로만 정의되는 불변 객체입니다. 속성 값이 같으면 같은 값 객체로 보며, 거리·도시·우편번호로 이루어진 Address나 금액과 통화로 이루어진 Money가 그 예입니다. 엔티티의 속성으로 쓰여 도메인 모델의 표현력을 높입니다.
애그리게이트
애그리게이트(aggregate)는 하나 이상의 엔티티와 값 객체를 하나의 단위로 묶은 것입니다. 애그리게이트 루트(aggregate root)라고 부르는 루트 엔티티를 하나 가지며, 이 루트를 통해서만 내부의 다른 객체에 접근하고 바꿀 수 있습니다. 애그리게이트는 데이터 일관성을 지키는 트랜잭션의 단위가 되며, 늘 유효한 상태를 유지해야 합니다. 예를 들어 Order 애그리게이트는 루트인 Order와 여러 OrderItem을 포함하고, OrderItem은 Order 없이 따로 존재하지 않습니다.
도메인 서비스, 리포지토리, 팩토리
도메인 서비스(domain service)는 특정 엔티티나 값 객체에 속하기 어렵고 여러 도메인 객체에 걸친 업무 로직을 담습니다. 계좌 이체처럼 여러 애그리게이트를 다루는 업무가 그 예입니다.
리포지토리(repository)는 애그리게이트 루트를 저장하고 조회하는 일을 추상화한 인터페이스입니다. 도메인 로직이 데이터베이스 접근 로직과 섞이지 않게 해 도메인 모델을 순수하게 유지하고, 데이터베이스 기술이 바뀌어도 도메인 계층이 받는 영향을 줄입니다.
팩토리(factory)는 복잡한 도메인 객체를 만드는 로직을 감싸, 생성의 복잡성을 숨기고 늘 유효한 상태의 객체가 만들어지게 합니다.
작은 주문으로 애그리게이트 규칙 살펴보기
주문 항목을 배열에 넣는 것만으로는 애그리게이트의 역할이 잘 드러나지 않습니다. 주문을 확정할 때 지켜야 할 규칙을 정하고, 그 규칙을 Order가 직접 판단하게 만들어 보겠습니다. 여기서 불변 조건(invariant)은 메서드 호출이 끝난 뒤에도 늘 지켜져야 하는 규칙입니다.
이 예제의 주문은 DRAFT(작성 중)에서 시작합니다. 항목의 수량은 1 이상의 안전한 정수여야 하고, 항목이 하나 이상 있어야 CONFIRMED(확정됨)로 바뀔 수 있습니다. 확정한 주문에는 항목을 더 넣거나 다시 확정할 수 없으며, 다시 확정하는 것은 성공으로 치지 않고 상태 오류로 거부합니다. 아래 코드는 NestJS나 데이터베이스를 쓰지 않는 순수 TypeScript 모델이며, 뒤의 User 계층 예제와는 별개의 모델입니다.
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()는 도메인 규칙 오류만 기록하고, 예상하지 못한 오류는 그대로 다시 던집니다.
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_ORDER | DRAFT, 항목 0개 |
addItem('book-1', 0) | INVALID_QUANTITY | DRAFT, 항목 0개 |
addItem('book-1', 2) | OK | DRAFT, 항목 1개·수량 2 |
항목이 있는 주문에서 confirm() | OK | CONFIRMED, 항목 1개·수량 2 |
확정 뒤 addItem() 또는 confirm() | ORDER_ALREADY_CONFIRMED | CONFIRMED, 항목 1개·수량 2 |
결과가 다르다면 requireDraft()가 두 변경 메서드의 맨 앞에 있는지, 수량 검사와 빈 주문 검사가 실제 변경보다 먼저 끝나는지 확인합니다. 이 단계에서는 DB 저장보다 Order 안의 판단을 먼저 따라가면 됩니다.
이 예제는 수량과 확정 규칙만 다룹니다. 주문·상품 ID의 형식과 존재 여부, 가격, 재고, 결제, 취소는 검사하지 않습니다. 같은 상품을 두 번 추가하면 별도 항목으로 남고 수량을 합치지 않으므로, 상품별 합계나 그 합계가 안전한 정수인지도 보장하지 않습니다.
도메인 메서드에서 저장 포트로 연결하기
주문을 확정해 달라는 요청을 받으면 애플리케이션 계층은 주문을 조회하고, confirm()을 호출한 뒤 저장합니다. 확정할 수 있는지 판단하는 코드는 계속 Order 안에 둡니다. 저장 포트(repository port)는 애플리케이션에 필요한 조회·저장 작업을 인터페이스로 나타낸 것이며, 실제 DB 호출은 이를 구현한 어댑터가 맡습니다.
import type { Order } from './order';
export interface OrderRepository {
findById(id: string): Promise<Order | null>;
save(order: Order): Promise<void>;
}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 항목 추가·confirm 조건·CONFIRMED·application save 성공/실패에서도 이미 바뀐 메모리 상태가 중요하다.
Nest · Order aggregate
Order가 상태를 결정하고 Application이 저장을 요청합니다
항목이 하나 이상인 DRAFT만 CONFIRMED로 바뀝니다. 거부된 호출은 항목과 상태를 그대로 남깁니다. confirm() 뒤의 save()는 별도 저장 단계이며, 실패해도 메모리 상태가 자동으로 되돌아가지는 않습니다.
- DRAFT에서 작성
new Order()는 빈 DRAFT로 시작합니다. 양의 안전한 정수 수량만 추가할 수 있습니다. 잘못된 수량은 INVALID_QUANTITY로 거부되고 기존 항목은 유지됩니다.
- 항목이 있어야 확정
빈 주문의 confirm()은 EMPTY_ORDER입니다. 항목이 하나 이상이면 CONFIRMED가 됩니다. 항목 유무는 DRAFT 안의 조건이며 별도 상태가 아닙니다.
- 확정 뒤 변경 거부
addItem()과 confirm()은 각각 ORDER_ALREADY_CONFIRMED를 던집니다. 재확정은 성공으로 취급하지 않으며 상태와 항목은 그대로입니다.
- Application에서 저장 요청
주문을 조회하고 confirm()에 성공하면 save()를 호출합니다. 주문이 없거나 규칙 위반이면 save()까지 가지 않습니다.
- 저장 실패의 경계
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()는 별도 저장 단계이며, 실패해도 메모리 상태가 자동으로 되돌아가지는 않습니다.
- DRAFT에서 작성
new Order()는 빈 DRAFT로 시작합니다. 양의 안전한 정수 수량만 추가할 수 있습니다. 잘못된 수량은 INVALID_QUANTITY로 거부되고 기존 항목은 유지됩니다.
- 항목이 있어야 확정
빈 주문의 confirm()은 EMPTY_ORDER입니다. 항목이 하나 이상이면 CONFIRMED가 됩니다. 항목 유무는 DRAFT 안의 조건이며 별도 상태가 아닙니다.
- 확정 뒤 변경 거부
addItem()과 confirm()은 각각 ORDER_ALREADY_CONFIRMED를 던집니다. 재확정은 성공으로 취급하지 않으며 상태와 항목은 그대로입니다.
- Application에서 저장 요청
주문을 조회하고 confirm()에 성공하면 save()를 호출합니다. 주문이 없거나 규칙 위반이면 save()까지 가지 않습니다.
- 저장 실패의 경계
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만 CONFIRMED로 바뀌고, 거부된 호출은 항목과 상태를 그대로 남깁니다. 항목이 있는지 없는지는 같은 DRAFT 상태 안의 조건일 뿐 별도의 상태 이름이 아니며, Order가 상태를 결정한 뒤에 애플리케이션이 저장을 요청한다는 순서가 드러납니다.
같은 규칙을 지켜도 저장 결과가 충돌하는 경우
이 주문 모델은 한 객체 안에서 수량과 상태 전이를 검사합니다. 리포지토리에서 같은 주문을 두 번 읽어 서로 다른 객체를 만들었다면, 두 객체의 규칙 검사가 서로를 막아 주지는 않습니다. 다음은 실제 DB 실행 결과가 아니라, 버전 7인 주문을 두 요청이 읽었다고 가정한 저장 순서입니다.
| 순서 | 요청 A의 객체 | 요청 B의 객체 | 조건 없이 저장할 때의 상태 |
|---|---|---|---|
| 1 | 상품 1개가 있는 DRAFT를 읽음 | 같은 DRAFT를 따로 읽음 | DRAFT, 버전 7 |
| 2 | confirm() 성공 | 이전 객체에 addItem('p2', 2) 성공 | 아직 DRAFT, 버전 7 |
| 3 | 확정한 객체를 저장 | 아직 저장하지 않음 | CONFIRMED |
| 4 | 작업 종료 | 오래된 DRAFT 전체를 덮어씀 | DRAFT로 되돌아갈 수 있음 |
B의 객체는 여전히 DRAFT이므로 addItem() 자체는 규칙을 어기지 않습니다. 이 충돌을 막으려면 리포지토리에도 읽었을 때의 버전을 넘기고, 현재 저장된 버전과 예상 버전이 같을 때만 갱신하는 조건을 쓰기와 함께 원자적으로 적용해야 합니다. 이를 낙관적 잠금이라고 하며, SQL로 버전을 비교해 갱신하는 예는 데이터베이스 교재의 격리 수준과 잠금에서 볼 수 있습니다. 먼저 조회하고 나중에 조건 없는 save()를 호출하는 두 단계로는 그 사이의 다른 쓰기를 막을 수 없으며, 지금의 OrderRepository 인터페이스에는 이런 버전 조건이 없습니다.
또 각 quantity가 안전한 정수라는 조건은 모든 항목의 합계가 안전하다는 조건과 다릅니다. 수량을 각각 9007199254740991과 2로 넣는 것은 지금 모델에서 허용되지만, 수학적인 합은 9007199254740993인데 JavaScript의 Number로 더하면 9007199254740992가 됩니다. 합계 제한이 업무 규칙이라면 따로 검사해야 합니다.
더 깊이: TypeORM의 @VersionColumn만으로 막지 못하는 덮어쓰기
TypeORM에는 버전을 다루는 @VersionColumn()이 있지만, 이것만으로 위 표의 4단계를 막을 수는 없습니다. TypeORM 엔티티 문서에 따르면 이 열은 save()나 갱신을 일으킨 upsert가 실행될 때마다 자동으로 1씩 늘어나는 번호이고, 소스 코드의 UPDATE 문은 version = version + 1을 더할 뿐 WHERE에 읽었던 버전을 조건으로 넣지 않습니다. 그래서 요청 B가 오래된 객체로 save()해도 버전만 하나 더 올라가며 A의 확정을 덮어씁니다.
TypeORM이 제공하는 낙관적 잠금 모드(lock: { mode: 'optimistic', version })도 조회 시점의 검사입니다. 소스 코드를 보면 이 모드는 읽어 온 행의 버전이 기대한 값과 다를 때 OptimisticLockVersionMismatchError를 던지지만, 검사를 통과한 뒤 저장하기 전까지 다른 요청이 끼어드는 것은 막지 못합니다. 쓰기와 함께 원자적으로 비교하려면 update({ id, version: expected }, changes)처럼 예상 버전을 갱신 조건에 넣고, 결과의 affected가 1이 아니면 충돌로 처리합니다. 3장 트랜잭션 글에서 재고를 조건부로 줄이고 변경 건수를 확인한 것과 같은 방식입니다.
NestJS에서 DDD 적용하기
NestJS는 모듈, 의존성 주입(DI), 계층형 구조를 제공하므로 DDD의 계층을 코드 구조로 나누어 표현할 수 있습니다. 다만 NestJS 모듈의 범위와 도메인의 범위가 늘 일치하지는 않으므로, 의존성의 방향을 따로 확인해야 합니다.
NestJS와 DDD의 계층은 다음과 같이 대응합니다.
- 표현 계층(presentation layer): 클라이언트의 요청을 처리하고 응답을 돌려줍니다. NestJS의 controller가 여기에 해당합니다.
- 애플리케이션 계층(application layer): 요청을 받아 도메인 객체를 써서 업무 흐름을 조율하고 영속 계층과 상호작용합니다. 도메인 로직 자체를 담기보다 도메인 객체를 지휘하며, NestJS의 service나 CQRS의 command·query handler가 해당합니다.
- 도메인 계층(domain layer): 업무 규칙과 상태 전이를 나타내며, 엔티티·값 객체·애그리게이트·도메인 서비스가 있습니다. NestJS에서는
src/order/domain같은 별도 폴더에 순수 클래스로 구현합니다. - 인프라 계층(infrastructure layer): 데이터베이스 접근, 외부 서비스 연동, 메시징 같은 기술적인 세부를 구현합니다. 도메인 계층은 인프라 계층에 의존하지 않아야 하며(의존성 역전 원칙), 리포지토리 구현체,
TypeOrmModule이나MongooseModule같은 데이터베이스 모듈, 외부 API 클라이언트가 여기에 있습니다.
바운디드 컨텍스트와 도메인 계층
가장 먼저 시스템을 논리적인 바운디드 컨텍스트로 나눕니다. 각 컨텍스트는 UserModule, OrderModule, ProductModule처럼 NestJS의 독립 모듈이 되거나, 7장처럼 별도의 마이크로서비스가 될 수 있습니다.
각 모듈 안에는 domain 폴더를 두고 엔티티, 값 객체, 애그리게이트 루트, 도메인 서비스를 정의합니다. 강한 일관성이 필요한 업무 규칙은 앞의 Order처럼 애그리게이트 루트 안에 감싸고, 값 객체는 불변으로 만들어 값으로 같은지를 판단하며, 여러 애그리게이트에 걸친 로직은 도메인 서비스에 둡니다.
리포지토리 추상화
도메인 계층에는 리포지토리의 인터페이스만 정의하고, 인프라 계층에서 이 인터페이스를 구현하는 실제 데이터베이스 리포지토리를 만든 뒤, NestJS의 DI로 애플리케이션 계층에 주입합니다.
아래 User 코드는 계층 연결을 보여 주는 발췌입니다. User.create(), User.fromPersistence(), UserEntity.fromDomain()의 구현과 controller, DTO는 생략했습니다. 앞 장의 CRUD용 User를 그대로 가져온다고 이 메서드들이 생기지 않으므로, 별도의 도메인 모델과 영속 모델을 준비해야 합니다.
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 메서드
}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는 실행할 때 사라지므로 DI token으로 쓸 수 없습니다. 그래서 모듈의 providers와 @Inject()에는 같은 USER_REPOSITORY Symbol을 쓰고, IUserRepository는 정적 타입으로만 씁니다.
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 {}provide와 useClass의 짝은 USER_REPOSITORY를 요청받으면 TypeOrmUserRepository 인스턴스를 넘기라는 등록입니다. 그래서 저장 기술을 바꿀 때는 이 useClass만 다른 구현으로 바꾸고, 토큰으로 주입받는 쪽의 코드는 그대로 둡니다. TypeOrmModule.forFeature([UserEntity])는 TypeOrmUserRepository가 @InjectRepository(UserEntity)로 받는 TypeORM 리포지토리를 이 모듈 안에 등록하므로, 빠뜨리면 그 구현체를 만들 때 의존성을 찾지 못합니다.
애플리케이션 서비스
UserService 같은 애플리케이션 서비스는 controller의 요청을 받아 도메인 객체와 리포지토리를 호출하고, 트랜잭션을 관리하며, 필요하면 DTO와 도메인 객체를 서로 바꿉니다. 아래 코드에서는 생성자의 @Inject(USER_REPOSITORY)를 보면 됩니다. 서비스는 TypeOrmUserRepository를 가져오지 않고 토큰과 IUserRepository 타입만 알기 때문에, 애플리케이션 계층이 인프라 계층의 구현에 직접 의존하지 않습니다.
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와 잘 어울립니다. 명령 쪽의 쓰기 모델은 DDD의 애그리게이트와 리포지토리로 업무 규칙과 일관성을 엄격하게 지키고, 조회 쪽의 읽기 모델은 효율적인 조회를 위해 단순하고 역정규화한 데이터 구조로 만들며 복잡한 업무 로직을 담지 않습니다.
DDD 구조를 코드로 옮길 때 핵심은 “어느 계층이 결정을 내리는가”를 분명히 하는 것입니다. controller와 애플리케이션 서비스는 흐름을 조율하고, 실제 업무 규칙은 애그리게이트와 도메인 모델 안에 둡니다.
Nest · DDD dependency design
DDD 경계와 의존성 방향
화살표는 코드 의존성을 나타냅니다. 작은 Order 예제는 Domain의 확정 규칙과 Application의 조회·저장 순서를 보여 줍니다. 본문 User 발췌의 Symbol DI·mapper 연결에 application input·응답 DTO 분리와 transaction 조율을 추가할 때의 설계이며, 이 기능들이 발췌에 모두 구현된 것은 아닙니다.
-
언어와 bounded context
도메인 전문가와 같은 용어를 쓰고 그 모델이 유효한 경계를 먼저 정합니다. module이나 microservice와 자동으로 같지 않습니다.
-
Interface adapter
Controller가 HTTP DTO를 application input으로 번역하며 domain entity를 외부 응답으로 직접 내보내지 않습니다.
-
Application
Use case와 transaction 흐름을 조율하지만 실제 업무 invariant는 Aggregate에 맡깁니다.
-
Domain
Entity·Value Object·Aggregate의 명명된 행동이 유효 상태를 결정하며 Nest와 ORM을 import하지 않습니다.
-
Repository port
compile-time interface와 runtime Symbol token을 분리해 JavaScript에서 사라지는 interface를 DI token으로 쓰지 않습니다.
-
Infrastructure
Mapper와 repository adapter가 persistence record를 번역하고 안쪽 port를 구현합니다.
-
모델 feedback
업무 대화, 코드 이름, invariant test가 어긋나면 용어와 context 경계를 다시 다듬습니다.
Order가 확정 가능 여부를 결정하고 Application이 저장을 요청합니다. 별도 주문 상태도에서 허용·거부를 확인한 뒤 이 계층 그림으로 연결하세요. DTO 분리·실제 transaction·persistence mapping은 모델의 규칙과 구분해 구현해야 합니다.
위 그림은 바운디드 컨텍스트 안에서 코드 의존성이 도메인 쪽으로 향하는 모습을 보여 줍니다. 화살표는 코드 의존성의 방향이며, 표현 계층과 인프라 계층이 모두 애플리케이션과 도메인에 의존하고 도메인은 아무것에도 의존하지 않습니다. 작은 Order 예제의 확정 규칙은 도메인에, 조회·확정·저장의 순서는 애플리케이션에 있고, DTO 분리와 트랜잭션 조율은 여기에 더할 설계로 표시되어 있습니다.
DDD를 적용할 때 고려할 점
- 복잡성: DDD는 복잡한 도메인에 효과적이지만, 단순한 CRUD 애플리케이션에 적용하면 쓸데없는 부담과 복잡성만 늘 수 있습니다. 프로젝트의 규모와 도메인의 복잡성을 먼저 평가합니다.
- 도메인 전문가와의 협업: DDD의 성공은 개발자가 도메인 전문가와 가까이 협력해 유비쿼터스 언어를 만들고 모델을 다듬는 데 달려 있습니다.
- 점진적 적용: 모든 개념을 한 번에 적용하기보다, 핵심 바운디드 컨텍스트부터 차례로 적용하는 편이 현실적입니다.
- 학습 부담: 기존 계층형 구조나 CRUD 방식과 다른 사고방식이 필요하므로 팀이 익숙해질 시간이 필요합니다.
- 이벤트 소싱: CQRS처럼 DDD도 이벤트 소싱과 함께 쓰여 업무 이벤트의 이력을 보존하고 읽기 모델을 다시 만드는 데 활용됩니다.
DDD를 적용했는지는 폴더 구조가 아니라 도메인의 판단을 어디에 두었는지로 점검할 수 있습니다. 단순한 CRUD 시스템에서는 모델을 나누는 비용이 이점보다 클 수 있으므로, 프로젝트의 규모와 도메인의 복잡성을 보고 적용 범위를 정합니다.
정리
- DDD는 유비쿼터스 언어, 바운디드 컨텍스트, 도메인 모델로 복잡한 업무 규칙을 코드에 반영하는 설계 방식입니다.
- 애그리게이트 루트는 내부 객체에 대한 변경을 한곳에서 받아 불변 조건을 검사하며, 거부된 호출은 상태를 바꾸지 않아야 합니다.
- 애플리케이션 계층은 조회, 도메인 메서드 호출, 저장의 순서를 조율하고, 상태 변경을 허용할지는 도메인 모델이 결정합니다.
- TypeScript interface는 실행 중에 없으므로, 리포지토리는 Symbol 같은 token으로 등록하고
@Inject()로 주입합니다. - 같은 애그리게이트를 동시에 바꾸는 요청은 도메인 규칙만으로 막을 수 없으므로, 버전을 조건으로 한 원자적 저장 같은 장치가 필요합니다.
연습 문제
1. 거부와 복사를 따라가기
빈 DRAFT 주문에 수량 1.5를 추가해 거부된 뒤 수량 2를 추가합니다. items로 받은 배열의 첫 객체의 수량을 99로 바꾼 다음 주문을 확정합니다. 확정 뒤 수량 0을 추가하려 하면 어떤 오류가 먼저 날까요? 이 주문을 ConfirmOrder로 다시 처리하면 save()가 호출될까요?
해설 보기
1.5는 안전한 정수가 아니므로 INVALID_QUANTITY로 거부되고 항목은 추가되지 않습니다. 다음 수량 2는 허용됩니다. items는 배열과 각 항목 객체를 새로 만들어 돌려주므로, 받은 객체를 99로 바꿔도 내부 수량은 2 그대로입니다.
확정 뒤의 추가는 requireDraft()가 수량 검사보다 먼저 실행되므로 ORDER_ALREADY_CONFIRMED가 납니다. 이미 확정한 주문을 ConfirmOrder로 다시 처리하면 confirm()이 먼저 거부하므로 save()까지 가지 않습니다.
2. 같은 상품을 두 번 추가하면
DRAFT 주문에 addItem('p1', 2)와 addItem('p1', 3)을 차례로 호출했습니다. items의 길이와 수량 합은 얼마일까요? 같은 상품이 자동으로 합쳐진다고 설명해도 될까요?
해설 보기
길이는 2이고 수량 합은 5입니다. addItem()은 검증한 뒤 새 항목을 push()하므로 productId가 같아도 두 항목을 따로 보관합니다.
상품별로 합치거나 중복을 거부하는 규칙은 지금 구현에 없습니다. 합치기가 필요하다면 가격이나 옵션까지 포함해 무엇을 같은 항목으로 볼지 정하고, 구현과 테스트를 함께 더해야 합니다.
3. 버전 조건으로 충돌 막기
위 표에서 A와 B가 모두 예상 버전 7로 저장합니다. 리포지토리가 성공할 때마다 버전을 1 늘리고 버전 조건을 원자적으로 검사한다면 두 저장은 모두 성공할까요? 실패한 쪽은 어떻게 처리해야 할까요?
해설 보기
A가 먼저 성공하면 저장된 버전은 8이 됩니다. B의 예상 버전 7은 현재 버전 8과 다르므로 저장이 실패해야 하고, B의 오래된 DRAFT로 덮어쓰지 않아야 합니다.
충돌이 난 뒤에는 최신 주문을 다시 읽고 업무 의도를 다시 검토합니다. 이미 확정된 주문에 상품을 추가하려던 요청은 지금 상태 규칙에 따라 거부될 수 있으므로, 오래된 작업을 그대로 자동 재시도하는 것과는 구분해야 합니다.
이것으로 12장을 마칩니다. 12장에서는 서버리스, WebSocket, CQRS, DDD라는 고급 주제를 차례로 살펴보았습니다. 다음 장의 프로젝트 요구사항 분석과 설계부터는 지금까지 배운 내용을 모아 하나의 프로젝트를 설계하고 구현합니다.