안녕하세요, 또 오랜만에 글을 작성하네요.
슬슬 회고 글 작성 준비도 해야 하는데, 점점 더 게을러지는 거 같아요.
아무튼, 오늘도 돌아온 DI 시리즈입니다. 이번에는 Resolver
가 의존성을 구성하는 요소들만 받아서 스스로 인스턴스화도 하고, 의존성도 해결하면서 모든 요소들의 인스턴스를 만들어내도록 해 볼 예정입니다.
점점 NestJS의 DI 시스템에 가까워지고 있는 기분이 드는데요!
오늘도 시작해볼게요.
여느 때와 마찬가지로, 먼저 기대하는 형태를 보고 시작해볼게요.
class Hello {
@Inject(CalculatorToken)
calculator!: ICalculator;
@Inject(LoggerToken)
logger!: ILogger;
constructor(
@Inject(HelloWorldToken)
readonly helloWorld: IHelloWorld,
@Inject(FileManager)
readonly fileManager: FileManager
) {}
}
class Something {
constructor(
@Inject(Hello)
private readonly hello: Hello
) {}
addOneAndTwo(): number {
return this.hello.calculator.add(1, 2);
}
}
function main() {
const resolver = new Resolver([
{ provide: CalculatorToken, useValue: new Calculator() },
{ provide: LoggerToken, useValue: new Logger() },
{ provide: HelloWorldToken, useClass: HelloWorld },
FileManager,
{ provide: Hello, useClass: Hello },
Something,
]);
resolver.resolve();
const hello = resolver.get(Hello);
console.log(hello.calculator.add(1, 2));
hello.logger.log('Hello from logger');
hello.helloWorld.sayHello();
hello.fileManager.write('some.txt', 'Hello!');
const something = resolver.get(Something);
console.log(something.addOneAndTwo());
}
몇몇 클래스의 구현체는 적당히 구현되어 있다고 가정하고 위 코드를 봐주시면 될 거 같아요.
저번 글과 크게 다른 점은 두 가지가 있는데요.
첫 번째로 Resolver
가 받는 파라미터가 크게 바뀌었습니다. 이전에는 먼저 토큰과 인스턴스를 등록시키고, 이후에 의존성을 주입 받을 클래스를 넣어주는 방식으로 처리했었는데요.
이번엔, 한 번에 합니다!
즉, 모든 구성 요소들을 Resolver
의 파라미터로 넣어주기 때문에, 먼저 등록되던 인스턴스들과 이후 의존성 주입 받을 클래스의 구분이 사라집니다. 이에 따라 동적으로 프로바이더를 받을 수 있게 되면서, 기존에는 의존성의 깊이가 제한되었으나 클래스 Something
에서 보듯이 그 제한이 사라진 모습을 볼 수 있습니다.
두 번째로 프로바이더를 Resolver
에 넣어줄 때의 방식이 다양해졌습니다. NestJS의 모듈에 프로바이더를 등록하는 방법을 따왔는데요. 생성자를 등록할 수도 있고, 그 외에도 ClassProvider
, ValueProvider
를 다양하게 등록할 수 있게 되었습니다.
관련 커밋: 바로가기
기존에는 주입할 의존성들의 인스턴스가 모두 Resolver
에 등록되어 있었기 때문에, 클래스의 메타데이터만 읽어서 어떤 의존성이 필요한지 파악한 뒤, 이를 주입하면서 인스턴스를 생성하기만 하면 됐습니다.
하지만, 지금은 클래스를 받아도 이를 바로 인스턴스화 시킬 수 없습니다. 이제 Resolver
에는 실제 인스턴스가 아니라 프로바이더 정보가 들어오기 때문에, 이들을 적절한 순서에 따라 인스턴스화 해가며 모든 의존성을 해결해야 합니다.
따라서, 토큰 정보와 인스턴스를 담을 공간이 하나 필요한데요. 이러한 공간을 NestJS에서는 InstanceWrapper
라 정의하고 있습니다. 우리도 NestJS를 따라서 InstanceWrapper
라고 정의해볼게요.
export class InstanceWrapper<T = any> {
private readonly _token: Token<T>;
private _instance: T | undefined = undefined;
private _creatorFn: Type<T> | undefined;
private _isResolved = false;
constructor(token: Token, creatorFn?: Type<T>) {
this._token = token;
this._creatorFn = creatorFn;
}
// ... getter ...
public resolve(instance: T): void {
if (this._isResolved) {
return;
}
this._instance = instance;
this._isResolved = true;
}
}
토큰 정보와 실제 인스턴스, 인스턴스를 생성할 수 있는 함수와 의존성이 해결되어 인스턴스를 갖고 있는지 여부까지, 총 4가지의 프로퍼티를 갖는 클래스를 정의합니다.
createFn
이 undefined
도 가능한데, 이는 ValueProvider
의 경우입니다. 생성자 프로바이더 및 ClassProvider
는 createFn
으로 생성자가 들어오게 됩니다.
이제 인스턴스를 직접 사용하고 있던 InstanceContainer
가 문제가 되는데요.
export class InstanceContainer {
private readonly instanceMap: Map<Token, InstanceWrapper<any>> = new Map();
register<T>(token: Token<T>, wrapper: InstanceWrapper<T>): void {
this.instanceMap.set(token, wrapper);
}
get<T>(token: Token<T>): InstanceWrapper<T> {
if (!this.instanceMap.has(token)) {
throw new Error(`No instance found for token ${token.toString()}`);
}
return this.instanceMap.get(token) as InstanceWrapper<T>;
}
get wrappers(): InstanceWrapper<any>[] {
return Array.from(this.instanceMap.values());
}
}
이제 의존성 처리 과정에서 실제 인스턴스가 있을 수도 있고 없을 수도 있습니다. 따라서 InstanceContainer
가 토큰과 인스턴스의 맵이 아닌, 토큰과 인스턴스 래퍼의 맵을 사용하도록 수정해주었습니다.
관련 커밋: 바로가기
export type Provider<T = any> =
| ConstructorProvider<T>
| ValueProvider<T>
| ClassProvider<T>;
export type ConstructorProvider<T = any> = Type<T>;
export type ValueProvider<T = any> = {
provide: Token;
useValue: T;
};
export type ClassProvider<T = any> = {
provide: Token;
useClass: Type<T>;
};
이제 ValueProvider
외에도 생성자 및 ClassProvider
를 받을 수 있어야 하기 때문에, 두 프로바이더를 정의해뒀습니다.
관련 커밋: 바로가기
이번 글의 핵심입니다. 천천히 하나씩 가볼게요.
리졸버 구현은 크게 두 가지로 나뉩니다.
InstanceContainer
에 등록하는 구간InstanceContainer
에 등록된 프로바이더를 기반으로 의존성을 처리하면서 인스턴스화를 진행하는 구간먼저 첫 번째부터 가볼게요.
export class Resolver {
private readonly instanceContainer: InstanceContainer;
constructor(providers: Provider[]) {
this.instanceContainer = new InstanceContainer();
this.registerProviders(providers);
}
private registerProviders(providers: Provider[]): void {
providers.forEach((provider) => {
if ('useValue' in provider) { // = ValueProvider
this.registerValueProvider(provider);
} else if ('useClass' in provider) { // = ClassProvider
this.registerClassProvider(provider);
} else { // = ConsturctorProvider
this.registerClassProvider({ provide: provider, useClass: provider });
}
});
}
private registerValueProvider<T>(provider: ValueProvider<T>): void {
const token = provider.provide;
const instance = provider.useValue;
const wrapper = new InstanceWrapper(token);
wrapper.resolve(instance);
this.instanceContainer.register(token, wrapper);
}
private registerClassProvider<T>(provider: ClassProvider<T>): void {
const token = provider.provide;
const ctor = provider.useClass;
const wrapper = new InstanceWrapper(token, ctor);
this.instanceContainer.register(token, wrapper);
}
// ...
}
프로바이더 별로 따로 InstanceWrapper
를 생성해야 합니다. 따라서 파라미터로 받은 프로바이더를 각 유형에 따라 분기하여 InstanceContainer
에 등록합니다.
이때, ValueProvider
는 이미 실제 인스턴스가 존재하므로 바로 resolve
처리 후 등록시키고, 생성자 프로바이더는 토큰이 생성자인 클래스 프로바이더와 동일하다 볼 수 있으므로 registerClassProvider<T>
를 사용합니다.
이제 InstanceContainer
에 InstanceWrapper
가 적절하게 등록되었으니 두 번째 단계로 가볼게요.
export class Resolver {
// ...
resolve(): void {
this.instanceContainer.wrappers.forEach((wrapper) => {
this.loadProvider(wrapper);
});
}
// ...
}
Resolver@resolve
를 호출하면 컨테이너가 갖고 있는 모든 프로바이더의 의존성을 처리하여 인스턴스화를 진행해야 합니다. 이를 위해 resolve
에서는 모든 InstanceWrapper
를 돌면서 loadProvider
를 호출합니다.
export class Resolver {
// ...
private loadProvider<T>(wrapper: InstanceWrapper<T>): void {
if (wrapper.isResolved) {
return;
}
const ctorParams = this.resolveConstructorParameters(wrapper);
const propParams = this.resolveProperties(wrapper);
const instance = this.instantiate(wrapper, ctorParams);
this.applyProperties(instance, propParams);
wrapper.resolve(instance);
}
// ...
private applyProperties<T>(
instance: T,
properties: PropertyResolveResult[]
): void {
properties.forEach((p) => {
(instance as any)[p.propertyKey] = p.instance;
});
}
private instantiate<T>(wrapper: InstanceWrapper<T>, ctorParams: any[]): T {
const instance: T = new wrapper.creatorFn!(...ctorParams);
wrapper.resolve(instance);
return instance;
}
}
loadProvider
는 하나의 InstanceWrapper
를 받아서 해당 래퍼가 갖고 있는 프로바이더를 인스턴스화 시키는 역할을 갖고 있습니다.
현재 프로바이더는 ValueProvider
, ClassProvider
, 그리고 생성자 프로바이더가 있는데요. 여기서 ValueProvider
는 생성하는 시점에 이미 실제 인스턴스를 갖고 있으므로 첫 번째 분기에서 무시됩니다.
ValueProvider
를 제외한 ClassProvider
와 생성자 프로바이더는 모두 각자의 생성자와 프로퍼티를 갖게 되는데요. 둘 모두 생성자 파라미터와 프로퍼티에 대한 의존성 처리가 필요하므로, 각각의 의존성을 해결하는 resolveConstructorParameters
와 resolveProperties
를 호출하여 필요한 의존성을 가져옵니다.
가져온 의존성을 기반으로 인스턴스화를 진행하고, 프로퍼티를 주입합니다. 이제 모든 의존성이 처리되었으므로 InstanceWrapper@resolve
를 호출하여 완료 표시를 해둡니다. 이렇게 완료된 래퍼는 이후 loadProvider
를 호출하면 첫 번째 분기에서 무시되게 됩니다.
이제 각 생성자와 프로퍼티의 의존성을 가져오는 곳을 살펴볼게요.
type PropertyResolveResult = {
propertyKey: string | symbol;
instance: any;
};
export class Resolver {
// ...
private resolveConstructorParameters<T>(wrapper: InstanceWrapper<T>): any[] {
if (!wrapper.creatorFn) {
return [];
}
const metadata: ParameterInjectMetadata[] =
Reflect.getMetadata(PARAMETER_INJECT_METADATA, wrapper.creatorFn) ?? [];
const result: any[] = [];
metadata.forEach((m) => {
const wrapper = this.instanceContainer.get(m.token);
this.loadProvider(wrapper);
result[m.index] = wrapper.instance;
});
return result;
}
private resolveProperties<T>(
wrapper: InstanceWrapper<T>
): PropertyResolveResult[] {
if (!wrapper.creatorFn) {
return [];
}
const metadata: PropertyInjectMetadata[] =
Reflect.getMetadata(PROPERTY_INJECT_METADATA, wrapper.creatorFn) ?? [];
const result = metadata.map<PropertyResolveResult>((m) => {
const wrapper = this.instanceContainer.get(m.token);
this.loadProvider(wrapper);
return { propertyKey: m.propertyKey, instance: wrapper.instance };
});
return result;
}
// ...
}
예전에 썼던 NestJS 의존성 처리 글에서 NestJS는 큰 재귀 호출을 통해 의존성을 처리한다고 했었는데요, 여기서도 마찬가지로 재귀 호출을 사용하여 구현했습니다.
코드를 보면 생각보다 훨씬 더 간단한데요. 필요한 의존성의 토큰을 메타데이터로부터 가져와서 컨테이너에서 찾고, 다시 loadProvider
를 호출한 뒤 결과로 반환해주는게 끝이거든요.
앞서 loadProvider
는 InstanceWrapper
를 받아서 그 의존성을 처리하는 역할을 한다고 말씀드렸는데요, 말그대로 필요한 의존성들의 의존성을 처리하고, 그 인스턴스를 가져와 반환하는 것입니다.
플로우 차트로 보면 위와 같이 처리됩니다. 말 그대로 재귀를 통한 DFS 구현입니다. 심플하죠?
NestJS는 여기에 추가적으로 수많은 구성 요소와 요구 사항이 들어가며 거대한 재귀를 이루게 된 것입니다.
여기까지 오면, 이렇게 우리가 의도한 대로 의존성이 잘 처리되어 여러 인스턴스를 가져다 쓸 수 있게 됩니다.
점점 NestJS와 비슷한 모양이 되어가고 있어요. 한 번에 모든 글을 짠 쓰는 것도 좋지만, 이렇게 한 발짝 한 발짝 가는 것도 좋네요.
다음엔 여기에 모듈 시스템을 추가해볼게요.
읽어주셔서 감사합니다.