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

러리·2026년 1월 2일
post-thumbnail

안녕하세요, 오랜만의 글입니다.

이제 NestJS를 닮은 DI 시스템 직접 구현하기 시리즈 마무리를 좀 지어보려구요. 사실 구현 자체는 예전에 했었는데, 글 쓰기를 계속 미루다 이제 쓰네요.

이번에는 단일 리졸버의 구성에서, 모듈 구성을 적용하는 과정입니다. 코드는 여기를 참고해주세요(Github).

시작해보겠습니다.


기대하는 형태

이번 수정이 아마 NestJS 형태와 가장 닮은 꼴이 되지 않을까 싶은데요. 아래와 같은 동작이 가능해야 합니다.

@Module({
  providers: [
    { provide: LoggerToken, useValue: new Logger() },
    { provide: CalculatorToken, useValue: new Calculator() },
  ],
  exports: [LoggerToken, CalculatorToken],
})
class UtilModule {}

@Module({
  imports: [UtilModule],
  providers: [{ provide: HelloWorldToken, useClass: HelloWorld }, FileManager],
  exports: [UtilModule, FileManager, HelloWorldToken],
})
class ImplementationsModule {}

@Module({
  imports: [ImplementationsModule],
  providers: [{ provide: Hello, useClass: Hello }, Something],
})
class MyModule {}

function main() {
  const resolver = DependencyResolverFactory.create(MyModule);
  const hello = resolver.get(Hello);
}

사실상 NestFactoryDependencyResolverFactory라는 이름으로 수정한 것 외에는 현재 NestJS 코드와 크게 다를 바가 없어지는데요. 좀 크게 변경되긴 했습니다.

구현하기

단일 리졸버 때는 단일 프로바이더 컨테이너에 저장하던 InstanceWrapper를 모듈 별로 저장하고, 의존성을 찾을 때 imports에 등록된 모듈들이 export하는 프로바이더를 탐색하도록 수정했는데요.

이제 구현으로 들어가보겠습니다.

모듈 데코레이터 정의

export const MODULE_METADATA = 'MODULE_METADATA' as const;

export type ModuleMetadata = {
  imports?: Type<any>[];
  providers?: Provider[];
  exports?: (Token<any> | Provider)[];
};

export const Module = (metadata: ModuleMetadata) => (target: object) => {
  Reflect.defineMetadata(MODULE_METADATA, metadata, target);
};

모듈을 선언하는 @Module은 클래스 데코레이터로 정의하였고, 메타데이터로 imports, providers, exports를 받을 수 있도록 구현하였습니다. HTTP 서버를 구현하는 것이 아니기 때문에 controllers는 받지 않습니다.

추가로, 실제 NestJS 구현에서는 @Module 데코레이터에서 저렇게 메타데이터를 통째로 객체로 저장하는 것이 아니라, imports, providers, controllers, exports를 각 메타데이터로 저장하도록 구현되어 있는데요(코드). 굳이 그럴 필요 있나 싶어 통째로 넣었습니다.

모듈 구현

export class Module {
  private readonly _token: Token;

  private _imports: Module[] = [];
  private _providers: InstanceWrapper[] = [];
  private _exports: Token[] = [];

  constructor(token: Token) {
    this._token = token;
  }

  get token(): Token {
    return this._token;
  }

  get imports(): Module[] {
    return this._imports;
  }

  get providers(): InstanceWrapper[] {
    return this._providers;
  }

  get exports(): Token[] {
    return this._exports;
  }

  addImport(module: Module): void {
    this.imports.push(module);
  }

  addProvider(provider: InstanceWrapper): void {
    this._providers.push(provider);
  }

  addExport(exportTargetToken: Token): void {
    const isImportedModuleToken =
      this._imports.find((m) => m.token === exportTargetToken) !== undefined;

    const isProviderToken =
      this._providers.find((p) => p.token === exportTargetToken) !== undefined;

    if (!isImportedModuleToken && !isProviderToken) {
      throw new Error(`Unknown token: ${exportTargetToken.toString()}`);
    }

    this._exports.push(exportTargetToken);
  }

  hasProvider(token: Token): boolean {
    return this._providers.some((wrapper) => wrapper.token === token);
  }

  getProvider<T>(token: Token): InstanceWrapper<T> {
    const wrapper: InstanceWrapper<T> | undefined = this._providers.find(
      (wrapper) => wrapper.token === token
    );

    if (!wrapper) {
      const tokenString = stringifyToken(token);
      const moduleTokenString = stringifyToken(this._token);

      throw new Error(
        `Provider ${tokenString} is not exists in module ${moduleTokenString}.`
      );
    }

    return wrapper;
  }

  hasExport(token: Token): boolean {
    return this._exports.includes(token);
  }
}

이제 모듈을 구현해줍니다. 프로바이더도 실체인 인스턴스와 InstanceWrapper가 있듯이, 모듈도 선언적으로 정의된 모듈 메타데이터를 실체화해야 하는데요. 위 Module 클래스가 바로 실체화된 모듈입니다. NestJS에서도 이를 위한 클래스가 존재합니다(코드).

addExport 메서드를 보면 다른 추가 메서드들과는 다르게 구현에 뭔가 좀 더 들어가 있는데요. _imports_providers는 추가하는 데에 큰 제약이 없지만 _exports의 경우 이미 모듈에 존재하는 것들만 내보낼 수 있습니다. 따라서 파라미터의 토큰이 _imports_providers에 존재하는지 확인합니다.

모듈 컨테이너 구현

export class ModuleContainer {
  private readonly moduleMap: Map<Token, Module> = new Map();

  register(module: Module) {
    this.moduleMap.set(module.token, module);
  }

  has(token: Token): boolean {
    return this.moduleMap.has(token);
  }

  get(token: Token): Module {
    if (!this.moduleMap.has(token)) {
      throw new Error(`No module found for token ${token.toString()}`);
    }

    return this.moduleMap.get(token)!;
  }

  get modules(): Module[] {
    return Array.from(this.moduleMap.values());
  }
}

모듈들도 인스턴스처럼 이제 여러 개가 될 수 있습니다. 따라서 어딘가에 잘 담아뒀다가 필요할 때 꺼내쓸 수 있어야 하는데요. 이를 위해 간단하게 ModuleContainer를 구현했습니다.

모듈 리졸버 구현

export class ModuleResolver {
  private readonly wrapperFactory = new InstanceWrapperFactory();

  constructor(private readonly moduleContainer: ModuleContainer) {}

  resolve(moduleCtor: Type<any>) {
    this.resolveModule(moduleCtor);
  }

  private resolveModule(moduleCtor: Type<any>): Module {
    if (this.moduleContainer.has(moduleCtor)) {
      return this.moduleContainer.get(moduleCtor);
    }

    const newModule = new Module(moduleCtor);
    const metadata = this.reflectModuleMetadata(moduleCtor);
    
    // 의존하고 있는 모듈을 먼저 실체화시킨 뒤(DFS), 등록한다. 
    metadata.imports
      ?.map((importedModule) => this.resolveModule(importedModule))
      .forEach((importedModule) => newModule.addImport(importedModule));
    
    // 자신이 갖고 있는 모든 프로바이더를 실체화시킨 뒤, 등록한다.
    metadata.providers
      ?.map((provider) => this.wrapperFactory.createInstanceWrapper(provider))
      .forEach((wrapper) => newModule.addProvider(wrapper));
    
    metadata.exports?.forEach((export_) => {
      // `export` 대상을 string token이나 symbol token으로 넣은 경우
      if (typeof export_ === 'symbol' || typeof export_ === 'string') {
        newModule.addExport(export_);
        return;
      }
      
      // 이외의 경우 프로바이더나 모듈 자체를 넣은 것인데,
      // `provide`가 별도로 정의된 경우 생성자 프로바이더를 제외한 모든 프로바이더이며,
      // `provide`가 별도로 정의되지 않은 경우 생성자 프로바이더이거나 모듈 클래스 자체입니다.
      // 이 경우 그 자체로 토큰이 되므로 그대로 `addExport`에 넣습니다.
      newModule.addExport('provide' in export_ ? export_.provide : export_);
    });

    this.moduleContainer.register(newModule);

    return newModule;
  }

  private reflectModuleMetadata(moduleCtor: Type<any>): ModuleMetadata {
    const metadata = Reflect.getMetadata(MODULE_METADATA, moduleCtor);

    if (metadata === undefined) {
      throw new Error(
        `Given module ${moduleCtor.name} hasn't module decorator`
      );
    }

    return metadata as ModuleMetadata;
  }
}

모듈도 의존을 갖고 있고, 실체화를 해주어야 하기 때문에 별도 리졸버를 구현합니다. 모듈 리졸버의 책임은 주어진 모듈(moduleCtor)에 대한 실체화된 모듈을 생성하고, 해당 모듈의 메타데이터에 정의된 imports, providers, exports를 실체화시켜 모듈에 등록하는 것인데요.

모듈은 imports에 의해 의존 관계를 가질 수 있는데요. 현재 모듈을 정상적으로 리졸브하기 위해서는 의존하고 있는 모듈이 먼저 리졸브되어야 하기 때문에, DFS로 모듈 의존 관계를 순회하며 재귀적으로 리졸브시킵니다.

어쨌든 정상적으로 모듈 간 의존 관계가 정의되어 있다면 시작이 되는 모듈의 imports를 타고 모든 모듈에 도달할 수 있기 때문에, 모든 순회를 마치면 아래 상태에 다다르게 됩니다.

  1. 모든 모듈이 실체화됩니다.
  2. 각 모듈의 모든 프로바이더가 InstanceWrapper로 실체화되어 모듈에 등록됩니다.
  3. 모듈 간 의존이 실체화된 모듈에 적용됩니다.

따라서, 이제 의존하는 모듈의 프로바이더에 접근할 수 있는 상태가 됩니다.

그럼 이제 프로바이더의 리졸브가 가능하겠죠?

프로바이더 리졸버 구현

코드가 한 번에 보기엔 좀 길어서, 나눠서 소개 드리겠습니다.

리졸브 시작

export class ProviderResolver {
  constructor(private readonly moduleContainer: ModuleContainer) {}
  
  resolve(): void {
    this.moduleContainer.modules.forEach((module) =>
      this.resolveProvidersInModule(module)
    );
  }

  private resolveProvidersInModule(module: Module): void {
    module.providers.forEach((wrapper) => this.loadProvider(module, wrapper));
  }
  
  // ...
}

리졸브를 시작하는 구간입니다. resolve()를 호출하면, 현재 컨테이너에 등록된 모든 모듈의 프로바이더를 로딩합니다.

프로바이더 로딩

class ProviderResolver {
  private loadProvider<T>(
    module: Module,
    wrapper: InstanceWrapper<T>
  ): InstanceWrapper<T> {
    if (wrapper.isResolved) {
      return wrapper;
    }

    const ctorParams = this.resolveConstructorParameters(module, wrapper);
    const propParams = this.resolveProperties(module, wrapper);

    const instance = this.instantiate(wrapper, ctorParams);
    this.applyProperties(instance, propParams);

    wrapper.resolve(instance);
    return wrapper;
  }
  
  private resolveConstructorParameters<T>(
    module: Module,
    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.loadDependentProvider(module, m.token);
      result[m.index] = wrapper.instance;
    });

    return result;
  }

  private resolveProperties<T>(
    module: Module,
    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.loadDependentProvider(module, m.token);
      return { propertyKey: m.propertyKey, instance: wrapper.instance };
    });

    return result;
  }
  
  // ...
}

어떤 모듈 module에 존재하는 프로바이더 wrapper의 인스턴스화를 진행합니다. 여기 내용 자체는 이전 글에서 단일 리졸버로 구현할 때도 동일한 흐름으로 구현되었기 때문에 깊게 다루지는 않습니다.

생성자 파라미터에 있는 의존성과 프로퍼티에 있는 의존성에 대한 인스턴스들을 만들고, 이걸 활용해서 현재 프로바이더의 인스턴스화를 처리합니다.

이번 변경의 핵심은 그래서 어떻게 의존성을 갖고 오는데?인데요. 위 구현에서 loadDependentProvider 부분입니다. 이 부분을 살펴보겠습니다.

의존성 검색 구현

export class ProviderResolver {
  private loadDependentProvider<T>(
    module: Module,
    token: Token<T>
  ): InstanceWrapper<T> {
    let targetModule: Module = module;
    let targetWrapper: InstanceWrapper<T> | null = null;

    // 현재 모듈에서 프로바이더를 검색합니다.
    targetWrapper = this.findProviderFromThisModule(module, token);

    // 현재 모듈에 없다면 임포트 받은 모듈에서 프로바이더를 검색합니다.
    if (targetWrapper === null) {
      const wrappers = module.imports
        .map((importedModule) =>
          this.findProviderFromImportedModule(importedModule, token)
        )
        .filter((wrapper) => wrapper !== null);

      targetWrapper = wrappers[0] ?? null;
    }
      
    // 그래도 없으면 에러를 발생시킵니다.
    if (targetWrapper === null) {
      const tokenString = stringifyToken(token);
      const moduleTokenString = stringifyToken(module.token);
      throw new Error(
        `Unknown dependency ${tokenString} from module ${moduleTokenString}`
      );
    }
      
    // 의존하는 프로바이더 먼저 인스턴스화하고, 그 결과를 반환하여
    // 이번 인스턴스 생성 때 활용할 수 있도록 합니다.
    return this.loadProvider(targetModule, targetWrapper);
  }

  private findProviderFromThisModule<T>(
    module: Module,
    token: Token<T>
  ): InstanceWrapper<T> | null {
    return module.hasProvider(token) ? module.getProvider(token) : null;
  }

  private findProviderFromImportedModule<T>(
    importedModule: Module,
    token: Token<T>
  ): InstanceWrapper<T> | null {
    // 의존하는 모듈이 export하는 것들 중에서 모듈이 아닌 것, 즉 프로바이더들을 걸러냅니다.
    const exportedProviders = importedModule.exports.filter(
      (token) => !this.moduleContainer.has(token)
    );
    
    // export 되는 프로바이더 중에 찾고 있는 프로바이더가 존재한다면
    // 의존하고 있는 모듈의 프로바이더들 중에서 해당하는 프로바이더를 찾아서 반환합니다.
    if (exportedProviders.includes(token)) {
      const targetWrapper: InstanceWrapper<T> | undefined =
        importedModule.providers.find((wrapper) => wrapper.token === token);
      
      // export 되지만 실제로 프로바이더로는 존재하지 않는 경우 에러를 발생시킵니다.
      // 대부분의 경우 `addExport`할 때 확인하기 때문에 실제로 발생할 가능성은 없긴 합니다.
      if (targetWrapper === undefined) {
        const tokenString = stringifyToken(token);
        const moduleTokenString = stringifyToken(importedModule.token);

        throw new Error(
          `Token ${tokenString} is registered for exporting at module ${moduleTokenString}, but there is no provider with that token`
        );
      }

      return targetWrapper;
    }
    
    // 찾으려는 프로바이더가 의존하는 모듈이 export하는 프로바이더에 없다면,
    // 의존하는 모듈이 의존하고 있으면서 export하는 모듈에서 DFS로 재귀적으로 찾습니다.
    const exportedModules = importedModule.exports
      .filter((token) => this.moduleContainer.has(token))
      .map((moduleToken) => this.moduleContainer.get(moduleToken));

    const providerFromExportedModule: InstanceWrapper<T> | undefined =
      exportedModules
        .map((exportedModule) =>
          this.findProviderFromImportedModule(exportedModule, token)
        )
        .find((wrapper) => wrapper != null);
    
    // 만약 의존하는 모듈에도 존재하지 않는다면 `null`을 반환합니다.
    return providerFromExportedModule ?? null;
  }
}

요 부분은 별도 글로 설명하는 것보다는 코드를 따라가면서 읽으시는게 편하실 것 같아 코드에 주석으로 설명을 남겨두었는데요. 추가적으로 모듈의 구성에 관해 이것저것 얹어보겠습니다.

기본적으로 모듈은 자신이 의존하는 모듈(imports)들이나 갖고 있는 프로바이더(providers)들만 export할 수 있도록 되어 있는데요. 자신이 의존하고 있는 모듈을 그대로 export 하면, "자신을 의존하는 모듈"이 "자신이 의존하는 모듈"의 export에 있는 프로바이더 혹은 모듈을 참조할 수 있게 해줍니다.

이를 그림으로 그리면 다음과 같습니다.

UtilModuleSomeProvider를 소유하며 export하고 있고, ImplementationsModuleUtilModule을 의존하면서도 export합니다. 이렇게 되면 ImplementationsModule을 의존하는 MyModuleUtilModule에 존재하는 SomeProvider에 접근할 수 있는 형태가 됩니다.

위 과정이 지나면 모든 모듈의 모든 프로바이더의 의존성이 리졸브됩니다.

근데 이걸 호출해주는 곳이 있어야 하잖아요?

의존성 리졸버 팩토리 구현

export class DependencyResolverFactory {
  static create(moduleCtor: Type<any>): DependencyResolverApp {
    const moduleContainer = new ModuleContainer();
    const moduleResolver = new ModuleResolver(moduleContainer);
    const providerResolver = new ProviderResolver(moduleContainer);

    moduleResolver.resolve(moduleCtor);
    providerResolver.resolve();

    return new DependencyResolverApp(moduleContainer);
  }
}

의존성 처리 흐름을 구현하기 위해 DependencyResolverFactory.create를 구현했습니다. NestJS로 치면 NestFactory.create와 동일한 역할이라고 봐주시면 될 것 같습니다.

일단 먼저 필요한 클래스들을 생성하고, 모듈 리졸버로 모듈 간 의존성 및 실체화를 진행한 뒤, 프로바이더 리졸버로 등록된 모든 모듈의 프로바이더를 리졸브시킵니다.

모든 처리가 완료된 뒤에는, 생성된 인스턴스를 참조할 수 있도록 별도 앱으로 생성하여 반환합니다.

의존성 리졸버 앱 구현

export class DependencyResolverApp {
  constructor(private readonly moduleContainer: ModuleContainer) {}

  get<T>(token: Token<T>): T {
    const wrapper = this.moduleContainer.modules
      .flatMap((m) => m.providers)
      .find((wrapper) => wrapper.token === token);

    if (wrapper === undefined) {
      throw new Error(`Instance of ${token.toString()} is not found`);
    }

    if (wrapper.instance === undefined) {
      throw new Error(`Instance of ${token.toString()} is not resolved`);
    }

    return wrapper.instance;
  }
}

반환하는 그 앱이구요. 모듈 컨테이너의 모든 프로바이더에서 찾으려는 토큰과 동일한 프로바이더를 찾아, 그 인스턴스를 반환합니다. 이렇게 하면 모듈 기반 DI 시스템이 완성됩니다.

사용 예시

@Module({
  providers: [
    { provide: LoggerToken, useValue: new Logger() },
    { provide: CalculatorToken, useValue: new Calculator() },
  ],
  exports: [LoggerToken, CalculatorToken],
})
class UtilModule {}

@Module({
  imports: [UtilModule],
  providers: [{ provide: HelloWorldToken, useClass: HelloWorld }, FileManager],
  exports: [UtilModule, FileManager, HelloWorldToken],
})
class ImplementationsModule {}

@Module({
  imports: [ImplementationsModule],
  providers: [{ provide: Hello, useClass: Hello }, Something],
})
class MyModule {}

function main() {
  const resolver = DependencyResolverFactory.create(MyModule);

  const hello = resolver.get(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!

  const something = resolver.get(Something);
  console.log(something.addOneAndTwo()); // 3
}

main();

이렇게 시나리오가 모두 정상적으로 동작하는 모습을 볼 수 있습니다.


마치며

이렇게 타입스크립트 약 560라인 만으로 NestJS를 닮은 DI 시스템을 직접 구현해보았습니다. 요즘에는 AI 활용해서 개발을 많이 하다보니, AI를 일절 안 쓰고 이렇게 코드 작성해보는게 재밌었네요.

도메인도 산 김에 블로그를 벨로그에서 그냥 직접 만든 블로그로 옮길까 고민하고 있는데요. 다음 글은 아마 별도 블로그에서 쓰지 않을까 싶습니다.

읽어주셔서 감사합니다.

profile
하고 싶은걸 합니다.

0개의 댓글