icon

안동민 개발노트

12장 : 고급 주제와 최신 트렌드

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
  • 애플리케이션 계층 (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를 사용하여 애플리케이션 계층에서 인터페이스를 주입받아 사용합니다.
src/user/domain/repositories/user.repository.interface.ts
import { User } from '../entities/user.entity';
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 '../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에 인터페이스와 구현체를 연결합니다.

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 { 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)를 도메인 객체로 변환하거나 그 반대로 변환하는 역할을 합니다.

src/user/application/user.service.ts
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를 적절히 적용하면, 장기적으로 더 성공적인 소프트웨어 시스템을 만들 수 있습니다.

목차