@Module()은 어떻게 앱을 조립하는가 — NestJS 소스코드 추적 (4편)
들어가며
3편에서 DI 컨테이너가 의존성을 해결하는 과정을 봤습니다. Injector가 프로바이더를 찾을 때 "현재 모듈에서 먼저 찾고, 없으면 import된 모듈에서 찾는다"고 했습니다. 그런데 실제 NestJS 앱은 하나의 거대한 provider 목록이 아니라, @Module()로 나뉜 여러 모듈의 조합입니다. imports, exports, providers, controllers — 이 네 개의 배열이 어떻게 앱을 조립하는지 추적해보겠습니다.
이번 글에서 추적할 핵심 소스 파일은 세 개입니다.
packages/common/decorators/modules/module.decorator.ts — @Module() 데코레이터
packages/core/scanner.ts — DependenciesScanner
packages/core/injector/module.ts — Module 내부 클래스
모듈이 필요한 이유 — 모든 provider를 한 곳에 넣으면?
만약 모듈 시스템이 없다면 어떤 일이 벌어질까요?
// 모듈 없이 모든 프로바이더를 한 곳에
@Module({
providers: [
UserService, UserRepository,
AuthService, JwtService, BcryptService,
PostService, PostRepository,
CommentService, CommentRepository,
EmailService, TemplateService,
CacheService, RedisService,
LoggerService, MetricsService,
// ... 50개, 100개, 200개 ...
],
controllers: [
UserController, AuthController, PostController,
CommentController, AdminController,
// ...
],
})
export class AppModule {}
세 가지 문제가 생깁니다.
첫째, 모든 프로바이더가 서로를 주입할 수 있습니다. CommentService가 EmailService를 직접 주입받을 수 있고, MetricsService가 UserRepository를 직접 접근할 수 있습니다. 경계가 없으면 의존성이 스파게티처럼 얽힙니다.
둘째, 어떤 서비스가 어떤 서비스에 의존하는지 파악하기 어렵습니다. 100개의 프로바이더가 있으면 이론적으로 100 x 99 = 9,900가지 의존 관계가 가능합니다.
셋째, 재사용이 어렵습니다. EmailService와 TemplateService를 다른 프로젝트에서 쓰고 싶어도, 전체 프로바이더 목록에서 필요한 것들만 분리해내기가 까다롭습니다.
모듈은 이 문제를 "경계"로 해결합니다. 각 모듈은 자신의 프로바이더를 캡슐화하고, 외부에 공개할 것만 exports로 명시합니다.
@Module() 데코레이터 소스코드
packages/common/decorators/modules/module.decorator.ts를 보겠습니다.
// packages/common/decorators/modules/module.decorator.ts (실제 소스)
export function Module(metadata: ModuleMetadata): ClassDecorator {
const propsKeys = Object.keys(metadata);
validateModuleKeys(propsKeys);
return (target: Function) => {
for (const property in metadata) {
if (Object.hasOwnProperty.call(metadata, property)) {
Reflect.defineMetadata(property, (metadata as any)[property], target);
}
}
};
}
놀라울 정도로 단순합니다. 2편에서 본 @Injectable()처럼, @Module()도 결국 Reflect.defineMetadata를 호출할 뿐입니다.
구체적으로 어떤 키로 저장되는지 보겠습니다.
// packages/common/constants.ts
export const MODULE_METADATA = {
IMPORTS: 'imports',
PROVIDERS: 'providers',
CONTROLLERS: 'controllers',
EXPORTS: 'exports',
};
메타데이터 키가 문자열 그 자체입니다. 그래서 아래 코드는
@Module({
imports: [AuthModule],
providers: [PostService, PostRepository],
controllers: [PostController],
exports: [PostService],
})
export class PostModule {}
내부적으로 이렇게 변환됩니다.
Reflect.defineMetadata('imports', [AuthModule], PostModule)
Reflect.defineMetadata('providers', [PostService, PostRepository], PostModule)
Reflect.defineMetadata('controllers', [PostController], PostModule)
Reflect.defineMetadata('exports', [PostService], PostModule)
validateModuleKeys()는 허용된 네 개의 키(imports, providers, controllers, exports) 외의 키가 들어오면 경고를 출력합니다. 오타를 방지하기 위한 안전장치입니다.
@Module() 자체는 메타데이터를 저장하기만 합니다. "앱을 조립하는" 실제 작업은 DependenciesScanner가 담당합니다.
DependenciesScanner — 모듈 그래프 구축
packages/core/scanner.ts의 DependenciesScanner는 @Module()에 저장된 메타데이터를 읽어서 전체 앱의 모듈 그래프를 구축합니다. 1편에서 NestFactory.create() 내부에서 호출되었던 그 클래스입니다.
scan() — 전체 스캐닝의 진입점
// packages/core/scanner.ts (간략화)
export class DependenciesScanner {
constructor(
private readonly container: NestContainer,
private readonly metadataScanner: MetadataScanner,
private readonly applicationConfig: ApplicationConfig,
) {}
public async scan(
module: Type<any>,
options?: { overrides?: ModuleOverride[] },
) {
// 1단계: 모듈 트리 구축
await this.scanForModules({
moduleDefinition: module, // AppModule
overrides: options?.overrides,
});
// 2단계: 각 모듈의 의존성 수집
await this.scanModulesForDependencies();
// 3단계: 글로벌 모듈 바인딩
this.container.bindGlobalScope();
}
}
세 단계로 나뉩니다. 하나씩 추적해보겠습니다.
1단계: scanForModules() — 재귀적 모듈 탐색
// packages/core/scanner.ts (간략화)
public async scanForModules({
moduleDefinition,
scope = [],
ctxRegistry = [],
overrides = [],
}: ModulesScanParameters): Promise<Module[]> {
// 현재 모듈을 컨테이너에 등록
const [moduleRef] = await this.container.addModule(moduleDefinition, scope);
// 이미 처리된 모듈이면 스킵 (순환 방지)
if (ctxRegistry.includes(moduleDefinition)) {
return [moduleRef];
}
ctxRegistry.push(moduleDefinition);
// imports 배열에서 하위 모듈 추출
const modules = this.isDynamicModule(moduleDefinition)
? [
...this.reflectMetadata(MODULE_METADATA.IMPORTS, moduleDefinition.module),
...(moduleDefinition.imports || []),
]
: this.reflectMetadata(MODULE_METADATA.IMPORTS, moduleDefinition);
// 각 하위 모듈에 대해 재귀 호출
for (const innerModule of modules) {
await this.scanForModules({
moduleDefinition: innerModule,
scope: [...scope, moduleDefinition],
ctxRegistry,
overrides,
});
}
return [moduleRef];
}
핵심은 재귀입니다. AppModule의 imports를 읽고, 각 import된 모듈의 imports를 또 읽고, 그 안의 imports를 또 읽습니다. 트리의 말단(imports가 빈 모듈)에 도달할 때까지 계속합니다.
ctxRegistry는 이미 방문한 모듈의 목록입니다. 같은 모듈을 여러 곳에서 import하더라도 한 번만 스캔합니다. 순환 import를 방지하는 역할도 합니다.
이 과정을 다이어그램으로 그려보겠습니다.
scanForModules(AppModule)
│
├── container.addModule(AppModule)
├── imports 읽기 → [AuthModule, PostModule, SharedModule]
│
├── scanForModules(AuthModule)
│ ├── container.addModule(AuthModule)
│ ├── imports 읽기 → [JwtModule, SharedModule]
│ │
│ ├── scanForModules(JwtModule)
│ │ ├── container.addModule(JwtModule)
│ │ └── imports 읽기 → [] (말단)
│ │
│ └── scanForModules(SharedModule)
│ ├── container.addModule(SharedModule)
│ └── imports 읽기 → [] (말단)
│
├── scanForModules(PostModule)
│ ├── container.addModule(PostModule)
│ ├── imports 읽기 → [AuthModule, SharedModule]
│ │
│ ├── scanForModules(AuthModule) → 이미 ctxRegistry에 있음 → 스킵
│ └── scanForModules(SharedModule) → 이미 ctxRegistry에 있음 → 스킵
│
└── scanForModules(SharedModule) → 이미 ctxRegistry에 있음 → 스킵
결과적으로 NestContainer.modules에는 다섯 개의 모듈이 등록됩니다: AppModule, AuthModule, PostModule, SharedModule, JwtModule.
2단계: scanModulesForDependencies() — 의존성 수집
모듈 트리가 완성되면, 각 모듈의 providers, controllers, exports를 수집합니다.
// packages/core/scanner.ts (간략화)
public async scanModulesForDependencies(
modules: Map<string, Module> = this.container.getModules(),
) {
for (const [token, { metatype }] of modules) {
// 각 모듈에 대해 네 가지 리플렉션
await this.reflectImports(metatype, token, metatype.name);
this.reflectProviders(metatype, token);
this.reflectControllers(metatype, token);
this.reflectExports(metatype, token);
}
}
각 reflect 메서드가 무엇을 하는지 보겠습니다.
reflectProviders() — 프로바이더 등록
// packages/core/scanner.ts (간략화)
public reflectProviders(module: Type<any>, token: string) {
// Reflect.getMetadata('providers', module) → [PostService, PostRepository, ...]
const providers = this.reflectMetadata(MODULE_METADATA.PROVIDERS, module);
providers.forEach((provider) => {
this.container.addProvider(provider, token);
// → NestContainer가 해당 모듈의 _providers Map에 등록
});
}
Reflect.getMetadata('providers', PostModule)을 호출하면, 앞서 @Module()에서 저장했던 [PostService, PostRepository]가 반환됩니다. 이것을 하나씩 컨테이너에 등록합니다.
reflectControllers() — 컨트롤러 등록
// packages/core/scanner.ts (간략화)
public reflectControllers(module: Type<any>, token: string) {
const controllers = this.reflectMetadata(MODULE_METADATA.CONTROLLERS, module);
controllers.forEach((controller) => {
this.container.addController(controller, token);
});
}
프로바이더와 동일한 패턴입니다. 메타데이터를 읽고, 컨테이너에 등록합니다.
reflectExports() — export 대상 마킹
// packages/core/scanner.ts (간략화)
public reflectExports(module: Type<any>, token: string) {
const exports = this.reflectMetadata(MODULE_METADATA.EXPORTS, module);
exports.forEach((exportedProvider) => {
this.container.addExportedProviderOrModule(exportedProvider, token);
});
}
exports도 같은 패턴이지만, addExportedProviderOrModule에서 중요한 검증이 일어납니다.
// packages/core/injector/module.ts (간략화)
public addExportedProviderOrModule(provider: InjectionToken) {
// 이 토큰이 현재 모듈의 providers에 있는지, 또는 imports에 있는지 확인
if (!this._providers.has(provider) && !this._imports.has(provider as any)) {
throw new UnknownExportException(provider, this.metatype.name);
// "AuthModule tries to export a provider that is neither
// a provider nor a module imported by this module"
}
this._exports.add(provider);
}
자기 모듈에 없는 프로바이더를 export하려 하면 예외가 발생합니다. 있지도 않은 것을 외부에 공개할 수는 없으니까요.
모듈 그래프 시각화
실제 NestJS 앱에서 흔히 볼 수 있는 모듈 구조를 그려보겠습니다.
AppModule
├── imports: [AuthModule, PostModule, SharedModule]
├── providers: [AppService]
└── controllers: [AppController]
AuthModule
├── imports: [JwtModule, SharedModule]
├── providers: [AuthService, AuthGuard]
├── controllers: [AuthController]
└── exports: [AuthService, AuthGuard] ← 외부에서 사용 가능
PostModule
├── imports: [AuthModule, SharedModule]
├── providers: [PostService, PostRepository]
├── controllers: [PostController]
└── exports: [PostService]
SharedModule
├── providers: [LoggerService, CacheService]
└── exports: [LoggerService, CacheService]
JwtModule (외부 패키지 - @nestjs/jwt)
├── providers: [JwtService]
└── exports: [JwtService]
이것을 의존성 그래프로 그리면:
┌─────────────┐
│ AppModule │
└─────┬───────┘
┌────────┼────────┐
▼ ▼ ▼
┌──────────┐ ┌──────────┐ ┌──────────────┐
│AuthModule│ │PostModule│ │ SharedModule │
└────┬─────┘ └────┬─────┘ └──────────────┘
│ │ ▲ ▲
│ │ │ │
│ └──────────────┘ │
│ (imports) │
└────────────────────────────────┘
│
▼
┌──────────┐
│ JwtModule │
└──────────┘
화살표 방향이 imports 관계입니다. PostModule이 AuthModule을 import하면, AuthModule이 exports한 프로바이더를 PostModule에서 주입받을 수 있습니다.
exports가 중요한 이유
NestJS에서 가장 흔한 실수 중 하나가 export를 빠뜨리는 것입니다.
// AuthModule
@Module({
providers: [AuthService, JwtService],
// exports: [AuthService] ← 이걸 빠뜨리면?
})
export class AuthModule {}
// PostModule
@Module({
imports: [AuthModule],
providers: [PostService],
})
export class PostModule {}
// PostService
@Injectable()
export class PostService {
constructor(private readonly authService: AuthService) {}
// Error! Nest can't resolve dependencies of the PostService (?).
}
에러 메시지가 (?)로 표시되는 것은 해당 의존성을 어디서도 찾을 수 없다는 뜻입니다.
왜 이런 일이 생기는지 3편의 lookupComponent 로직을 다시 떠올려보겠습니다.
PostService의 의존성 해결:
AuthService 토큰으로 검색
1. PostModule._providers에서 찾기
→ PostService, PostRepository만 있음 → 없음
2. PostModule._imports의 모듈들에서 찾기
→ AuthModule을 확인
→ AuthModule._exports에 AuthService가 있는가?
→ exports에 없음 → 접근 불가!
3. 결과: "Nest can't resolve dependencies"
프로바이더는 기본적으로 모듈 스코프입니다. 해당 모듈 내부에서만 접근할 수 있고, 외부에서 접근하려면 반드시 exports에 명시해야 합니다. 이것이 NestJS 모듈 시스템의 캡슐화 원리입니다.
lookupComponentInParentModules가 import된 모듈을 탐색할 때, 해당 모듈의 _exports Set에 포함된 토큰만 접근 가능한 것으로 취급합니다.
// packages/core/injector/injector.ts (간략화 - lookupComponentInParentModules 내부)
const instanceWrapper = await this.lookupComponentInImports(
moduleRef, // PostModule
name, // 'AuthService'
wrapper,
// ...
);
// lookupComponentInImports에서:
// 1. PostModule의 _imports를 순회
// 2. 각 import된 Module의 exports를 확인
// 3. exports에 해당 토큰이 있으면 → 그 모듈의 _providers에서 인스턴스 반환
// 4. exports에 없으면 → 접근 불가
이 설계가 실수를 유발하기도 하지만, 모듈 간 경계를 명확하게 만들어줍니다. 어떤 서비스가 외부에 공개되는지, 어떤 서비스가 내부 구현 세부사항인지 코드만 보고 알 수 있습니다.
동적 모듈 (Dynamic Module)
지금까지 본 모듈은 정적(static) 모듈이었습니다. @Module() 데코레이터에 고정된 값을 넣었습니다. 하지만 실무에서는 설정에 따라 다르게 구성해야 하는 모듈이 많습니다.
// 정적 모듈 — 설정을 바꿀 수 없습니다
@Module({
providers: [DatabaseService],
})
export class DatabaseModule {}
// 동적 모듈 — 호출 시점에 설정을 전달할 수 있습니다
@Module({})
export class DatabaseModule {
static forRoot(options: DatabaseOptions): DynamicModule {
return {
module: DatabaseModule,
providers: [
{ provide: 'DATABASE_OPTIONS', useValue: options },
DatabaseService,
],
exports: [DatabaseService],
};
}
}
DynamicModule 인터페이스
// packages/common/interfaces/modules/dynamic-module.interface.ts
export interface DynamicModule extends ModuleMetadata {
module: Type<any>; // 모듈 클래스 참조 (필수)
global?: boolean; // 글로벌 스코프 여부 (선택)
// ModuleMetadata로부터 상속:
// imports?, providers?, controllers?, exports?
}
DynamicModule은 ModuleMetadata를 확장하면서 module 프로퍼티가 추가됩니다. @Module() 데코레이터의 메타데이터에 module 프로퍼티가 없는 것과 대비됩니다 — 데코레이터에서는 클래스 자체가 대상이니까요.
DependenciesScanner에서의 처리
scanForModules에서 동적 모듈은 어떻게 처리될까요?
// packages/core/scanner.ts (간략화)
public async scanForModules({ moduleDefinition, ... }) {
// ...
// imports 배열 추출 시 분기
const modules = !this.isDynamicModule(moduleDefinition)
? this.reflectMetadata(MODULE_METADATA.IMPORTS, moduleDefinition)
: [
// 데코레이터의 imports + 동적 모듈의 imports 병합
...this.reflectMetadata(MODULE_METADATA.IMPORTS, moduleDefinition.module),
...(moduleDefinition.imports || []),
];
// ...
}
동적 모듈일 때는 두 소스의 imports를 병합합니다.
첫째, @Module() 데코레이터에 이미 적혀 있는 정적 imports.
둘째, forRoot()가 반환한 객체의 imports.
providers, controllers, exports도 마찬가지로 병합됩니다. 이것이 동적 모듈의 핵심 — 정적 메타데이터와 동적 메타데이터의 병합입니다.
forRoot()와 forRootAsync() 패턴
NestJS 생태계에서 forRoot()와 forRootAsync()는 관례적 패턴입니다. 프레임워크가 강제하는 것이 아니라, 커뮤니티의 컨벤션입니다.
| 패턴 | 용도 | 예시 |
|---|---|---|
| forRoot() | 동기적 설정 | TypeOrmModule.forRoot({ host: 'localhost' }) |
| forRootAsync() | 비동기 설정 (다른 서비스에 의존) | TypeOrmModule.forRootAsync({ useFactory: (config) => ..., inject: [ConfigService] }) |
| forFeature() | 기능별 하위 설정 | TypeOrmModule.forFeature([User, Post]) |
| register() | 매번 다른 설정으로 인스턴스화 | HttpModule.register({ timeout: 5000 }) |
모두 내부적으로는 DynamicModule 객체를 반환하는 정적 메서드일 뿐입니다.
ConfigModule.forRoot()의 내부
실무에서 가장 많이 사용하는 ConfigModule.forRoot()가 내부에서 무엇을 하는지 간략하게 살펴보겠습니다.
// @nestjs/config 패키지 (간략화)
@Module({})
export class ConfigModule {
static forRoot(options?: ConfigModuleOptions): DynamicModule {
// 1. .env 파일 로딩
// 2. 환경변수 검증 (validationSchema)
// 3. 설정값을 Map에 저장
return {
module: ConfigModule,
global: options?.isGlobal ?? false, // 글로벌 여부
providers: [
{ provide: CONFIGURATION_TOKEN, useValue: configObject },
ConfigService, // ConfigService가 CONFIGURATION_TOKEN을 주입받음
],
exports: [ConfigService, CONFIGURATION_TOKEN],
};
}
}
ConfigModule.forRoot({ isGlobal: true })를 호출하면 global: true가 포함된 DynamicModule이 반환되고, 이것이 글로벌 모듈로 처리됩니다. 이어서 글로벌 모듈에 대해 알아보겠습니다.
글로벌 모듈 (@Global())
일반적으로 프로바이더를 사용하려면 해당 모듈을 import해야 합니다. 하지만 ConfigService처럼 앱 전체에서 사용하는 프로바이더를 매번 import하는 것은 번거롭습니다.
// @Global()을 사용하면 한 번 import로 전체에서 사용 가능
@Global()
@Module({
providers: [ConfigService],
exports: [ConfigService],
})
export class ConfigModule {}
소스코드에서의 처리
@Global() 데코레이터는 메타데이터 키 하나를 설정할 뿐입니다.
// packages/common/decorators/modules/global.decorator.ts (간략화)
export function Global(): ClassDecorator {
return (target: Function) => {
Reflect.defineMetadata(GLOBAL_MODULE_METADATA, true, target);
};
}
NestContainer가 모듈을 등록할 때 이 메타데이터를 확인합니다.
// packages/core/injector/container.ts (간략화)
private async setModule(
{ token, type, dynamicMetadata }: ModuleFactory,
scope: Type<any>[],
): Promise<Module | undefined> {
const moduleRef = new Module(type, this);
// ...
// 글로벌 모듈인지 확인
if (this.isGlobalModule(type, dynamicMetadata)) {
this.addGlobalModule(moduleRef);
}
return moduleRef;
}
private isGlobalModule(
metatype: Type<any>,
dynamicMetadata?: Partial<DynamicModule>,
): boolean {
// @Global() 데코레이터가 있거나, DynamicModule에서 global: true인 경우
return (
!!Reflect.getMetadata(GLOBAL_MODULE_METADATA, metatype) ||
!!dynamicMetadata?.global
);
}
글로벌 모듈은 globalModules Set에 추가됩니다. 그리고 스캐닝이 끝난 후 bindGlobalScope()가 호출됩니다.
// packages/core/injector/container.ts (간략화)
public bindGlobalScope() {
this.modules.forEach((moduleRef) => this.bindGlobalsToImports(moduleRef));
}
public bindGlobalsToImports(moduleRef: Module) {
this.globalModules.forEach((globalModule) => {
if (moduleRef !== globalModule) {
moduleRef.addImport(globalModule);
// → 모든 모듈의 _imports에 글로벌 모듈을 추가
}
});
}
여기서 핵심이 보입니다. 글로벌 모듈은 사실 모든 모듈의 _imports에 자동으로 추가되는 것입니다. "마법"이 아니라, 단순히 모든 모듈에 import를 자동 추가하는 것입니다.
글로벌 모듈의 동작 원리:
@Global()
@Module({ providers: [ConfigService], exports: [ConfigService] })
class ConfigModule {}
이것은 아래와 동일합니다:
모든 모듈에 imports: [ConfigModule]을 자동 추가
UserModule._imports.add(ConfigModule)
PostModule._imports.add(ConfigModule)
AuthModule._imports.add(ConfigModule)
// ... 모든 모듈
그래서 글로벌 모듈의 프로바이더도 여전히 exports에 포함되어야 합니다. import가 자동으로 될 뿐, export 규칙은 동일합니다.
전체 모듈 스캐닝 흐름 다이어그램
지금까지 추적한 전체 과정을 하나로 정리하겠습니다.
NestFactory.create(AppModule)
│
├─ 1. DependenciesScanner.scan(AppModule)
│ │
│ ├─ 1a. scanForModules(AppModule)
│ │ AppModule의 @Module({ imports: [...] })을 읽음
│ │ │
│ │ ├─ container.addModule(AppModule)
│ │ ├─ container.addModule(AuthModule)
│ │ │ └─ container.addModule(JwtModule)
│ │ │ └─ container.addModule(SharedModule)
│ │ ├─ container.addModule(PostModule)
│ │ │ └─ AuthModule → 이미 등록됨 → 스킵
│ │ │ └─ SharedModule → 이미 등록됨 → 스킵
│ │ └─ container.addModule(SharedModule) → 스킵
│ │
│ │ 결과: NestContainer.modules에 5개 모듈 등록
│ │
│ ├─ 1b. scanModulesForDependencies()
│ │ 각 모듈에 대해:
│ │ │
│ │ ├─ reflectImports(module)
│ │ │ → Module._imports Set에 import된 Module 참조 추가
│ │ │
│ │ ├─ reflectProviders(module)
│ │ │ → Reflect.getMetadata('providers', metatype)
│ │ │ → 각 provider를 Module._providers Map에 등록
│ │ │
│ │ ├─ reflectControllers(module)
│ │ │ → Reflect.getMetadata('controllers', metatype)
│ │ │ → 각 controller를 Module._controllers Map에 등록
│ │ │
│ │ └─ reflectExports(module)
│ │ → Reflect.getMetadata('exports', metatype)
│ │ → 각 export를 Module._exports Set에 추가
│ │ → 존재하지 않는 프로바이더를 export하면 예외 발생
│ │
│ └─ 1c. container.bindGlobalScope()
│ @Global() 모듈을 모든 모듈의 _imports에 자동 추가
│
├─ 2. InstanceLoader.createInstancesOfDependencies()
│ (3편에서 다룬 내용)
│ 등록된 프로바이더들의 실제 인스턴스 생성
│
└─ 3. 앱 준비 완료
실수하기 쉬운 패턴 정리
모듈 시스템을 사용하면서 자주 마주치는 문제들을 정리해보겠습니다.
| 상황 | 증상 | 해결 |
|---|---|---|
| exports 누락 | Nest can't resolve dependencies of the X (?) |
해당 모듈의 exports에 프로바이더 추가 |
| imports 누락 | 같은 에러. 프로바이더가 있는 모듈 자체를 import하지 않음 | 해당 모듈을 imports에 추가 |
| 순환 import | A circular dependency has been detected 또는 undefined 에러 |
forwardRef(() => Module) 사용, 또는 모듈 구조 재설계 |
| @Global() 없이 전역 접근 시도 | 일부 모듈에서 해당 프로바이더를 찾지 못함 | @Global() 추가하거나, 필요한 곳마다 import |
| @Global() + exports 누락 | @Global()을 붙였는데도 다른 모듈에서 접근 불가 | 글로벌 모듈이라도 exports는 필수 |
| 동적 모듈 module 프로퍼티 누락 | forRoot()가 반환한 객체에 module이 없음 |
{ module: MyModule, ... } 형태로 반환 |
정리
NestJS의 모듈 시스템은 생각보다 단순한 메커니즘으로 동작합니다.
@Module() 데코레이터는 imports, providers, controllers, exports 네 개의 배열을 Reflect.defineMetadata로 클래스에 저장할 뿐입니다. 그 자체로는 아무 일도 하지 않습니다.
DependenciesScanner가 실제 조립 작업을 합니다. scanForModules로 모듈 트리를 재귀적으로 탐색하고, scanModulesForDependencies로 각 모듈의 providers, controllers, exports를 수집하여 NestContainer에 등록합니다.
모듈 스코프가 핵심 설계 원칙입니다. 프로바이더는 기본적으로 해당 모듈 내부에서만 접근 가능하고, 외부에 공개하려면 exports에 명시해야 합니다. 글로벌 모듈도 예외가 아니라, 모든 모듈에 자동 import되는 편의 기능일 뿐입니다.
돌이켜 보면, 2편에서 4편까지 추적한 흐름이 하나로 연결됩니다.
2편: @Injectable()이 Reflect.metadata로 타입 정보를 저장
→ 3편: Injector가 그 메타데이터를 읽어 의존성을 해결하고 인스턴스를 생성
→ 4편: @Module()이 프로바이더의 범위를 결정하고, Scanner가 모듈 그래프를 구축
데코레이터가 메타데이터를 저장하고, 스캐너가 메타데이터를 읽어 구조를 파악하고, 인젝터가 구조를 기반으로 인스턴스를 만드는 것. 결국 메타데이터 기반 프레임워크라는 NestJS의 설계 철학이 이 세 편의 글을 관통하고 있다는 생각이 듭니다.
다음 5편에서는 이렇게 조립된 앱에 실제 HTTP 요청이 들어왔을 때 무슨 일이 벌어지는지 — Guard, Interceptor, Pipe, Exception Filter가 어떤 순서로 실행되는지 추적해보겠습니다.
관련 글
NestFactory.create()를 호출하면 무슨 일이 일어나는가 — NestJS 소스코드 추적 (1편)
NestJS로 서버를 만들 때마다 실행하는 NestFactory.create(AppModule) 한 줄. 이 한 줄이 내부에서 DI 컨테이너 생성, 모듈 스캔, 인스턴스 로딩, Express 바인딩까지 5단계를 거친다는 사실을 NestJS 소스코드를 직접 추적하며 확인합니다.
DI 컨테이너는 어떻게 의존성을 해결하는가 — NestJS 소스코드 추적 (3편)
@Injectable()이 메타데이터를 저장하는 것과 실제로 인스턴스를 생성해서 주입하는 것은 다른 문제입니다. NestContainer, InstanceLoader, Injector — 세 클래스가 협력하여 의존성을 해결하는 과정을 NestJS 소스코드에서 직접 추적합니다.
@Injectable()의 실체 — Reflect.metadata와 데코레이터가 하는 일 (2편)
@Injectable() 하나 붙이면 DI가 된다는 건 알지만, 이 데코레이터가 정확히 무슨 일을 하는 걸까요? NestJS 소스코드와 TypeScript 컴파일러 출력을 직접 추적하며, 데코레이터가 '실행'이 아니라 '등록'이라는 사실을 확인합니다.