빌드노트
JSON을 보냈는데 @ModelAttribute의 name은 null이다
JSON 본문은 @ModelAttribute의 폼·쿼리 바인딩과 다른 경로로 읽힌다. 같은 DTO에 요청 매개변수·폼·JSON을 보내 얻은 응답을 비교하고, 200인데 필드가 null인 경우와 변환기가 지원하지 않아 415인 경우를 구분한다.
Spring MVC 컨트롤러에 {"name":"pen"}을 보냈는데 @ModelAttribute Input input의 name이 null일 수 있다. DTO는 만들어지고 요청도 200으로 끝나니 필드 이름이나 setter부터 의심하기 쉽다. 먼저 실제 요청과 인자의 조합을 확인한다. JSON 본문을 객체로 읽으려면 @RequestBody를 사용하고, 폼·쿼리 매개변수를 묶어 받으려면 @ModelAttribute를 사용한다. Content-Type: application/json만 붙인다고 @ModelAttribute가 JSON을 읽지는 않는다.
브라우저에서는 개발자 도구 Network에서 해당 요청의 Content-Type과 Payload를 함께 본다. 본문이 {"name":"pen"}인 JSON인지, name=pen인 폼 데이터인지 구분한 뒤 컨트롤러 선언과 대조한다. 화면에서 같은 이름을 입력했더라도 서버가 값을 꺼내는 위치는 다르다.
같은 DTO로 요청 형식만 비교했다
별도 예제에서 Windows 11, OpenJDK 17.0.2, Spring Framework·spring-test 6.2.19, Jackson 2.19.2를 사용했다. 서버를 띄우지 않는 standalone MockMvc로 비교했고, Spring Boot 자동 설정과 Bean Validation은 넣지 않았다. 다음 코드는 테스트 클래스 안에 둔 DTO와 컨트롤러다. 실제 실험에서 호출 횟수를 세는 부분만 생략했다. 두 메서드는 받은 DTO를 그대로 응답하므로 어떤 값이 들어왔는지 볼 수 있다.
public static class Input {
private String name;
public Input() {}
public String getName() { return name; }
public void setName(String name) { this.name = name; }
}
@RestController
public static class BindingController {
@PostMapping("/model")
public Input model(@ModelAttribute Input input) {
return input;
}
@PostMapping("/body")
public Input body(@RequestBody Input input) {
return input;
}
}
두 매핑에는 consumes를 선언하지 않았다. 요청 타입을 매핑에서 거절하는 조건을 빼고, 인자를 준비하는 경로를 비교하기 위해서다. JSON 읽기와 응답 쓰기에는 new ObjectMapper()를 전달한 MappingJackson2HttpMessageConverter 하나만 명시적으로 등록했다. 별도 Jackson 모듈이나 속성 설정은 추가하지 않았다.
자기 프로젝트의 MVC 테스트에서 아래처럼 JSON 요청 하나를 보내면 빈 필드 증상을 확인할 수 있다. 앞의 중첩 클래스와 spring-webmvc, spring-test, jackson-databind, Servlet API를 사용할 수 있는 Java 테스트가 준비돼 있어야 한다. post는 MockMvcRequestBuilders.post의 정적 import이고, 나머지 타입은 코드에 적힌 클래스 이름을 import한다. 실제 비교에서는 매 요청마다 같은 설정으로 새 컨트롤러와 MockMvc를 만들었다.
MockMvc mvc = MockMvcBuilders.standaloneSetup(new BindingController())
.setMessageConverters(
new MappingJackson2HttpMessageConverter(new ObjectMapper()))
.build();
MvcResult result = mvc.perform(post("/model")
.contentType("application/json")
.content("{\"name\":\"pen\"}")).andReturn();
System.out.println(result.getResponse().getStatus());
System.out.println(result.getResponse().getContentAsString());
실제로 출력된 값은 다음과 같다.
200
{"name":null}
이 요청에서는 컨트롤러가 1회 실행됐고 해결된 예외도 없었다. input 자체가 null인 경우가 아니라, 만들어진 객체의 name이 null이었다. 본문이 없었던 것도 아니다. 요청에는 {"name":"pen"}이 있었지만 Servlet 요청 매개변수 목록은 비어 있었다.
같은 name=pen을 다른 방식으로 보낸 결과도 비교했다.
post("/model").param("name", "pen")→ 200,{"name":"pen"}. Mock 요청의 매개변수 목록에 직접 값을 넣은 경우다.post("/model").formField("name", "pen")→ 200,{"name":"pen"}. 요청 타입은application/x-www-form-urlencoded;charset=UTF-8, 본문은name=pen이었다.- 위 JSON 요청의 URL만
/body로 변경 → 200,{"name":"pen"}. 매개변수 목록이 비어 있어도 JSON 본문에서 이름을 읽었다.
셋 모두 컨트롤러가 1회 실행됐다. 같은 DTO의 setter가 폼·매개변수 입력에서는 작동했고 JSON도 @RequestBody에서는 읽혔다. 이 비교에서 @ModelAttribute의 빈 필드를 설명하는 차이는 DTO의 이름이 아니라 입력을 읽는 경로였다.
폼 데이터도 본문에 있는데 왜 다를까
Spring MVC의 ModelAttribute 문서 (새 탭에서 열림)는 바인딩 대상에 요청 매개변수, URI 변수, 요청 헤더 등을 포함한다. 여기서 요청 매개변수는 Servlet API의 개념이며 쿼리 문자열과 폼 데이터를 포함한다. 폼 본문의 name=pen은 그 매개변수로 제공되지만, JSON 객체의 name 속성이 자동으로 같은 매개변수가 되는 것은 아니다.
반면 RequestBody 문서 (새 탭에서 열림)는 메시지 변환기를 통해 본문을 읽어 객체로 만든다고 설명한다. 위 예제에서는 Jackson 변환기가 그 일을 했다. @RestController를 사용하고 응답을 JSON으로 돌려준다는 사실도 요청 인자를 JSON으로 읽게 만들지는 않는다. 두 메서드는 모두 같은 @RestController 안에 있었다.
값의 출처를 더 분명하게 보려고 JSON 본문은 {"name":"pen"}으로 유지하고, 두 URL에 쿼리 ?name=query를 함께 보냈다. /model은 {"name":"query"}, /body는 {"name":"pen"}을 반환했다. 둘 다 200이었다. 쿼리와 JSON 중 어느 쪽이 항상 우선한다는 공통 규칙이 아니라, 각 인자가 서로 다른 입력을 읽은 결과다. 빈 필드를 가리려고 같은 값을 쿼리에 덧붙이면 이런 차이를 놓칠 수 있다.
MockMvc의 .param()은 요청 매개변수 목록을 직접 구성한다. MockMvc 요청 문서 (새 탭에서 열림)도 이 사용법을 설명한다. 이번 폼 비교에 쓴 .formField()는 6.2.19의 요청 빌더 구현 (새 탭에서 열림)에서 매개변수 목록과 폼 본문을 함께 구성한다. 따라서 이 결과는 MVC 바인딩 경로를 비교한 것이며, 실제 Servlet 컨테이너가 네트워크로 받은 폼을 파싱하는 과정까지 검사한 것은 아니다.
내 API에서는 전송 형식과 인자를 함께 맞춘다
클라이언트가 JSON을 보내기로 한 API라면 DTO 인자를 @RequestBody Input input으로 받고 실제 요청 본문과 Content-Type: application/json을 함께 맞춘다. 위 예제에서는 /body가 그 수정에 해당한다. 기존 폼 제출을 받는 API라면 @ModelAttribute를 유지하고 필드 이름과 실제 폼·쿼리 매개변수가 맞는지 확인한다. JSON 요청을 흉내 내면서 테스트에 .param()만 넣으면 문제 조건을 재현하지 못한다.
어노테이션을 지우는 것만으로는 JSON 수신으로 전환되지 않을 수 있다. Spring 문서는 다른 인자 처리기가 맡지 않은 복합 타입을 암묵적인 @ModelAttribute로 처리한다고 설명한다. JSON을 받으려는 의도는 @RequestBody로 명시하는 편이 분명하다.
형식을 바꾼 뒤 415가 나온다면 앞의 200·빈 필드와 구분한다. 이번 구성에서 /body에 .formField("name", "pen")을 보내면 415와 HttpMediaTypeNotSupportedException이 발생했고 메서드 본문은 실행되지 않았다. 정상 JSON 문자열을 text/plain으로 보내도 같은 결과였다. 매핑은 선택됐지만 해당 타입을 DTO로 읽을 변환기가 없었다. 등록한 Jackson 변환기의 지원 타입 (새 탭에서 열림)은 기본적으로 application/json과 application/*+json이다. 이 415를 피하려고 모든 폼 API를 JSON으로 바꾸거나 허용 타입만 넓힐 이유는 없다. 먼저 API가 받기로 한 형식을 정하고 전송 방식·인자·변환기를 맞춘다.
JSON을 @RequestBody로 바꿔도 필드가 계속 비어 있다면, 그때는 실제 JSON 키와 DTO의 프로퍼티·생성자, Jackson 설정을 확인한다. 폼·쿼리 입력도 비어 있다면 매개변수 이름과 바인더 설정을 본다. 이번 비교는 기본 생성자와 getter·setter가 있는 합성 DTO 하나에 한정된다. 실제 HTTP 서버·프록시·필터, 사용자 정의 바인더·변환기·예외 처리기, 다른 버전과 Bean Validation은 확인하지 않았다.