안녕하세요!
이번에도 재밌는 주제를 하나 갖고 왔어요. 바로, "Nestjs의 DI 시스템 직접 구현해보기!" 인데요.
최종적으로 나올 결과물이 Nestjs랑 닮긴 했는데, 백엔드 어플리케이션 개발용은 아니고 Nestjs에서 DI 시스템을 조금 단순화 시킨 형태로 구현해 볼 예정이에요.
그래서, 우리가 흔히 모듈에서 보는 imports
, controllers
, providers
, exports
중에서 controllers
가 없어요. 또, 프로바이더를 정의하는 여러 방법 중에서 클래스 생성자를 넣는 방법만 구현해 볼 예정이에요. 그러니까 useFactory
, useValue
같은 건 안 할 거에요!
이번 주제는 시리즈로 한 번 작성해 볼까 해요. 그래서 이번 글에서 모든 구현을 마치는게 아니라, 단계를 하나하나씩 밟아 나가면서 점점 Nestjs에 가까워지도록 글을 써내려가 볼 거에요.
그럼 시작해볼까요?
본격적으로 데코레이터 기반 DI를 구현하기 전에, 우리가 일반적으로 인스턴스를 어떻게 생성하고 주입하는지를 살펴볼게요.
const calculator = new Calculator();
const logger = new Logger();
// ...
const someService = new SomeService(calculator, logger, /* ... */);
const otherService = new OtherService(calculator, logger, /* ... */);
const helloController = new HelloController(someService, otherService);
만약 IoC Container가 없다면 이렇게 직접 인스턴스를 생성해서 생성자를 호출해야 했어요.
물론 기존에도 잘 사용하던 방법이지만, 인스턴스 생성을 직접 관리해줘야 하고, 생성된 인스턴스 또한 계속 관리해줘야 해요. 또, 인스턴스가 필요한 곳이 있으면 파라미터로 계속 넘겨줄 수 밖에 없었어요.
이런 상황을 개선하기 위해 우리는 Nestjs와 같이 IoC Container를 지원해주는 프레임워크를 같이 사용해서 개발을 진행해요.
근데... 이거 직접 만들어보면 재밌지 않을까요?!
이미 우리는 예전에 Nestjs 의존성 주입의 내부 구현을 살펴봤기 때문에, 충분히 가능할 거 같아요.
Nestjs를 닮은 데코레이터 기반 DI 구현기는 앞서 말씀드렸듯 시리즈로 진행될 예정인데요!
이번에는 '미리 생성된 인스턴스를 데코레이터가 붙어 있는 생성자의 파라미터와 프로퍼티에 주입' 하는 기능을 구현해 볼 거에요!
class Hello {
@InjectCalculator()
calculator!: ICalculator;
@InjectLogger()
logger!: ILogger;
constructor(
@InjectHelloWorld()
readonly helloWorld: IHelloWorld,
@InjectFileManager()
readonly fileManager: IFileManager,
) {}
}
const resolver = new Resolver({
calculator: new Calculator(),
logger: new Logger(),
helloWorld: new HelloWorld(),
fileManager: new FileManager(),
});
const hello = resolver.resolve(Hello);
// ^? const hello: Hello
먼저 우리가 기대하는 형태를 잡아볼게요.
구현을 바로 들어가지 않고 기대하는 형태를 살펴보는 이유는, 구현만 쫓다보면 어떻게든 동작은 하는데 깔끔하지 않거나, 구현의 어려움 때문에 포기하는 점들이 생길 수 있어서 그래요.
이번 글에서는,
우리가 기대하는 형태를 잡았으니, 이제 실제로 구현을 해 볼게요. 총 네 단계로 이루어져 있고, 각각 실제 구현체에 대한 커밋 해시를 남겨뒀어요. 혹시 코드가 궁금하신 분들은 직접 가서 살펴보셔도 좋을 거 같아요.
아래 단계를 모두 마친 뒤에 만들어지는 결과는 여기서 살펴보실 수 있어요!
관련 커밋: 바로가기
의존성 주입을 본격적으로 시작하기 전에, 뭔가를 주입해주려면 당연히 그 실체, 즉 구현체가 존재해야 해요. 구현체가 없으면 당연히 주입해줄 수도 없겠죠?
그래서 먼저 주입될 인스턴스의 구현체를 구현해볼거에요. Calculator
, Logger
, HelloWorld
, FileManager
클래스와 인터페이스를 각각 구현해줄거에요. 클래스 이름은 점점 생각나는게 없어서 대충 지었는데.. 구분만 할 수 있으면 되죠!
지금 만드는 클래스들은 큰 의미는 없어서, 내용은 대충 만들어줘도 돼요.
// ICalculator.ts
export interface ICalculator {
add: (a: number, b: number) => number;
}
// Calculator.ts
import { ICalculator } from '@lery/instances/calculator/ICalculator';
export class Calculator implements ICalculator {
add(a: number, b: number): number {
return a + b;
}
}
// IFileManager.ts
export interface IFileManager {
read: (path: string) => string;
write: (path: string, content: string) => void;
}
// FileManager.ts
import { IFileManager } from '@lery/instances/fileManager/IFileManager';
export class FileManager implements IFileManager {
read(path: string): string {
return `Reading file from ${path}`;
}
write(path: string, content: string): void {
console.log(`Writing file to ${path} with content: ${content}`);
}
}
// IHelloWorld.ts
export interface IHelloWorld {
sayHello: () => void;
}
// HelloWorld.ts
import { IHelloWorld } from '@lery/instances/helloWorld/IHelloWorld';
export class HelloWorld implements IHelloWorld {
sayHello(): void {
console.log('Hello, World!');
}
}
// ILogger.ts
export interface ILogger {
log: (message: string) => void;
error: (message: string) => void;
}
// Logger.ts
import { ILogger } from '@lery/instances/logger/ILogger';
export class Logger implements ILogger {
log(message: string): void {
console.log(message);
}
error(message: string): void {
console.error(message);
}
}
앞서 말씀드린 것처럼 아직 커스텀하게 인스턴스를 주입해줄 수는 없기 때문에, 주입될 인스턴스들은 별도의 의존성을 갖고 있진 않아요.
Resolver
구현관련 커밋: 바로가기
의존성을 주입해주려면, 의존성을 관리하면서 의존성을 실제로 처리해주는 클래스가 필요해요. 이걸 우리는 Resolver
라고 이름 지어줄게요. 나중에 규모가 점점 커지면 의존성을 관리하는 클래스를 따로 분리하는 것도 좋을 거 같아요.
Resolver
는 실제로 의존성을 주입해줄 거라서, 데코레이터를 통해 주입될 인스턴스를 미리 모두 갖고 있어야 해요. 그러기 위해선 Resolver
안에서 인스턴스들을 직접 만들거나, 인스턴스를 생성자에서 받아야 해요.
전자의 방법은 Resolver
가 인스턴스에 완전하게 직접적으로 의존하는 형태가 되기 때문에, 우리는 후자의 방법으로 Resolver
를 구현해볼게요.
import { ICalculator } from '@lery/instances/calculator/ICalculator';
import { IFileManager } from '@lery/instances/fileManager/IFileManager';
import { IHelloWorld } from '@lery/instances/helloWorld/IHelloWorld';
import { ILogger } from '@lery/instances/logger/ILogger';
type Instances = {
calculator: ICalculator;
logger: ILogger;
helloWorld: IHelloWorld;
fileManager: IFileManager;
};
export class Resolver {
constructor(private readonly instances: Instances) {}
}
그럼 우린 이제 앞서 말했던 기대하는 형태에서 Resolver
생성하는 부분을 해결했어요!
const resolver = new Resolver({
calculator: new Calculator(),
logger: new Logger(),
helloWorld: new HelloWorld(),
fileManager: new FileManager(),
});
관련 커밋: 바로가기
이제 주입의 대상이 될 파라미터나 프로퍼티를 지정할 수 있도록 데코레이터를 구현해볼게요. 메타데이터를 통해서 처리할 거라서, 먼저 reflect-metadata
라이브러리를 설치해줍니다.
"dependencies": {
"reflect-metadata": "^0.2.2"
}
그런 다음 메타데이터의 키와 값의 타입을 정의해요.
export const PARAMETER_INJECT_METADATA = 'PARAMETER_INJECT_METADATA' as const;
export const PROPERTY_INJECT_METADATA = 'PROPERTY_INJECT_METADATA' as const;
export type ParameterInjectMetadata = {
token: string;
index: number;
};
export type PropertyInjectMetadata = {
token: string;
propertyKey: string | symbol;
};
두 메타데이터는 공통적으로 token
프로퍼티를 갖고 있어요. token
은 해당 파라미터나 프로퍼티가 어떤 의존성을 주입 받을지를 결정해요. Calculator
일 수도 있고 Logger
일 수도 있는거죠.
파라미터의 메타데이터는 자신이 몇 번째에 있는 파라미터인지가 중요하기 때문에 추가적으로 index
를 가져요. 또, 생성자 외에 다른 메서드에는 파라미터가 주입되지 않을 거라서 따로 다른 프로퍼티를 갖고 있진 않아요.
프로퍼티의 메타데이터는 자신이 여러 프로퍼티 중 누구인지가 중요하기 때문에 프로퍼티의 이름으로 propertyKey
를 갖고 있어요. 클래스의 프로퍼티는 symbol
타입이 될 수도 있기 때문에 string | symbol
타입으로 정의되어 있어요.
이제 데코레이터의 구현을 살펴볼거에요. 하지만 그 전에 먼저 파라미터 데코레이터의 타입과 프로퍼티 데코레이터의 타입을 살펴볼게요.
type ParameterDecorator = (
target: Object,
propertyKey: string | symbol | undefined,
parameterIndex: number
) => void
type PropertyDecorator = (
target: Object,
propertyKey: string | symbol
) => void
파라미터 데코레이터와 프로퍼티 데코레이터는 기본적으로 생김새가 비슷해요.
하지만 파라미터 데코레이터는 파라미터의 위치까지 갖고 있어야 하기 때문에 추가적으로 parameterIndex: number
파라미터를 갖고 있으며, 생성자의 경우 별도의 프로퍼티가 아니기 때문에 propertyKey
가 undefined
이 될 수 있어요.
한편 프로퍼티 데코레이터는 그냥 붙은 프로퍼티가 뭔지만 알면 되기 때문에 프로퍼티 이름인 propertyKey
가 들어오죠.
그러면 파라미터와 프로퍼티에 동시에 데코레이터를 사용할 수 있게 하려면 어떤 타입을 가져야 할까요?
해당 타입을 적용해서 주입 데코레이터를 구현해봤어요.
import {
PARAMETER_INJECT_METADATA,
PROPERTY_INJECT_METADATA,
ParameterInjectMetadata,
PropertyInjectMetadata,
} from '@lery/core/metadata/InjectMetadata';
export const Inject =
(token: string) =>
(
target: object,
propertyKey: string | symbol | undefined,
index?: number
) => {
const appendMetadata = <T>(key: string, metadata: T, target: object) => {
const prevMetadata: T[] = Reflect.getMetadata(key, target) ?? [];
Reflect.defineMetadata(key, prevMetadata.concat(metadata), target);
};
if (index !== undefined) {
const metadata: ParameterInjectMetadata = { token, index };
appendMetadata(PARAMETER_INJECT_METADATA, metadata, target);
}
if (propertyKey !== undefined) {
const metadata: PropertyInjectMetadata = { token, propertyKey };
appendMetadata(PROPERTY_INJECT_METADATA, metadata, target.constructor);
}
};
이렇게 하면 파라미터와 프로퍼티 둘 다 사용할 수 있어요. 이때, index
가 undefined
이 아니라면 파라미터 데코레이터이고, 그 외의 경우에는 프로퍼티 데코레이터라고 볼 수 있어요. 이에 따라 분기해서 메타데이터를 추가해주는 코드가 구현되어 있어요.
이때, 파라미터 데코레이터라도 propertyKey
가 undefined
이 아닐 수 있긴 하지만, 일단 우리는 생성자에서만 사용할 것이기 때문에 위와 같이 구현해뒀어요. 완전하게 분리한다면 else if
를 사용하면 돼요.
아무튼, 이렇게 주입 데코레이터의 구현을 마쳤고, 이제 실제로 주입될 의존성을 구분할 수 있도록 각 의존성에 대한 데코레이터를 만들어줄게요.
export const CalculatorToken = 'Calculator';
export const InjectCalculator = () => Inject(CalculatorToken);
export const LoggerToken = 'Logger';
export const InjectLogger = () => Inject(LoggerToken);
export const HelloWorldToken = 'HelloWorld';
export const InjectHelloWorld = () => Inject(HelloWorldToken);
export const FileManagerToken = 'FileManager';
export const InjectFileManager = () => Inject(FileManagerToken);
관련 커밋: 바로가기
이렇게 데코레이터까지 모두 구현하면 주입 처리를 구현하는 건 간단해요. 메타데이터를 읽어서 토큰에 맞는 인스턴스를 주입해주기만 하면 되거든요!
비어있는 Resolver
에 resolve<T>
메서드를 구현해줄게요.
export class Resolver {
constructor(private readonly instances: Instances) {}
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) ?? [];
return metadata.map((m) => this.mapTokenToInstance(m.token));
}
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.mapTokenToInstance(m.token);
});
}
private mapTokenToInstance(token: string): any {
switch (token) {
case CalculatorToken:
return this.instances.calculator;
case LoggerToken:
return this.instances.logger;
case HelloWorldToken:
return this.instances.helloWorld;
case FileManagerToken:
return this.instances.fileManager;
default:
throw new Error(`Token ${token} not found`);
}
}
}
mapTokenToInstance
가 메타데이터에 있는 토큰을 실제 인스턴스로 매핑해주면, 이걸 받아서 파라미터와 프로퍼티에다가 바로 주입해주는 형식이에요.
resolveConstructorParameters<T>
에서는 파라미터 메타데이터를 모두 읽어와서 순서대로 인스턴스를 반환해주고 있고, 그 반환 값을 통해 대상 클래스의 생성자를 호출하여 객체를 생성했어요.
그런 다음 resolveProperty<T>
에서 프로퍼티 메타데이터를 모두 읽어서 각 프로퍼티에 맞는 인스턴스를 주입해주고 있어요.
이렇게 완성된 객체를 resolve<T>
가 최종적으로 반환함으로써 의존성 주입 처리가 완료돼요.
그럼 아래와 같이 의존성을 주입 받을 수 있어요.
class Hello {
@InjectCalculator()
calculator!: ICalculator;
@InjectLogger()
logger!: ILogger;
constructor(
@InjectHelloWorld()
readonly helloWorld: IHelloWorld,
@InjectFileManager()
readonly fileManager: IFileManager
) {}
}
function main() {
const resolver = new Resolver({
calculator: new Calculator(),
logger: new Logger(),
helloWorld: new HelloWorld(),
fileManager: new FileManager(),
});
const hello = resolver.resolve(Hello);
console.log(hello);
/*
Hello {
helloWorld: FileManager {},
fileManager: HelloWorld {},
calculator: Calculator {},
logger: Logger {}
}
*/
}
main();
nestjs가 실제로 어떻게 인스턴스를 주입해주는지를 살펴본 이후부터 쭉 쓰고 싶었던 글 주제 중 하나인데요. 여유 될 때마다 글 조금씩 써서 열심히 올려보겠습니다.
다음에는 동적으로 의존성을 Resolver
에 추가해서 의존성을 처리할 수 있도록 구현해볼게요.
읽어주셔서 감사합니다!