본문 바로가기
빌드노트 검색

빌드노트

Spring MVC에서 /reports는 열리는데 /reports/는 404다

Spring MVC에서 끝 슬래시 하나로 404가 달라질 때 실제 경로와 버전, 선택된 핸들러를 비교한다. Spring 6.2.12의 독립 실행으로 단일 매핑·두 경로 매핑·308 리다이렉트의 차이를 확인하고 지원할 주소를 정한다.

Spring MVC에서 /reports는 응답하는데 /reports/만 404라면, 컨트롤러의 경로 매핑과 끝 슬래시 처리부터 비교한다. 기본 PathPatternParser를 사용하는 Spring MVC 6에서는 끝의 /를 자동으로 허용하지 않는다. 두 주소를 모두 지원하려면 경로를 둘 다 선언하거나, 한쪽에서 대표 주소로 리다이렉트하도록 정해야 한다.

Spring의 PathPatternParser API에는 끝 슬래시 자동 매칭의 기본값이 6.0부터 true에서 false로 바뀌었다고 명시돼 있다. 예전 버전에서 되던 주소가 업그레이드 뒤 실패했다면 확인할 차이다. 다만 버전 번호만으로 원인을 확정할 수는 없다. 별도 경로 설정이나 명시적 매핑이 있으면 결과가 달라진다. Spring 6.2.12 PathPatternParser API (새 탭에서 열림)

실패한 요청과 매핑을 같은 기준으로 비교한다

브라우저 개발자 도구의 Network에서 실패한 요청의 메서드와 경로를 그대로 확인한다. 아래 비교는 GET을 대상으로 한다. 클래스에 @RequestMapping("/api"), 메서드에 @GetMapping("/reports")가 있다면 비교할 주소는 /api/reports와 /api/reports/다. 클래스와 메서드의 경로를 합쳐 읽어야 한다. RequestMapping의 경로 결합 규칙 (새 탭에서 열림)

그다음 실제 실행에 쓰이는 Spring Framework 버전을 확인한다. Spring Boot 버전과 혼동하지 않도록, Java 코드에서는 org.springframework.core.SpringVersion.getVersion()으로 확인할 수 있다. Spring MVC는 6.0부터 PathPattern을 기본으로 사용하지만, 프로젝트가 경로 매칭 설정을 바꿨는지도 함께 봐야 한다. Spring MVC의 경로 패턴 설명 (새 탭에서 열림)

이미 MockMvc 테스트가 있다면, 같은 컨트롤러에 두 요청을 보내 상태 코드와 선택된 메서드가 달라지는지 확인한다. 다음 코드는 Java 17 이상에서 기존 테스트 메서드 안에 넣는 비교다. mvc는 그 테스트가 구성한 MockMvc이며, /reports는 자신의 매핑 경로로 바꾼다. MvcResult는 org.springframework.test.web.servlet, HandlerMethod는 org.springframework.web.method에서 가져오고, get은 MockMvcRequestBuilders.get을 static import한다. 테스트 메서드는 throws Exception으로 선언할 수 있다.

for (String path : new String[]{"/reports", "/reports/"}) {
    MvcResult r = mvc.perform(get(path)).andReturn();
    Object h = r.getHandler();
    String method = h instanceof HandlerMethod hm ? hm.getMethod().getName() : "none";
    System.out.printf("%s status=%d handler=%s%n",
            path, r.getResponse().getStatus(), method);
}

이 비교를 Windows의 OpenJDK 17.0.2, Spring Framework 6.2.12에서 별도로 실행했다. 컨트롤러에는 @GetMapping("/reports") 메서드 하나만 두고 "ok"를 반환하게 했다. MockMvc의 standaloneSetup을 사용했으며, 경로 파서나 끝 슬래시 옵션을 지정하지 않았다. 실제 구성된 파서는 PathPatternParser였고 자동 매칭 옵션은 false였다.

/reports status=200 handler=reports
/reports/ status=404 handler=none

첫 요청은 reports 메서드를 선택했다. 두 번째 요청에는 핸들러가 없었고, 응답의 Location 헤더도 없었다. /reports/를 /reports로 자동 이동시킨 결과가 아니라, 이 컨트롤러의 매핑과 일치하지 않은 결과였다.

위 코드의 none은 HandlerMethod가 없다는 표시다. 실제 앱에서는 다른 종류의 핸들러가 선택될 수도 있으므로, 필요하면 r.getHandler()의 원래 값도 확인한다. 404인데 컨트롤러 메서드가 선택됐다면 이 예제와 같은 매핑 탈락으로 단정하지 않는다. 두 요청이 모두 404라면 전체 경로와 컨트롤러 등록부터 다시 보고, 둘 다 200이라면 이미 두 경로를 지원하는 매핑이나 설정이 있는지 확인한다.

두 주소에서 같은 조회를 실행하려면

두 주소를 API 계약으로 받아들이기로 했다면, 기존 조회 메서드의 경로 목록에 끝 슬래시가 있는 주소를 추가한다. 다음은 클래스에 별도 경로 접두사가 없는 @RestController의 예다. 실제 코드에서는 기존 반환형과 조회 로직을 유지하고 매핑만 바꾼다.

@GetMapping({"/reports", "/reports/"})
public String reports() {
    return "ok";
}

이 매핑으로 별도 컨트롤러를 구성하자 두 GET 요청이 모두 200을 반환했고, 둘 다 같은 reports 메서드를 선택했다. 선택된 패턴은 각각 /reports와 /reports/였다. 두 요청 모두 Location 헤더가 없었다. 즉, 주소를 바꾸는 절차 없이 같은 조회를 실행한다. 경로를 배열로 선언하는 형식은 RequestMapping API (새 탭에서 열림)에서 확인할 수 있다.

대표 주소 하나로 모으려면

대표 주소를 /reports로 정했다면 기존의 단일 @GetMapping("/reports")는 유지하고, 같은 @RestController에 /reports/ 전용 메서드를 추가한다. 이 선택에서는 앞의 두 경로 배열 매핑을 적용하지 않는다. 다음 코드는 고정된 GET 주소에 영구 리다이렉트 308을 돌려주는 예다. URI는 java.net, ResponseEntity와 HttpStatus는 org.springframework.http에서 가져온다.

@GetMapping("/reports/")
public ResponseEntity<Void> redirect() {
    return ResponseEntity.status(HttpStatus.PERMANENT_REDIRECT)
            .location(URI.create("/reports"))
            .build();
}

별도 실행에서 /reports/는 redirect 메서드를 선택해 308과 Location: /reports를 반환했다. 그 Location을 대상으로 GET을 한 번 더 보내자 reports 메서드가 선택돼 200을 반환했다. 여기서는 MockMvc가 리다이렉트를 자동으로 따라간 것이 아니라, 목적지를 읽어 다음 요청을 직접 보냈다. 상태와 응답 헤더를 명시하는 반환형은 ResponseEntity API (새 탭에서 열림)에 설명돼 있다.

이 예는 앱이 루트 경로에 있고 쿼리 문자열이 없는 경우다. /app 같은 컨텍스트 경로가 있으면 Location에도 그 경로를 반영해야 한다. 조회 조건이 쿼리 문자열에 있다면 목적지로 보존할지도 정해야 한다. 위의 고정 Location만 복사해서는 그 조건이 전달되지 않는다. 영구 이동 정책이 정해진 GET을 대상으로 한 예이며, 다른 요청 메서드의 이동 동작은 이번에 검사하지 않았다.

끝 슬래시 자동 매칭을 다시 켜는 setMatchOptionalTrailingSeparator(true)는 6.0부터 deprecated다. 공식 API는 프록시·필터·컨트롤러 등을 통한 명시적 리다이렉트를 안내한다. 기존 설정을 되살리기 전에, 두 주소에서 직접 응답할지 대표 주소로 보낼지를 먼저 결정하는 편이 변경 범위를 분명하게 만든다. 끝 슬래시 옵션의 deprecated 안내 (새 탭에서 열림)

이번 관찰은 서버를 열지 않고 DispatcherServlet을 실행하는 MockMvc 비교다. MockMvc가 이 방식으로 요청 매핑을 검사한다는 범위는 공식 테스트 문서 (새 탭에서 열림)에서도 확인할 수 있다. 실제 앱의 프록시·보안 필터·정적 자원 처리, 다른 Spring 버전은 실행하지 않았다. 같은 상태 코드가 보여도 그 계층이 원인이라는 근거로 이 결과를 쓰지는 않는다.

← 전체 글 보기