빌드노트
Spring Boot에서 JSON 필드 오타가 200으로 통과할 때
Spring Boot 3.5.7의 자동 구성 매퍼는 모르는 JSON 속성을 무시했다. 오타·추가 필드·누락을 실제 요청으로 비교하고, FAIL_ON_UNKNOWN_PROPERTIES와 필수 값 검증을 각각 선택할 기준을 설명한다.
Spring Boot의 @RequestBody DTO에는 name이 있는데 클라이언트가 naem을 보내도 요청이 성공한다면, JSON을 읽는 매퍼의 미지 속성 정책을 확인한다. Spring Boot 3.5.7의 자동 구성 매퍼는 DTO가 모르는 속성을 기본적으로 무시한다. 오타를 거절하려면 이 정책을 엄격하게 바꾸고, 이름의 누락도 막아야 한다면 값 검증을 별도로 둔다. 미지 속성 거절만으로 필수 값 검사가 생기지는 않는다.
설정 파일보다 MVC가 사용하는 매퍼를 먼저 본다
Jackson 2를 사용하는 기존 Spring MVC 프로젝트라면, 웹 컨텍스트가 올라오는 통합 테스트에서 RequestMappingHandlerAdapter를 주입받아 확인할 수 있다. 아래의 adapter가 그 빈이고, Input.class는 조사할 요청 DTO 타입이다. 조회에 쓰는 타입은 org.springframework.http.converter.json.MappingJackson2HttpMessageConverter, org.springframework.http.MediaType, com.fasterxml.jackson.databind.DeserializationFeature다. 테스트 메서드 안에서 다음 코드를 실행하면 JSON을 읽을 수 있는 첫 Jackson 변환기의 기본 매퍼 정책을 출력한다.
var converter = adapter.getMessageConverters().stream()
.filter(c -> c instanceof MappingJackson2HttpMessageConverter
&& c.canRead(Input.class, MediaType.APPLICATION_JSON))
.map(c -> (MappingJackson2HttpMessageConverter) c)
.findFirst().orElseThrow();
System.out.println(converter.getObjectMapper()
.isEnabled(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES));
별도 실험의 기본 구성에서는 false가 나왔다. 같은 Jackson 2.19.2에서 직접 만든 new ObjectMapper()는 true였고, 추가 필드를 읽으면 UnrecognizedPropertyException을 던졌다. 라이브러리 자체 기본값과 Boot가 구성한 매퍼의 기본값이 달랐다. 새 매퍼를 만들어 시험한 결과로 실제 요청 경로의 정책을 판단하면 안 된다. 이 차이는 Boot 3.5.7의 매퍼 구성 문서 (새 탭에서 열림)와 Jackson 2.19.2의 기능 정의 (새 탭에서 열림)에서도 확인했다.
요청 계약에 없는 필드를 거절하려면 Boot 자동 구성을 사용하는 앱의 application.properties에 다음 값을 넣고 새 컨텍스트에서 다시 확인한다.
spring.jackson.deserialization.fail-on-unknown-properties=true
실험에서는 위 설정을 읽힌 컨텍스트의 출력이 true로 바뀌었다. 자동 구성된 ObjectMapper 빈과 MVC 변환기의 매퍼가 같은 인스턴스인지도 확인했다. 반대로 명시적으로 false를 지정하면 기본 구성과 같은 결과였다.
설정을 넣어도 출력이 false라면 현재 활성 설정과 사용자 정의 ObjectMapper, 빌더, 변환기, 매퍼 사용자화 코드를 확인한다. Boot 문서는 직접 매퍼나 빌더 빈으로 대체하면 자동 구성이 비활성화되는 조건을 설명한다. 위 조회에서 변환기를 찾지 못하면 해당 컨텍스트가 Jackson 2 MVC를 사용하는지부터 확인한다. 타입별 매퍼를 따로 등록한 구성에서는 그 매퍼도 조사해야 한다.
오타 필드는 사라지고 원래 필드는 null로 남았다
실제 서비스와 분리한 새 Java 프로젝트에서 Spring Boot 3.5.7, Spring Framework 6.2.12, Jackson 2.19.2를 고정했다. Windows 11과 OpenJDK 17.0.2에서 Boot의 Jackson·MVC 자동 구성을 적용한 웹 컨텍스트를 만들고 MockMvc로 요청했다. HTTP 서버는 열지 않았다. 먼저 값 제약이 없는 다음 DTO와 메서드를 사용했다. 메서드는 @RestController 클래스 안에 두며, 받은 DTO를 그대로 응답한다.
public record Input(String name, Integer quantity) {}
@PostMapping("/plain")
public Input plain(@RequestBody Input input) {
return input;
}
모든 요청은 POST /plain, Content-Type: application/json이었다. 정상 응답 200은 이 메서드가 정한 결과다. 설정을 바꾸기 전후에 본문만 바꿔 비교했다.
- 정상
{"name":"pen","quantity":2}: 두 구성 모두 200이고 DTO는name="pen", quantity=2였다. - 추가 속성
{"name":"pen","quantity":2,"memo":"x"}: 기본 구성은 200이고memo만 버렸다. 엄격 구성은 400이었다. - 오타
{"naem":"pen","quantity":2}: 기본 구성은 200이고 DTO는name=null, quantity=2였다. 엄격 구성은 400이었다. - 누락
{"quantity":2}: 엄격 구성까지 두 구성 모두 200이고name=null, quantity=2였다.
성공한 요청은 각각 컨트롤러 본문을 한 번 실행했다. 엄격 구성에서 거절된 두 요청은 본문을 실행하지 않았으며, HttpMessageNotReadableException의 내부 원인은 UnrecognizedPropertyException이었다. 알 수 없는 속성 이름도 각각 memo, naem으로 확인했다. name이 생략된 입력에는 모르는 이름 자체가 없어서 이 정책이 거절할 대상이 없었다.
자기 API에서 같은 비교를 하려면 실제 웹 컨텍스트에 연결된 MockMvc mvc가 있는 테스트에서 요청 본문을 바꿔 보낸다. post는 MockMvcRequestBuilders.post의 정적 import이고, MvcResult는 org.springframework.test.web.servlet.MvcResult다. 다음은 오타가 들어간 입력의 상태와 응답 본문을 확인한 코드다. URL과 본문 필드는 자기 컨트롤러에 맞춘다.
MvcResult result = mvc.perform(post("/plain")
.contentType("application/json")
.content("{\"naem\":\"pen\",\"quantity\":2}")).andReturn();
System.out.println(result.getResponse().getStatus());
System.out.println(result.getResponse().getContentAsString());
기본 구성에서 실제 출력은 다음과 같았다.
200
{"name":null,"quantity":2}
엄격 구성에서는 상태가 400으로 바뀌었다. 원인을 확인할 때는 result.getResolvedException()과 그 원인 예외를 함께 본다. 별도 매퍼를 넣은 standaloneSetup 테스트는 앱의 Boot 자동 구성 정책을 그대로 보여 주지 않을 수 있다. 이 실험은 웹 컨텍스트에 등록된 변환기로 요청을 처리했다.
필수 이름 검사는 역직렬화 뒤의 다른 단계다
이름을 생략하거나 공백으로 보내는 요청도 거절하려면 DTO의 값 제약과 요청 인자의 검증 시작점을 함께 둔다. 다음은 이름만 필수로 만든 대조군이다. Valid와 NotBlank는 각각 jakarta.validation.Valid, jakarta.validation.constraints.NotBlank이며, Boot 프로젝트에는 spring-boot-starter-validation 등 Bean Validation 구현이 준비되어 있어야 한다.
public record CheckedInput(@NotBlank String name, Integer quantity) {}
@PostMapping("/validated")
public CheckedInput validated(@Valid @RequestBody CheckedInput input) {
return input;
}
Jakarta Bean Validation 3.0의 NotBlank 정의 (새 탭에서 열림)는 null을 허용하지 않고 비공백 문자를 요구한다. Hibernate Validator 8.0.3.Final을 사용한 비교에서 name 생략, name:null, name:" "는 모두 400, MethodArgumentNotValidException, 필드 오류 name:NotBlank였다. 컨트롤러 본문은 실행되지 않았다. 값 제약이 없는 앞의 /plain에서는 세 입력 모두 엄격 설정에서도 통과했다.
오타 입력은 어느 단계에서 막혔는지도 달랐다. 기본 매퍼와 검증을 함께 쓰면 naem은 먼저 무시되고 name=null에 대한 name:NotBlank 오류가 났다. 엄격 매퍼에서는 naem을 읽는 단계에서 HttpMessageNotReadableException이 났다. 상태는 둘 다 400이지만 원인은 다르다. Spring 6.2.12의 요청 인자 처리 코드 (새 탭에서 열림)는 본문 변환 후 값 검증을 수행하며, Spring MVC 문서 (새 탭에서 열림)도 @Valid @RequestBody의 검증 오류 처리를 설명한다.
반대로 이름이 정상인 추가 속성 입력은 기본 매퍼와 검증을 함께 써도 200이었다. @Valid를 붙였다는 이유만으로 memo처럼 DTO가 모르는 속성까지 금지되지는 않았다. 이 글에서 이름 제약을 비교한 이유는 중첩 객체의 검증 전파가 아니라, 속성 이름 검사와 값 검사의 차이를 확인하기 위해서다.
엄격성은 요청 계약에 맞춰 선택한다
클라이언트가 보내는 필드를 서버가 모두 알아야 하는 계약이라면 엄격 정책이 오타를 빨리 드러낸다. 다만 클라이언트가 새 선택 필드를 먼저 보내는 경우에도 같은 400이 발생한다. 엄격 설정을 적용하기 전에 정상 요청뿐 아니라 추가 속성, 오타, 필수 값 누락을 따로 보내 보고 어떤 입력을 호환 대상으로 볼지 정한다.
특정 DTO만 추가 속성을 허용해야 한다면 그 타입에 @JsonIgnoreProperties(ignoreUnknown = true)를 둘 수 있다. Jackson의 해당 옵션 정의 (새 탭에서 열림)에 따라 모르는 이름을 무시한다. 별도 검증을 붙이지 않은 허용 DTO로 대조하자, 전역 엄격 정책이 true여도 추가 속성은 200, 오타는 name=null인 채 200이었다. 엄격 정책을 켰는데 특정 타입만 통과한다면 이 선언도 확인한다. 허용한 타입에도 필요한 값 제약은 그대로 따로 적용한다.
이 관찰은 Boot 3.5.7의 Jackson 2 자동 구성, 위 record DTO와 기본 MVC 예외 처리 범위다. Spring Boot 4·Jackson 3, 사용자 정의 역직렬화기·생성자·타입별 매퍼, 실제 서버의 필터와 프록시는 실행하지 않았다. 다른 구성의 성공 여부나 오류 응답 JSON까지 같다고 볼 수는 없다. 현재 요청을 읽는 매퍼의 정책을 확인한 뒤, 미지 속성 허용 여부와 필수 값 조건을 각각 결정한다.