Express로 프로젝트 규모가 커지면 의존성 관리가 개발자 몫이 된다. 서비스 객체를 직접 만들고, 전달하고, 생명주기를 추적해야 한다. Nest.js는 이 문제를 프레임워크 수준에서 해결한다. DI 컨테이너와 Module 시스템으로 애플리케이션에 구조를 부여한다.

IoC와 DI

Nest.js의 기반은 IoC Container와 Dependency Injection이다. @Injectable() 데코레이터로 클래스를 Provider로 등록하면, 다른 클래스의 생성자에서 그 타입을 선언하는 것만으로 인스턴스가 자동으로 주입된다.

@Injectable()
class OrderService {
  constructor(
    private readonly productService: ProductService,
    private readonly paymentService: PaymentService,
  ) {}
}

개발자가 new로 인스턴스를 만들지 않는다. IoC Container가 등록된 Provider에서 타입을 찾아 생성자에 건넨다. DIP, IoC, DI의 위계 자체는 dependency-injection 글에서 다뤘다.

Module 시스템

Nest.js 애플리케이션은 Module 단위로 구성된다. @Module 데코레이터가 네 가지 메타데이터를 받는다.

  • imports: 이 모듈이 의존하는 다른 모듈
  • providers: 이 모듈이 제공하는 서비스, 리포지토리 등
  • controllers: HTTP 요청을 처리하는 컨트롤러
  • exports: 다른 모듈에 노출할 Provider
@Module({
  imports: [TypeOrmModule.forFeature([ProductEntity])],
  providers: [OrderService, ProductService],
  controllers: [OrderController],
  exports: [OrderService],
})
class OrderModule {}

Module 경계가 캡슐화를 강제한다. 이 경계가 없으면 프로젝트 전체가 하나의 의존성 그래프로 엉킨다. exports에 포함되지 않은 Provider는 외부에서 접근할 수 없다. OrderModuleProductService를 export하지 않으면, 다른 모듈은 이를 직접 사용할 수 없다. OrderService만 외부에 노출된다.

Global Module

모든 모듈에서 공통으로 사용하는 Provider가 있다. 로거, 설정 관리, 에러 추적 같은 것들이다. @Global 데코레이터를 붙이면 한 번 등록으로 전체 애플리케이션에서 접근 가능하다.

@Global()
@Module({
  providers: [Logger, ConfigStore],
  exports: [Logger, ConfigStore],
})
class SharedModule {}

Dynamic Module

설정에 따라 동작이 달라지는 모듈이 있다. TypeORM의 DB 연결이 대표적이다.

TypeOrmModule.forRoot({
  type: "mysql",
  host: "localhost",
});

forRoot는 루트 모듈에서 한 번 호출하여 전역 설정을 등록한다. forFeature는 특정 엔티티를 사용할 모듈에서 호출한다. 같은 모듈 클래스가 다양한 설정으로 재사용된다.

Provider

Provider는 Nest.js에서 의존성으로 주입할 수 있는 모든 것이다. 서비스, 리포지토리, 팩토리 등이 해당한다. @Injectable() 데코레이터를 붙이면 IoC 컨테이너에 등록된다. 다양한 등록 방식을 통해 구현체를 유연하게 교체할 수 있다.

등록 방식

Provider를 Module에 등록하는 방법은 네 가지다.

useClass: 클래스 자체를 Provider로 등록한다.

{ provide: OrderRepository, useClass: OrderRepositoryImpl }

useFactory: 팩토리 함수로 Provider를 생성한다. 다른 Provider를 주입받아 동적으로 인스턴스를 만들 수 있다.

{
  provide: CACHE_CLIENT,
  useFactory: (config: ConfigStore) => CacheClient.from(config),
  inject: [ConfigStore],
}

useValue: 이미 생성된 값을 Provider로 등록한다.

useExisting: 기존 Provider에 대한 별칭을 만든다.

Custom Provider와 Symbol 토큰

TypeScript에서 인터페이스는 런타임에 존재하지 않는다. DI 토큰으로 사용할 수 없다. 이 문제를 Symbol로 해결한다.

const ORDER_REPOSITORY = Symbol("ORDER_REPOSITORY");

interface OrderRepository {
  fetchById(orderId: string): Promise<Order | null>;
}

모듈에서 Symbol을 토큰으로, 구현체를 Provider로 등록한다.

{ provide: ORDER_REPOSITORY, useClass: OrderRepositoryImpl }

사용하는 쪽에서는 @Inject 데코레이터로 토큰을 지정한다.

constructor(@Inject(ORDER_REPOSITORY) private readonly repo: OrderRepository) {}

이 패턴은 DIP(의존성 역전 원칙)를 구현하는 핵심 도구다. 비즈니스 로직이 인터페이스에만 의존하고, 구현체는 Module 설정에서 교체할 수 있다.

Scope

Provider의 생명주기를 제어한다.

  • DEFAULT: 싱글톤. 애플리케이션 전체에서 하나의 인스턴스를 공유한다.
  • REQUEST: 요청마다 새 인스턴스를 생성한다.
  • TRANSIENT: 주입할 때마다 새 인스턴스를 생성한다.

대부분의 경우 DEFAULT(싱글톤)가 적합하다. REQUEST 스코프는 요청별 상태가 필요한 경우에만 사용한다.

Controller와 요청 파이프라인

Controller는 HTTP 요청을 받아 적절한 서비스에 위임한다. 라우팅과 HTTP 관심사만 담당한다.

@Controller("/orders")
class OrderController {
  constructor(private readonly orderService: OrderService) {}

  @Post()
  public async create(@Body() request: CreateOrderDto): Promise<OrderDto> {
    return this.orderService.create(request);
  }
}

Nest.js는 요청 처리 과정에 개입할 수 있는 네 가지 도구를 제공한다.

  • Guard: 인증/인가 검사. 요청이 Controller에 도달하기 전에 실행된다.
  • Interceptor: 요청/응답 변환, 로깅, 메트릭 수집. AOP 패턴으로 교차 관심사를 분리한다.
  • Pipe: 입력 데이터 변환과 유효성 검증.
  • Filter: 예외 처리. 발생한 에러를 적절한 HTTP 응답으로 변환한다.

전역으로 등록하면 모든 요청에 일괄 적용된다.

@Module({
  providers: [
    { provide: APP_INTERCEPTOR, useClass: MetricInterceptor },
    { provide: APP_FILTER, useClass: DefaultExceptionFilter },
  ],
})
class AppModule {}

실전 적용 패턴

리포지토리 패턴과 DIP

앞에서 본 Symbol 토큰 패턴을 실제 데이터 접근 계층에 적용하면 리포지토리 패턴이 된다. 데이터 접근을 추상화한다. 비즈니스 로직 계층에 인터페이스를 정의하고, 인프라 계층에서 구현체를 제공한다.

// 인터페이스 (비즈니스 계층)
const PRODUCT_REPOSITORY = Symbol("PRODUCT_REPOSITORY");

interface ProductRepository {
  fetchById(productId: string): Promise<Product | null>;
}

// 구현체 (인프라 계층)
@Injectable()
class ProductRepositoryImpl implements ProductRepository {
  constructor(
    @InjectRepository(ProductEntity)
    private readonly repository: Repository<ProductEntity>,
  ) {}

  public async fetchById(productId: string): Promise<Product | null> {
    return this.repository.findOneBy({ id: productId });
  }
}

Module에서 토큰과 구현체를 연결한다.

{ provide: PRODUCT_REPOSITORY, useClass: ProductRepositoryImpl }

DB를 교체하거나 테스트에서 mock을 주입할 때 Module 설정만 바꾸면 된다. 비즈니스 로직은 변경할 필요가 없다.

Module 경계를 통한 캡슐화

Module의 exports로 외부에 Facade만 노출한다. 내부 구현은 감춘다.

@Module({
  providers: [OrderService, ProductService, ShippingService],
  exports: [OrderService],
})
class OrderServiceModule {}

다른 모듈은 OrderService만 사용할 수 있다. ProductServiceShippingServiceOrderServiceModule 내부 구현이다.

Express로 프로젝트가 커지면 의존성 관리가 개발자 몫이 된다고 했다. Nest.js는 DI 컨테이너와 Module 시스템으로 이 문제를 프레임워크 수준에서 해결한다. 의존성 방향을 제어하고, 모듈 경계로 캡슐화를 강제하고, Provider 패턴으로 구현체를 교체 가능하게 만든다.

참고