본문 바로가기
Backend/NestJS

[NestJS] 와일드카드 프록시가 컨트롤러 루트 경로를 놓친 이유

미지시료 2026. 10. 17.

하위 경로 요청은 정상적으로 전달되는데 같은 컨트롤러의 루트 요청만 404가 발생했다. NestJS 와일드카드 경로가 매칭하는 범위를 실제 라우터에서 확인하고, 루트와 하위 경로를 함께 프록시하도록 수정한 과정을 정리했다.





시작하며

API Gateway에는 특정 경로 아래의 모든 요청을 내부 서비스로 전달하는 프록시 Controller가 있었다.

@Controller(['resources', 'api/resources'])
export class GatewayResourceController {
  @All('*path')
  async proxy(@Req() req: Request) {
    return this.gatewayService.proxyToCore(req);
  }
}

GET /resources/42나 PATCH /resources/42/status 같은 요청은 정상적으로 내부 서비스까지 전달됐다. 그래서 *path가 /resources 아래의 모든 경로를 처리한다고 생각했다.

이후 하위 경로가 없는 전체 조회 API를 연결하면서 예상하지 못한 차이가 나타났다.

GET /resources/42       → 정상 전달
GET /resources/search   → 정상 전달
GET /resources          → 404

내부 서비스에는 GET /resources Handler가 있었고 직접 호출하면 정상 응답을 반환했다. Gateway에서는 프록시 메서드까지 요청이 들어오지 않았다. 내부 HTTP 호출이 실패한 것이 아니라, 호출을 시작할 Handler가 선택되지 않은 상태였다.

원인은 내부 API가 아니라 Gateway에 선언한 와일드카드 경로의 매칭 범위였다.





*path는 빈 경로까지 포함하지 않았다

NestJS의 Controller 경로와 Handler 경로는 합쳐져 최종 요청 경로가 된다.

@Controller('resources')
class GatewayResourceController {
  @All('*path')
  proxy() {}
}

이 선언에서 *path는 Controller 접두어 뒤에 이어지는 이름이 있는 와일드카드 경로다. 사용 중인 라우터에서는 하나 이상의 경로 세그먼트가 있어야 매칭됐다.

요청 경로 Controller 접두어 뒤에 남는 경로 *path 매칭
/resources/42 42 성공
/resources/search search 성공
/resources/42/status 42/status 성공
/resources 빈 경로 실패

나는 /resources도 같은 접두어를 쓰니 함께 처리될 것이라고 생각했다. 하지만 라우터에서 접두어 자체와 그 뒤에 경로가 붙은 요청은 다른 매칭 대상이었다.

식별자나 명령 경로가 있는 API만 호출할 때는 이 차이가 드러나지 않았다. 하위 경로가 없는 전체 조회를 연결하고 나서야 빈 경로가 빠져 있다는 것을 확인했다.





내부 호출보다 먼저 Gateway 라우팅을 확인했다

처음에는 프록시가 만든 내부 요청 URL을 의심했다. 하위 경로와 루트 경로의 결과를 비교하고, 프록시 메서드 진입 여부를 확인하면서 범위를 줄였다.

확인 항목 확인 결과 판단
내부 서비스를 직접 호출 루트 조회 정상 내부 API 자체는 존재함
Gateway의 하위 경로 호출 정상 내부 서비스 주소와 기본 프록시 흐름은 동작함
Gateway의 루트 경로 호출 404 경로 형태에 따라 결과가 달라짐
프록시 메서드 진입 여부 진입하지 않음 내부 HTTP 호출 전 라우팅 문제
실제 NestJS 라우터에서 재현 같은 결과 와일드카드 패턴의 매칭 범위가 원인

확인 방향을 바꾼 단서는 프록시 메서드에 진입하지 않는다는 점이었다. 그 안의 URL 변환이나 HTTP Client 설정을 고쳐도 실행 자체가 안 되는 문제는 해결할 수 없었다. 실제 NestJS 라우터에서도 같은 매칭 차이를 재현해 원인을 확인했다.

Client
  ↓
NestJS Router  ← 루트 경로가 여기서 매칭되지 않음
  ↓
Gateway Controller
  ↓
HTTP Client
  ↓
Internal API

같은 404라도 Gateway가 Handler를 찾지 못한 경우와 내부 서비스가 반환한 경우는 조사할 위치가 다르다. 어느 단계까지 요청이 도달했는지 나누고 나니 내부 호출 코드를 더 살펴볼 필요가 없었다.





루트와 하위 경로를 각각 선언했다

수정은 빈 경로와 와일드카드 경로를 함께 등록하는 방식으로 적용했다.

@Controller(['resources', 'api/resources'])
export class GatewayResourceController {
  @All(['', '*path'])
  async proxy(@Req() req: Request) {
    return this.gatewayService.proxyToCore(req);
  }
}

빈 문자열은 접두어 자체를, 와일드카드는 그 뒤의 하위 경로를 받는다.

''      → /resources
*path   → /resources/42, /resources/search, ...

수정 후에는 다음 두 종류의 요청이 같은 프록시 메서드로 들어온다.

Handler 경로 처리하는 요청
'' Controller 자체의 루트 경로
'*path' Controller 아래에 하나 이상의 세그먼트가 있는 경로

루트와 하위 경로를 배열로 명시하는 방식을 선택했다. 빈 경로를 받겠다는 의도가 선언에 드러나고, 선택적 와일드카드 문법으로 두 경우를 합칠 필요도 없었다. 다만 이 선언 역시 사용하는 라우터의 문법을 전제로 하므로 버전과 무관한 해결책은 아니다.

@All()은 같은 경로에서 GET, POST, PATCH, DELETE 등 모든 HTTP 메서드를 받기 위해 사용했다. 메서드 범위는 @All()이, 경로 범위는 그 안에 전달한 패턴이 결정한다. 루트가 매칭되도록 바꿨다고 인증이나 권한 검사가 생략되는 것은 아니다. 프록시 Controller의 기존 Guard와 접근 정책도 그대로 유지했다.





경로 별칭은 내부 서비스의 실제 경로로 정규화했다

Gateway는 기존 클라이언트와 새 클라이언트가 서로 다른 접두어를 사용해도 같은 기능에 접근할 수 있도록 두 Controller 경로를 받았다.

@Controller(['resources', 'api/resources'])

하지만 내부 서비스가 두 별칭을 모두 알고 있어야 하는 것은 아니다. 내부 서비스의 표준 경로가 /resources 하나라면 Gateway에서 외부 별칭을 표준 경로로 바꿔 전달할 수 있다.

const downstreamPath = req.originalUrl.replace(
  /^\/api\/resources/,
  '/resources',
);

return this.gatewayService.proxyToCore(req, downstreamPath);

예를 들면 다음과 같이 변환된다.

외부 요청 내부 서비스에 전달할 경로
/resources /resources
/resources/42 /resources/42
/api/resources /resources
/api/resources/42?active=true /resources/42?active=true

여기서 루트 매칭과 경로 변환은 서로 다른 문제다.

  • @All(['', '*path'])는 요청이 프록시 메서드에 들어오도록 한다.
  • 접두어 변환은 프록시 메서드에 들어온 요청을 내부 서비스가 이해하는 경로로 바꾼다.

루트 경로가 Router에서 매칭되지 않으면 변환 코드가 실행되지 않는다. 반대로 Handler에 들어왔더라도 내부 서비스에 없는 별칭을 그대로 전달하면 그쪽에서 404가 발생한다. 라우팅 선언과 접두어 변환을 따로 확인한 이유다.





모든 와일드카드 Controller를 바꿀 필요는 없다

코드에서 @All('*path')를 발견했다고 모두 @All(['', '*path'])로 바꾸는 것은 적절하지 않다.

어떤 프록시는 애초에 하위 경로만 전달하도록 설계됐을 수 있다. 그런 Controller에 빈 경로를 추가하면 이전에는 존재하지 않던 루트 요청까지 받아 내부 서비스로 전달하게 된다. 수정 범위가 넓어지는 만큼 의도하지 않은 API 노출 가능성도 생긴다.

다음 조건을 만족할 때 루트 경로를 추가해야 한다.

  • 내부 서비스에 접두어와 정확히 같은 루트 API가 있다.
  • 외부에서도 그 루트 API를 제공해야 한다.
  • Gateway의 인증과 권한 정책이 해당 요청에도 적용된다.
  • 외부 별칭을 사용한다면 내부 표준 경로로 올바르게 변환한다.

이번 수정도 전체 Gateway의 와일드카드를 일괄 변경하지 않고, 실제로 루트 조회를 전달해야 하는 Controller에만 적용했다.





경로 하나가 아니라 경로 행렬로 확인한다

하위 경로 하나가 정상이라는 사실만으로는 루트 요청까지 확인한 것이 아니었다. 회귀 확인 항목은 Controller 접두어, 남은 경로, HTTP 메서드를 조합해 정리했다.

구분 확인할 요청 기대 결과
표준 접두어 루트 GET /resources 내부 /resources로 전달
표준 접두어 하위 경로 GET /resources/42 하위 경로 유지
별칭 접두어 루트 GET /api/resources 내부 /resources로 변환
별칭 접두어 하위 경로 PATCH /api/resources/42 Method와 Body 유지
Query 포함 GET /api/resources?active=true Query String 유지
존재하지 않는 하위 경로 GET /resources/unknown/path 내부 서비스의 오류 계약 유지
인증 없는 요청 보호된 각 경로 Gateway에서 거부

이 표를 테스트로 옮길 때는 응답 성공 여부뿐 아니라 프록시에 들어온 요청과 내부 서비스로 전달한 요청을 함께 비교해야 한다.

  • 루트, 한 단계 하위 경로, 여러 단계 하위 경로를 나눠 Handler 진입 여부를 확인한다.
  • 표준 접두어와 외부 별칭이 같은 내부 경로로 전달되는지 비교한다.
  • HTTP 메서드, 쿼리와 본문이 프록시를 지나도 유지되는지 확인한다.
  • 루트 경로에도 기존 인증과 권한 검사를 적용한다.
  • 내부 서비스의 오류 상태와 메시지가 Gateway 응답에 유지되는지 확인한다.

당시에는 이 경계 조건을 자동화 테스트로 잡지 못했다. 하위 경로의 정상 동작만으로 프록시 범위를 판단했던 경험 이후로는 루트 경로와 하위 경로를 별개의 확인 사례로 다룬다. 위 항목은 회귀 확인 기준이며, 모두 자동화됐다는 뜻은 아니다.





마치며

와일드카드 프록시는 접두어 아래의 모든 요청을 받는 것처럼 보이지만, 실제 매칭 범위는 사용하는 라우터 문법으로 결정된다. 이번에는 이름이 있는 와일드카드가 최소 한 개의 경로 세그먼트를 요구해 Controller 루트 요청만 빠지고 있었다.

해결 방법은 복잡하지 않았다.

@All(['', '*path'])

수정은 한 줄이었지만, 내부 URL을 계속 의심했다면 그 줄까지 도달하기 어려웠을 것이다. Handler 진입 여부와 내부 경로 변환을 나눠 확인하면서 404가 발생한 위치를 좁힐 수 있었다.

프록시 경로를 만들 때는 와일드카드 하나로 끝났다고 생각하지 않고 다음 세 가지를 함께 확인해야 한다.

  1. 루트 API를 외부에 제공한다면 Controller 접두어 자체도 매칭하도록 등록한다.
  2. 하위 경로와 Query String이 내부 요청까지 유지되는지 확인한다.
  3. 외부 별칭은 내부 서비스가 받는 표준 경로로 변환한다.

하위 API가 정상이라는 사실은 루트 API도 정상이라는 보장이 아니었다. 경로 패턴의 경계값 역시 API 계약의 일부다.