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

러리·2024년 12월 21일
0
post-thumbnail

안녕하세요, 또 오랜만에 글을 작성하네요.
슬슬 회고 글 작성 준비도 해야 하는데, 점점 더 게을러지는 거 같아요.

아무튼, 오늘도 돌아온 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가지의 프로퍼티를 갖는 클래스를 정의합니다.

createFnundefined도 가능한데, 이는 ValueProvider의 경우입니다. 생성자 프로바이더 및 ClassProvidercreateFn으로 생성자가 들어오게 됩니다.

이제 인스턴스를 직접 사용하고 있던 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를 받을 수 있어야 하기 때문에, 두 프로바이더를 정의해뒀습니다.

동적 인스턴스 의존성 리졸버 구현

관련 커밋: 바로가기

이번 글의 핵심입니다. 천천히 하나씩 가볼게요.

리졸버 구현은 크게 두 가지로 나뉩니다.

  1. 생성자 파라미터로부터 프로바이더 목록을 받아 InstanceContainer에 등록하는 구간
  2. 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>를 사용합니다.

이제 InstanceContainerInstanceWrapper가 적절하게 등록되었으니 두 번째 단계로 가볼게요.

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와 생성자 프로바이더는 모두 각자의 생성자와 프로퍼티를 갖게 되는데요. 둘 모두 생성자 파라미터와 프로퍼티에 대한 의존성 처리가 필요하므로, 각각의 의존성을 해결하는 resolveConstructorParametersresolveProperties를 호출하여 필요한 의존성을 가져옵니다.

가져온 의존성을 기반으로 인스턴스화를 진행하고, 프로퍼티를 주입합니다. 이제 모든 의존성이 처리되었으므로 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를 호출한 뒤 결과로 반환해주는게 끝이거든요.

앞서 loadProviderInstanceWrapper를 받아서 그 의존성을 처리하는 역할을 한다고 말씀드렸는데요, 말그대로 필요한 의존성들의 의존성을 처리하고, 그 인스턴스를 가져와 반환하는 것입니다.

의존성 처리 순서를 플로우 차트로 나타낸 그림

플로우 차트로 보면 위와 같이 처리됩니다. 말 그대로 재귀를 통한 DFS 구현입니다. 심플하죠?

NestJS는 여기에 추가적으로 수많은 구성 요소와 요구 사항이 들어가며 거대한 재귀를 이루게 된 것입니다.

구현 끝!

처리된 인스턴스의 메서드를 호출하여 정상 동작함을 보여주는 모습

여기까지 오면, 이렇게 우리가 의도한 대로 의존성이 잘 처리되어 여러 인스턴스를 가져다 쓸 수 있게 됩니다.

마치며

점점 NestJS와 비슷한 모양이 되어가고 있어요. 한 번에 모든 글을 짠 쓰는 것도 좋지만, 이렇게 한 발짝 한 발짝 가는 것도 좋네요.

다음엔 여기에 모듈 시스템을 추가해볼게요.

읽어주셔서 감사합니다.

profile
하고 싶은걸 합니다.

0개의 댓글