컨트롤러 라우팅 순서로 인해 발생한 Validation failed 에러
NestJS에서 정적 라우트(/readme)가 동적 라우트(/:id)보다 나중에 선언되어 발생한 에러 원인 분석과 해결 방법
컨트롤러 라우팅 순서로 인해 발생한 Validation failed 에러
에러 로그
boostus_backend | [Nest] 367 - 03/03/2026, 10:07:23 PM ERROR GET /api/projects/readme?repository=https%3A%2F%2Fgithub.com%2Fboostcampw2025%2Fweb01-boostus - 400
boostus_backend | BadRequestException: Validation failed (numeric string is expected)
문제 상황 분석
@ApiTags('프로젝트')
@Controller('projects')
export class ProjectController {
constructor(private readonly projectService: ProjectService) {}
@Public()
@UseGuards(ViewerKeyGuard)
@Get(':id')
@GetProjectSwagger()
findOne(@Param('id', ParseIntPipe) id: number, @ViewerKey() viewerKey: string) {
return this.projectService.findOneWithViewCount(id, viewerKey);
}
...
@Public()
@Get('/readme')
@GetReadmeSwagger()
async getReadme(@Query('repository') repositoryUrl: string) {
return this.projectService.getRepoReadme(repositoryUrl);
}
}
위와 같이 ProjectController API 가 구현되어 있다.
- /project/:id -> id를 통한 프로젝트 단일 조회
- /project/readme -> 쿼리 파라미터로 받은 repository 를 통해 리드미 조회
기존에는 /project/readme 가 상단에 있었으나 스웨거 문서 가독성을 위해 하단으로 내린 뒤 에러가 발생했음을 고려하여 문제를 파악해보았고,
NestJS 가 라우트를 선언한 순서대로 매칭하기 때문임을 알게 되었다.
구체적인 URI 를 따라가는게 아니라 먼저 선언된 라우트에 매칭되어버리면서, /project/readme 라는 요청이 /project/:id 로 들어가버린 것이다.
그래서 id 자리에 numeric string 을 기대하는데 문자열이 들어와버렸다고 Validation failed 에러가 발생했던 것이다.
정적 라우트(/readme)와 동적 라우트(/:id)가 충돌할 때, 등록 순서/매칭 우선순위에 따라 먼저 걸린 쪽으로 처리되는 성질 때문입니다.
문제 해결 방법
방법 1 : 라우트 순서를 변경한다
기존처럼 /readme 를 /:id 위로 올리면 손쉽게 해결 가능하다.
그러나 스웨거 문서에 나타나는 정렬 순서를 따로 신경써서 조치해야 한다. 이게 지금처럼 엔드포인트 개수가 많지 않을 때는 큰 문제가 없지만 만약 엔드포인트 개수가 많아진다면 컨트롤러에서 메서드 순서와 스웨거 정렬 순서 등 신경 써야 할 요소가 많아질 것 같다.
방법 2 : 정규식 활용하기
/:id 에 정규식을 추가해 실제로 numeric string이 들어왔을때만 매칭되도록 한다.
@Get(':id(\\d+)')
findOne(@Param('id', ParseIntPipe) id: number, @ViewerKey() viewerKey: string) {
return this.projectService.findOneWithViewCount(id, viewerKey);
}
이렇게 하면 /readme 와 같은 uri 는 매칭되지 않고 넘어가기 때문에, 이게 더 근본적인 해결책이 맞는 것 같다.
또 다른 문제 발생
문제 상황 분석
boostus_backend | [Nest] 387 - 03/03/2026, 10:36:39 PM ERROR [LegacyRouteConverter] Unsupported route path: "/api/projects/:id(\d+)". In previous versions, the symbols ?, *, and + were used to denote optional or repeating path parameters. The latest version of "path-to-regexp" now requires the use of named parameters. For example, instead of using a route like /users/* to capture all routes starting with "/users", you should use /users/*path. For more details, refer to the migration guide.
boostus_backend | /app/node_modules/path-to-regexp/src/index.ts:289
boostus_backend | throw new PathError(
boostus_backend | ^
boostus_backend |
boostus_backend | PathError [TypeError]: Unexpected ( at index 17, expected end: /api/projects/:id(\d+); visit https://git.new/pathToRegexpError for info
라우트 경로 문자열 안에 정규식을 끼워 넣는 문법이 최신 express 에서는 동작하지 않는다..!
예전(path-to-regexp 구버전)에는 :id(\d+) 처럼 파라미터명 바로 뒤에 정규식을 붙이는 문법이 지원됐으나, path-to-regexp v8부터 이 인라인 정규식 문법이 완전히 제거되었다.
NestJS v10+는 내부적으로 이 라이브러리의 최신 버전을 사용하기 때문에 동일하게 영향을 받는다고 한다.
문제 해결 방법
방법 1 : 컨트롤러 분리하기
/:id 엔드포인트를 별도의 컨트롤러로 분리하고, 정적 경로인 /readme를 가진 컨트롤러를 모듈에 먼저 등록한다.
// readme.controller.ts
@ApiTags("프로젝트")
@Controller("projects")
export class ProjectReadmeController {
@Public()
@Get("readme")
async getReadme(@Query("repository") repositoryUrl: string) {
return this.projectService.getRepoReadme(repositoryUrl);
}
}
// project.controller.ts
@ApiTags("프로젝트")
@Controller("projects")
export class ProjectController {
@Public()
@UseGuards(ViewerKeyGuard)
@Get(":id")
findOne(
@Param("id", ParseIntPipe) id: number,
@ViewerKey() viewerKey: string,
) {
return this.projectService.findOneWithViewCount(id, viewerKey);
}
}
그리고 module.ts 에서 다음과 같이 readme 경로를 먼저 등록한다.
이렇게 하면 NestJS는 모듈에 등록된 컨트롤러 순서대로 라우트를 등록하므로, readme 경로가 먼저 매칭된다.
// project.module.ts
@Module({
controllers: [
ProjectReadmeController, // 정적 경로 먼저 등록
ProjectController,
],
})
export class ProjectModule {}
방법 2 : @Get('readme') 메서드를 :id 위로 올리고 스웨거 operationId로 정렬 제어
이전과 마찬가지로 라우트 순서를 원복하고, 스웨거 문서 상에서만 정렬을 조정하는 방법이다.
해결 방법 선택
메서드 순서를 원복하고 스웨거 정렬 제어를 해보자!
컨트롤러를 분리하는게 근본적인 해결책처럼 보였으나, 분리된 컨트롤러도 결국 모듈에 등록하는 순서에 의존하게 된다.
따라서 라우팅 순서에 의존하게 된다는 본질은 그대로고, 그 의존성이 컨트롤러 파일에서 모듈로 옮겨갔을 뿐이다.
또한 컨트롤러 분리가 의미있어지는 순간은 라우팅 순서 문제가 아니라 컨트롤러가 너무 커져서 응집도를 높이기 위한 리팩토링을 할 때인 것 같다.
따라서 /readme, /colloborator 등 /:id 와 라우팅 충돌이 발생할 수 있는 엔드포인트를 상단으로 올리고 스웨거 문서화 정렬 옵션에 대해 학습하고 적용하여 해결해보면 좋겠다.