NestJS middleware는 어떻게 작동할까 (3)

들어가며


  • 목표는 이전 글과 동일합니다.
    • nestJS 미들웨어의 코드가 어떻게 구성되어 있는지 확인해본다.
    • 직접 사용해본다.
    • 사용해보며 문제가 있거나 / 이슈가 열려있다면 기여해본다.
  • 이번 글에서는 이전 글에서 분석한 MiddlewareBuilder 를 사용하는 부분부터알아보겠습니다.

돌아보며


  • middlewareModuleloadConfiguration 메소드에서 MiddlewareBuilder를 생성, 구성을 하고 사용하는 부분이 있었습니다.
 public async loadConfiguration(
    middlewareContainer: MiddlewareContainer,
    moduleRef: Module, // 모듈에 대한 참조, configure함수 호출
    moduleKey: string,
  ) {
    // 현재 모듈에 대한 참조에서 인스턴스를 가져옴
    const { instance } = moduleRef;
    
    // 모듈에 configure 메서드가 없을 경우 미들웨어를 구성하지 않고 종료
    if (!instance.configure) {
      return;
    }
    
    // 미들웨어를 구성하고 빌드하는데에 사용되는 MiddlewareBuilder 생성
    // 모듈마다 MiddlewareBuilder를 생성
    const middlewareBuilder = new MiddlewareBuilder(
      this.routesMapper,
      this.httpAdapter,
      this.routeInfoPathExtractor,
    );
    try {
      // 모듈의 configure 메서드를 호출하여 미들웨어 빌더를 전달
      // 모듈에서 미들웨어 구성을 수행
      await instance.configure(middlewareBuilder);
    } catch (err) {
      if (!this.appOptions.preview) {
        throw err;
      }
      const warningMessage =
        `Warning! "${moduleRef.name}" module exposes a "configure" method that throws an exception in the preview mode` +
        ` (possibly due to missing dependencies). Note: you can ignore this message, just be aware that some of those conditional middlewares will not be reflected in your graph.`;
      this.logger.warn(warningMessage);
    }

    if (!(middlewareBuilder instanceof MiddlewareBuilder)) {
      return;
    }
    // 미들웨어 빌더를 사용해서 config 빌드
    const config = middlewareBuilder.build();
    // 빌드된 config을 미들웨어 컨테이너에 삽입
    middlewareContainer.insertConfig(config, moduleKey);
  }
  • 이전글에서 알아 보았듯이, middlewareBuilder.build() 의 경우 [...this.middlewareCollection] 즉 middlewareCollection의 배열을 반환합니다.
  • 이렇게 반환된 config을 사용하여 처리하는 부분부터 살펴보겠습니다.

1. loadConfiguration


middlewareContainer.insertConfig(config, moduleKey);
  • 빌드된 config을 미들웨어 컨테이너에 삽입합니다.

insertConfig


public insertConfig(
  configList: MiddlewareConfiguration[],
  moduleKey: string,
) {
  const middleware = this.getMiddlewareCollection(moduleKey);
  const targetConfig = this.getTargetConfig(moduleKey);

  const configurations = configList || [];
  const insertMiddleware = <T extends Type<unknown>>(metatype: T) => {
    const token = metatype;
    middleware.set(
      token,
      new InstanceWrapper({
        scope: getClassScope(metatype),
        durable: isDurable(metatype),
        name: token?.name ?? token,
        metatype,
        token,
      }),
    );
  };
  configurations.forEach(config => {
    [].concat(config.middleware).map(insertMiddleware);
    targetConfig.add(config);
  });
}
  • insertConfig 메서드는 미들웨어 설정 목록(config List config)과 모듈 키(moduleKey)를 인자로 받습니다.
const middleware = this.getMiddlewareCollection(moduleKey);
  • getMiddlewareCollection 메서드를 호출하여 해당 모듈 키에 대한 미들웨어 컬렉션을 가져옵니다.

getMiddlewareCollection


public getMiddlewareCollection(
  moduleKey: string,
): Map<InjectionToken, InstanceWrapper> {
  if (!this.middleware.has(moduleKey)) {
    const moduleRef = this.container.getModuleByKey(moduleKey);
    this.middleware.set(moduleKey, moduleRef.middlewares);
  }
  return this.middleware.get(moduleKey);
}

  • 주어진 moduleKey에 대한 미들웨어 컬렉션을 반환합니다.
private readonly middleware = new Map<
		string,
    Map<InjectionToken, InstanceWrapper>
>();
  • 해당 Map에서 가져옵니다.
  • 모듈 키에 대한 미들웨어 컬렉션이 없으면 새로 가져와서 설정합니다.
const targetConfig = this.getTargetConfig(moduleKey);
  • getTargetConfig 메서드를 호출하여 해당 모듈 키에 대한 타겟 설정을 가져옵니다.

getTargetConfig


private getTargetConfig(moduleName: string) {
  if (!this.configurationSets.has(moduleName)) {
    this.configurationSets.set(
      moduleName,
      new Set<MiddlewareConfiguration>(),
    );
  }
  return this.configurationSets.get(moduleName);
}
  • 주어진 moduleName에 대한 타겟 설정을 반환합니다.
private readonly configurationSets = new Map<
		string,
    Set<MiddlewareConfiguration>
>();
  • 해당 Map에서 가져옵니다.
  • 타겟 설정이 없으면 새로 생성하여 설정합니다.
const configurations = configList || [];
const insertMiddleware = <T extends Type<unknown>>(metatype: T) => {
    const token = metatype;
    middleware.set(
      token,
      new InstanceWrapper({
        scope: getClassScope(metatype),
        durable: isDurable(metatype),
        name: token?.name ?? token,
        metatype,
        token,
      }),
    );
  };
  • config List config가 제공되지 않으면 빈 배열로 초기화합니다.
  • insertMiddleware 함수는 미들웨어 클래스를 받아서 InstanceWrapper로 감싸고 middleware 맵에 추가합니다.
  • InstanceWrapper는 클래스의 인스턴스를 관리하는 데 사용됩니다.
  • scope, durable, name, metatype, token 속성을 설정합니다.
configurations.forEach(config => {
  [].concat(config.middleware).map(insertMiddleware);
  targetConfig.add(config);
});
  • configurations 배열을 돌며 다음을 수행합니다.
    • **[].concat(config.middleware)**를 사용하여 middleware를 배열로 변환합니다.
    • **map(insertMiddleware)**를 호출하여 각 미들웨어를 insertMiddleware 함수로 처리합니다.
      • insertMiddleware 함수는 미들웨어 클래스를 받아서 InstanceWrapper로 감싸고 middleware 맵에 추가합니다. (middleware 맵에 추가)
    • **targetConfig.add(config)**를 호출하여 targetConfig 세트에 설정을 추가합니다. (targetConfig 맵에 추가)
  • 해당 과정을 통해 전체 미들웨어를 관리하는 middlewareContainer의 필드인 middleware set에 미들웨어에 대한 인스턴스를 추가하고, config urationSets config set에 미들웨어의 설정에 대한 값들을 추가하여 저장합니다.
    • 이러한 과정을 통해서 각 모듈의 미들웨어들이 초기화되고 저장되는 것 입니다.

예시


const configList = [
  {
    middleware: [SomeMiddleware],
    forRoutes: ['/user'],
  },
  {
    middleware: [AnotherMiddleware],
    forRoutes: ['/admin'],
  }
];
const moduleKey = 'SomeModule';
middlewareContainer.insertConfig(configList, moduleKey);
  • moduleKey에 대한 미들웨어 컬렉션을 가져옵니다.
  • moduleKey에 대한 타겟 설정을 가져옵니다.
  • 각 설정을 순회하며, 각 설정의 미들웨어를 insertMiddleware 함수로 처리하여 미들웨어 컬렉션에 추가하고, 설정을 타겟 설정에 추가합니다.

+middleware, configurationSets이 뭔데?


middleware


private readonly middleware = new Map<
  string,
  Map<InjectionToken, InstanceWrapper>
>();
  • 역할
    • 이 맵은 모듈 키(string)를 기준으로 각 모듈의 미들웨어 인스턴스를 관리합니다.
    • 각 모듈 키에 대해 또 다른 맵을 저장하며, 이 내부 맵은 미들웨어의 InjectionToken과 해당 미들웨어의 InstanceWrapper를 매핑합니다.
    • 모듈안에 여러가지 미들웨어가 있을 수 있기 때문입니다.
  • 구조
    • 최상위 키: 모듈 키(string), 예를 들어 'AppModule', 'UserModule' 등입니다.
    • 내부 맵: 미들웨어의 InjectionToken을 키로 하고, InstanceWrapper를 값으로 가지는 맵입니다.
  • e.g
{
  'AppModule': {
    SomeMiddlewareToken: InstanceWrapper { ... },
    AnotherMiddlewareToken: InstanceWrapper { ... },
  },
  'UserModule': {
    UserMiddlewareToken: InstanceWrapper { ... },
  },
}

configurationSets


private readonly configurationSets = new Map<
  string,
  Set<MiddlewareConfiguration>
>();
  • 역할
    • 이 맵은 모듈 키(string)를 기준으로 각 모듈의 미들웨어 설정(MiddlewareConfiguration)을 관리합니다.
    • 각 모듈 키에 대해 Set을 저장하며, 이 집합은 해당 모듈의 모든 미들웨어 설정을 포함합니다.
  • 구조
    • 최상위 키: 모듈 키(string), 예를 들어 'AppModule', 'UserModule' 등 입니다.
    • 값: 해당 모듈의 미들웨어 설정입니다.
  • e.g
{
  'AppModule': Set<MiddlewareConfiguration> { ... },
  'UserModule': Set<MiddlewareConfiguration> { ... },
}

2. resolveMiddleware


  public async resolveMiddleware(
    middlewareContainer: MiddlewareContainer,
    modules: Map<string, Module>,
  ) {
    const moduleEntries = [...modules.entries()];
    const loadMiddlewareConfiguration = async ([moduleName, moduleRef]: [
      string,
      Module,
    ]) => {
      // 해당 메소드를 살펴보았었습니다.
      await this.loadConfiguration(middlewareContainer, moduleRef, moduleName);
      await this.resolver.resolveInstances(moduleRef, moduleName);
    };
    await Promise.all(moduleEntries.map(loadMiddlewareConfiguration));
  }
  • 위에서 알아본 loadConfiguration 메소드는 각 모듈에서 middlewareBuilder를 통해서 설정된 구성을 초기화하고 해당 정보들을 middlewareContainer에 저장하는 역할을 하였습니다.
  • resolveMiddleware 통해서 모든 모듈에 대해서 해당 작업을 수행하는 것을 확인할 수 있습니다.
  • 이제 살펴보지 않은 그 아래에 있는 메소드인this.resolver.resolveInstances 에 대해서 알아보겠습니다.

resolveInstances


await this.resolver.resolveInstances(moduleRef, moduleName);
  • 미들웨어 인스턴스를 생성하고 필요한 종속성을 주입하여 준비 상태로 만듭니다.
  • 우선 미들웨어 클래스로부터 인스턴스를 생성하고, 해당 인스턴스에 필요한 의존성을 주입합니다. 그리고 미들웨어 인스턴스를 초기화합니다.
  • 이를 통해 미들웨어가 실제로 사용될 수 있는 상태되면, 미들웨어는 요청을 처리할 준비가 완료됩니다.

MiddlewareResolver


import { InjectionToken } from '@nestjs/common';
import { Injector } from '../injector/injector';
import { InstanceWrapper } from '../injector/instance-wrapper';
import { Module } from '../injector/module';
import { MiddlewareContainer } from './container';

export class MiddlewareResolver {
  constructor(
    private readonly middlewareContainer: MiddlewareContainer,
    private readonly injector: Injector,
  ) {}

  public async resolveInstances(moduleRef: Module, moduleName: string) {
    const middlewareMap =
      this.middlewareContainer.getMiddlewareCollection(moduleName);
    const resolveInstance = async (wrapper: InstanceWrapper) =>
      this.resolveMiddlewareInstance(wrapper, middlewareMap, moduleRef);
    await Promise.all([...middlewareMap.values()].map(resolveInstance));
  }

  private async resolveMiddlewareInstance(
    wrapper: InstanceWrapper,
    middlewareMap: Map<InjectionToken, InstanceWrapper>,
    moduleRef: Module,
  ) {
    await this.injector.loadMiddleware(wrapper, middlewareMap, moduleRef);
  }
}

resolveInstances


  public async resolveInstances(moduleRef: Module, moduleName: string) {
    const middlewareMap =
      this.middlewareContainer.getMiddlewareCollection(moduleName);
    const resolveInstance = async (wrapper: InstanceWrapper) =>
      this.resolveMiddlewareInstance(wrapper, middlewareMap, moduleRef);
    await Promise.all([...middlewareMap.values()].map(resolveInstance));
  }
  • **getMiddlewareCollection(moduleName)**를 호출하여 해당 모듈의 미들웨어 컬렉션(InstanceWrapper)을 가져옵니다.
  • **Promise.all([...middlewareMap.values()].map(resolveInstance))**를 사용하여 모든 미들웨어 인스턴스를 비동기적으로 해결합니다.
    • resolveInstance 를 통해 각 InstanceWrapper를 처리합니다.

resolveMiddlewareInstance


 private async resolveMiddlewareInstance(
    wrapper: InstanceWrapper,
    middlewareMap: Map<InjectionToken, InstanceWrapper>,
    moduleRef: Module,
  ) {
    await this.injector.loadMiddleware(wrapper, middlewareMap, moduleRef);
  }
  • **this.injector.loadMiddleware(wrapper, middlewareMap, moduleRef)**를 호출하여 미들웨어 인스턴스를 생성하고 필요한 종속성을 주입합니다.

InstanceWrapper는 인스턴스가 아니다


  • 처음에는 InstanceWrapper 가 인스턴스인데, loadMiddleware로 인스턴스를 생성한다는 것이 이해가 가질 않았습니다.
  • 결론적으로 InstanceWrapper가 실제 인스턴스는 아닙니다. InstanceWrapper는 클래스 타입을 포함하고 있습니다. (여기서는 미들웨어 클래스와 해당 클레스의 메타데이터를 래핑합니다.)
    • InstanceWrapper는 단순히 클래스 타입과 그 인스턴스를 포함하는 래퍼입니다.
  • loadMiddleware 메서드는 InstanceWrapper를 사용하여 실제 인스턴스를 생성하고 필요한 종속성을 주입합니다. 이 과정을 통해 미들웨어 인스턴스가 생성되고 초기화됩니다.

loadMiddleware


public async loadMiddleware(
  wrapper: InstanceWrapper,
  collection: Map<InjectionToken, InstanceWrapper>,
  moduleRef: Module,
  contextId = STATIC_CONTEXT,
  inquirer?: InstanceWrapper,
) {
  const { metatype, token } = wrapper;
  const targetWrapper = collection.get(token);
  if (!isUndefined(targetWrapper.instance)) {
    return;
  }
  targetWrapper.instance = Object.create(metatype.prototype);
  await this.loadInstance(
    wrapper,
    collection,
    moduleRef,
    contextId,
    inquirer || wrapper,
  );
}
  • 파라미터

    • wrapper: 미들웨어 인스턴스를 감싸는 InstanceWrapper
    • collection: 미들웨어 인스턴스를 저장하는 Map
    • moduleRef: 현재 모듈 참조
    • contextId: 컨텍스트 ID (기본값은 STATIC_CONTEXT)
    • inquirer: 인스턴스를 요청하는 다른 InstanceWrapper (옵션)
  • 타겟 래퍼 가져오기

const targetWrapper = collection.get(token);
  • collection에서 token에 해당하는 targetWrapper를 가져옵니다.

  • 인스턴스 존재 확인

if (!isUndefined(targetWrapper.instance)) { return; }
  • targetWrapper에 인스턴스가 이미 존재하면 더 이상 작업을 진행하지 않습니다.

  • 인스턴스 생성

targetWrapper.instance = Object.create(metatype.prototype);
  • 미들웨어 클래스의 프로토타입을 기반으로 새로운 인스턴스를 생성합니다.

  • 인스턴스 로드

await this.loadInstance(wrapper, collection, moduleRef, contextId, inquirer || wrapper);
  • loadInstance 메서드를 호출하여 인스턴스를 로드하고 종속성을 주입합니다.

loadInstance


public async loadInstance<T>(
  wrapper: InstanceWrapper<T>,
  collection: Map<InjectionToken, InstanceWrapper>,
  moduleRef: Module,
  contextId = STATIC_CONTEXT,
  inquirer?: InstanceWrapper,
) {
  const inquirerId = this.getInquirerId(inquirer);
  const instanceHost = wrapper.getInstanceByContextId(
    this.getContextId(contextId, wrapper),
    inquirerId,
  );

  if (instanceHost.isPending) {
    const settlementSignal = wrapper.settlementSignal;
    if (inquirer && settlementSignal?.isCycle(inquirer.id)) {
      throw new CircularDependencyException(`"${wrapper.name}"`);
    }

    return instanceHost.donePromise.then((err?: unknown) => {
      if (err) {
        throw err;
      }
    });
  }

  const settlementSignal = this.applySettlementSignal(instanceHost, wrapper);
  const token = wrapper.token || wrapper.name;

  const { inject } = wrapper;
  const targetWrapper = collection.get(token);
  if (isUndefined(targetWrapper)) {
    throw new RuntimeException();
  }
  if (instanceHost.isResolved) {
    return settlementSignal.complete();
  }
  try {
    const t0 = this.getNowTimestamp();
    const callback = async (instances: unknown[]) => {
      const properties = await this.resolveProperties(
        wrapper,
        moduleRef,
        inject as InjectionToken[],
        contextId,
        wrapper,
        inquirer,
      );
      const instance = await this.instantiateClass(
        instances,
        wrapper,
        targetWrapper,
        contextId,
        inquirer,
      );
      this.applyProperties(instance, properties);
      wrapper.initTime = this.getNowTimestamp() - t0;
      settlementSignal.complete();
    };
    await this.resolveConstructorParams<T>(
      wrapper,
      moduleRef,
      inject as InjectionToken[],
      callback,
      contextId,
      wrapper,
      inquirer,
    );
  } catch (err) {
    settlementSignal.error(err);
    throw err;
  }
}
  • 인스턴스 가져오기
const instanceHost = wrapper.getInstanceByContextId(...);
  • 주어진 컨텍스트 ID와 inquirerId를 사용하여 인스턴스를 가져옵니다.

  • 대기 중인 인스턴스 확인

if (instanceHost.isPending) { ... }
  • 인스턴스가 대기 중인 상태라면, 대기 완료를 기다립니다.

  • 정착 신호 적용

const settlementSignal = this.applySettlementSignal(instanceHost, wrapper);
  • 정착 신호를 적용하여 인스턴스 로드가 완료되었는지 확인합니다.

  • 토큰 및 타겟 래퍼 가져오기

const token = wrapper.token || wrapper.name;
const targetWrapper = collection.get(token);
  • 토큰을 사용하여 타겟 래퍼를 가져옵니다.

  • 타겟 래퍼 확인:

if (isUndefined(targetWrapper)) { throw new RuntimeException(); }
  • 타겟 래퍼가 존재하지 않으면 예외를 발생시킵니다.

  • 인스턴스 생성 및 종속성 주입:

await this.resolveConstructorParams<T>(...);
const instance = await this.instantiateClass(...);
this.applyProperties(instance, properties);
  • resolveConstructorParams 메서드를 사용하여 생성자 파라미터를 해결하고 인스턴스를 생성합니다.
  • instantiateClass 메서드를 사용하여 인스턴스를 생성합니다
  • applyProperties 메서드를 사용하여 속성을 주입합니다.
  • 해당 과정을 통해서 진짜 (미들웨어) 인스턴스가 만들어집니다!
  • 결론적으로 resolveMiddleware 메소드는 모든 모듈을 순회하며 구성을 초기화하고, 해당 정보들을 실제 인스턴스로 만드는 과정입니다.

이어가며


  • 모든 모듈에 대해 미들웨어 구성을 로드 / 처리 / 컨테이너에 삽입하는 부분만 살펴보았는데요, 이번에도 글이 너무 길어지는 관계로 이를 적용하는 부분에 대해서는 다음 글에 이어서 작성하겠습니다.