NestJS 심화 활용
들어가며
의존성 주입(DI, Dependency Injection)은 모던 백엔드 프레임워크의 핵심 설계 철학이자 아키텍처의 근간을 이루는 중요한 개념입니다. 이번 글에서는 NestJS 프레임워크의 DI를 프레임워크 코드를 파헤쳐가며 심도 있게 분석하며, 프레임워크 기능들을 우아하게 사용하기 위해 필요한 여러 인사이트를 도출합니다.
글에선 NestJS 패키지 내부의 코드가 부분부분 발췌될 예정입니다. 글 읽으시면서 등장하는 코드는 대충 읽지 마시고 꼭 천천히 읽어보시길 바랍니다. 아무래도 프레임워크 코드다 보니 설계가 되게 깔끔하게 되어있어 읽는 것 만으로도 꽤나 인사이트를 줄 수 있을 겁니다. 마찬가지로 코드 읽으시면서 따라오는게 이 글을 통해 얻어가는 것이 더 많을 것임을 보장합니다.
NestJS DI의 내부 구조와 활용
흔히 DI라고 하면 사용자 입장에선 아래 처럼 생각합니다.
Injectable이 붙은 class를 Singletone(혹은 scope에 맞게) 프레임워크가 알아서 주입해주는 것
실제로 동작을 보면 NestJS는 TypeScript의 Reflect Metadata API와 데코레이터를 통해 DI를 구현합니다.
- Reflect Metadata API : 클래스와 생성자에 메타데이터를 정의하여 런타임에 인스턴스를 동적으로 주입합니다.
- DI 컨테이너 : NestJS가 제공하는 컨테이너는 프로바이더의 라이프사이클을 관리하며, 모듈 컴파일 시점에 프로바이더를 등록하고, 런타임 시에 요청에 따라 인스턴스를 생성 및 반환합니다.
한번 내부 구조를 뜯어보겠습니다.
Provider와 @Injectable 데코레이터
Injectable() 데코레이터 코드의 주석
요약하면 @Injectable은 Provider로 만들어주는 데코레이터이며, Nest에서 Provider란 DI 가능한 요소로 모듈에 들어 있어야 DI가 가능하다 정도로 정의됩니다.
왜 그럴까, 그리고 어떻게 동작할까. @Injectable() 의 구현부 코드를 보겠습니다.
// packages/common/decorators/core/injectable.decorator.ts
export function Injectable(options?: InjectableOptions): ClassDecorator {
return (target: object) => {
Reflect.defineMetadata(INJECTABLE_WATERMARK, true, target);
Reflect.defineMetadata(SCOPE_OPTIONS_METADATA, options, target);
};
}
Injectable 데코레이터의 기능입니다. 굉장히 심플한데, Reflect를 통해 class의 메타데이터를 마킹합니다. 이 마킹을 어떻게 활용하고 있을까 찾아보겠습니다.
Nest Factory - initialize()
// packages/core/nest-factory.ts
export class NestFactoryStatic {
// 생략
private async initialize(
module: any,
container: NestContainer,
graphInspector: GraphInspector,
config = new ApplicationConfig(),
options: NestApplicationContextOptions = {},
httpServer: HttpServer | null = null,
) {
UuidFactory.mode = options.snapshot
? UuidFactoryMode.Deterministic
: UuidFactoryMode.Random;
const injector = new Injector({ preview: options.preview! });
const instanceLoader = new InstanceLoader(
container,
injector,
graphInspector,
);
const metadataScanner = new MetadataScanner();
const dependenciesScanner = new DependenciesScanner(
container,
metadataScanner,
graphInspector,
config,
);
//생략
}
}
NestJS의 app을 만들어주는 nest-factory 클래스의 initialize()
메서드입니다.
injector, instanceLoader, metadataScanner, dependencyScanner를 차례로 생성합니다.
같은 메서드의 좀 더 아래를 보겠습니다.
// packages/core/nest-factory.ts
initialize(){
// 생략
await ExceptionsZone.asyncRun(
async () => {
await dependenciesScanner.scan(module);
await instanceLoader.createInstancesOfDependencies();
dependenciesScanner.applyApplicationProviders();
},
teardown,
this.autoFlushLogs,
);
scan ➡ createInstance ➡ apply 순으로 진행합니다.
하나하나 주요한 기능들인데, 먼저 Scanner의 scan 메서드를 보겠습니다.
Scanner
// packages/core/scanner.ts
export class DependenciesScanner {
// 생략
public async scan(
module: Type<any>,
options?: { overrides?: ModuleOverride[] },
) {
await this.registerCoreModule(options?.overrides);
await this.scanForModules({
moduleDefinition: module,
overrides: options?.overrides,
});
await this.scanModulesForDependencies();
this.addScopedEnhancersMetadata();
this.calculateModulesDistance();
this.container.bindGlobalScope();
}
}
의존성 스캔하는 부분(scan Modules For Dependencies)만 좀 더 보면,
// packages/core/scanner.ts
export class DependenciesScanner {
// 생략
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 관련 메서드들을 호출하고 있습니다. @Module에서 자주보던 imports, providers, controllers, exports들을 확인할 수 있습니다. reflect메서드의 기능은 모두 유사하여 하나만 꼽아 보겠습니다.
// packages/core/scanner.ts
export class DependenciesScanner {
// 생략
public reflectProviders(module: Type<any>, token: string) {
const providers = [
...this.reflectMetadata(MODULE_METADATA.PROVIDERS, module),
...this.container.getDynamicMetadataByToken(
token,
MODULE_METADATA.PROVIDERS as "providers",
)!,
];
providers.forEach(provider => {
this.insertProvider(provider, token);
this.reflectDynamicMetadata(provider, token);
});
}
}
// packages/core/scanner.ts
export class DependenciesScanner {
// 생략
public reflectMetadata<T = any>(
metadataKey: string,
metatype: Type<any>,
): T[] {
return Reflect.getMetadata(metadataKey, metatype) || [];
}
}
@Injectable()로 코드에서 마킹해둔 metaData를 읽고 있습니다. 이를 통해 스캐너의 기능은 @Module, @Controller, @Injectable 등 데코레이터가 붙어있는 코드를 그래프 탐색하듯 순회하여 의존하고 있는 클래스들을 체크하는 것이라 볼 수 있습니다.
insertProvider() 부분이 중요한데, Provider 개념과 같이 보겠습니다.
NestJS의 Provider
// packages/core/scanner.ts
export class DependenciesScanner {
// 생략
public insertProvider(provider: Provider, token: string) {
const isCustomProvider = this.isCustomProvider(provider);
if (!isCustomProvider) {
return this.container.addProvider(provider, token);
}
const applyProvidersMap = this.getApplyProvidersMap();
const providersKeys = Object.keys(applyProvidersMap);
const type = provider.provide;
if (!providersKeys.includes(type as string)) {
return this.container.addProvider(provider as any, token);
}
const uuid = UuidFactory.get(type.toString());
const providerToken = `${type as string} (UUID: ${uuid})`;
let scope = (provider as ClassProvider | FactoryProvider).scope;
if (isNil(scope) && (provider as ClassProvider).useClass) {
scope = getClassScope((provider as ClassProvider).useClass);
}
this.applicationProvidersApplyMap.push({
type,
moduleKey: token,
providerKey: providerToken,
scope,
});
const newProvider = {
...provider,
provide: providerToken,
scope,
} as Provider;
const enhancerSubtype =
ENHANCER_TOKEN_TO_SUBTYPE_MAP[
type as
| typeof APP_GUARD
| typeof APP_PIPE
| typeof APP_FILTER
| typeof APP_INTERCEPTOR
];
const factoryOrClassProvider = newProvider as
| FactoryProvider
| ClassProvider;
if (this.isRequestOrTransient(factoryOrClassProvider.scope!)) {
return this.container.addInjectable(newProvider, token, enhancerSubtype);
}
this.container.addProvider(newProvider, token, enhancerSubtype);
}
}
통상적인 케이스providers: [CatsService]
의 경우 바로 container에 등록합니다. 다만 CustomProvider의 경우 별도의 처리를 하는데 자세히 보겠습니다.
Custom Provider
public isCustomProvider(
provider: Provider,
): provider is
| ClassProvider
| ValueProvider
| FactoryProvider
| ExistingProvider {
return provider && !isNil((provider as any).provide);
}
종류 | 예시 | 설명 |
---|---|---|
일반 Provider | providers: [UserService] | 클래스 자체를 주입 대상 등록 |
CustomProvider | provide + useClass / useValue / useFactory / useExisting | Nest에게 Token을 provide하며 “이 Token에는 이걸 써!”라고 명시 |
코드를 보면 provide가 정의된 경우를 CustomProvider 취급 하는데, 자세한 예시를 보여드리자면 아래와 같은 케이스가 커스텀 provider입니다.
import { connection } from './connection';
@Module({
providers: [
// NormalProvider
NormalProvider,
// ValueProvider
{
provide: 'CONNECTION1',
useValue: connection,
},
// ClassProvider
{
provide: ConfigService,
useClass:
process.env.NODE_ENV === 'development'
? DevelopmentConfigService
: ProductionConfigService,
};
// FactoryProvider
{
provide: 'CONNECTION2',
useFactory: (optionsProvider: MyOptionsProvider, optionalProvider?: string) => {
const options = optionsProvider.get();
return new DatabaseConnection(options);
},
inject: [MyOptionsProvider, { token: 'SomeOptionalProvider', optional: true }],
},
// ExistingProvider
{
provide: "AliasedNormalProvider",
useExisting: NormalProvider
}
],
})
export class AppModule {}
provide
에 아래와 같은 InjectionToken을 넣은 경우 CustomProvider입니다.
export type InjectionToken<T = any> =
| string
| symbol
| Type<T>
| Abstract<T>
| Function;
Token은 Provider를 주입할 때 식별하기 위한 일종의 식별자입니다. 사용가능한 타입들을 잘 봐두시기 바랍니다. 위를 통해 DIP(의존관계 역전원칙)의 의존 관계 역전 등 여러 테크닉을 구사할 수 있습니다. 먼저 조금은 생소할 수도 있는 symbol을 보고 가겠습니다,
symbol이란?
Symbol은 ES6에서 도입된 원시 데이터 타입 중 하나로, 생성할 때마다 고유한 값(유일 식별자)을 생성합니다. 이로 인해 객체의 프로퍼티 키로 사용하거나, 의존성 주입 시 Provider의 토큰으로 활용되어 이름 충돌 없이 고유한 식별을 가능하게 합니다. 특히, Java Spring 스타일의 인터페이스 기반 DIP 구현에서, Symbol을 이용해 프로바이더 식별 및 관리가 쉬워져 의존성 역전을 효과적으로 구현할 수 있습니다.
Provider 활용 의존관계 역전 구현
Interface 기반 DIP
Custom Provider과 Symbol을 활용한 Java Spring 스타일의 interface 기반 DIP 코드 예제입니다.
const USER_SERVICE = Symbol("USER_SERVICE");
export interface UserService {}
@Injectable()
export class UserServiceImpl implements UserService {}
@Module({
providers: [
{
provide: USER_SERVICE,
useClass: UserServiceImpl
}
]
})
export class UserModule{}
@Controller()
export class UserController(
constructor(
@Inject(USER_SERVICE) private readonly userService: UserService
){}
)
이렇게 심볼을 통해 provide를 해줘야하는 이유는 TS에서는 interface가 런타임에서 휘발되기 때문입니다. 별도로 Inject를 명시해야한다던가 용법이 굉장히 귀찮죠?
제가 조금 더 추천하는 좀 더 TS 스러운 Abstract Class 기반 DIP 예시입니다.
Abstract Class 기반 DIP
export abstract class UserReader{
abstract findOne(criteria: FindOneUserCriteria): Promise<User>
}
@Injectable()
export class RepositoryUserReader extends UserReader{
override findOne(){} // 생략
}
@Module({
providers: [
{
provide: UserReader,
useClass: RepositoryUserReader
}
]
})
export class UserModule{}
@Injectable()
export class UserService(
constructor(
private readonly userReader: UserReader
){}
)
AbstractClass+abstract 메서드를 사용하면 런타임에도 class 정보가 남아있기 때문에 다음과 같은 Inject 토큰 명시 없이 사용할 수 있습니다.
Instance Loader
// packages/core/nest-factory.ts
initialize(){
// 생략
await ExceptionsZone.asyncRun(
async () => {
await dependenciesScanner.scan(module);
await instanceLoader.createInstancesOfDependencies();
dependenciesScanner.applyApplicationProviders();
},
teardown,
this.autoFlushLogs,
);
다시 initialize()
로 돌아와서, Instance Loader 쪽을 보겠습니다.
// packages/core/injector/instance-loader.ts
export class InstanceLoader<TInjector extends Injector = Injector> {
// 생략
public async createInstancesOfDependencies(
modules: Map<string, Module> = this.container.getModules(),
) {
this.createPrototypes(modules);
try {
await this.createInstances(modules);
} catch (err) {
this.graphInspector.inspectModules(modules);
this.graphInspector.registerPartial(err);
throw err;
}
this.graphInspector.inspectModules(modules);
}
private async createInstances(modules: Map<string, Module>) {
await Promise.all(
[...modules.values()].map(async moduleRef => {
await this.createInstancesOfProviders(moduleRef);
await this.createInstancesOfInjectables(moduleRef);
await this.createInstancesOfControllers(moduleRef);
const { name } = moduleRef;
this.isModuleWhitelisted(name) &&
this.logger.log(MODULE_INIT_MESSAGE`${name}`);
}),
);
}
}
Scanner쪽과 비슷한 구조입니다. 마찬가지로 하나만 보겠습니다.
// packages/core/injector/instance-loader.ts
export class InstanceLoader<TInjector extends Injector = Injector> {
private async createInstancesOfProviders(moduleRef: Module) {
const { providers } = moduleRef;
const wrappers = [...providers.values()];
await Promise.all(
wrappers.map(async item => {
await this.injector.loadProvider(item, moduleRef);
this.graphInspector.inspectInstanceWrapper(item, moduleRef);
}),
);
}
}
주입에 관한 상세 로직은 Injector 쪽에 들어있습니다.
Injector
이 Injector
클래스는 NestJS의 내부 의존성 주입 시스템(DI Container) 의 핵심 구현체로, 클래스 인스턴스를 생성하고 의존성을 주입하는 역할을 담당합니다. NestJS 프레임워크에서 컴포넌트, 서비스, 컨트롤러, 미들웨어 등의 객체를 생성하고 필요한 의존성을 제공하는 핵심 로직입니다.
// packages/core/injector/injector.ts
export class Injector {
public async loadInstance<T>(
wrapper: InstanceWrapper<T>,
collection: Map<InjectionToken, InstanceWrapper>,
moduleRef: Module,
contextId = STATIC_CONTEXT,
inquirer?: InstanceWrapper,
) {
const inquirerId = this.getInquirerId(inquirer);
const instanceHost = wrapper.getInstanceByContextId(
this.getContextId(contextId, wrapper),
inquirerId,
);
if (instanceHost.isPending) {
const settlementSignal = wrapper.settlementSignal;
if (inquirer && settlementSignal?.isCycle(inquirer.id)) {
throw new CircularDependencyException(`"${wrapper.name}"`);
}
return instanceHost.donePromise!.then((err?: unknown) => {
if (err) {
throw err;
}
});
}
const settlementSignal = this.applySettlementSignal(instanceHost, wrapper);
const token = wrapper.token || wrapper.name;
const { inject } = wrapper;
const targetWrapper = collection.get(token);
if (isUndefined(targetWrapper)) {
throw new RuntimeException();
}
if (instanceHost.isResolved) {
return settlementSignal.complete();
}
try {
const t0 = this.getNowTimestamp();
const callback = async (instances: unknown[]) => {
const properties = await this.resolveProperties(
wrapper,
moduleRef,
inject as InjectionToken[],
contextId,
wrapper,
inquirer,
);
const instance = await this.instantiateClass(
instances,
wrapper,
targetWrapper,
contextId,
inquirer,
);
this.applyProperties(instance, properties);
wrapper.initTime = this.getNowTimestamp() - t0;
settlementSignal.complete();
};
await this.resolveConstructorParams<T>(
wrapper,
moduleRef,
inject as InjectionToken[],
callback,
contextId,
wrapper,
inquirer,
);
} catch (err) {
wrapper.removeInstanceByContextId(
this.getContextId(contextId, wrapper),
inquirerId,
);
settlementSignal.error(err);
throw err;
}
}
}
loadInstance() 메서드로 주어진 InstanceWrapper
에 해당하는 인스턴스를 실제로 생성하고 의존성 주입을 수행합니다. InstanceWrapper과 Injector의 기능을 정리하고 가겠습니다.
클래스 | 설명 |
---|---|
Injector | 의존성 인스턴스를 생성하고 주입하는 책임 |
InstanceWrapper | 생성된 인스턴스의 스코프/컨텍스트 상태를 추적하는 래퍼 |
핵심 메서드인 만큼 코드가 되게 깁니다. 기능을 요약하자면,
- 초기 contextId 기반 인스턴스 조회
- pending 상태(순환 참조 등) 체크
- settlementSignal을 통한 비동기 완료 핸들링
- 생성자 의존성(resolveConstructorParams)과 프로퍼티 의존성(resolveProperties) resolve
- instantiateClass() 호출로 인스턴스 실제 생성
한부분 한부분 쪼개보면 도출할 수 있는 인사이트가 되게 많은데, 제일 초반 부분에 스코프와 관련된 핵심 처리가 등장합니다.
NestJS의 Scope와 싱글톤 원리
const instanceHost = wrapper.getInstanceByContextId(
this.getContextId(contextId, wrapper),
inquirerId,
);
Nest는 스코프(Scope) 에 따라 인스턴스를 다르게 보관합니다. 그리고 의존성을 생성할 때 항상 contextId
를 기준으로 인스턴스를 가져옵니다.
스코프 | 보관 위치 | 컨텍스트 |
---|---|---|
DEFAULT | 싱글톤 | STATIC_CONTEXT |
REQUEST | 요청마다 새로 생성 | 요청의 contextId |
TRANSIENT | 의존한 인스턴스마다 새로 생성 | inquirerId 기반 분기 |
// packages/core/injector/instance-wrapper.ts
export class InstanceWrapper<T = any> {
private readonly values = new WeakMap<ContextId, InstancePerContext<T>>();
// 생략
public getInstanceByContextId(
contextId: ContextId,
inquirerId?: string,
): InstancePerContext<T> {
if (this.scope === Scope.TRANSIENT && inquirerId) {
return this.getInstanceByInquirerId(contextId, inquirerId);
}
const instancePerContext = this.values.get(contextId);
return instancePerContext
? instancePerContext
: contextId !== STATIC_CONTEXT
? this.cloneStaticInstance(contextId)
: {
instance: null as T,
isResolved: true,
isPending: false,
};
}
}
가져오는 쪽 코드를 보면, const instancePerContext = this.values.get(contextId)
이 부분에서 contextId 기반으로 인스턴스를 가져오는데, 싱글톤의 경우 STATIC_CONTEXT라는 별도의 전용 contextId가 존재해서 같은 로직으로 Request Scope와 Default Scope를 같이 처리합니다.
export const STATIC_CONTEXT: ContextId = Object.freeze({
id: STATIC_CONTEXT_ID,
});
STATIC_CONTEXT 정의 부분
private readonly values = new WeakMap<ContextId, InstancePerContext<T>>();
또한 InstanceWrapper의 프로퍼트 부분을 보면 WeakMap으로 contextId에 해당하는 인스턴스를 저장해놓고 있습니다. STATIC_CONTEXT에 해당하는 인스턴스 또한 이렇게 싱글톤으로 관리되고 있는 걸 알 수 있습니다. 마찬가지로 RequestScope 별 1개의 인스턴스를 유지하는 방식도 Wrapper를 통해 Map으로 관리되기에 가능하였습니다.
정리하자면
-
Default
스코프: 항상STATIC_CONTEXT
사용 → 싱글톤 -
Request
스코프:contextId
별로 따로 저장됨 -
Transient
스코프:contextId + inquirerId
조합으로 저장됨
스코프 + 컨텍스트 조합으로 인스턴스를 구분하여 반환하는 기능을 수행합니다.
Resolve와 Context-Aware
const callback = async (instances: unknown[]) => {
const properties = await this.resolveProperties(
wrapper,
moduleRef,
inject as InjectionToken[],
contextId,
wrapper,
inquirer,
);
const instance = await this.instantiateClass(
instances,
wrapper,
targetWrapper,
contextId,
inquirer,
);
this.applyProperties(instance, properties);
wrapper.initTime = this.getNowTimestamp() - t0;
settlementSignal.complete();
};
await this.resolveConstructorParams<T>(
wrapper,
moduleRef,
inject as InjectionToken[],
callback,
contextId,
wrapper,
inquirer,
);
이후 플로우는 생성자 리졸브 ➡ (콜백함수 실행) ➡ 프로퍼티 리졸브 ➡ 인스턴스 생성 으로 이어집니다.
Resolve 라는 개념 또한 핵심인데, NestJS DI 시스템에서 필요할 때마다(즉, 런타임에) 해당 의존성을 가져온다는 의미입니다.
가령 Request 스코프의 인스턴스가 존재하면, NestJS는 요청 단위로 새로운 인스턴스를 생성하고 그에 맞는 인스턴스를 주입합니다.
그렇다면 싱글톤 인스턴스가 Request 스코프의 인스턴스를 의존성으로 지닌다면 어떻게 될까요? 지금까지 본 코드에 따르면 내부에 존재하는 Request 스코프의 인스턴스가 요청단위로 런타임에 생성되어 싱글톤에 주입될 것이라 예상할 수 있습니다.
다만 문제는 싱글톤 인스턴스는 말 그대로 한번만 생성되어야 하는데, 이렇게 인스턴스 내부에 의존성을 매 요청마다 주입해야 하는 경우, 소비자인 싱글톤 인스턴스도 요청단위로 새로운 인스턴스가 생겨야만 Request 스코프의 인스턴스를 주입 받을 수 있지 않을까요?
일단 이후 과정을 코드로 보겠습니다.
// packages/core/injector/instance-wrapper.ts
export class InstanceWrapper<T = any> {
// 생략
public async resolveConstructorParams<T>(
wrapper: InstanceWrapper<T>,
moduleRef: Module,
inject: InjectorDependency[] | undefined,
callback: (args: unknown[]) => void | Promise<void>,
contextId = STATIC_CONTEXT,
inquirer?: InstanceWrapper,
parentInquirer?: InstanceWrapper,
) {
let inquirerId = this.getInquirerId(inquirer);
const metadata = wrapper.getCtorMetadata();
if (metadata && contextId !== STATIC_CONTEXT) {
const deps = await this.loadCtorMetadata(
metadata,
contextId,
inquirer,
parentInquirer,
);
return callback(deps);
}
const isFactoryProvider = !isNil(inject);
const [dependencies, optionalDependenciesIds] = isFactoryProvider
? this.getFactoryProviderDependencies(wrapper)
: this.getClassDependencies(wrapper);
let isResolved = true;
const resolveParam = async (param: unknown, index: number) => {
try {
if (this.isInquirer(param, parentInquirer)) {
return parentInquirer && parentInquirer.instance;
}
if (inquirer?.isTransient && parentInquirer) {
inquirer = parentInquirer;
inquirerId = this.getInquirerId(parentInquirer);
}
const paramWrapper = await this.resolveSingleParam<T>(
wrapper,
param as Type | string | symbol,
{ index, dependencies },
moduleRef,
contextId,
inquirer,
index,
);
const instanceHost = paramWrapper.getInstanceByContextId(
this.getContextId(contextId, paramWrapper),
inquirerId,
);
if (!instanceHost.isResolved && !paramWrapper.forwardRef) {
isResolved = false;
}
return instanceHost?.instance;
} catch (err) {
const isOptional = optionalDependenciesIds.includes(index);
if (!isOptional) {
throw err;
}
return undefined;
}
};
const instances = await Promise.all(dependencies.map(resolveParam));
isResolved && (await callback(instances));
}
}
각 파라미터를 비동기로 resolve하기 위해 resolveParam
함수를 정의합니다.
- 만약 해당 파라미터가 INQUIRER (즉, 요청자)라면, 이미 resolve된 부모 인스턴스를 반환합니다.
- Transient 스코프의 경우, 부모 inquirer가 존재하면 이를 대체하여 사용합니다.
- resolveSingleParam()를 호출하여, 해당 파라미터에 해당하는 InstanceWrapper를 얻고, 그 후 paramWrapper.getInstanceByContextId()를 통해 현재 contextId (즉, 요청 혹은 다른 컨텍스트)에 맞는 인스턴스를 가져옵니다.
이후 모든 의존성 resolve후 Promise.all(dependencies.map(resolveParam))
를 사용해 모든 파라미터의 인스턴스를 resolve합니다.
만약 모든 의존성이 resolve되었다면, 준비된 인스턴스 배열을 callback으로 전달하여 아까 보았던 콜백 함수인 실제 인스턴스 생성(instantiateClass
) 및 프로퍼티 주입(applyProperties
)이 진행됩니다.
private getContextId(
contextId: ContextId,
instanceWrapper: InstanceWrapper,
): ContextId {
return contextId.getParent
? contextId.getParent({
token: instanceWrapper.token,
isTreeDurable: instanceWrapper.isDependencyTreeDurable(),
})
: contextId;
}
여태까지 내용을 정리해보겠습니다.
메서드 | 역할 |
---|---|
getInstanceByContextId() | 스코프 + 컨텍스트 조합으로 인스턴스 분리 |
loadInstance() | 의존성 그래프 따라 재귀적으로 인스턴스 생성 |
resolveConstructorParams() | 생성자 파라미터를 context-aware하게 주입 |
Nest Injection Scopes에 대한 공식 문서 내용입니다. 스코프에 대한 설명을 해줍니다.
Request Scope <-> Singleton
-
CatsService
가
Request
스코프로 설정되어 있다면, 이를 주입받는
CatsController
도
Request 스코프로 전환
됩니다. - 반면, CatsRepository 는 별도의 스코프가 지정되지 않았고, CatsService와의 의존성 체인에 직접 포함되어 있지 않으므로 기본(singleton) 스코프로 유지됩니다.
Transient Scope <-> Singleton
- Transient로 지정된 의존성은 주입 시점마다 매번 새로운 인스턴스가 생성됩니다. 단, 이 경우에도 의존성 주입 체인 전체가 Transient가 되는 것은 아닙니다.
- DogsService 는 여전히 Singleton 으로 유지됩니다.
- Transient 한 LoggerService 는 DogsService에 주입될 때, DogsService 인스턴스 전용 LoggerService 인스턴스가 생성됩니다.
이제 동작 원리를 이해하셨을까요? contextId에 따라 매 요청마다 다른 인스턴스로 리졸브되는 메커니즘이 contextId로 그루핑되는 Request 스코프의 특성 때문에 주입받는 사용자측을 리졸브하는 시점에서 Request 스코프로 버블링 되는 것입니다.
아래와 같은 사항을 명심하시면 좋을 것 같습니다.
“Request 스코프 기능의 사용에는 상위 인스턴스 생성 스코프 버블링으로 인해 굉장한 주의가 필요하다”
“Transient는 싱글톤 인스턴스를 해치지 않고 사용할 수 있다”
Apply Application Providers
// packages/core/nest-factory.ts
initialize(){
// 생략
await ExceptionsZone.asyncRun(
async () => {
await dependenciesScanner.scan(module);
await instanceLoader.createInstancesOfDependencies();
dependenciesScanner.applyApplicationProviders();
},
teardown,
this.autoFlushLogs,
);
다시 initialize()
로 돌아와 보겠습니다. applyApplicationProviders()
메서드로 넘어갑니다.
// packages/core/scanner.ts
export class DependenciesScanner {
public applyApplicationProviders() {
const applyProvidersMap = this.getApplyProvidersMap();
const applyRequestProvidersMap = this.getApplyRequestProvidersMap();
const getInstanceWrapper = (
moduleKey: string,
providerKey: string,
collectionKey: "providers" | "injectables",
) => {
const modules = this.container.getModules();
const collection = modules.get(moduleKey)![collectionKey];
return collection.get(providerKey);
};
// Add global enhancers to the application config
this.applicationProvidersApplyMap.forEach(
({ moduleKey, providerKey, type, scope }) => {
let instanceWrapper: InstanceWrapper;
if (this.isRequestOrTransient(scope!)) {
instanceWrapper = getInstanceWrapper(
moduleKey,
providerKey,
"injectables",
)!;
this.graphInspector.insertAttachedEnhancer(instanceWrapper);
return applyRequestProvidersMap[type as string](instanceWrapper);
}
instanceWrapper = getInstanceWrapper(
moduleKey,
providerKey,
"providers",
)!;
this.graphInspector.insertAttachedEnhancer(instanceWrapper);
applyProvidersMap[type as string](instanceWrapper.instance);
},
);
}
}
provider maps를 먼저 가져옵니다.
// packages/core/scanner.ts
export class DependenciesScanner {
public getApplyProvidersMap(): { [type: string]: Function } {
return {
[APP_INTERCEPTOR]: (interceptor: NestInterceptor) =>
this.applicationConfig.addGlobalInterceptor(interceptor),
[APP_PIPE]: (pipe: PipeTransform) =>
this.applicationConfig.addGlobalPipe(pipe),
[APP_GUARD]: (guard: CanActivate) =>
this.applicationConfig.addGlobalGuard(guard),
[APP_FILTER]: (filter: ExceptionFilter) =>
this.applicationConfig.addGlobalFilter(filter),
};
}
}
APP_INTERCEPTOR, APP_PIPE, APP_GUARD, APP_FILTER 로 등록된 프로바이더들은 글로벌 컨텍스트에 등록됩니다.
main.ts에서 app.addGlobalXXX()
를 수행하는 것이나 모듈 프로바이더에서 {provide: APP_FILTER, useClass: XXXFilter}
로 등록하는 것이나 동일 로직으로 동작함을 알 수 있습니다.
이후 graphInspector에 등록을 해줍니다.
this.graphInspector.insertAttachedEnhancer(instanceWrapper);
이 부분을 타고타고 가면
export class GraphInspector {
private readonly graph: SerializedGraph;
public insertAttachedEnhancer(wrapper: InstanceWrapper) {
const existingNode = this.graph.getNodeById(wrapper.id)!;
existingNode.metadata.global = true;
this.graph.insertAttachedEnhancer(existingNode.id);
}
}
export class SerializedGraph {
private readonly extras: Extras = {
orphanedEnhancers: [],
attachedEnhancers: [],
};
public insertAttachedEnhancer(nodeId: string) {
this.extras.attachedEnhancers.push({
nodeId,
});
}
}
이런식으로 SerializedGraph에서 관리되는 모습.
(NestJS에서는 Guards, Interceptors, Pipes, Filters를 Enhancher라고 정의합니다.)
export class NestFactoryStatic {
public async create<T extends INestApplication = INestApplication>(
moduleCls: IEntryNestModule,
serverOrOptions?: AbstractHttpAdapter | NestApplicationOptions,
options?: NestApplicationOptions,
): Promise<T> {
const [httpServer, appOptions] = this.isHttpServer(serverOrOptions!)
? [serverOrOptions, options]
: [this.createHttpAdapter(), serverOrOptions];
const applicationConfig = new ApplicationConfig();
const container = new NestContainer(applicationConfig, appOptions);
const graphInspector = this.createGraphInspector(appOptions!, container);
this.setAbortOnError(serverOrOptions, options);
this.registerLoggerConfiguration(appOptions);
await this.initialize(
moduleCls,
container,
graphInspector,
applicationConfig,
appOptions,
httpServer,
);
const instance = new NestApplication(
container,
httpServer,
applicationConfig,
graphInspector,
appOptions,
);
const target = this.createNestInstance(instance);
return this.createAdapterProxy<T>(target, httpServer);
}
}
Initialize()
가 끝났습니다. 드디어 bootstrap()함수에 쓰는 app을 반환받습니다.
이후 factory에서 열심히 만든 모듈이 담긴 container, applicationConfig 등을 app으로 넘겨주고, app.init()
에서 실제 nestApp에 resolve됩니다.