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는 외부에서 접근할 수 없다. OrderModule이 ProductService를 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만 사용할 수 있다. ProductService나 ShippingService는 OrderServiceModule 내부 구현이다.
Express로 프로젝트가 커지면 의존성 관리가 개발자 몫이 된다고 했다. Nest.js는 DI 컨테이너와 Module 시스템으로 이 문제를 프레임워크 수준에서 해결한다. 의존성 방향을 제어하고, 모듈 경계로 캡슐화를 강제하고, Provider 패턴으로 구현체를 교체 가능하게 만든다.
참고
- Dependency Injection — DIP, IoC, DI 의 위계 — Nest.js 가 구현하는 IoC/DI 의 추상 수준과 DIP 원칙의 위계.