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

목표


  • nestJS 미들웨어의 코드가 어떻게 구성되어 있는지 확인해본다.
  • 직접 사용해본다.
  • 사용해보며 문제가 있거나 / 이슈가 열려있다면 기여해본다.
Documentation | NestJS - A progressive Node.js framework
Documentation | NestJS - A progressive Node.js framework
Nest is a framework for building efficient, scalable Node.js server-side applications. It uses progressive JavaScript, is built with TypeScript and combines elements of OOP (Object Oriented Programming), FP (Functional Programming), and FRP (Functional Reactive Programming).

0. 미들웨어


  • 미들웨어는 route hanlder 이전에 호출되는 함수이다.
  • request, response 객체에 접근 권한이 있다.
  • 애플리케이션의 request-response cycle에서 next() 미들웨어 함수에도 접근 권한이 있다.

Untitled

1. 사용법


import { Injectable, NestMiddleware } from '@nestjs/common';
import { Request, Response, NextFunction } from 'express';

@Injectable()
export class LoggerMiddleware implements NestMiddleware {
  use(req: Request, res: Response, next: NextFunction) {
    console.log('Request...');
    next();
  }
}
  • nestMiddleware를 상속한 미들웨어를 만들어서 사용하면 된다.
import { Module, NestModule, MiddlewareConsumer } from '@nestjs/common';
import { LoggerMiddleware } from './common/middleware/logger.middleware';
import { CatsModule } from './cats/cats.module';

@Module({
  imports: [CatsModule],
})
export class AppModule implements NestModule {
  configure(consumer: MiddlewareConsumer) {
    consumer
      .apply(LoggerMiddleware)
      .forRoutes('cats');
  }
}
  • 모듈단에서 configure한다.
    • consumer에 메소드를 체이닝해서 사용하는 방식으로 사용한다.
    • 해당 예시에서는 apply(미들웨어를 등록), forRoutes(사용할 라우트들을 지정) 메소드를 사용한다.

2. 구현체 확인


export class AppModule implements NestModule {
  configure(consumer: MiddlewareConsumer) {
    consumer
      .apply(LoggerMiddleware)
      .forRoutes('cats');
  }
}
  • 구현체를 확인하기 위해 진입점을 먼저 잘 찾아야한다.
  • 모듈 configure 메소드를 먼저 집중해 보자. MiddlewareConsumer 클래스를 매개변수로 받아서 해당 객체의 메소드들을(apply, forRoutes, exclude..) 체이닝해서 적용하고 있다.
  • 그러면 우선 살펴볼 부분은 다음과 같다.
      1. 해당 configure 메소드를 호출하는 부분 ⇒ 어떻게 여러 미들웨어가 초기화 되는가
      1. 미들웨어 적용하고 제어할 수 있는 MiddlewareConsumer 클래스 ⇒ 어떠한 원리로 하나의 미들웨어가 적용되고 제어되는가

3. 미들웨어의 초기화


  • 해당 부분을 살펴보려면 다시 nest-factory부터 살펴보아야한다.
import { NestFactory } from '@nestjs/core';
import { AppModule } from './app.module';

async function bootstrap() {
  const app = await NestFactory.create(AppModule);
  await app.listen(3000);
}
bootstrap();
  • nest의 main.ts에서 애플리케이션을 초기화하고 시동할 때 사용하는 bootstrap()입니다.
  • 해당 부분에서 NestFactory.create(AppModule)하는 부분을 기억하시나요?

nest-factory.ts


// nest-factory.ts
public async create<T extends INestApplication = INestApplication>(
    moduleCls: any,
    serverOrOptions?: AbstractHttpAdapter | NestApplicationOptions,
    options?: NestApplicationOptions,
  ): Promise<T> {
    ...

    // DI 컨테이너, http 서버, 애플리케이션 설정, 그래프검사기, 옵션을 사용해서
    // NestApplication 인스턴스를 생성합니다.
    const instance = new NestApplication(
      container,
      httpServer,
      applicationConfig,
      graphInspector,
      appOptions,
    );
    
    // 실제 애플리케이션 인스턴스를 중간 레이어인 프록시나 인터셉터로 감쌉니다.
    const target = this.createNestInstance(instance);
    
    // 실제 애플리케이션 인스턴스에 위임하는 프록시를 생성해 반환합니다.
    // 적절한 http 서버 어댑터가 사용되도록 보장합니다.
    return this.createAdapterProxy<T>(target, httpServer);
  }
  • 해당 부분에서 DI 컨테이너, http 서버, 애플리케이션 설정, 그래프 의존성 검사기, 옵션을 활용해서 NestApplication을 만듭니다.
    • 이미 여기의 DI 컨테이너는 의존성 분석을 마치고 인스턴스화도 마친 상황입니다.

3.1 NestApplication


// packages/core/nest-application.ts
export class NestApplication
  extends NestApplicationContext<NestApplicationOptions>
  implements INestApplication
{
  protected readonly logger = new Logger(NestApplication.name, {
    timestamp: true,
  });
  private readonly middlewareModule: MiddlewareModule;
  private readonly middlewareContainer = new MiddlewareContainer(
    this.container,
  );
  private readonly microservicesModule =
    MicroservicesModule && new MicroservicesModule();
  private readonly socketModule = SocketModule && new SocketModule();
  private readonly routesResolver: Resolver;
  private readonly microservices: any[] = [];
  private httpServer: any;
  private isListening = false;

  constructor(
    container: NestContainer,
    private readonly httpAdapter: HttpServer,
    private readonly config: ApplicationConfig,
    private readonly graphInspector: GraphInspector,
    appOptions: NestApplicationOptions = {},
  ) {
    super(container, appOptions);

    this.selectContextModule();
    this.registerHttpServer();
    this.injector = new Injector({ preview: this.appOptions.preview });
    this.middlewareModule = new MiddlewareModule();
    this.routesResolver = new RoutesResolver(
      this.container,
      this.config,
      this.injector,
      this.graphInspector,
    );
  }

  // 2. middlewareModule register를 하는 registerModules를 따라갑니다.
  public async registerModules() {
    this.registerWsModule();
    ...
     
    // 1. middlewareModule register하는 부분입니다.
    await this.middlewareModule.register(
      this.middlewareContainer,
      this.container,
      this.config,
      this.injector,
      this.httpAdapter,
      this.graphInspector,
      this.appOptions,
    );
  }

  // 4. registerModules를 호출하는 init을 따라갑니다.
  public async init(): Promise<this> {
    if (this.isInitialized) {
      return this;
    }

    this.applyOptions();
    await this.httpAdapter?.init();

    const useBodyParser =
      this.appOptions && this.appOptions.bodyParser !== false;
    useBodyParser && this.registerParserMiddleware();

    // 3. registerModules를 호출하는 부분을 찾습니다.
    await this.registerModules();
    // 7. middlewareModule에 route를 적용하는 부분을 찾습니다.
    await this.registerRouter();
    await this.callInitHook();
    await this.registerRouterHooks();
    await this.callBootstrapHook();

    this.isInitialized = true;
    this.logger.log(MESSAGES.APPLICATION_READY);
    return this;
  }
  
  public async registerRouter() {
    await this.registerMiddleware(this.httpAdapter);

    const prefix = this.config.getGlobalPrefix();
    const basePath = addLeadingSlash(prefix);
    this.routesResolver.resolve(this.httpAdapter, basePath);
  }

  public async listen(port: number | string): Promise<any>;
  public async listen(port: number | string, hostname: string): Promise<any>;
  public async listen(port: number | string, ...args: any[]): Promise<any> {
    this.assertNotInPreviewMode('listen');
    // 5. init을 호출하는 부분을 찾습니다.
    // 애플리케이션이 init되지 않았을때만 init을 합니다.
    !this.isInitialized && (await this.init());

    return new Promise((resolve, reject) => {
      const errorHandler = (e: any) => {
        this.logger.error(e?.toString?.());
        reject(e);
      };
      this.httpServer.once('error', errorHandler);

      const isCallbackInOriginalArgs = isFunction(args[args.length - 1]);
      const listenFnArgs = isCallbackInOriginalArgs
        ? args.slice(0, args.length - 1)
        : args;

      this.httpAdapter.listen(
        port,
        ...listenFnArgs,
        (...originalCallbackArgs: unknown[]) => {
          if (this.appOptions?.autoFlushLogs ?? true) {
            this.flushLogs();
          }
          if (originalCallbackArgs[0] instanceof Error) {
            return reject(originalCallbackArgs[0]);
          }

          const address = this.httpServer.address();
          if (address) {
            this.httpServer.removeListener('error', errorHandler);
            this.isListening = true;
            resolve(this.httpServer);
          }
          if (isCallbackInOriginalArgs) {
            args[args.length - 1](...originalCallbackArgs);
          }
        },
      );
    });
  }

  // 6. middlewareModule에 route를 적용하는 부분
  private async registerMiddleware(instance: any) {
    await this.middlewareModule.registerMiddleware(
      this.middlewareContainer,
      instance,
    );
  }
}

  • 이제 NestApplication 를 볼까요?
  • 중점적으로 볼 것은 middlewareModule 클래스를 초기화하고 작동시키는 부분을 찾는 것입니다.
  • 주석의 번호 순서에 따라 읽어보시면 찾아가는 과정을 보실 수 있습니다.

결론적으로 순서는 다음과 같습니다.


  1. nestApplication.listen()을 호출

    • 해당 listen은 프로젝트 main.ts 에서 흔히 호출하는 listen() 메소드입니다.

      import { NestFactory } from '@nestjs/core';
      import { AppModule } from './app.module';
      
      async function bootstrap() {
        const app = await NestFactory.create(AppModule);
        // this one!
        await app.listen(3000);
      }
      bootstrap();
      
  2. initialize되지 않았을 때, nestApplication.init()을 호출

  3. nestApplication.registerModules()을 호출

    • middlewareModule.register() 호출 - 미들웨어 모듈을 초기화 시작
    await this.middlewareModule.register(
          this.middlewareContainer,
          this.container,
          this.config,
          this.injector,
          this.httpAdapter,
          this.graphInspector,
          this.appOptions,
    );
    
  4. nestApplication.registerRouter() 호출

    • registerMiddleware()을 호출 - 미들웨어 모듈에 미들웨어를 등록
     await this.middlewareModule.registerMiddleware(
          this.middlewareContainer,
          instance,
    );
    

MiddlewareModule



export class MiddlewareModule<
  TAppOptions extends
    NestApplicationContextOptions = NestApplicationContextOptions,
> {
  private readonly routerProxy = new RouterProxy();
  private readonly exceptionFiltersCache = new WeakMap();
  private readonly logger = new Logger(MiddlewareModule.name);

  private injector: Injector;
  private routerExceptionFilter: RouterExceptionFilters;
  private routesMapper: RoutesMapper;
  private resolver: MiddlewareResolver;
  private container: NestContainer;
  private httpAdapter: HttpServer;
  private graphInspector: GraphInspector;
  private appOptions: TAppOptions;
  private routeInfoPathExtractor: RouteInfoPathExtractor;

  public async register(
    middlewareContainer: MiddlewareContainer,
    container: NestContainer,
    config: ApplicationConfig,
    injector: Injector,
    httpAdapter: HttpServer,
    graphInspector: GraphInspector,
    options: TAppOptions,
  ) {
    ...
  }

  public async resolveMiddleware(
    middlewareContainer: MiddlewareContainer,
    modules: Map<string, Module>,
  ) {
    ...
  }

  public async loadConfiguration(
    middlewareContainer: MiddlewareContainer,
    moduleRef: Module,
    moduleKey: string,
  ) {
    ...
  }

  public async registerMiddleware(
    middlewareContainer: MiddlewareContainer,
    applicationRef: any,
  ) {
    ...
  }

  public async registerMiddlewareConfig(
    middlewareContainer: MiddlewareContainer,
    config: MiddlewareConfiguration,
    moduleKey: string,
    applicationRef: any,
  ) {
   ...
  }

  public async registerRouteMiddleware(
    middlewareContainer: MiddlewareContainer,
    routeInfo: RouteInfo,
    config: MiddlewareConfiguration,
    moduleKey: string,
    applicationRef: any,
  ) {
	  ...
  }

  private async bindHandler(
    wrapper: InstanceWrapper<NestMiddleware>,
    applicationRef: HttpServer,
    routeInfo: RouteInfo,
    moduleRef: Module,
    collection: Map<InjectionToken, InstanceWrapper>,
  ) {
    ...
  }

  private async createProxy<TRequest = unknown, TResponse = unknown>(
    instance: NestMiddleware,
    contextId = STATIC_CONTEXT,
  ): Promise<(req: TRequest, res: TResponse, next: () => void) => void> {
    ...
  }

  private async registerHandler(
    applicationRef: HttpServer,
    routeInfo: RouteInfo,
    proxy: <TRequest, TResponse>(
      req: TRequest,
      res: TResponse,
      next: () => void,
    ) => void,
  ) {
    ...
  }

  private getContextId(request: unknown, isTreeDurable: boolean): ContextId {
    ...
  }
}

  • 이제 위 과정의 NestApplication 에서 살펴본 middlewareModule.register() / nestApplication.registerRouter() 를 살펴보려고 합니다.

register


 public async register(
    middlewareContainer: MiddlewareContainer,
    container: NestContainer,
    config: ApplicationConfig,
    injector: Injector,
    httpAdapter: HttpServer,
    graphInspector: GraphInspector,
    options: TAppOptions,
  ) {
    // 전달된 옵션 저장
    this.appOptions = options;

    // http 어댑터에 대한 참조 가져와서 할당
    const appRef = container.getHttpAdapterRef();
    
    // 라우터 예외 필터를 생성
    this.routerExceptionFilter = new RouterExceptionFilters(
      container,
      config,
      appRef,
    );
    
    // 라우트 매핑, 미들웨어 resolver, 라우트 정보 추출기 생성
    this.routesMapper = new RoutesMapper(container, config);
    this.resolver = new MiddlewareResolver(middlewareContainer, injector);
    this.routeInfoPathExtractor = new RouteInfoPathExtractor(config);
    
    // 매개변수로 NestApplication에서 전달받은 인젝터, 컨테이너,
    // httpAdapter, graphInspector 초기화
    this.injector = injector;
    this.container = container;
    this.httpAdapter = httpAdapter;
    this.graphInspector = graphInspector;

    // 컨테이너로부터 모듈을 가져옴
    const modules = container.getModules();
    
    // 미들웨어를 reslove한다.
    await this.resolveMiddleware(middlewareContainer, modules);
  }
  • NestApplication 에서 전달받은 매개변수를 받아서 할당(초기화)합니다.
  • middlewareContainer를 기반으로 각 모듈에 대해 resolveMiddleware를 합니다.

resolveMiddleware


 public async resolveMiddleware(
    middlewareContainer: MiddlewareContainer,
    modules: Map<string, Module>,
  ) {
    // 모듈의 맵의 각 항목을 [key, value] 형태 배열로 변환
    const moduleEntries = [...modules.entries()];
    
    // 각 모듈에 대해 미들웨어 구성을 load하는 함수이다.
    // 모듈의 이름과 모듈 참조를 받는다. (key, value)
    const loadMiddlewareConfiguration = async ([moduleName, moduleRef]: [
      string,
      Module,
    ]) => {
      // 미들웨어 구성을 로드하기 위해 loadConfiguration 호출, 
      // 미들웨어 구성을 처리하고 컨테이너에 삽입
      await this.loadConfiguration(middlewareContainer, moduleRef, moduleName);
      
      // resolver를 사용해서 모듈 내의 모든 미들웨어 인스턴스를 resolve 
      await this.resolver.resolveInstances(moduleRef, moduleName);
    };
    
    // 위 함수 비동기적으로 모두 실행
    await Promise.all(moduleEntries.map(loadMiddlewareConfiguration));
  }
  • 모듈을 모두 순회하면서, 미들웨어 구성을 load합니다.
  • loadConfiguration 를 통해 모듈 내의 미들웨어 구성을 로드하고 컨테이너에 삽입합니다.
  • resolveInstances 를 통해 모듈 내의 미들웨어 인스턴스를 resolve합니다.

loadConfiguration


 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);
  }
  • await instance.configure(middlewareBuilder)
    • 드디어 configure를 호출하는 부분을 찾았습니다.
  • 다시 미들웨어를 적용한 예제 코드를 살펴보겠습니다.
export class AppModule implements NestModule {
  // this!
  configure(consumer: MiddlewareConsumer) {
    consumer
      .apply(LoggerMiddleware)
      .forRoutes('cats');
  }
}
  • AppModuleconfigure를 통해서 미들웨어를 적용한 모듈입니다.

  • 해당 모듈은 loadConfiguration 에서 moduleRef를 통해 전달될 것이고, 해당 인스턴스에는 configure 메서드가 있을 것입니다.

  • 이에 따라 instance.configure(middlewareBuilder) 를 통해서 모듈의 미들웨어 구성이 진행됩니다.

  • 그렇다면, 이 middlewareBuilder는 무엇일까요?

    • 예제 코드에서 볼 수 있듯, configure 메소드의 매개변수로 설정된 클래스입니다.
     const middlewareBuilder = new MiddlewareBuilder(
          this.routesMapper,
          this.httpAdapter,
          this.routeInfoPathExtractor,
     );
    
    • 뒷내용에서 나오긴 하지만, MiddlewareConsumerMiddlewareBuilder클래스의 인터페이스입니다.
    • 따라서 middlewareBuilder를 매개변수로 사용한다고 생각하면 됩니다.
    • 아래에서 해당 클래스를 더 살펴보겠습니다.

+ configure (module)을 통해서 어떻게 해당 미들웨어가 적용되는지?


await instance.configure(middlewareBuilder);
 
const config = middlewareBuilder.build();

middlewareContainer.insertConfig(config, moduleKey);
  • 다음 메소드들을 하나씩 살펴보면서 어떻게 각 모듈에서 설정한대로 미들웨어가 초기화되는지 살펴보겠습니다.

await instance.configure(middlewareBuilder)


  • 모듈 instanceconfigure 메서드를 호출합니다.
  • middlewareBuilder 객체를 매개변수로 전달합니다.
  • 해당 메소드는 특정 모듈에서 미들웨어 구성을 처리, 설정을 적용하는 역할을 합니다.
  • 설정된 미들웨어 정보는 middlewareBuilder 내부에 저장됩니다.
  • 해당 메소드를 통해 모듈의 미들웨어 구성 정보가 담긴 middlewareBuilder가 생성됩니다.
  • middlewareBuilder 객체에서는 내부적으로 middlewareCollection이라는 Set을 가지고 있는데, 해당 Set은 MiddlewareConfiguration 객체들을 저장합니다.
    • MiddlewareConfiguration 객체들은 미들웨어와 관련된 설정 정보를 포함하고 있습니다.
    • apply() 메서드를 통해서 middlewareCollection에 추가됩니다. 추가할 미들웨어를 받아 구성하는 프록시 객체를 반환합니다. 해당 객체를 이용해서 exclude, forRoute등을 이용해 추가적인 구성이 가능해집니다.
    • forRoutes() 메서드를 통해서 위에서 반환한 프록시 객체에서 호출됩니다. 미들웨어가 적용될 특정 라우트를 지정합니다.

middlewareBuilder 객체에서 정보를 저장하는 법


  • middlewareBuilder 객체에서는 내부적으로 middlewareCollection이라는 Set을 가지고 있는데, 해당 Set은 MiddlewareConfiguration 객체들을 저장합니다.
    • MiddlewareConfiguration 객체들은 미들웨어와 관련된 설정 정보를 포함하고 있습니다.
    • apply() 메서드를 통해서 middlewareCollection에 추가됩니다. 추가할 미들웨어를 받아 구성하는 프록시 객체를 반환합니다. 해당 객체를 이용해서 exclude, forRoute등을 이용해 추가적인 구성이 가능해집니다.
    • forRoutes() 메서드를 통해서 위에서 반환한 프록시 객체에서 호출됩니다. 미들웨어가 적용될 특정 라우트를 지정합니다.

const config = middlewareBuilder.build();


  • 모든 구성이 완료되면 build() 메서드를 통해 최종적으로 구성된 미들웨어 설정들의 배열을 생성합니다. 해당 배열은 MiddlewareConfiguration 객체 Set의 배열이며, nest 애플리케이션의 미들웨어로 등록될 때 사용됩니다.

     
     public build(): MiddlewareConfiguration[] {
        return [...this.middlewareCollection];
     }
    
    • 여러 모듈을 돌면서 만들어진 middlewareCollection들을 배열로 누적해서 관리합니다.

middlewareContainer.insertConfig(config, moduleKey);


  • middlewareContainerMiddlewareConfiguration 객체(들)을 삽입합니다.
  • moduleKey는 해당 작업하고 있는 모듈을 식별하는 키입니다.
  • 각 모듈에 대한 미들웨어 설정을 middlewareContainer 에 저장하여서, 나중에 미들웨어를 등록하고 사용할 수 있도록 합니다. (미들웨어 설정의 중앙화)
  • 해당 부분은 middlewareBuilder 가 어떻게 구성되어있는지 먼저 확인하고, 살펴보도록 하겠습니다.
    • 최종적으로 해당 메소드 이후 middlewareContainer 에서 어떻게 관리되고 NestApplication에 등록되고 사용되는지 확인하겠습니다.

이어가며


  • 글이 너무 길어지는 관계로 middlewareBuilder 에 대해서는 다음 글에 이어서 작성하겠습니다.