NestJS에서 Controller를 route path에 등록되는 원리

Jaden Kim·2024년 6월 8일
0
post-thumbnail

NestJS에서 컨트롤러와 메서드를 등록하는 법

NestJS에서 컨트롤러를 정의할 때에는, 컨트롤러 메서드에 @Controller 데코레이터로 원하는 route path를 등록한다.
그리고 내부의 각 메서드에서 원하는 HTTP Method 및 하위 경로를 지정하여, 각 경로에 메서드 핸들러를 등록한다.

import { Controller, Get } from "@nestjs/common";

@Controller("user")
export class UserController {
  // GET /user
  @Get()
  findAll(): string {
    return "return all users";
  }

  // POST /user/create
  @Post("create")
  create(): string {
    return "create new user";
  }
}

해당 컨트롤러는 모듈의 controllers 내에 추가되어, 특정 모듈의 컨트롤러로 명시된다.

import { Module } from "@nestjs/common";
import { UserController } from "./user.controller";

@Module({
  controllers: [UserController],
})
export class UserModule {}

Nest 앱이 bootstrap 되는 시점에 해당 모듈이 탐색되고, 모듈에 등록된 컨트롤러가 인스턴스화 되어 라우팅이 등록된다.

@Controller , @Get / @Post 등의 데코리이터의 역할

NestJS에서 지원하는 데코레이터의 가장 주요한 역할은 특정 클래스/메서드에 metadata를 설정하는 것이다.
이는 @Controller도 마찬가지이며, 데코레이터가 전달 받은 route 경로를 대상 클래스의 path metadata로 지정한다.

function Controller(prefix?: string | string[]): ClassDecorator {
  // ... prefix에 기본값 등을 설정

  return (target: Function) => {
    Reflect.defineMetadata(PATH_METADATA, path, target);
  };
}

@Get, @Post 등의 HTTP Method 데코레이터도 동일하게 전달받은 경로를 path, HTTP Method를 method metadata에 저장한다.

import { SetMetadata } from "@nestjs/common";

export const Get = (path?: string): MethodDecorator => {
  return (target, key, descriptor) => {
    Reflect.defineMetadata(PATH_METADATA, path, descriptor.value);
    Reflect.defineMetadata(
      METHOD_METADATA,
      RequestMethod.GET,
      descriptor.value
    );
  };
};

각 모듈을 순회하면서 controller들에 대한 라우팅 처리

NestApp은 init() 되는 시점에 RoutesResolver를 통해 어플리케이션에 등록된 모든 컨트롤러들을 route path에 매칭하여 등록한다.

export class NestApplication {
  private readonly routesResolver: RoutesResolver;

  public async init() {
    await this.registerRouter();
  }

  public async registerRouter() {
    this.routesResolver.resolve(this.httpAdapter, basePath);
  }
}

이 때 RoutesResolver는 모든 모듈을 순회하면서, 각 모듈에 대해 라우팅을 등록하는 메서드를 호출한다.

export class RoutesResolver {
  constructor(
    private readonly routerProxy: RouterProxy,
    private readonly routerExplorer: RouterExplorer
  ) {}

  public resolve() {
    const modules = this.container.getModules();
    modules.forEach(({ controllers, metatype }, moduleName) => {
      this.registerRouters(
        controllers,
        moduleName,
        globalPrefix,
        modulePath,
        applicationRef,
      );
    });
  }
}

path 메타데이터를 기반으로 라우팅 등록

각 모듈에 대해서 호출하는 registerRouters() 메서드를 자세히 살펴보자.
registerRouters()에서는 먼저 routerExplorer.extractRouterPath() 메서드를 통해 컨트롤러에 등록된 path metadata를 추출해서 받아온다.
@Controller의 path에는 string 또는 string[]로 route path를 지정할 수 있는데, extractRouterPath()는 해당 값을 바탕으로 일관된 형태의 데이터 구조로 route path 목록을 가져온다.

@Controller('search') -> ['/search']
@Controller(['search', '/create']) -> ['/search', 'create']

이제 해당 route path를 모두 순회하면서 routerExplorer.explore() 를 호출한다.
해당 메서드는 컨트롤러의 모든 메서드들을 탐색하여 route path에 등록하는 역할을 한다.

public registerRouters(
    routes: Map<string | symbol | Function, InstanceWrapper<Controller>>,
    moduleName: string,
    globalPrefix: string,
    modulePath: string,
    applicationRef: HttpServer,
  ) {
    routes.forEach(instanceWrapper => {
      const { metatype } = instanceWrapper;

      const routerPaths = this.routerExplorer.extractRouterPath(
        metatype as Type<any>,
      );

      routerPaths.forEach(path => {
        const routePathMetadata: RoutePathMetadata = {
          ctrlPath: path,
          modulePath,
          globalPrefix,
          controllerVersion,
          versioningOptions,
        };

        this.routerExplorer.explore(
          instanceWrapper,
          moduleName,
          applicationRef,
          host,
          routePathMetadata,
        );
      });
    });
  }

컨트롤러의 각 메서드 순회 및 handler 등록

RouterExplorer.explore 메서드는 컨트롤러의 메서드를 모두 순회하면서, 실질적으로 각 route에 handler를 등록한다.
PathsExplorer.scanForPaths 메서드에서 클래스의 메서드들을 모두 순회하여 라우팅 매핑 정보를 파싱하고, applyPathsToRouterProxy 메서드에서 매핑 정보를 이용하여 각 route에 handler를 등록한다.
applyPathsToRouterProxy는 핸들러와 함께 필요한 미들웨어, 파이프라인, 예외 필터 등을 등록하여 전처리 및 후처리가 될 수 있게 한다.

export class RouterExplorer {
  ...
  public explore<T extends HttpServer = any>(
    instanceWrapper: InstanceWrapper,
    moduleKey: string,
    applicationRef: T,
    host: string | RegExp | Array<string | RegExp>,
    routePathMetadata: RoutePathMetadata,
  ) {
    const { instance } = instanceWrapper;
    const routerPaths = this.pathsExplorer.scanForPaths(instance);
    this.applyPathsToRouterProxy(
      applicationRef,
      routerPaths,
      instanceWrapper,
      moduleKey,
      routePathMetadata,
      host,
    );
  }
}

PathsExplorer.scanForPaths에서 최종적으로 컨트롤러의 모든 메서드를 추출해서, 각 메서드의 프로토타입을 바탕으로 라우팅 매핑 정보를 반환하는 것을 확인할 수 있다.
exploreMethodMetadata() 메서드가 각 컨트롤러 메서드의 path, method metadata를 기반으로 등록할 하위 route path와 HTTP Method를 추출한다.

export class PathsExplorer {
  public scanForPaths(
    instance: Controller,
    prototype?: object
  ): RouteDefinition[] {
    const instancePrototype = isUndefined(prototype)
      ? Object.getPrototypeOf(instance)
      : prototype;

    return this.metadataScanner
      .getAllMethodNames(instancePrototype)
      .reduce((acc, method) => {
        const route = this.exploreMethodMetadata(
          instance,
          instancePrototype,
          method
        );

        if (route) {
          acc.push(route);
        }

        return acc;
      }, []);
  }

  public exploreMethodMetadata(
    instance: Controller,
    prototype: object,
    methodName: string
  ): RouteDefinition | null {
    const instanceCallback = instance[methodName];
    const prototypeCallback = prototype[methodName];

    const routePath = Reflect.getMetadata(PATH_METADATA, prototypeCallback); // path metadata
    if (isUndefined(routePath)) {
      return null;
    }
    const requestMethod: RequestMethod = Reflect.getMetadata(
      METHOD_METADATA,
      prototypeCallback
    ); // method metadata

    return {
      path,
      requestMethod,
      targetCallback: instanceCallback,
      methodName,
      version,
    };
  }
}

정리

NestJS에서 라우팅 매핑 정보를 바탕으로, 각 route에 handler를 등록하는 플로우를 정리하면 다음과 같다.

  1. NestApplication.init() -> RoutesResolver.resolve() 호출
  2. RoutesResolver.resolve()에서 각 모듈을 순회하면서 this.registerRouters()를 호출
  3. RoutesResolver.registerRouters()에서 모듈 내의 컨트롤러들을 순회하면서 각 컨트롤러의 path metadata 추출, 각 path에 대해서 컨트롤러의 메서드를 핸들러로 등록하도록 routerExplorer.explore()를 호출
  4. RouterExplorer.explore()에서 컨트롤러의 모든 메서드를 탐색하기 위해 pathsExplorer.scanForPaths() 호출
  5. PathsExplorer.scanForPaths()에서 컨트롤러의 모든 메서드를 순회하며 this.exploreMethodMetadata()를 호출하여 각 메서드의 라우팅 매핑 정보 획득
  6. PathsExplorer.exploreMethodMetadata()에서 메서드에 등록된 path, method metadata를 바탕으로 라우팅 매핑 정보 추출
    • 등록된 metadata가 없는 경우(@Get 등의 데코레이터가 등록되지 않은 메서드) null 반환
  7. PathsExplorer.scanForPaths()로부터 반환 받은 route 매핑 정보를 바탕으로, this.applyPathsToRouterProxy()를 호출하여 각 route path에 handler 등록
    • controller의 path 메타데이터, 각 메서드들의 pathmethod 메타데이터, globalPrefix 등 모든 데이터를 반영하여 route path를 등록
    • 추가적으로 미들웨어, 파이프라인, 예외 필터 등을 적용하여 적절한 전처리/후처리가 이루어질 수 있도록

0개의 댓글