모듈과 의존성 주입 — Spring의 @Component가 NestJS에서는
new UserService()를 직접 호출하지 않는데 어떻게 서비스 객체가 주입되는 걸까요? Spring에서@Autowired로 빈을 주입받던 경험이 있다면, NestJS의 DI도 같은 원리입니다. 다만 모듈 단위로 스코프가 나뉜다는 점이 다릅니다.
모듈이란
NestJS에서 모듈 은 관련된 컨트롤러, 서비스, 기타 프로바이더를 하나로 묶는 단위입니다. Spring의 @Configuration 클래스와 비슷한 역할을 합니다.
@Module({
controllers: [UserController], // 이 모듈의 라우트 핸들러
providers: [UserService], // 이 모듈의 DI 대상
exports: [UserService], // 다른 모듈에서 사용 가능하게 공개
imports: [TypeOrmModule.forFeature([User])], // 다른 모듈 가져오기
})
export class UserModule {}
| 속성 | 역할 | Spring 대응 |
|---|---|---|
controllers | HTTP 요청 처리 | @Controller 빈 스캔 |
providers | DI 컨테이너에 등록할 서비스 | @Component, @Service |
exports | 외부 모듈에 공개 | public 접근 제어 |
imports | 다른 모듈의 exports를 가져옴 | @Import |
의존성 주입 — @Injectable과 생성자 주입
Spring에서 @Service + @Autowired로 하던 것을 NestJS에서는 @Injectable() + 생성자 파라미터 로 합니다.
@Injectable()
export class UserService {
constructor(
@InjectRepository(User)
private readonly userRepository: Repository<User>,
) {}
findAll(): Promise<User[]> {
return this.userRepository.find();
}
}
NestJS의 DI 컨테이너는 이런 순서로 동작합니다.
@Module의providers에UserService가 등록됩니다.UserController의 생성자에UserService타입 파라미터가 있습니다.- NestJS가 TypeScript의 타입 메타데이터 를 읽어서
UserService인스턴스를 생성하고 주입합니다.
Spring과 다른 점은 패키지 스캔이 없다 는 것입니다. Spring은 @ComponentScan으로 패키지 전체를 뒤져서 빈을 찾지만, NestJS는 providers 배열에 명시적으로 등록 해야 합니다.
커스텀 프로바이더 — useClass, useValue, useFactory
단순히 클래스를 등록하는 것 외에, 더 세밀한 제어가 필요할 때 커스텀 프로바이더를 씁니다.
@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 대응 | 동작 |
|---|---|---|
DEFAULT | singleton | 앱 전체에서 인스턴스 1개 (기본값) |
REQUEST | request | HTTP 요청마다 새 인스턴스 |
TRANSIENT | prototype | 주입받을 때마다 새 인스턴스 |
@Injectable({ scope: Scope.REQUEST })
export class RequestScopedService {
// 각 HTTP 요청마다 새로 생성됩니다
}
스코프 전파 — 이걸 모르면 성능이 터집니다
NestJS에서 가장 조심해야 할 부분입니다. REQUEST 스코프 서비스를 주입받는 모든 상위 프로바이더도 REQUEST 스코프가 됩니다.
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 해야 합니다.
// 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() 데코레이터로 글로벌 모듈로 만들 수 있습니다.
@Global()
@Module({
providers: [ConfigService],
exports: [ConfigService],
})
export class ConfigModule {}
한번 등록하면 다른 모듈에서 imports 없이 바로 주입받을 수 있습니다. 하지만 남용하면 모듈 간 의존성이 보이지 않게 되므로, 정말 공통적인 것만 글로벌로 만들어야 합니다.
주의할 점
순환 의존성(Circular Dependency)
모듈 A가 B를 import하고, B도 A를 import하면 순환 참조가 발생합니다.
// 이렇게 하면 런타임 에러
@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 프로젝트의 구조가 왜 이렇게 되어 있는지 보이기 시작합니다. 다음 글에서는 컨트롤러와 라우팅을 자세히 다루겠습니다.