NestJS와 함께하는 DDD
유비쿼터스 언어와 경계 컨텍스트를 바탕으로 엔티티·값 객체·애그리게이트·리포지토리의 책임을 설계합니다.
지난 절에서는 NestJS에서 CQRS (Command Query Responsibility Segregation) 패턴 구현 방법을 알아봤습니다.
이제 12장 네 번째 절에서는 복잡한 비즈니스 도메인을 모델링하고 관리하기 위한 접근 방식인 도메인 주도 설계(Domain-Driven Design, DDD)를 다루고, 이를 NestJS 프로젝트에 적용하는 방법을 살펴보겠습니다.
소프트웨어는 결국 현실 세계의 복잡한 비즈니스 문제를 해결하기 위해 존재합니다.
DDD는 이러한 비즈니스 도메인을 깊이 이해하고, 그 지식을 소프트웨어 모델에 반영함으로써, 복잡성을 관리하고 유연하며 확장 가능한 시스템을 구축하는 데 중점을 둡니다.
폴더를 나누는 것보다 같은 말을 쓰는 사람과 시스템의 책임 경계를 코드로 보존하는 것이 중요하다.
- Presentation · 요청과 응답
Controller가 외부 입력을 받고 DTO로 변환한다.
- Application · 사용 사례 조정
Service/Handler가 유스케이스 순서를 정한다.
- Domain · 업무 규칙
Entity, Value Object, Aggregate가 핵심 결정을 가진다.
- Infrastructure · 기술 세부사항
Repository 구현과 외부 API, DB 접근을 맡는다.
도메인 주도 설계(DDD)란?
도메인 주도 설계(DDD)는 소프트웨어 개발에서 복잡한 도메인(비즈니스 영역)의 모델링에 집중하는 접근 방식입니다.
에릭 에반스(Eric Evans)가 2003년 저서 "Domain-Driven Design: Tackling Complexity in the Heart of Software"에서 처음 제시했습니다.
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)
- 정의: 복잡한 도메인 객체(엔티티, 애그리게이트)의 생성 로직을 캡슐화하는 객체입니다.
- 중요성: 객체 생성의 복잡성을 외부에서 숨기고, 항상 유효한 상태의 객체가 생성되도록 보장합니다.
NestJS에서 DDD 적용
NestJS에서 DDD를 적용할 때 bounded context, aggregate, repository contract, 계층 책임을 나눠 봅니다.
Nest 모듈 안에서 Controller, Application Service, Domain, Repository, Infrastructure 책임을 분리해 연결한다.
- HTTP 입력
Controller 요청을 해석하고 DTO를 만든다. Application Service 흐름을 만들지만 규칙은 갖지 않는다. Domain Model 불변식과 상태 변경을 책임진다. Repository Port 도메인이 필요한 저장 기능만 약속한다. Infrastructure TypeORM, Prisma, 외부 API를 감싼다. controller application domain repository infrastructure
- HTTP 입력
Controller 요청을 해석하고 DTO를 만든다.
- 유스케이스 조정
Application Service 흐름을 만들지만 규칙은 갖지 않는다.
- 비즈니스 규칙
Domain Model 불변식과 상태 변경을 책임진다.
- 저장 계약
Repository Port 도메인이 필요한 저장 기능만 약속한다.
- 구현 세부
Infrastructure TypeORM, Prisma, 외부 API를 감싼다.
NestJS는 모듈화, DI(Dependency Injection), 계층형 아키텍처를 제공하므로 DDD의 계층을 코드 구조로 나눠 표현할 수 있습니다.
다만 NestJS 모듈 경계와 도메인 경계가 항상 일치하는 것은 아니므로 의존성 방향을 별도로 확인해야 합니다.
NestJS와 DDD 계층 매핑- 표현 계층 (Presentation Layer): 클라이언트 요청을 처리하고 응답을 반환합니다.
- NestJS:
Controllers
- NestJS:
- 애플리케이션 계층 (Application Layer): 사용자 요청을 수신하고, 도메인 계층의 객체를 사용하여 비즈니스 로직을 조정하고, 영속성 계층과 상호작용합니다. 도메인 로직 자체를 포함하기보다, 도메인 객체를 오케스트레이션합니다.
- NestJS:
Services(또는CommandHandlers,QueryHandlers- CQRS와 함께)
- NestJS:
- 도메인 계층 (Domain Layer): 비즈니스 규칙과 상태 전이를 표현하며, 도메인 모델(엔티티, 값 객체, 애그리게이트, 도메인 서비스)이 위치합니다.
- NestJS: 별도의 디렉토리(예:
src/domain)에User(엔티티),Address(값 객체),OrderAggregate(애그리게이트),OrderService(도메인 서비스) 등의 클래스로 구현.
- NestJS: 별도의 디렉토리(예:
- 인프라 계층 (Infrastructure Layer): 도메인 계층의 요구사항을 충족시키기 위한 기술적인 세부 사항(데이터베이스 접근, 외부 서비스 연동, 메시징)을 구현합니다. 도메인 계층은 인프라 계층에 의존하지 않아야 합니다(의존성 역전 원칙).
- NestJS:
Repositories구현체, 데이터베이스 모듈(TypeOrmModule,MongooseModule), 외부 API 클라이언트.
- NestJS:
바운디드 컨텍스트 정의: 가장 먼저 시스템을 논리적인 바운디드 컨텍스트로 분리합니다.
각 컨텍스트는 NestJS의 독립적인 모듈 또는 마이크로서비스가 될 수 있습니다. (예: UserModule, OrderModule, ProductModule).
- 각 모듈(컨텍스트) 내부에
domain디렉토리를 생성하고, 그 안에 엔티티, 값 객체, 애그리게이트 루트, 도메인 서비스 등을 정의합니다. - 애그리게이트 루트: 강한 일관성(Strong Consistency)이 필요한 도메인 규칙을 애그리게이트 루트 내부에 캡슐화합니다. (예:
Order엔티티 내부에addOrderItem,cancel등 비즈니스 메서드 포함). - 값 객체: 불변 객체로 정의하고, 동등성(Equality)을 값으로 판단하도록 구현합니다. (예:
Address클래스). - 도메인 서비스: 여러 애그리게이트에 걸친 비즈니스 로직을 처리합니다.
- 도메인 계층에서는 리포지토리의 인터페이스(추상 클래스) 만 정의합니다. (예:
IUserRepository). - 인프라 계층에서 이 인터페이스를 구현하는 실제 데이터베이스 리포지토리(예:
TypeOrmUserRepository)를 만듭니다. - NestJS의 DI를 사용하여 애플리케이션 계층에서 인터페이스를 주입받아 사용합니다.
import { User } from '../entities/user.entity';
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 '../infrastructure/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);
}
}NestJS 모듈에서는 providers에 인터페이스와 구현체를 연결합니다.
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 { IUserRepository } from './domain/repositories/user.repository.interface';
@Module({
imports: [TypeOrmModule.forFeature([UserEntity])],
controllers: [UserController],
providers: [
UserService,
{
provide: IUserRepository, // 인터페이스를 토큰으로 사용
useClass: TypeOrmUserRepository, // 실제 구현체
},
],
exports: [IUserRepository], // 다른 모듈에서 리포지토리 사용 시
})
export class UserModule {}애플리케이션 서비스 구현: UserService와 같은 애플리케이션 서비스는 컨트롤러의 요청을 받아 도메인 계층의 객체(User 엔티티, IUserRepository 등)를 호출하고, 트랜잭션을 관리하며, 필요한 경우 DTO(Data Transfer Object)를 도메인 객체로 변환하거나 그 반대로 변환하는 역할을 합니다.
import { Inject, Injectable } from '@nestjs/common';
import { 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(IUserRepository) 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);
}
}CQRS와 DDD의 조합: CQRS는 DDD와 매우 잘 어울립니다.
- 명령(Command) 계층: 쓰기 모델은 DDD의 애그리게이트와 리포지토리 패턴을 사용하여 비즈니스 규칙과 일관성을 엄격하게 유지합니다.
- 조회(Query) 계층: 읽기 모델은 단순하고 평탄화된 데이터 구조로, 효율적인 조회를 위해 최적화됩니다. 도메인 모델의 복잡한 비즈니스 로직을 포함하지 않습니다.
DDD 구조를 코드로 옮길 때 핵심은 "어느 계층이 결정을 내리는가"를 분명히 하는 것입니다.
컨트롤러와 애플리케이션 서비스는 흐름을 조율하고, 실제 비즈니스 규칙은 애그리게이트와 도메인 모델 안에 남겨야 합니다.
유스케이스 흐름, 도메인 판단, 저장소 구현을 같은 코드에 섞으면 변경 이유가 불분명해진다.
- Use case orchestration
Application DTO를 command로 바꾸고 트랜잭션과 호출 순서를 조정한다.
- Aggregate root
Domain 상태 변경 가능 여부와 불변식을 스스로 판단한다.
- Repository adapter
Infrastructure ORM과 DB 세부 구현은 port 뒤에 숨긴다.
| 변경 위치 | 책임 | 좋은 신호 | 나쁜 신호 |
|---|---|---|---|
| 주문 취소 | Aggregate method | 도메인 테스트만으로 검증 | Service 파일이 상태 직접 변경 |
| 저장/조회 | Repository adapter | port가 명령 행위 이름 사용 | TypeORM 타입이 domain까지 노출 |
| 트랜잭션 | Application service | use case 단위 commit/rollback | aggregate가 Nest provider 호출 |
DDD 적용 시 고려사항
- 복잡성: DDD는 복잡한 도메인에 효과적이지만, 단순한 CRUD 애플리케이션에 적용하면 불필요한 오버헤드와 복잡성만 증가시킬 수 있습니다. 프로젝트의 규모와 도메인 복잡성을 신중하게 평가해야 합니다.
- 도메인 전문가와의 협업: DDD의 성공은 개발자가 도메인 전문가와 긴밀하게 협력하여 유비쿼터스 언어를 개발하고 도메인 모델을 정교화하는 능력에 달려 있습니다.
- 점진적 적용: DDD의 모든 개념을 한 번에 적용하기보다는, 핵심적인 바운디드 컨텍스트부터 점진적으로 적용해 나가는 것이 현실적입니다.
- 학습 곡선: DDD는 기존의 레이어드 아키텍처나 CRUD 방식과는 다른 사고방식을 요구하므로, 팀원들의 학습과 적응 시간이 필요합니다.
- 이벤트 소싱: CQRS와 마찬가지로, DDD는 종종 이벤트 소싱과 함께 사용하여 비즈니스 이벤트의 변경 이력을 보존하고 읽기 모델을 재생성하는 데 활용됩니다.
요청 형식, 유스케이스 순서, 불변 규칙, 저장 기술이 서로의 결정을 침범하지 않도록 경계를 둔다.
- Presentation
HTTP 입력·응답 DTO·상태 코드 업무 규칙을 판단하지 않는다
- Application
유스케이스 순서·트랜잭션·외부 호출 조율 규칙의 세부를 소유하지 않는다
- Domain
엔티티·값 객체·애그리게이트의 불변 조건 상태 변경을 직접 판단한다
- Port
Repository·외부 서비스에 필요한 인터페이스 기술 타입을 Domain 밖에 둔다
- Adapter
ORM·DB·메시지 브로커 구현 Port 계약을 기술로 수행한다
DDD 적용 여부는 폴더 구조가 아니라 도메인 판단을 어디에 둘지로 점검할 수 있습니다.
팀의 말에서 경계를 찾고 불변 규칙과 기술 Port를 코드화한 뒤 어긋난 이름을 다시 수정한다.
- Language
도메인 전문가의 용어를 클래스와 테스트 이름으로 모은다.
- Context
같은 단어의 뜻이 달라지는 지점에서 경계를 나눈다.
- Rules
불변 조건과 상태 전이를 Entity·Aggregate에 둔다.
- Ports
Repository 인터페이스로 ORM과 외부 기술을 차단한다.
- Rename
대화와 코드의 의미가 어긋나면 모델 이름과 경계를 고친다.
도메인 주도 설계는 복잡한 비즈니스 규칙을 모델, 언어, 경계로 나누어 코드에 반영하는 접근 방식입니다.
NestJS의 견고한 아키텍처와 DI 시스템은 DDD 원칙을 적용해 응집도 높고 유지보수하기 쉬운 애플리케이션을 구축하는 기반을 제공합니다.
프로젝트 규모와 도메인 복잡성을 고려해 DDD를 적용해야 하며, 단순 CRUD 시스템에서는 모델 분리 비용이 이점보다 클 수 있습니다.
모든 기능에 계층을 늘리지 말고 독립 판단과 변화가 집중된 핵심 영역에만 도메인 모델을 적용한다.
- 아니오 · 단순 CRUD
Controller → Service → Repository 입출력과 저장이 중심이면 짧은 경로가 이해와 변경에 유리하다.
- 예 · 도메인 모델
Application → Aggregate → Repository Port Use case는 조율하고 Aggregate가 규칙을 판단하며 저장 기술은 Port 밖에 둔다.
| 신호 | 도입 가치 | 주의 |
|---|---|---|
| 규칙 변화 | 도메인 메서드로 독립 검증 | Controller에 규칙 금지 |
| 용어 충돌 | Bounded Context로 의미 분리 | 공통 모델 강요 금지 |
| 저장 교체 | Repository Port로 기술 차단 | ORM 타입 누수 금지 |
도메인 주도 설계와 유비쿼터스 언어의 관계를 정리한 보조 다이어그램입니다.
DDD에서 중요한 것은 module 이름이 아니라, 어떤 말과 규칙이 어느 책임으로 새고 있는지 찾는 일이다.
- Controller / DTO
Interface HTTP, 인증, 입력 형식을 command로 바꾼다.
- Use case / Port
Application 흐름과 트랜잭션을 조정하고 도메인 규칙 판단은 넘긴다.
- Aggregate / Event
Domain 상태 전이, 불변식, 업무 언어를 직접 가진다.
- Repository Adapter
Infrastructure Prisma, TypeORM, 외부 API 구현을 contract 뒤에 숨긴다.
| 영역 | 새는 신호 | 정리 대상 |
|---|---|---|
| 언어 ubiquitous language | command, event 이름이 업무 말을 잃음 | bounded context를 나누고 용어 통일 |
| 규칙 aggregate invariant | aggregate method가 외부 타입에 의존 | 규칙을 domain method로 event로 이동 |
| 의존성 repository contract | domain 테스트가 DB adapter 없이 어려움 | port와 mapper로 외부 shape 차단 |