NestJS와 함께하는 DDD (도메인 주도 설계)
도메인 주도 설계(Domain-Driven Design, DDD)는 복잡한 소프트웨어 시스템을 개발할 때 도메인 전문가와 개발자 간의 공통 언어를 사용하여 핵심 도메인과 도메인 로직에 중점을 두는 소프트웨어 설계 접근 방식입니다.
NestJS의 모듈화된 구조와 의존성 주입 시스템은 DDD 원칙을 적용하기에 매우 적합합니다.
DDD의 핵심 개념과 NestJS 적용 이점
- 유비쿼터스 언어 : NestJS의 명확한 구조로 도메인 개념을 코드로 표현
- 바운디드 컨텍스트 : NestJS 모듈을 활용한 명확한 경계 설정
- 엔티티와 값 객체 : TypeScript의 강력한 타입 시스템 활용
- 애그리게이트 : NestJS 서비스를 통한 일관성 있는 비즈니스 로직 구현
- 도메인 이벤트 : NestJS의 이벤트 시스템과 통합
바운디드 컨텍스트 구현
NestJS 모듈을 사용한 바운디드 컨텍스트 구현
// user-management.module.ts
@Module({
imports: [TypeOrmModule.forFeature([User])],
providers: [UserService, UserRepository],
exports: [UserService],
})
export class UserManagementModule {}
// order-processing.module.ts
@Module({
imports: [TypeOrmModule.forFeature([Order]), UserManagementModule],
providers: [OrderService, OrderRepository],
})
export class OrderProcessingModule {}
DDD 빌딩 블록 구현
- 엔티티
@Entity()
export class User {
@PrimaryGeneratedColumn('uuid')
id: string;
@Column()
name: string;
@Column()
email: string;
changeEmail(newEmail: string) {
// 이메일 변경 비즈니스 로직
this.email = newEmail;
}
}
- 값 객체
export class Address {
constructor(
public readonly street: string,
public readonly city: string,
public readonly zipCode: string
) {}
equals(other: Address): boolean {
return this.street === other.street &&
this.city === other.city &&
this.zipCode === other.zipCode;
}
}
- 애그리게이트
@Entity()
export class Order {
@PrimaryGeneratedColumn('uuid')
id: string;
@Column(() => Address)
shippingAddress: Address;
@OneToMany(() => OrderItem, item => item.order)
items: OrderItem[];
addItem(product: Product, quantity: number) {
const item = new OrderItem(this, product, quantity);
this.items.push(item);
}
calculateTotal(): number {
return this.items.reduce((total, item) => total + item.subtotal(), 0);
}
}
도메인 이벤트 구현
NestJS 이벤트 시스템을 활용한 도메인 이벤트 구현
export class OrderPlacedEvent {
constructor(public readonly orderId: string) {}
}
@Injectable()
export class OrderService {
constructor(private eventEmitter: EventEmitter2) {}
placeOrder(order: Order) {
// 주문 처리 로직
this.eventEmitter.emit('order.placed', new OrderPlacedEvent(order.id));
}
}
@Injectable()
export class OrderPlacedHandler {
@OnEvent('order.placed')
handleOrderPlaced(event: OrderPlacedEvent) {
// 주문 완료 후 처리 로직
}
}
리포지토리 패턴 구현
NestJS와 TypeORM을 활용한 리포지토리 구현
@Injectable()
export class UserRepository {
constructor(
@InjectRepository(User)
private userRepository: Repository<User>,
) {}
async findById(id: string): Promise<User | undefined> {
return this.userRepository.findOne(id);
}
async save(user: User): Promise<User> {
return this.userRepository.save(user);
}
}
전략적 설계와 전술적 설계
전략적 설계
- 컨텍스트 맵 작성 : 각 바운디드 컨텍스트 간의 관계 정의
- 하위 도메인 식별 : 핵심, 지원, 일반 하위 도메인 구분
전술적 설계
- 애그리게이트 설계 : 일관성 경계 정의
- 도메인 서비스 구현 : 복잡한 비즈니스 로직 처리
@Injectable()
export class OrderDomainService {
placeOrder(user: User, items: OrderItem[], shippingAddress: Address): Order {
const order = new Order(user, shippingAddress);
items.forEach(item => order.addItem(item.product, item.quantity));
if (!this.validateOrder(order)) {
throw new Error('Invalid order');
}
return order;
}
private validateOrder(order: Order): boolean {
// 주문 유효성 검사 로직
}
}
CQRS와 DDD 결합
CQRS 패턴을 DDD와 결합하여 구현
export class CreateOrderCommand {
constructor(
public readonly userId: string,
public readonly items: { productId: string; quantity: number }[],
public readonly shippingAddress: Address
) {}
}
@CommandHandler(CreateOrderCommand)
export class CreateOrderHandler {
constructor(
private userRepository: UserRepository,
private orderDomainService: OrderDomainService,
private orderRepository: OrderRepository
) {}
async execute(command: CreateOrderCommand) {
const user = await this.userRepository.findById(command.userId);
const order = this.orderDomainService.placeOrder(user, command.items, command.shippingAddress);
await this.orderRepository.save(order);
}
}
DDD 기반 NestJS 애플리케이션 테스트
단위 테스트
describe('OrderDomainService', () => {
it('should create a valid order', () => {
const service = new OrderDomainService();
const user = new User('John Doe', '[email protected]');
const items = [
{ product: new Product('Product 1', 10), quantity: 2 },
{ product: new Product('Product 2', 15), quantity: 1 },
];
const address = new Address('123 Main St', 'City', '12345');
const order = service.placeOrder(user, items, address);
expect(order.user).toBe(user);
expect(order.items.length).toBe(2);
expect(order.calculateTotal()).toBe(35);
});
});
통합 테스트
describe('Order Module Integration', () => {
let app: INestApplication;
beforeAll(async () => {
const moduleFixture: TestingModule = await Test.createTestingModule({
imports: [OrderModule],
}).compile();
app = moduleFixture.createNestApplication();
await app.init();
});
it('should place an order', async () => {
const response = await request(app.getHttpServer())
.post('/orders')
.send({
userId: 'user-id',
items: [{ productId: 'product-id', quantity: 2 }],
shippingAddress: { street: '123 Main St', city: 'City', zipCode: '12345' },
});
expect(response.status).toBe(201);
expect(response.body).toHaveProperty('id');
});
});
확장성 전략과 성능 최적화
- 마이크로서비스 아키텍처 채택 : 각 바운디드 컨텍스트를 독립적인 서비스로 구현
- 캐싱 전략 구현 : Redis를 활용한 애그리게이트 캐싱
- 비동기 처리 : 이벤트 기반 아키텍처를 통한 시스템 결합도 감소
- 데이터베이스 최적화 : 읽기/쓰기 분리, 인덱싱 전략 수립
Best Practices 및 주의사항
- 도메인 전문가와의 지속적인 협업
- 유비쿼터스 언어 사용 : 코드와 문서에 일관된 용어 사용
- 작은 단위로 시작 : 핵심 도메인부터 DDD 적용
- 지속적인 리팩토링 : 도메인 모델 개선
- 컨텍스트 간 명확한 경계 설정
- 애그리게이트 크기 최소화 : 성능과 확장성 고려
- 도메인 이벤트 활용 : 느슨한 결합 구현
- 풍부한 도메인 모델 구현 : 빈약한 도메인 모델 지양
- 테스트 주도 개발(TDD) 적용
- 성능과 복잡성 균형 : 과도한 추상화 주의
NestJS와 DDD를 결합하면 복잡한 비즈니스 로직을 가진 대규모 애플리케이션을 효과적으로 구축할 수 있습니다.
NestJS의 모듈화된 구조는 DDD의 바운디드 컨텍스트 개념과 자연스럽게 어울리며, 의존성 주입 시스템은 도메인 서비스와 리포지토리의 구현을 용이하게 합니다.
DDD의 핵심 빌딩 블록인 엔티티, 값 객체, 애그리게이트 등을 TypeScript를 활용하여 명확하게 표현할 수 있으며 NestJS의 이벤트 시스템은 도메인 이벤트를 효과적으로 구현하고 처리할 수 있게 해줍니다.
CQRS 패턴과 DDD를 결합하면 복잡한 도메인 로직과 쿼리 성능 최적화를 동시에 달성할 수 있습니다.
NestJS의 CQRS 모듈은 이러한 패턴을 쉽게 구현할 수 있게 해줍니다.