빌드노트
Spring에서 같은 URL인데 GET은 되고 POST는 405다
Spring MVC의 POST 405를 실제 요청 메서드·Allow·클래스와 메서드 매핑으로 좁힌다. 짧은 MockMvc 출력으로 404와 구별하고, consumes 415·필수 인자 400·필터 거절이 멈추는 단계를 비교한다.
Spring MVC에서 같은 URL의 GET은 성공하는데 POST만 405라면, 먼저 실제로 보낸 메서드와 그 경로의 매핑 조건을 맞춰 본다. @GetMapping이 있다는 사실은 POST 처리까지 보장하지 않는다. 기본 MVC 처리에서는 경로 후보가 있어도 HTTP 메서드가 맞지 않으면 HttpRequestMethodNotSupportedException이 발생하고 405로 응답한다. 매핑 구현 (새 탭에서 열림)과 기본 예외 처리기 (새 탭에서 열림)에서 이 연결을 확인할 수 있다.
실패한 요청의 메서드와 Allow를 매핑에 대조한다
브라우저 개발자 도구의 Network에서 실패한 요청 자체를 고른다. Request URL과 Request Method, 응답의 Allow를 확인한다. 주소창에서 같은 URL이 열린다는 것은 GET 확인에 불과하다. POST가 실패했는데 Allow: GET이 왔다면, 그 경로에 GET만 등록돼 있는지부터 살펴볼 근거가 된다.
컨트롤러에서는 클래스와 메서드의 경로를 함께 읽는다. 다음은 @RestController와 @RequestMapping("/lab")이 붙은 합성 컨트롤러에서 메서드만 추린 것이다. 반환문은 매핑 비교용이며 데이터를 저장하지 않는다.
@GetMapping("/read-only")
public String readOnly() { return "read-only"; }
@GetMapping("/entries")
public String read() { return "entries"; }
@PostMapping("/entries")
@ResponseStatus(HttpStatus.CREATED)
public String create() { return "created"; }
여기서 실제 경로는 /lab/read-only와 /lab/entries다. /lab/entries는 GET과 POST가 각각 다른 메서드로 연결된다. Java 메서드 이름이 아닌 매핑 애너테이션이 요청 방식을 정한다. 애너테이션은 org.springframework.web.bind.annotation, HttpStatus는 org.springframework.http에서 가져온다. Spring MVC 매핑 문서 (새 탭에서 열림)는 클래스의 공통 매핑과 메서드별 조건을 설명한다.
조회 API에 실수로 POST를 보냈다면 클라이언트를 GET 계약에 맞춘다. 생성 동작을 POST로 받아야 하는데 GET만 등록했다면, 그 동작의 @PostMapping을 별도로 선언한다. 메서드 조건 없는 @RequestMapping으로 바꾸면 기본적으로 모든 HTTP 메서드와 매칭되므로, 먼저 허용할 동작을 정해야 한다.
405일 때 선택된 핸들러와 예외를 확인한다
기존 Spring MVC 테스트에 MockMvc 객체 mvc가 있다면, 실패 요청과 정상 요청을 나란히 출력할 수 있다. 경로는 자신의 테스트 대상에 맞게 바꾼다. 아래는 앞의 합성 매핑을 등록한 경우다. 프로젝트 버전에 맞는 spring-test와 Servlet API가 필요하며, 예외를 던질 수 있는 테스트 메서드 안에서 실행한다. get·post는 MockMvcRequestBuilders, print는 MockMvcResultHandlers의 정적 import다. 두 클래스는 각각 org.springframework.test.web.servlet.request와 org.springframework.test.web.servlet.result 패키지에 있다.
mvc.perform(post("/lab/read-only")).andDo(print());
mvc.perform(get("/lab/read-only")).andDo(print());
Windows 11, OpenJDK 17.0.2, Spring Framework 6.2.19, Jakarta Servlet API 6.0.0에서 standaloneSetup으로 실행했다. 보안 필터·사용자 정의 예외 처리기·정적 자원 매핑은 넣지 않았다. 출력에서 확인한 값을 추리면 다음과 같다. —는 해당 헤더나 선택된 핸들러가 없다는 뜻이다.
요청 상태 Allow 선택된 메서드
POST /lab/read-only 405 GET —
GET /lab/read-only 200 — readOnly
POST 출력의 Resolved Exception은 HttpRequestMethodNotSupportedException이었다. 별도 호출 기록에서도 POST는 컨트롤러 본문을 실행하지 않았고 GET은 한 번 실행했다. 이 예외의 getMethod()는 POST, getSupportedMethods()는 GET 하나였다. 예외 API (새 탭에서 열림)는 각각 실패한 메서드와 알려진 지원 목록을 반환한다고 설명한다. 따라서 이 POST는 JSON을 읽다가 실패한 것이 아니라 호출할 핸들러를 고르지 못한 경우다.
없는 /lab/missing은 GET·POST 모두 404였고 NoHandlerFoundException이 남았다. 반면 /lab/entries는 GET이 read를 호출해 200, POST가 create를 호출해 201이었다. 같은 경로의 DELETE는 405였고 Allow에는 GET과 POST가 있었다. URL을 바꿔야 하는지, 보낸 메서드나 등록된 메서드를 고쳐야 하는지를 이 차이로 좁힐 수 있다.
컨트롤러가 호출되지 않았다는 사실만으로는 부족하다
consumes도 매핑 조건이다. 별도 POST 매핑에 consumes="application/json"과 필수 @RequestParam("value")를 두고 비교했다. value=ok가 있어도 Content-Type: text/plain이면 415·핸들러 없음·호출 0회였다. application/json에서 value를 생략하면 400·핸들러 선택됨·호출 0회였고, value=ok까지 주면 200·호출 1회였다. 이 대조에는 @RequestBody를 쓰지 않았다. consumes 불일치는 선택 전, 필수 인자 누락은 선택 후에 멈췄다. 415를 모두 본문 변환 실패로 묶거나, 핸들러가 선택됐다고 본문까지 실행됐다고 판단하면 단계를 잘못 짚는다. 매핑 조건 구현 (새 탭에서 열림)도 메서드와 consumes 불일치를 구분한다.
MVC 앞의 필터가 요청을 거절할 수도 있다. 실험에서 POST를 즉시 403으로 끝내는 합성 필터를 붙이자, 원래 405였던 /lab/read-only와 201이던 /lab/entries의 POST가 모두 403으로 바뀌었다. 둘 다 핸들러와 호출 기록, MVC 해결 예외가 없었다. 이 필터는 실제 Spring Security나 CSRF 구현이 아니다. 실제 앱에서는 보안 체인을 포함한 테스트와 보안 로그를 확인한다. Spring Security 6.5의 CSRF 문서 (새 탭에서 열림)도 토큰 검증 실패가 필터에서 처리를 끝낼 수 있음을 설명한다. 상태 코드나 호출 0회만 보고 원인을 확정할 수는 없다.
Allow 역시 관찰한 응답의 맥락에서 읽는다. 이번 구성의 POST 405는 Allow: GET이었지만 HEAD는 GET 핸들러로 성공했고, 일반 OPTIONS는 컨트롤러 호출 없이 Allow: GET,HEAD,OPTIONS를 반환했다. 공식 문서 (새 탭에서 열림)의 HEAD·자동 OPTIONS 지원에 해당한다. 목록은 순서보다 포함된 항목으로 비교한다. 이 OPTIONS에는 CORS 사전 요청 헤더를 넣지 않았다.
MockMvc는 실행 중인 서버 없이 MVC 처리를 검사한다 (새 탭에서 열림). 위 관찰은 고정 매핑과 기본 예외 처리, 합성 필터의 비교 범위다. 실제 서버·프록시·인증 및 CSRF 체인·메서드 변경 필터·JSON 본문 변환·다른 Spring 버전은 확인하지 않았다. 외부 응답의 Allow가 없거나 매핑과 맞지 않으면, 서버가 본 실제 메서드와 MVC 예외 기록을 대조해 응답을 만든 계층부터 확인한다.