new UserService()를 직접 호출하지 않는데 어떻게 서비스 객체가 주입되는 걸까요? Spring에서 @Autowired로 빈을 주입받던 경험이 있다면, NestJS의 DI도 같은 원리입니다. 다만 모듈 단위로 스코프가 나뉜다는 점이 다릅니다.


모듈이란

NestJS에서 모듈 은 관련된 컨트롤러, 서비스, 기타 프로바이더를 하나로 묶는 단위입니다. Spring의 @Configuration 클래스와 비슷한 역할을 합니다.

TYPESCRIPT
@Module({
  controllers: [UserController],     // 이 모듈의 라우트 핸들러
  providers: [UserService],          // 이 모듈의 DI 대상
  exports: [UserService],            // 다른 모듈에서 사용 가능하게 공개
  imports: [TypeOrmModule.forFeature([User])], // 다른 모듈 가져오기
})
export class UserModule {}
속성역할Spring 대응
controllersHTTP 요청 처리@Controller 빈 스캔
providersDI 컨테이너에 등록할 서비스@Component, @Service
exports외부 모듈에 공개public 접근 제어
imports다른 모듈의 exports를 가져옴@Import

의존성 주입 — @Injectable과 생성자 주입

Spring에서 @Service + @Autowired로 하던 것을 NestJS에서는 @Injectable() + 생성자 파라미터 로 합니다.

TYPESCRIPT
@Injectable()
export class UserService {
  constructor(
    @InjectRepository(User)
    private readonly userRepository: Repository<User>,
  ) {}

  findAll(): Promise<User[]> {
    return this.userRepository.find();
  }
}

NestJS의 DI 컨테이너는 이런 순서로 동작합니다.

  1. @ModuleprovidersUserService가 등록됩니다.
  2. UserController의 생성자에 UserService 타입 파라미터가 있습니다.
  3. NestJS가 TypeScript의 타입 메타데이터 를 읽어서 UserService 인스턴스를 생성하고 주입합니다.

Spring과 다른 점은 패키지 스캔이 없다 는 것입니다. Spring은 @ComponentScan으로 패키지 전체를 뒤져서 빈을 찾지만, NestJS는 providers 배열에 명시적으로 등록 해야 합니다.


커스텀 프로바이더 — useClass, useValue, useFactory

단순히 클래스를 등록하는 것 외에, 더 세밀한 제어가 필요할 때 커스텀 프로바이더를 씁니다.

TYPESCRIPT
@Module({
  providers: [
    // 1. 기본 — 클래스 자체를 프로바이더로
    UserService,

    // 2. useClass — 인터페이스에 구현체 매핑 (전략 패턴)
    {
      provide: 'PAYMENT_SERVICE',
      useClass: process.env.NODE_ENV === 'test'
        ? MockPaymentService
        : StripePaymentService,
    },

    // 3. useValue — 상수, 설정값 주입
    {
      provide: 'API_KEY',
      useValue: process.env.API_KEY,
    },

    // 4. useFactory — 비동기 초기화, 다른 의존성 필요 시
    {
      provide: 'DATABASE_CONNECTION',
      useFactory: async (configService: ConfigService) => {
        return createConnection(configService.get('DB_URL'));
      },
      inject: [ConfigService],
    },
  ],
})
export class AppModule {}

Spring의 @Bean 메서드에서 조건부로 빈을 생성하는 것과 같은 패턴입니다. 특히 useFactory는 비동기 초기화가 가능해서 DB 연결 같은 작업에 유용합니다.


DI 스코프 — DEFAULT, REQUEST, TRANSIENT

Spring의 빈 스코프(singleton, prototype, request)에 대응하는 개념입니다.

NestJS 스코프Spring 대응동작
DEFAULTsingleton앱 전체에서 인스턴스 1개 (기본값)
REQUESTrequestHTTP 요청마다 새 인스턴스
TRANSIENTprototype주입받을 때마다 새 인스턴스
TYPESCRIPT
@Injectable({ scope: Scope.REQUEST })
export class RequestScopedService {
  // 각 HTTP 요청마다 새로 생성됩니다
}

스코프 전파 — 이걸 모르면 성능이 터집니다

NestJS에서 가장 조심해야 할 부분입니다. REQUEST 스코프 서비스를 주입받는 모든 상위 프로바이더도 REQUEST 스코프가 됩니다.

PLAINTEXT
UserController (DEFAULT)
  └── UserService (DEFAULT)
        └── LoggingService (REQUEST)  ← REQUEST 스코프

→ LoggingService가 REQUEST이므로
→ UserService도 REQUEST로 전파
→ UserController도 REQUEST로 전파
→ 모든 요청마다 Controller, Service가 새로 생성됨!

Spring에서는 프록시 기반 request 스코프 빈이 싱글턴에 주입될 수 있지만, NestJS는 ** 프록시 없이 직접 전파 **합니다. REQUEST 스코프 하나를 잘못 넣으면 의존 체인 전체가 요청마다 재생성되어 ** 성능이 크게 저하 **됩니다.


모듈 간 의존성 — imports와 exports

모듈 A의 서비스를 모듈 B에서 쓰려면, A가 exports 하고 B가 imports 해야 합니다.

TYPESCRIPT
// auth.module.ts
@Module({
  providers: [AuthService],
  exports: [AuthService],        // 외부 공개
})
export class AuthModule {}

// user.module.ts
@Module({
  imports: [AuthModule],         // AuthModule의 exports를 가져옴
  controllers: [UserController],
  providers: [UserService],      // UserService에서 AuthService 주입 가능
})
export class UserModule {}

exports에 넣지 않은 프로바이더는 모듈 외부에서 접근할 수 없습니다. 이것은 Spring의 패키지 스캔과 근본적으로 다른 점입니다. NestJS는 명시적 공개 가 원칙입니다.

글로벌 모듈

모든 모듈에서 사용하는 공통 서비스(ConfigService, LoggerService 등)는 @Global() 데코레이터로 글로벌 모듈로 만들 수 있습니다.

TYPESCRIPT
@Global()
@Module({
  providers: [ConfigService],
  exports: [ConfigService],
})
export class ConfigModule {}

한번 등록하면 다른 모듈에서 imports 없이 바로 주입받을 수 있습니다. 하지만 남용하면 모듈 간 의존성이 보이지 않게 되므로, 정말 공통적인 것만 글로벌로 만들어야 합니다.


주의할 점

순환 의존성(Circular Dependency)

모듈 A가 B를 import하고, B도 A를 import하면 순환 참조가 발생합니다.

TYPESCRIPT
// 이렇게 하면 런타임 에러
@Module({ imports: [ModuleB] })
export class ModuleA {}

@Module({ imports: [ModuleA] })
export class ModuleB {}

forwardRef()로 해결할 수 있지만, 순환 의존성 자체가 설계 문제의 신호입니다. 공통 로직을 별도 모듈로 분리 하는 것이 근본적인 해결책입니다.


정리

포인트내용
모듈관련 컨트롤러 + 서비스를 묶는 단위, @Module로 선언
DI@Injectable() + 생성자 주입, providers에 명시적 등록
스코프DEFAULT(싱글턴), REQUEST(요청별), TRANSIENT(주입별)
핵심 함정REQUEST 스코프가 의존 체인 전체로 전파되어 성능 저하
exports/imports모듈 간 의존성은 명시적 공개가 원칙

모듈과 DI를 이해하면 NestJS 프로젝트의 구조가 왜 이렇게 되어 있는지 보이기 시작합니다. 다음 글에서는 컨트롤러와 라우팅을 자세히 다루겠습니다.

댓글 로딩 중...