Nestjs를 닮은 DI 시스템 직접 구현하기 - 2

러리·2024년 10월 3일
1
post-thumbnail

안녕하세요!
몇 개월 만에 돌아온 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가 지지고 볶고 해서 모든 의존성을 해결하도록 구현해볼게요.

읽어주셔서 감사합니다!

profile
하고 싶은걸 합니다.

0개의 댓글