junyeokk
Blog
NestJS·2025. 11. 28

NestJS Dependency Injection

Dependency Injection(DI, 의존성 주입)은 NestJS의 핵심 메커니즘이다. 모든 Service, Repository, Config가 이 방식으로 연결된다. DI를 이해하지 못하면 NestJS 코드가 "어떻게 이 객체가 여기에 있는 거지?"라는 의문의 연속이 된다.

의존성 주입이란

먼저 DI가 없는 코드를 보자.

typescript
class StoresService {
  private repository: StoresRepository;

  constructor() {
    this.repository = new StoresRepository(new EntityManager(/* ... */));
  }
}

StoresServiceStoresRepository를 직접 생성한다. Repository를 만들려면 EntityManager도 필요하고, EntityManager를 만들려면 DB 연결 정보도 필요하다. 의존성이 꼬리를 물고 이어진다. 테스트할 때 실제 DB 없이는 StoresService를 만들 수조차 없다.

DI는 이 문제를 해결한다. 객체를 직접 만들지 않고, 외부에서 "주입"받는다.

typescript
@Injectable()
class StoresService {
  constructor(private readonly repository: StoresRepository) {}
}

StoresServiceStoresRepository가 필요하다는 것만 선언한다. 실제로 누가 어떻게 만들어서 전달하는지는 신경 쓰지 않는다. 그 역할을 NestJS의 DI 컨테이너가 맡는다.

DI 컨테이너의 동작 원리

NestJS 애플리케이션이 시작되면 DI 컨테이너(IoC Container)가 다음 과정을 거친다.

1단계: 등록

모듈의 providers에 등록된 클래스를 스캔한다.

typescript
@Module({
  providers: [AdminStoresService, AdminDevicesService],
})
export class AdminModule {}

컨테이너는 "이 모듈에서 AdminStoresServiceAdminDevicesService를 만들어야 한다"고 기록한다.

2단계: 의존성 분석

각 provider의 생성자 파라미터를 분석해서 무엇이 필요한지 파악한다. TypeScript의 타입 정보를 런타임에 활용하는 것인데, 이는 emitDecoratorMetadata 컴파일러 옵션 덕분에 가능하다.

typescript
@Injectable()
export class AdminStoresService {
  constructor(private readonly storesRepository: StoresRepository) {}
}

컨테이너는 생성자를 보고 "AdminStoresService를 만들려면 StoresRepository가 필요하다"고 파악한다. 이때 StoresRepository가 현재 모듈에 없으면, import된 모듈의 exports에서 찾는다.

3단계: 인스턴스 생성

의존성 그래프를 만들고, 가장 말단(아무것도 의존하지 않는 것)부터 순서대로 인스턴스를 생성한다.

text
EntityManager (MikroORM이 제공)

StoresRepository (EntityManager 주입받음)

AdminStoresService (StoresRepository 주입받음)

AdminStoresController (AdminStoresService 주입받음)

EntityManager를 먼저 만들고, 그걸 넣어서 StoresRepository를 만들고, 그걸 다시 넣어서 AdminStoresService를 만든다. 개발자가 new를 한 번도 호출하지 않아도 모든 객체가 올바른 순서로 생성된다.

@Injectable 데코레이터

provider가 되려면 클래스에 @Injectable() 데코레이터가 필요하다. 이 데코레이터가 "이 클래스는 DI 컨테이너가 관리하는 대상이다"라고 표시한다.

typescript
@Injectable()
export class StoresRepository {
  constructor(private readonly em: EntityManager) {}
}

@Injectable()이 없으면 NestJS가 이 클래스의 생성자 메타데이터를 읽을 수 없어서 의존성 주입이 실패한다. Controller는 @Controller() 데코레이터가 @Injectable()의 역할을 겸한다.

Provider 등록 방식

기본 등록 (클래스 provider)

가장 일반적인 방식이다. 클래스 이름만 넣으면 된다.

typescript
providers: [AdminStoresService]

이 코드는 사실 아래의 축약형이다.

typescript
providers: [
  {
    provide: AdminStoresService,  // 토큰 (식별자)
    useClass: AdminStoresService, // 실제 클래스
  }
]

provide는 토큰(token)이라 불리는 식별자다. 다른 클래스에서 이 provider를 주입받을 때 이 토큰을 기준으로 찾는다. 보통은 클래스 자체가 토큰 역할을 한다.

커스텀 토큰 provider

클래스가 아닌 문자열이나 심볼을 토큰으로 쓸 수도 있다.

typescript
@Module({
  providers: [
    {
      provide: APP_FILTER,
      useClass: MikroOrmExceptionFilter,
    },
  ],
})
export class AppModule {}

APP_FILTER는 NestJS가 정의한 문자열 토큰이다. NestJS 내부에서 이 토큰으로 등록된 provider를 찾아 전역 예외 필터로 사용한다. useClassMikroOrmExceptionFilter를 지정해서, MikroORM에서 발생하는 예외를 자동으로 HTTP 응답으로 변환한다.

설정 주입 (@Inject)

클래스 타입이 아닌 토큰으로 등록된 provider는 생성자의 타입 정보만으로는 찾을 수 없다. 이때 @Inject() 데코레이터로 토큰을 명시한다.

typescript
@Injectable()
export class AdminAIModelsService {
  constructor(
    private readonly aiModelsRepository: AIModelsRepository,
    @Inject(s3Config.KEY)
    private readonly s3ConfigValue: ConfigType<typeof s3Config>,
  ) {}
}

aiModelsRepository는 클래스 타입(AIModelsRepository)이 곧 토큰이므로 @Inject()가 필요 없다. 반면 s3ConfigValueConfigType<typeof s3Config>라는 타입인데, 이 타입 자체로는 어떤 provider인지 특정할 수 없다. @Inject(s3Config.KEY) 데코레이터가 "s3Config라는 설정 토큰으로 등록된 값을 주입하라"고 명시하는 것이다.

NestJS의 ConfigModule.forFeature(s3Config)가 내부적으로 s3Config.KEY를 토큰으로 하는 provider를 등록하기 때문에 이 주입이 동작한다.

Provider Scope

기본적으로 provider는 싱글톤(singleton)이다. 애플리케이션이 시작될 때 한 번 생성되고, 모든 요청에서 같은 인스턴스를 공유한다.

typescript
@Injectable()  // 기본값: Scope.DEFAULT (싱글톤)
export class StoresRepository {}

scope를 변경할 수 있다.

Scope설명사용 시점
DEFAULT싱글톤. 애플리케이션 생명주기와 동일대부분의 경우 (기본값)
REQUESTHTTP 요청마다 새 인스턴스 생성요청별 상태가 필요할 때
TRANSIENT주입받을 때마다 새 인스턴스 생성주입 대상마다 독립적인 상태가 필요할 때
typescript
@Injectable({ scope: Scope.REQUEST })
export class RequestScopedService {}

REQUEST 스코프는 요청마다 인스턴스를 생성하므로 성능에 영향을 준다. 또한 REQUEST 스코프 provider를 주입받는 모든 provider도 자동으로 REQUEST 스코프가 된다. 이 전파 효과 때문에 신중하게 사용해야 한다.

대부분의 NestJS 애플리케이션에서는 DEFAULT 스코프로 충분하다. MikroORM의 EntityManager처럼 요청별 상태가 필요한 경우는 프레임워크가 내부적으로 처리해준다.

순환 의존성

A가 B에 의존하고, B가 A에 의존하면 순환 의존성(circular dependency)이 발생한다. DI 컨테이너가 무엇을 먼저 만들어야 할지 결정할 수 없다.

typescript
// A → B, B → A: 순환 의존성
@Injectable()
class ServiceA {
  constructor(private serviceB: ServiceB) {}
}

@Injectable()
class ServiceB {
  constructor(private serviceA: ServiceA) {}
}

NestJS는 forwardRef()로 이 문제를 우회할 수 있지만, 순환 의존성 자체가 설계 문제를 나타내는 신호인 경우가 많다. 가능하면 공통 로직을 별도 Service로 분리해서 의존 방향을 단방향으로 만드는 것이 좋다.

의존성 주입의 이점

DI가 단순히 new 대신 생성자 파라미터를 쓰는 것처럼 보일 수 있지만, 실제로는 세 가지 중요한 이점이 있다.

첫째, 테스트가 쉬워진다. AdminStoresService를 테스트할 때 실제 StoresRepository 대신 가짜(mock) 객체를 주입할 수 있다. DB 연결 없이도 비즈니스 로직을 검증할 수 있다.

둘째, 결합도가 낮아진다. AdminStoresServiceStoresRepository의 인터페이스(어떤 메서드가 있는지)만 알면 되고, 그 내부 구현이 MikroORM인지 Prisma인지는 모른다. ORM을 교체해도 Service 코드를 바꿀 필요가 없다.

셋째, 싱글톤 관리가 자동화된다. 같은 StoresRepositoryAdminModuleKioskModule에서 사용할 때, 직접 싱글톤 패턴을 구현할 필요 없이 DI 컨테이너가 하나의 인스턴스를 관리한다.

왜 DI 컨테이너인가

DI 패턴 자체는 NestJS 없이도 수동으로 구현할 수 있다. 생성자에 직접 인스턴스를 넘기면 된다. 하지만 애플리케이션이 커지면 수동 DI의 한계가 드러난다.

방식장점한계
수동 DI (직접 new + 전달)프레임워크 의존 없음, 흐름이 명시적의존성 그래프가 깊어지면 진입점 코드가 비대해짐, 스코프 관리 수동
Service Locator 패턴전역 레지스트리로 간편 조회의존성이 암묵적, 테스트 시 전역 상태 오염
IoC 컨테이너 (NestJS DI)선언적 등록, 자동 의존성 해석, 스코프 내장프레임워크 종속, 런타임 에러 가능성

수동 DI는 소규모 프로젝트에서 충분하지만, provider가 수십 개를 넘기면 생성 순서와 공유 인스턴스를 직접 관리하는 비용이 커진다. NestJS의 IoC 컨테이너는 모듈 단위로 provider를 등록하고, 의존성 그래프를 자동으로 해석해서 이 복잡성을 흡수한다.

정리

  • IoC 컨테이너가 의존성 그래프를 분석하고 말단부터 순서대로 인스턴스를 생성하므로, 개발자는 필요한 타입만 선언하면 된다
  • @Injectable() + providers 등록이 기본이고, 클래스가 아닌 토큰은 @Inject()로 명시해야 한다
  • REQUEST 스코프는 주입받는 모든 상위 provider로 전파되므로, 성능 영향을 고려해서 DEFAULT 스코프를 기본으로 유지한다

관련 문서