NestJS middleware를 수정해보자

1. 계기


  • NestJS middleware에 흥미가 생겨 내부 구현체를 공부하고 있었고 프로젝트에서 로깅을 위해서 본격적으로 사용해보고 있었는데요, 사용하다 이상한 점을 발견했습니다.

문제가 되는 코드


export class AppModule implements NestModule {
  configure(consumer: MiddlewareConsumer) {
    consumer.apply(LoggerMiddleware).exclude("/admin", "/upload").exclude("/health").forRoutes("*");
  }
}
  • NestJS에서 middleware를 적용하려면 다음과 같이 모듈에서 configure 해줘야 합니다.
    • configure 메서드에 MiddlewareConsumer를 매개변수로 받습니다.
    • MiddlewareConsumer 의 메서드들은 체이닝이 가능합니다.
      • apply → 적용할 미들웨어를 매개변수로 받습니다.
      • exclude → 미들웨어의 적용을 제외할 라우터를 받습니다.
      • forRoutes → 미들웨어가 적용될 라우터를 받습니다.
  • document를 살펴보면, Middleware consumer는 helper class로써, 메소드들이 chained될 수 있다고 명시되어 있습니다.
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).

문제점


  • 메소드 chaining이 된다는 것을 확인한 후, exclude를 여러번 사용해서 미들웨어 구성을 진행하였습니다.
    • 굳이 여러번 사용한 이유는, 경로를 그룹화해서 코드의 가독성을 높히기 위함이였습니다.
    • 또한 Spring FrameworkHttpSecurity 빌더의 경우 체이닝 방식을 사용하여 여러 설정(e.g 적용할 라우트)을 연속적으로 추가할 수 있는 구조인데요, 빌더의 목적은 다르지만 해당 방식에 익숙해서 사용한 점도 있습니다.
  • 하지만 이렇게 미들웨어를 구성하였을 때, 첫번째로 명시한 exclude는 동작하지 않고 두번째로 명시한 exclude만 동작하는 문제가 발생했습니다.

2. 확인해보기


  • 해당 동작 방식이 문제가 있는지 확인해기 위해 간단한 프로젝트 한개를 파보았습니다.
GitHub - dragontaek-lee/middleware-exclude-method-overwrites
GitHub - dragontaek-lee/middleware-exclude-method-overwrites
Contribute to dragontaek-lee/middleware-exclude-method-overwrites development by creating an account on GitHub.

app.module.ts


import { AppController } from "./app.controller";
import { Module, MiddlewareConsumer, RequestMethod } from "@nestjs/common";

const MIDDLEWARE_VALUE = "middleware";

@Module({
  imports: [],
  controllers: [AppController],
  providers: [],
})
export class AppModule {
  configure(consumer: MiddlewareConsumer) {
    consumer
      .apply((req, res, next) => res.send(MIDDLEWARE_VALUE))
      .exclude("test", "overview/:id", "wildcard/(.*)", {
        path: "middleware",
        method: RequestMethod.POST,
      })
      .exclude("multiple/exclude")
      .forRoutes("*");
  }
}
  • 테스트를 위해 모듈을 다음과 같이 구성하였습니다.
  • 우선 middleware 문자열을 반환하는 미들웨어를 하나 만들어두고, apply 해두었습니다.
    • 동작 방식은 다음과 같습니다.
      • 미들웨어를 정상적으로 거친다면 → “middleware" 문자열을 반환
      • 미들웨어를 거치지 않는다면 → “test” 문자열 반환
  • 두개의 exclude를 두었는데요, 다음과 같은 route를 exclude 합니다.
    • 첫번째 exclude → /test /overview/:id wildcard/(.*) /middleware (POST)
    • 두번째 exclude → /multiple/exclude
  • forRoutes를 통해 모든 route에 대해 적용해두었습니다.

controller.ts


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

const RETURN_VALUE = "test";

@Controller()
export class AppController {
  @Get("test")
  test() {
    return RETURN_VALUE;
  }

  @Get("test2")
  test2() {
    return RETURN_VALUE;
  }

  @Get("middleware")
  middleware() {
    return RETURN_VALUE;
  }

  @Post("middleware")
  noMiddleware() {
    return RETURN_VALUE;
  }

  @Get("wildcard/overview")
  testOverview() {
    return RETURN_VALUE;
  }

  @Get("overview/:id")
  overviewById() {
    return RETURN_VALUE;
  }

  @Get("multiple/exclude")
  multipleExclude() {
    return RETURN_VALUE;
  }
}
  • app.module.tsexclude에서 명시한 모든 route들을 등록해주었습니다.
  • 모든 route는 호출이 완료되면 “test” 문자열을 반환합니다.

exclude-middleware.spec.ts


  • 이제 의도된대로 호출된 exclude가 모두 잘 작동하는지 테스트 코드로 확인해보겠습니다.
import { Test } from "@nestjs/testing";
import * as request from "supertest";
import { INestApplication } from "@nestjs/common";
import { AppModule } from "./app.module";

const RETURN_VALUE = "test";

describe("middleware - multiple exclude method overwrites previous one", () => {
  let app: INestApplication;

  beforeEach(async () => {
    app = (
      await Test.createTestingModule({
        imports: [AppModule],
      }).compile()
    ).createNestApplication();

    await app.init();
  });

  // 1번 테스트코드
  it(`should exclude "/test" endpoint`, () => {
    return request(app.getHttpServer()).get("/test").expect(200, RETURN_VALUE);
  });

  // 2번 테스트코드
  it(`should exclude POST "/middleware" endpoint`, () => {
    return request(app.getHttpServer()).post("/middleware").expect(200, RETURN_VALUE);
  });

  // 3번 테스트코드
  it(`should exclude "/overview/:id" endpoint (by param)`, () => {
    return request(app.getHttpServer()).get("/overview/1").expect(200, RETURN_VALUE);
  });

  // 4번 테스트코드
  it(`should exclude "/wildcard/overview" endpoint (by wildcard)`, () => {
    return request(app.getHttpServer()).get("/wildcard/overview").expect(200, RETURN_VALUE);
  });

  // 5번 테스트코드
  it(`should exclude "/multiple/exclude" endpoint`, () => {
    return request(app.getHttpServer()).get("/multiple/exclude").expect(200, RETURN_VALUE);
  });

  afterEach(async () => {
    await app.close();
  });
});
  • 테스트 코드를 한개씩 살펴보겠습니다.
  • 의도대로 exclude여러번 호출해도 잘 작동한다면 제가 작성한 테스트 코드는 모두 통과해야합니다.
    • 1~4번 테스트케이스의 경우 첫번째 exclude에 명시된 route들에 대한 테스트 케이스입니다.
    • 5번 테스트케이스의 경우 두번째 exclude에 명시된 route들에 대한 테스트 케이스입니다.
    • 5개의 테스트 케이스 모두 미들웨어를 타지 않고 본래 호출의 리턴값을 반환해야 합니다.

1번 테스트코드

it(`should exclude "/test" endpoint`, () => {
  return request(app.getHttpServer()).get("/test").expect(200, RETURN_VALUE);
});
  • /test 엔드포인트의 경우 첫번째 exclude에 포함했으니, 미들웨어를 타지않고 RETURN_VALUE를 반환해야합니다.

2번 테스트코드

it(`should exclude POST "/middleware" endpoint`, () => {
  return request(app.getHttpServer()).post("/middleware").expect(200, RETURN_VALUE);
});
  • (POST) /middleware 엔드포인트의 경우도 첫번째 exclude에 포함했으니, 미들웨어를 타지않고 RETURN_VALUE를 반환해야합니다.

3번 테스트코드

it(`should exclude "/overview/:id" endpoint (by param)`, () => {
  return request(app.getHttpServer()).get("/overview/1").expect(200, RETURN_VALUE);
});
  • /overview/1 엔드포인트의 경우도 첫번째 exclude에 포함했으니, 미들웨어를 타지않고 RETURN_VALUE를 반환해야합니다.

4번 테스트코드

it(`should exclude "/wildcard/overview" endpoint (by wildcard)`, () => {
  return request(app.getHttpServer()).get("/wildcard/overview").expect(200, RETURN_VALUE);
});
  • /wildcard/overview 엔드포인트의 경우도 첫번째 exclude에 포함했으니, 미들웨어를 타지않고 RETURN_VALUE를 반환해야합니다.

5번 테스트코드

it(`should exclude "/multiple/exclude" endpoint`, () => {
  return request(app.getHttpServer()).get("/multiple/exclude").expect(200, RETURN_VALUE);
});
  • /multiple/exclude 엔드포인트의 경우도 두번째 exclude에 포함했으니, 미들웨어를 타지않고 RETURN_VALUE를 반환해야합니다.

테스트 코드 실행 결과


testTotal

  • 테스트 수행결과는 다음과 같이 5개중에서 4개가 실패한 상황입니다.
  • 실패한 케이스는 1,2,3,4 테스트 케이스인데요, 하나씩 살펴보겠습니다.

1번 테스트 코드

test

  • 미들웨어를 타지않고 RETURN_VALUE를 반환하는 것을 기대했지만, 미들웨어를 타서 MIDDLEWARE_VALUE를 반환하여 테스트가 실패했습니다.

2번 테스트 코드

Untitled

  • 미들웨어를 타지않고 RETURN_VALUE를 반환하는 것을 기대했지만, 미들웨어를 타서 MIDDLEWARE_VALUE를 반환하여 테스트가 실패했습니다.

3번 테스트 코드

Untitled

  • 미들웨어를 타지않고 RETURN_VALUE를 반환하는 것을 기대했지만, 미들웨어를 타서 MIDDLEWARE_VALUE를 반환하여 테스트가 실패했습니다.

4번 테스트 코드

Untitled

  • 미들웨어를 타지않고 RETURN_VALUE를 반환하는 것을 기대했지만, 미들웨어를 타서 MIDDLEWARE_VALUE를 반환하여 테스트가 실패했습니다.

결과


@Module({
  imports: [],
  controllers: [AppController],
  providers: [],
})
export class AppModule {
  configure(consumer: MiddlewareConsumer) {
    consumer
      .apply((req, res, next) => res.send(MIDDLEWARE_VALUE))
      .exclude("test", "overview/:id", "wildcard/(.*)", {
        path: "middleware",
        method: RequestMethod.POST,
      })
      .exclude("multiple/exclude")
      .forRoutes("*");
  }
}
  • 첫번째 exclude 메서드에 대한 테스트 코드 1~4번이 실패 → 첫번째, 즉 마지막이 아닌 exclude가 작동하지 않는다.
  • 두번째 exclude 메서드에 대한 테스트 코드 5번만이 성공 → 두번째, 즉 마지막이 아닌 exclude는 동작한다.
  • 결론적으로 마지막 exclude만 적용되며, 따라서 중복된 exclude 메서드 호출은 올바르게 작동하지 않는다는 것을 확인할 수 있습니다.

3. Issues / Pull requests 확인해보기


  • 이제 이러한 이슈를 구현체를 확인해보고 해결해보려고 합니다.
  • 작업을 시작하기 전에 두가지 확인할 점이 있습니다.
    • Issues에 해당 이슈가 제기되어 있는지
    • 이미 관련된 PR이 작성되어서 대기중인 상태인지

Issues


  • 이슈가 있는지 확인합니다. 이미 있다면, 논의 내용을 살펴보고 작업을 하겠다는 / 하고 있다는 사람이 있는지 확인해보면 좋습니다.
  • 이슈가 없다면, 이슈를 먼저 올립니다. 위에서 작업한 것 과 같이 minimum reproduction, 간단한 재현을 할 수 있는 프로젝트와 함께 이슈를 제기해야합니다.
  • 정확히 해당 이슈는 아니지만, 논의 사항에서 해당 이슈가 논의된 것을 확인할 수 있습니다. 이정도면 바로 작업을 착수 해도 될 것 같습니다!

Untitled

4. 내부 구현체 제대로 이해하기


  • 우선 해당 문제를 해결하기 위해 내부 구현체를 이해해야 하는데요, 우선 미들웨어를 구성하는데 있어 사용되는 주요 클래스와 메서드부터 알아보겠습니다.

기준 코드


export class AppModule {
  configure(consumer: MiddlewareConsumer) {
    consumer
      .apply((req, res, next) => res.send(MIDDLEWARE_VALUE))
      .exclude("test", "overview/:id", "wildcard/(.*)", {
        path: "middleware",
        method: RequestMethod.POST,
      })
      .exclude("multiple/exclude")
      .forRoutes("*");
  }
}
  • 우선 테스트 코드에서 작성한 코드를 기준 코드로 잡겠습니다.
  • 미들웨어와 exclude를 알아보기 위해, 우선 MiddlewareConsumer 에 대해 알아보겠습니다.
    • 사실 MiddlewareConsumerMiddlewareBuilder 클래스의 인터페이스입니다. 따라서 middlewareBuilder를 매개변수로 사용한다고 보면 되는데요, 이에 따라서 middlewareBuilder 를 알아보도록 하겠습니다.

4.1 middlewareBuilder


// packages/core/middleware/builder.ts
export class MiddlewareBuilder implements MiddlewareConsumer {
  private readonly middlewareCollection = new Set<MiddlewareConfiguration>();

  constructor(private readonly routesMapper: RoutesMapper, private readonly httpAdapter: HttpServer, private readonly routeInfoPathExtractor: RouteInfoPathExtractor) {}

  public apply(...middleware: Array<Type<any> | Function | any>): MiddlewareConfigProxy {
    //code
  }

  public build(): MiddlewareConfiguration[] {
    //code
  }

  public getHttpAdapter(): HttpServer {
    //code
  }

  private static readonly ConfigProxy = class implements MiddlewareConfigProxy {
    private excludedRoutes: RouteInfo[] = [];

    constructor(private readonly builder: MiddlewareBuilder, private readonly middleware: Array<Type<any> | Function | any>, private routeInfoPathExtractor: RouteInfoPathExtractor) {}

    public getExcludedRoutes(): RouteInfo[] {
      //code
    }

    public exclude(...routes: Array<string | RouteInfo>): MiddlewareConfigProxy {
      //code
    }

    public forRoutes(...routes: Array<string | Type<any> | RouteInfo>): MiddlewareConsumer {
      //code
    }

    private getRoutesFlatList(routes: Array<string | Type<any> | RouteInfo>): RouteInfo[] {
      //code
    }

    private removeOverlappedRoutes(routes: RouteInfo[]) {
      //code
    }
  };
}
  • 해당 객체는 미들웨어를 컨트롤하기 위해 여러 메소드를 제공하는 helper class 입니다.
  • 우선 예제 코드에서 봤던 메서드들이 보입니다. (apply, forRoutes, 등등)

4.2 apply


 public apply(
    ...middleware: Array<Type<any> | Function | any>
  ): MiddlewareConfigProxy {
    return new MiddlewareBuilder.ConfigProxy(
      this,
      flatten(middleware),
      this.routeInfoPathExtractor,
    );
  }
  • apply 메소드 부터 확인하겠습니다.
  • 해당 메소드는 매개변수로 미들웨어를 받고, ConfigProxy를 새로운 인스턴스로 생성하여 반환합니다.
  • 가장 먼저 apply가 호출되고, 체이닝 되어 반환된 해당 ConfigProxy 인스턴스의 exclude, forRoute 메서드를 사용할 수 있는 것 입니다.

4.3 ConfigProxy 클래스


private static readonly ConfigProxy = class implements MiddlewareConfigProxy {
    private excludedRoutes: RouteInfo[] = [];

    constructor(
      private readonly builder: MiddlewareBuilder,
      private readonly middleware: Array<Type<any> | Function | any>,
      private routeInfoPathExtractor: RouteInfoPathExtractor,
    ) {}

    public getExcludedRoutes(): RouteInfo[] {
      return this.excludedRoutes;
    }

    public exclude(
      ...routes: Array<string | RouteInfo>
    ): MiddlewareConfigProxy {
      this.excludedRoutes = this.getRoutesFlatList(routes).reduce(
        (excludedRoutes, route) => {
          for (const routePath of this.routeInfoPathExtractor.extractPathFrom(
            route,
          )) {
            excludedRoutes.push({
              ...route,
              path: routePath,
            });
          }

          return excludedRoutes;
        },
        [] as RouteInfo[],
      );

      return this;
    }

    public forRoutes(
      ...routes: Array<string | Type<any> | RouteInfo>
    ): MiddlewareConsumer {
      const { middlewareCollection } = this.builder;

      const flattedRoutes = this.getRoutesFlatList(routes);
      const forRoutes = this.removeOverlappedRoutes(flattedRoutes);
      const configuration = {
        middleware: filterMiddleware(
          this.middleware,
          this.excludedRoutes,
          this.builder.getHttpAdapter(),
        ),
        forRoutes,
      };
      middlewareCollection.add(configuration);
      return this.builder;
    }

    private getRoutesFlatList(
      routes: Array<string | Type<any> | RouteInfo>,
    ): RouteInfo[] {
      const { routesMapper } = this.builder;

      return iterate(routes)
        .map(route => routesMapper.mapRouteToRouteInfo(route))
        .flatten()
        .toArray();
    }

    private removeOverlappedRoutes(routes: RouteInfo[]) {
      const regexMatchParams = /(:[^\/]*)/g;
      const wildcard = '([^/]*)';
      const routesWithRegex = routes
        .filter(route => route.path.includes(':'))
        .map(route => ({
          method: route.method,
          path: route.path,
          regex: new RegExp(
            '^(' + route.path.replace(regexMatchParams, wildcard) + ')$',
            'g',
          ),
        }));

      return routes.filter(route => {
        const isOverlapped = (item: { regex: RegExp } & RouteInfo): boolean => {
          if (route.method !== item.method) {
            return false;
          }
          const normalizedRoutePath = stripEndSlash(route.path);
          return (
            normalizedRoutePath !== item.path &&
            item.regex.test(normalizedRoutePath)
          );
        };
        const routeMatch = routesWithRegex.find(isOverlapped);
        return routeMatch === undefined;
      });
    }
  };

4.3.1 exclude


public exclude(
      ...routes: Array<string | RouteInfo>
    ): MiddlewareConfigProxy {
      this.excludedRoutes = this.getRoutesFlatList(routes).reduce(
        (excludedRoutes, route) => {
          for (const routePath of this.routeInfoPathExtractor.extractPathFrom(
            route,
          )) {
            excludedRoutes.push({
              ...route,
              path: routePath,
            });
          }

          return excludedRoutes;
        },
        [] as RouteInfo[],
      );

      return this;
    }
  • 탐색하던 exclude 메서드가 나왔습니다!
  • 미들웨어 적용을 제외할 route들을 저장하는 메서드입니다.
  • 매개변수로 전달받은 routes를 바탕으로 제외할 route들의 배열 추출하여, configProxy 객체의 필드인 excludedRoutes에 저장합니다.
  • routes의 형태는 string, RouteInfo 두가지입니다.
    • 예시)
      • string/user 와 같은 문자열로 된 route입니다.
      • RouteInfo는 다음과 같습니다.
        RouteInfo {
            path: string; // path 문자열
            method: RequestMethod; // http Method (e.g GET, POST)
            version?: VersionValue; // 필수값은 아님
        }
        

4.3.1.1 getRoutesFlatList


private getRoutesFlatList(
	routes: Array<string | Type<any> | RouteInfo>,
): RouteInfo[] {
  const { routesMapper } = this.builder;

  return iterate(routes)
    .map(route => routesMapper.mapRouteToRouteInfo(route))
    .flatten()
    .toArray();
}
  • exclude에서 사용되는 getRoutesFlatList 메서드에 대해 알아보겠습니다.
  • 해당 메서드는 전달받았던 route들을 RouteInfo 타입에 맞게 변환하여 반환합니다.
  • 예를 들어, 다음과 같이 exclude 메소드가 수행되었다고 가정하겠습니다.
exclude("cat", { path: "dog", method: "GET" });
  • 가변인자 …routes 배열에 요소로서 추가가 되어, routes 배열은 다음과 같이 구성됩니다.
routes = ["cat", { path: "dog", method: "GET" }];
  • routesMapper.mapRouteToRouteInfo 의 경우 각 항목을 RouteInfo 객체로 변환하는 역할을 하는데요, 위의 routes를 기준으로 한다면, 문자열 ‘cat’은 RouteInfo 객체로 변환되고, 두번째 객체는 이미 RouteInfo 객체이기 때문에 그대로 유지됩니다. 변환된 결과는 아래와 같습니다.
[
  { path: "cat", method: RequestMethod.ALL },
  { path: "dog", method: "GET" },
];

4.3.1.2 reduce


  • 이제 reduce를 돌면서, 가공된 정보들을 excludedRoutes 배열에 저장합니다.
  • route 들에 대해 routeInfoPathExtractor.extractPathFrom 메서드가 호출됩니다. 이 메서드는 route 객체에서 경로를 추출합니다.
  • 위에서 사용한 예시를 그대로 사용해보겠습니다. RouteInfo 배열은 다음과 같습니다.
[
  { path: "cat", method: RequestMethod.ALL },
  { path: "dog", method: "GET" },
];
  • 첫 번째 route
  { path: 'cat', method: RequestMethod.ALL }
  • ['/cat'] 를 반환합니다.
  • 두 번째 route
  { path: 'dog', method: 'GET' }
  • ['/dog'] 를 반환합니다.
  • 최종적으로 아래와 같은 배열로 excludedRoutes 가 저장됩니다.
[
  { path: "/cat", method: RequestMethod.ALL },
  { path: "/dog", method: "GET" },
];
  • 마지막으로 return this를 통해 MiddlewareConfigProxy 를 반환해서 체이닝이 가능하게 합니다.
  • MiddlewareConfigProxy 를 반환함으로써 exclude를 한번더 호출 할 수 있습니다. (MiddlewareConfigProxy.exclude)

과정


flowchart TD A[middlewareModule.loadConfiguration] --> B{middlewareBuilder} B --> |middlewareBuilder 매개변수| C[instance.configure] C --> D[middlewareBuilder.apply] D --> |MiddlewareBuilder.ConfigProxy| E[MiddlewareBuilder.ConfigProxy.exclude] E --> |MiddlewareBuilder.ConfigProxy| F[MiddlewareBuilder.ConfigProxy.forRoutes] F --> |MiddlewareBuilder| G[MiddlewareBuilder.build] G --> |middlewareCollection Array| H[middlewareContainer.insertConfig]
  • 이외에도 총 MiddlewareBuilder가 구성되는 흐름을 살펴보면 위와 같습니다.

5. 내부 구현체 이해를 통해 문제점 발견하기


5.1 exclude 메서드에서 알 수 있는 정보


  • 구성된 정보에 따라 exclude할 path와 method 들을 ConfigProxy 클래스에 excludedRoutes 배열에 저장한다고 했는데요, 이 부분에 집중해보겠습니다.
private static readonly ConfigProxy = class implements MiddlewareConfigProxy {
    private excludedRoutes: RouteInfo[] = [];
    ...
}
  • 아래와 같이 두번 exclude가 호출된다고 가정하겠습니다.
.exclude("test", "overview/:id", "wildcard/(.*)", {
	path: "middleware",
	method: RequestMethod.POST,
})
.exclude("multiple/exclude")

5.1.1 첫번째 exclude 호출


  • 우선 해당 route들은 exclude 메서드의 getRoutesFlatList 메서드를 통해 아래와 같이 변경될 것입니다.
[
  { path: "test", method: RequestMethod.ALL },
  { path: "overview/:id", method: RequestMethod.ALL },
  { path: "wildcard/(.*)", method: RequestMethod.ALL },
  { path: "middleware", method: "POST" },
];
  • reduce를 돌면 아래와 같이 변경될 것입니다.
[
  { path: "/test", method: RequestMethod.ALL },
  { path: "/overview/:id", method: RequestMethod.ALL },
  { path: "/wildcard/(.*)", method: RequestMethod.ALL },
  { path: "/middleware", method: "POST" },
];
  • 마지막으로 해당 배열을 ConfigProxy.excludedRoutes 에 저장합니다.
public exclude(
      ...routes: Array<string | RouteInfo>
    ): MiddlewareConfigProxy {
      this.excludedRoutes = this.getRoutesFlatList(routes).reduce(
        //code
      );
      ...
    }
  • 이제 첫번째 exclude에 대한 정보들이 저장되었습니다.

5.1.2 두번째 exclude 호출


[{ path: "multiple/exclude", method: RequestMethod.ALL }];
  • 똑같은 방식으로 해당 route는 exclude 메서드의 getRoutesFlatList 메서드를 통해 위와 같이 변경될 것입니다.
[{ path: "/multiple/exclude", method: RequestMethod.ALL }];
  • reduce를 돌면 아래와 같이 변경될 것입니다.
public exclude(
      ...routes: Array<string | RouteInfo>
    ): MiddlewareConfigProxy {
      this.excludedRoutes = this.getRoutesFlatList(routes).reduce(
        //code
      );
      ...
    }
  • 마지막으로 해당 배열을 ConfigProxy.excludedRoutes 에 저장합니다.
  • 해당 부분이 뭔가 이상하지 않나요? 두번째 호출하였을 때 해당 값을 ConfigProxy.excludedRoutes 에 할당해버립니다. 이렇게되면 첫번째 값은 없어지고 덮어쓰여집니다.
  • 결국 두번이던 세번이든 여러번 exclude를 체이닝하게되면, 마지막으로 호출한 exclude 에 명시한 route 정보만 excludedRoutes 배열에 저장되고 미들웨어에서 제외되는 것입니다.

5. 해결하기


  • 결국 문제는 ConfigProxy.excludedRoutes 에 제외되는 배열을 그대로 할당해서 생기는 문제였습니다.
  • 기존의 excludedRoutes 배열 정보를 유지하면서, 새로 호출되어 추가되는 excludedRoutes배열을 추가하는 방식으로 변경하면 해당 문제는 해결될 것입니다.

방법 1. concat


public exclude(
	...routes: Array<string | RouteInfo>
): MiddlewareConfigProxy {
  const currentExcludedRoutes = this.getRoutesFlatList(routes).reduce((excludedRoutes, route) => {
    for (const routePath of this.routeInfoPathExtractor.extractPathFrom(route)) {
      excludedRoutes.push({
        ...route,
        path: routePath,
      });
    }

    return excludedRoutes;
  }, [] as RouteInfo[]);

  this.excludedRoutes = this.excludedRoutes.concat(currentExcludedRoutes);

  return this;
}
  • 현재 exclude 호출에 대한 RouteInfo 배열을 만들어내고, 기존 excludedRoutes에 병합하는 방법입니다.

방법 2. spread



public exclude(
	...routes: Array<string | RouteInfo>
): MiddlewareConfigProxy {
	this.excludedRoutes = [
		...this.excludedRoutes,
    ...this.getRoutesFlatList(routes).reduce((excludedRoutes, route) => {
	    for (const routePath of this.routeInfoPathExtractor.extractPathFrom(
	      route,
      )) {
		    excludedRoutes.push({
	        ...route,
          path: routePath,
        });
      }

      return excludedRoutes;
    }, [] as RouteInfo[]),
 ];

  return this;
}
  • spread 연산자로 기존 excludedRoutes 과 현재 exclude 호출에 대한 RouteInfo 배열을 병합해서 저장합니다.

선택한 방식


  • spread를 활용한 방법 2를 택하였는데요, 이유는 두가지입니다.
    • 첫번째는 spread 연산자를 활용하는게 가독성이 더 좋아보이고 직관적이라고 생각했기 때문입니다.
    • 두번째가 가장 큰 이유인데, nestjs/nest 프로젝트에서 많은 메소드에서 spread 연산자를 활용하고 있었기 때문입니다. 해당 프로젝트의 스타일에 맞추는게 좋을 것 같다고 판단했습니다.

6. 해결한 부분을 테스트하기


  • 이제 수정한 코드가 유닛 테스트 / 통합 테스트를 통과하는지 확인해야합니다.

유닛 테스트


  • 유닛 테스트를 돌리고, 모두 통과하는지 확인합니다.
$ npm run start

Untitled

  • 모두 통과하는 것을 확인할 수 있습니다!

통합 테스트


// integration/hello-world/e2e
import {
  Controller,
  Get,
  INestApplication,
  MiddlewareConsumer,
  Module,
  Post,
  RequestMethod,
} from '@nestjs/common';
import { Test } from '@nestjs/testing';
import * as request from 'supertest';
import { AppModule } from '../src/app.module';

const RETURN_VALUE = 'test';
const MIDDLEWARE_VALUE = 'middleware';

@Controller()
class TestController {
  @Get('test')
  test() {
    return RETURN_VALUE;
  }

  @Get('test2')
  test2() {
    return RETURN_VALUE;
  }

  @Get('middleware')
  middleware() {
    return RETURN_VALUE;
  }

  @Post('middleware')
  noMiddleware() {
    return RETURN_VALUE;
  }

  @Get('wildcard/overview')
  testOverview() {
    return RETURN_VALUE;
  }

  @Get('overview/:id')
  overviewById() {
    return RETURN_VALUE;
  }

  // 추가된 코드
  @Get('multiple/exclude')
  multipleExclude() {
    return RETURN_VALUE;
  }
}

@Module({
  imports: [AppModule],
  controllers: [TestController],
})
class TestModule {
  configure(consumer: MiddlewareConsumer) {
    consumer
      .apply((req, res, next) => res.send(MIDDLEWARE_VALUE))
      .exclude('test', 'overview/:id', 'wildcard/(.*)', {
        path: 'middleware',
        method: RequestMethod.POST,
      })
      .exclude('multiple/exclude') // 추가된 코드
      .forRoutes('*');
  }
}

describe('Exclude middleware', () => {
  let app: INestApplication;

  beforeEach(async () => {
    app = (
      await Test.createTestingModule({
        imports: [TestModule],
      }).compile()
    ).createNestApplication();

    await app.init();
  });

  it(**should exclude "/test" endpoint**, () => {
    return request(app.getHttpServer()).get('/test').expect(200, RETURN_VALUE);
  });

  it(**should not exclude "/test2" endpoint**, () => {
    return request(app.getHttpServer())
      .get('/test2')
      .expect(200, MIDDLEWARE_VALUE);
  });

  it(**should run middleware for "/middleware" endpoint**, () => {
    return request(app.getHttpServer())
      .get('/middleware')
      .expect(200, MIDDLEWARE_VALUE);
  });

  it(**should exclude POST "/middleware" endpoint**, () => {
    return request(app.getHttpServer())
      .post('/middleware')
      .expect(201, RETURN_VALUE);
  });

  it(**should exclude "/overview/:id" endpoint (by param)**, () => {
    return request(app.getHttpServer())
      .get('/overview/1')
      .expect(200, RETURN_VALUE);
  });

  it(**should exclude "/wildcard/overview" endpoint (by wildcard)**, () => {
    return request(app.getHttpServer())
      .get('/wildcard/overview')
      .expect(200, RETURN_VALUE);
  });

  // 추가된 코드
  it(**should exclude "/multiple/exclude" endpoint**, () => {
    return request(app.getHttpServer())
      .get('/multiple/exclude')
      .expect(200, RETURN_VALUE);
  });

  afterEach(async () => {
    await app.close();
  });
});

  • 내가 수정한 코드가 잘 작동하는지 확인하기 위해 통합 테스트에 다음과 같은 코드를 추가하였다.
// 추가된 코드, multiple/exclude 라우트 추가
@Get('multiple/exclude')
multipleExclude() {
  return RETURN_VALUE;
}

configure(consumer: MiddlewareConsumer) {
	consumer
	  .apply((req, res, next) => res.send(MIDDLEWARE_VALUE))
    .exclude('test', 'overview/:id', 'wildcard/(.*)', {
      path: 'middleware',
      method: RequestMethod.POST,
    })
    .exclude('multiple/exclude') // 추가된 코드, 여러개의 exclude 호출
    .forRoutes('*');
}

// 추가된 테스트 코드, 여러개 exclude는 미들웨어에서 제외되어야함
it(**should exclude "/multiple/exclude" endpoint**, () => {
  return request(app.getHttpServer())
    .get('/multiple/exclude')
    .expect(200, RETURN_VALUE);
});
  • 통합 테스트를 돌리고, 모두 통과하는지 확인한다.
$ sh scripts/run-integration.sh

Untitled

  • 통합 테스트도 모두 통과하는 것을 확인할 수 있습니다! 이제 PR만 올리면 됩니다.

7. PR을 작성하기


fix(core): prevent exclude method from overwriting previous calls by dragontaek-lee · Pull Request #13614 · nestjs/nest
fix(core): prevent exclude method from overwriting previous calls by dragontaek-lee · Pull Request #13614 · nestjs/nest
PR Checklist Please check if your PR fulfills the following requirements: The commit message follows our guidelines: https://github.com/nestjs/nest/blob/master/CONTRIBUTING.md Tests for the chan...
  • 작성한 코드에 대해 PR을 작성하였습니다.
  • 아무래도 명확한 Issue가 없었기 때문에, 해당 문제 상황에 대해서 간결하고 명확하게 설명했어야 했습니다.
    • 이에 따라서 minimum reproduction 을 제공하기 위해 github / codesandbox에 재현 코드를 올려두고 제공하였습니다. codesandbox의 경우 사용자 환경에 따라 구성이 잘 안되는 것 같기도 하더라고요. 아무래도 github에 올리는게 좋은 것 같습니다.
    • 추가적으로 간단한 코드 스니펫을 활용해서 상황을 설명하였습니다.
  • PR을 올리면, coveralls 봇이 실행하는 테스트들을 모두 통과해야합니다.

8. 기다리기


  • 오픈소스 PR에 대한 응답은 기다림의 연속입니다.
  • 아무래도 메인테이너도 현생이 있고, 특히 주말에는 응답을 안하더군요. 당연한 이치이고, 그저 편하게 기다리면 됩니다!

9. 기여 완료!


  • PR을 올린 후 3일후에 메인테이너 중 한분께 approve를 받았고, 1주일 뒤쯤에 nest의 아버지이자 해당 프로젝트를 총괄하는 kamilmysliwiec에게 코멘트를 받고 해당 PR은 merge가 되었습니다.
  • 다행히도 한번에 approve가 되었습니다.

다행히도 한번에 approve가 되었다.

  • nestjs/nest 리포지토리에 contributer 마크도 붙고, contributor 목록에 들어가서 좋았습니다!

Untitled

Untitled

  • 해당 기능 수정은 최신 릴리즈인 NestJS v10.3.9 에 포함되었습니다. 해당 버전부터 수정된 기능을 사용하실 수 있습니다.
Release v10.3.9 · nestjs/nest
Release v10.3.9 · nestjs/nest
v10.3.9 (2024-06-03) Bug fixes core #13453 fix(core): possible memory leak when using server side events (@zhengjitf) #13405 fix(core): auto flush logs on synchronous internal errors (@micalevisk...

Untitled

github 이름을 본명으로 해두길 잘한 것 같습니다.

  • github 이름을 본명으로 해두길 잘한 것 같습니다.

10. 후기


  • 사실 해당 기여가 제 첫 오픈소스 기여인데요, 여러모로 좋은 경험이였던 것 같습니다. 우선 애정을 가지고 있는 프로젝트에 기여를 하고 같이 개선해나가고 확장해나가는 재미가 있었고, 그 과정에서 프로젝트의 코드를 더 깊게 들여다보면서 질 좋은 학습도 동시에 된 것 같습니다.
  • 또한 무엇보다 가시적으로 나타나지는 않더라도, 해당 오픈소스를 사용하는 전세계 사람들의 프로젝트에서 내 코드가 영향을 끼치며 개발 생태계에 기여하고 있다는 사실은 참 즐겁고 성취감을 주는 일인 것 같습니다!