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
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 Framework의 HttpSecurity 빌더의 경우 체이닝 방식을 사용하여 여러 설정(e.g 적용할 라우트)을 연속적으로 추가할 수 있는 구조인데요, 빌더의 목적은 다르지만 해당 방식에 익숙해서 사용한 점도 있습니다.
- 하지만 이렇게 미들웨어를 구성하였을 때, 첫번째로 명시한 exclude는 동작하지 않고 두번째로 명시한 exclude만 동작하는 문제가 발생했습니다.
2. 확인해보기
- 해당 동작 방식이 문제가 있는지 확인해기 위해 간단한 프로젝트 한개를 파보았습니다.
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.ts의 exclude에서 명시한 모든 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를 반환해야합니다.
테스트 코드 실행 결과
- 테스트 수행결과는 다음과 같이 5개중에서 4개가 실패한 상황입니다.
- 실패한 케이스는 1,2,3,4 테스트 케이스인데요, 하나씩 살펴보겠습니다.
1번 테스트 코드
- 미들웨어를 타지않고 RETURN_VALUE를 반환하는 것을 기대했지만, 미들웨어를 타서 MIDDLEWARE_VALUE를 반환하여 테스트가 실패했습니다.
2번 테스트 코드
- 미들웨어를 타지않고 RETURN_VALUE를 반환하는 것을 기대했지만, 미들웨어를 타서 MIDDLEWARE_VALUE를 반환하여 테스트가 실패했습니다.
3번 테스트 코드
- 미들웨어를 타지않고 RETURN_VALUE를 반환하는 것을 기대했지만, 미들웨어를 타서 MIDDLEWARE_VALUE를 반환하여 테스트가 실패했습니다.
4번 테스트 코드
- 미들웨어를 타지않고 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, 간단한 재현을 할 수 있는 프로젝트와 함께 이슈를 제기해야합니다.
- 정확히 해당 이슈는 아니지만, 논의 사항에서 해당 이슈가 논의된 것을 확인할 수 있습니다. 이정도면 바로 작업을 착수 해도 될 것 같습니다!
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 에 대해 알아보겠습니다.
- 사실 MiddlewareConsumer는 MiddlewareBuilder 클래스의 인터페이스입니다. 따라서 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
- 모두 통과하는 것을 확인할 수 있습니다!
통합 테스트
// 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
- 통합 테스트도 모두 통과하는 것을 확인할 수 있습니다! 이제 PR만 올리면 됩니다.
7. PR을 작성하기
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가 되었습니다.
- nestjs/nest 리포지토리에 contributer 마크도 붙고, contributor 목록에 들어가서 좋았습니다!
- 해당 기능 수정은 최신 릴리즈인 NestJS v10.3.9 에 포함되었습니다. 해당 버전부터 수정된 기능을 사용하실 수 있습니다.
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...
- github 이름을 본명으로 해두길 잘한 것 같습니다.
10. 후기
- 사실 해당 기여가 제 첫 오픈소스 기여인데요, 여러모로 좋은 경험이였던 것 같습니다. 우선 애정을 가지고 있는 프로젝트에 기여를 하고 같이 개선해나가고 확장해나가는 재미가 있었고, 그 과정에서 프로젝트의 코드를 더 깊게 들여다보면서 질 좋은 학습도 동시에 된 것 같습니다.
- 또한 무엇보다 가시적으로 나타나지는 않더라도, 해당 오픈소스를 사용하는 전세계 사람들의 프로젝트에서 내 코드가 영향을 끼치며 개발 생태계에 기여하고 있다는 사실은 참 즐겁고 성취감을 주는 일인 것 같습니다!
