NestJS와 함께하는 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)
- 정의: 복잡한 도메인 객체(엔티티, 애그리게이트)의 생성 로직을 캡슐화하는 객체입니다.
- 중요성: 객체 생성의 복잡성을 외부에서 숨기고, 항상 유효한 상태의 객체가 생성되도록 보장합니다.
NestJS에서 DDD 적용
NestJS는 모듈화, DI(Dependency Injection), 계층형 아키텍처 지원 등 DDD를 적용하기에 매우 적합한 프레임워크입니다. NestJS의 계층은 DDD의 계층과 자연스럽게 매핑될 수 있습니다.
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에서 DDD 구현 가이드라인
바운디드 컨텍스트 정의: 가장 먼저 시스템을 논리적인 바운디드 컨텍스트로 분리합니다. 각 컨텍스트는 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 적용 시 고려사항
- 복잡성: DDD는 복잡한 도메인에 효과적이지만, 단순한 CRUD 애플리케이션에 적용하면 불필요한 오버헤드와 복잡성만 증가시킬 수 있습니다. 프로젝트의 규모와 도메인 복잡성을 신중하게 평가해야 합니다.
- 도메인 전문가와의 협업: DDD의 성공은 개발자가 도메인 전문가와 긴밀하게 협력하여 유비쿼터스 언어를 개발하고 도메인 모델을 정교화하는 능력에 달려 있습니다.
- 점진적 적용: DDD의 모든 개념을 한 번에 적용하기보다는, 핵심적인 바운디드 컨텍스트부터 점진적으로 적용해 나가는 것이 현실적입니다.
- 학습 곡선: DDD는 기존의 레이어드 아키텍처나 CRUD 방식과는 다른 사고방식을 요구하므로, 팀원들의 학습과 적응 시간이 필요합니다.
- 이벤트 소싱: CQRS와 마찬가지로, DDD는 종종 이벤트 소싱과 함께 사용하여 비즈니스 이벤트의 변경 이력을 보존하고 읽기 모델을 재생성하는 데 활용됩니다.
도메인 주도 설계는 단순한 코드 구조화 기법을 넘어, 소프트웨어 개발 팀이 복잡한 비즈니스 문제를 해결하고 변화에 유연하게 대응할 수 있도록 돕는 강력한 사고방식입니다. NestJS의 견고한 아키텍처와 DI 시스템은 DDD의 원칙을 적용하여 응집도 높고 유지보수하기 쉬운 애플리케이션을 구축하는 데 훌륭한 기반을 제공합니다. 프로젝트의 규모와 도메인 복잡성을 고려하여 DDD를 현명하게 적용한다면, 장기적으로 성공적인 소프트웨어 시스템을 만들 수 있을 것입니다.