안녕하세요!
몇 개월 만에 돌아온 DI 시리즈입니다.
저번 버전에서는 우리가 지정한 인스턴스만 주입 받을 수 있었지만, 그렇게 하면 DI 시스템을 사용하는 입장에서는 인스턴스를 추가해주기 위해 여러 코드를 수정해야 해요.
하지만 네스트를 보면 그렇지 않죠! 모듈에 넣어주면 바로바로 주입 받을 수 있어요.
그래서 이번에는 커스텀한 토큰을 이용해서 IoC 컨테이너를 쉽게 확장 가능하도록 수정해 볼 건데요!
네스트의 ValueProvider
를 구현하고, 이걸 우리의 DI 시스템에 잘 녹여내 볼 예정입니다.
그럼 시작해볼까요?
이번에도 기대하는 형태를 먼저 보고 갈게요.
이번 글의 목표는, 커스텀한 토큰을 통해 IoC 컨테이너에 인스턴스를 등록하고, 이 토큰을 사용하여 등록한 인스턴스를 주입 받아 의존성을 해결하는 Resolver
를 구현하는 거예요.
즉, 코드로 보면 아래와 같아요.
class Hello {
@Inject(CalculatorToken)
calculator!: ICalculator;
@Inject(LoggerToken)
logger!: ILogger;
constructor(
@Inject(HelloWorldToken)
readonly helloWorld: IHelloWorld,
@Inject(FileManagerToken)
readonly fileManager: IFileManager
) {}
}
function main() {
const resolver = new Resolver();
resolver.registerProviders([
{ provide: CalculatorToken, useValue: new Calculator() },
{ provide: LoggerToken, useValue: new Logger() },
{ provide: HelloWorldToken, useValue: new HelloWorld() },
{ provide: FileManagerToken, useValue: new FileManager() },
]);
const hello = resolver.resolve(Hello);
console.log(hello.calculator.add(1, 2)); // 3
hello.logger.log('Hello from logger'); // Hello from logger
hello.helloWorld.sayHello(); // Hello, World!
hello.fileManager.write('some.txt', 'Hello!'); // Writing file to some.txt with content: Hello!
}
main();
각 인스턴스는 각각의 토큰을 사용해서 Resolver
에 등록하고, @Inject(token)
데코레이터를 통해 주입 받아 사용할 수 있어요.
이제 구현을 시작해 볼게요. 이런저런 단계를 모두 거쳐서 완성된 코드를 먼저 보고 싶으시다면, 여기를 참고해주세요!
Token
구현이번 목표가 토큰 기반 주입인 만큼, 먼저 토큰을 구현해줘야 해요. 아래 구현은 Nestjs의 InjectionToken
을 참고했어요.
export type Token<T = any> = string | symbol | Type<T>;
문자열, Symbol
, 생성자 등 여러 유형의 토큰이 존재할 수 있기 때문에, 대표적인 세 가지만 포함하도록 했어요. 이때 Type<T>
는 T
객체를 만들 수 있는 생성자 함수를 가리켜요.
class Hello {}
즉, 위와 같은 클래스가 있을 때 Type<Hello>
는 Hello
자체가 되는거죠.
ValueProvider
구현네스트에는 여러 프로바이더가 존재해요. 하지만 이번에는 DI 시스템에 등록되는 인스턴스의 의존성을 해결하지는 않기 때문에, 이미 만들어진 인스턴스를 받아야 해요.
이 조건에 맞는 프로바이더가 바로 ValueProvider
에요. provide
에는 토큰, useValue
에는 해당 토큰으로 요청했을 때 주입해줄 값이 들어가게 돼요.
먼저 ValueProvider
를 구현해볼게요.
export type ValueProvider<T = any> = {
provide: Token;
useValue: T;
};
간단하죠? 말 그대로 토큰과 값이 끝이에요.
export type Provider<T = any> = ValueProvider<T>;
나중에는 다른 프로바이더도 추가될 예정이기 때문에, 이렇게 한 번 묶어주도록 할게요.
기존에는 토큰을 문자열로만 사용했지만, 토큰이 세 가지 유형으로 확장되었기 때문에 데코레이터와 메타데이터의 타입을 수정해줘야 해요.
export const Inject =
(token: Token) =>
(
target: object,
propertyKey: string | symbol | undefined,
index?: number
) => { /* ... */ }
export type ParameterInjectMetadata = {
token: Token;
index: number;
};
export type PropertyInjectMetadata = {
token: Token;
propertyKey: string | symbol;
};
이제 문자열 토큰 뿐만 아니라 Symbol
및 생성자 토큰도 받을 수 있게 바뀌었어요.
이제 의존성을 제한 없이 추가할 수 있게 되었어요. 이에 따라 Resolver
에서만 이들을 관리하기에는 Resolver
의 책임이 너무 커지는 거 같아요.
그래서, 의존성 정보를 담아두고 관리할 클래스 InstanceContainer
를 따로 구현해줄게요.
export class InstanceContainer {
private readonly instanceMap: Map<Token, any> = new Map();
register<T>(token: Token<T>, instance: T): void {
this.instanceMap.set(token, instance);
}
get<T>(token: Token<T>): T {
if (!this.instanceMap.has(token)) {
throw new Error(`No instance found for token ${token.toString()}`);
}
return this.instanceMap.get(token);
}
}
토큰 기반으로 인스턴스를 등록하고, 토큰을 통해 인스턴스를 조회할 수 있어요.
이제 마지막으로 Resolver
를 수정해 줄 차례에요. 기존에 의존성 토큰을 하드 코딩해서 매핑했던 부분을 제거하고, InstanceContainer
로부터 인스턴스를 가져오도록 수정해주기만 하면 돼요.
export class Resolver {
private readonly instanceContainer: InstanceContainer;
constructor() {
this.instanceContainer = new InstanceContainer();
}
registerProviders(providers: Provider[]): void {
providers.forEach((provider) => {
this.instanceContainer.register(provider.provide, provider.useValue);
});
}
resolve<T>(ctor: Type<T>): T {
const parameters = this.resolveConstructorParameters(ctor);
const instance = new ctor(...parameters);
this.resolveProperty(ctor, instance);
return instance;
}
resolveConstructorParameters<T>(ctor: Type<T>): any[] {
const metadata: ParameterInjectMetadata[] =
Reflect.getMetadata(PARAMETER_INJECT_METADATA, ctor) ?? [];
const result: any[] = [];
metadata.map((m) => {
result[m.index] = this.instanceContainer.get(m.token);
});
return result;
}
resolveProperty<T>(ctor: Type<T>, instance: T): void {
const metadata: PropertyInjectMetadata[] =
Reflect.getMetadata(PROPERTY_INJECT_METADATA, ctor) ?? [];
metadata.forEach((m) => {
(instance as any)[m.propertyKey] = this.instanceContainer.get(m.token);
});
}
}
registerProviders
를 통해 프로바이더를 InstanceContainer
에 등록하고, resolve
를 통해 의존성을 처리해요. 기존에는 mapTokenToInstance
를 통해 토큰을 인스턴스로 매핑해줬는데, 이 부분을 완전히 제거하고 InstanceContainer
만 의존하도록 수정했어요.
이렇게 수정해주면, 우리가 처음에 기대했던 형태를 완전히 구현한 코드가 완성됐어요.
이번에는 토큰 기반 의존성 주입을 구현해봤어요.
원래는 모두 Resolver
에 생성자만 넘겨주고, resolve
호출하면 모든 의존성이 알아서 해결되는 걸 구현해보려 했는데요. 네스트에서 모듈의 providers
배열에 생성자만 넣고 NestFactory.create()
하면 모든 의존성이 해결되는 것처럼요!
근데 이렇게 쓰면 글의 길이가 너무 길어질 거 같아 한 번 잘랐어요.
다음에는 네스트처럼 생성자만 넣어주면 알아서 Resolver
가 지지고 볶고 해서 모든 의존성을 해결하도록 구현해볼게요.
읽어주셔서 감사합니다!