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

빌드노트

빈 요청은 400인데 {}는 통과하는 Spring @RequestBody

JSON 헤더가 맞아도 본문 0바이트와 {}는 다르게 처리된다. MockMvc에서 응답·예외·컨트롤러 호출을 비교하고, required=false의 null 처리와 깨진 JSON이 여전히 400인 이유를 설명한다.

Spring MVC API에 Content-Type: application/json을 붙였는데 빈 요청이 400으로 끝나고 컨트롤러 첫 줄도 실행되지 않는다면, @RequestBody의 필수 여부를 확인한다. 기본값은 required=true다. 본문이 아예 없는 요청은 거절하지만, {}는 읽을 수 있는 JSON 객체이므로 DTO가 만들어질 수 있다. 본문 생략을 허용하는 API라면 required=false와 함께 null일 때의 처리를 정한다.

Spring 6.2.19의 RequestBody 문서 (새 탭에서 열림)는 본문 필수 여부와 생략 시 null 전달을 설명한다. 이 옵션이 DTO의 모든 필드를 필수로 만들거나, 잘못된 JSON을 무시해 주는 것은 아니다.

헤더를 고정하고 본문만 바꿨다

운영 API와 분리한 MockMvc 실험에서 네 가지 본문을 비교했다. Windows 11, OpenJDK 17.0.2, Spring Framework 6.2.19, Jackson 2.19.2를 사용했다. 기본 ObjectMapper를 넣은 MappingJackson2HttpMessageConverter로 읽었으며, @Valid와 필드 제약은 넣지 않았다. 아래 선언은 @RestController 클래스 안의 필수 입력 메서드다. 호출 수를 세는 코드만 덜어냈다.

public record Input(String name) {}
public record Reply(String mode, String name) {}

@PostMapping(path = "/required", consumes = "application/json")
public Reply required(@RequestBody Input input) {
    return new Reply("provided", input.name());
}

모든 요청은 POST /required, Content-Type: application/json이었다. 호출 수는 각 요청 전에 초기화한 카운터로 센 메서드 본문 실행 횟수다. 성공 상태 200은 이 예제 메서드가 정한 응답이다.

  • 본문 0바이트: 400, HttpMessageNotReadableException, 호출 0회. 메시지는 Required request body is missing:으로 시작했다.
  • {}: 200, 예외 없음, 호출 1회. 인자는 null이 아니라 name == null인 Input 객체였고, 응답은 {"mode":"provided","name":null}이었다.
  • {"name":: 400, HttpMessageNotReadableException, 호출 0회. 메시지는 JSON parse error:로 시작했고 내부 원인은 Jackson의 JsonEOFException이었다.
  • {"name":"pen"}: 200, 예외 없음, 호출 1회. 응답은 {"mode":"provided","name":"pen"}이었다.

0바이트 요청과 깨진 JSON은 상태 코드와 바깥 예외 이름까지 같았다. 400이라는 숫자만으로 본문 부재와 JSON 구문 오류를 구분할 수 없었다. 이 비교에서는 예외 메시지와 내부 원인까지 봐야 입력을 나눌 수 있었다.

Spring MVC 문서 (새 탭에서 열림)에 따르면 @RequestBody 인자는 메시지 변환기를 통해 읽힌다. 6.2.19의 인자 처리 코드 (새 탭에서 열림)는 읽은 결과가 null인데 필수 인자이면 예외를 던진다. 메서드를 실행하기 전에 인자를 준비하므로, 이 단계에서 실패하면 메서드 첫 줄의 로그도 찍히지 않는다.

{}가 통과했다는 사실도 필수 정보가 채워졌다는 뜻은 아니다. 이 DTO와 Jackson 설정은 생략한 name을 null로 두고 객체를 만들었다. 다른 생성자나 Jackson 설정에서도 항상 같다고 볼 수는 없다. 이름까지 필수인 API라면 본문 존재와 별도로 필드의 유효성을 검사해야 한다.

required=false는 깨진 JSON을 받아 주지 않는다

같은 DTO를 선택 입력으로 받는 메서드도 만들었다. 본문이 없으면 omitted, 객체가 있으면 provided로 응답하도록 의미를 정했다.

@PostMapping(path = "/optional", consumes = "application/json")
public Reply optional(@RequestBody(required = false) Input input) {
    if (input == null) {
        return new Reply("omitted", null);
    }
    return new Reply("provided", input.name());
}

0바이트 요청은 이번에 200이었고, 인자는 null, 호출은 1회였다. 응답은 {"mode":"omitted","name":null}이었다. {}는 여전히 객체로 전달되어 {"mode":"provided","name":null}을 반환했다. 필드 값이 모두 비어 있어도 본문을 생략한 경우와 구분한 것이다.

반면 {"name":는 required=false에서도 400, HttpMessageNotReadableException, 호출 0회였다. 내부 원인도 앞과 같은 JsonEOFException이었다. 정상 JSON은 200과 호출 1회를 유지했다. Jackson 변환기 구현 (새 탭에서 열림)은 JSON 읽기 오류를 HttpMessageNotReadableException으로 전달한다. 본문이 있으면 읽어야 하며, 필수 여부를 끈다고 이 오류가 정상 입력으로 바뀌지는 않는다.

추가로 공백 세 칸과 JSON 리터럴 null도 보냈다. 공백 세 칸은 두 메서드 모두 400과 호출 0회였으며, 내부 원인은 MismatchedInputException이었다. 본문을 생략하려고 공백을 보내면 이 구성에서는 생략으로 처리되지 않았다. 네 글자 null은 필수 메서드에서 400, 선택 메서드에서는 null 인자로 호출 1회와 200이었다. 따라서 선택 메서드 안에서 input == null만 보면 0바이트 입력과 JSON null을 구별할 수 없다.

내 API에서는 이 순서로 계약을 정한다

먼저 실제 전송된 요청의 헤더와 본문을 함께 확인한다. 비교용으로 정상 JSON의 타입만 text/plain으로 바꾸자 두 메서드 모두 415, HttpMediaTypeNotSupportedException, 호출 0회였다. 요청 매핑 문서 (새 탭에서 열림)는 consumes가 요청의 Content-Type으로 매핑을 좁힌다고 설명한다. 이 경우에는 헤더를 맞추는 것이 먼저다. 이미 JSON 타입으로 보낸 0바이트 요청의 400은 허용 미디어 타입을 넓혀 해결할 문제가 아니다.

그다음에는 API가 어떤 입력을 받는지 선택한다.

  • 본문이 필수라면 기본 @RequestBody를 유지하고 클라이언트가 계약에 맞는 JSON 객체를 보내게 한다. 400만 피하려고 {}를 보내면 필요한 필드가 여전히 없을 수 있다.
  • 본문 생략이 의미 있는 선택 입력이라면 required=false로 받고, 위 코드처럼 input == null인 경로에서 기본 동작을 정한다. 바로 input.name()을 호출하지 않는다.
  • 본문을 사용하지 않는 동작이라면 불필요한 @RequestBody 인자를 제거할 수 있다. 다만 이것만으로 본문을 금지하는 정책이 생기지는 않는다. 인자가 없는 비교 메서드는 같은 JSON 헤더에서 0바이트뿐 아니라 깨진 {"name":도 읽지 않고 200과 호출 1회를 반환했다. 본문이 오면 거절해야 하는 계약이라면 별도 검사를 정해야 한다.

기존 Spring MVC 프로젝트에서 확인할 때는 spring-test가 제공하는 MockMvc와 JSON 변환기를 갖춘 테스트를 사용한다. MockMvcRequestBuilders.post를 정적 import하고, 테스트의 mvc가 앞의 필수 입력 메서드를 가리키는 조건에서 다음처럼 0바이트를 명시할 수 있다. controller.calls는 테스트가 가진 컨트롤러의 카운터이며, 각 메서드 맨 앞에서 한 번 증가시키도록 둔다. 반환 객체에서 추측한 횟수가 아니다.

controller.calls = 0;
MvcResult result = mvc.perform(post("/required")
    .contentType("application/json").content(new byte[0])).andReturn();
Exception ex = result.getResolvedException();
System.out.printf("%d / %s / calls=%d%n",
    result.getResponse().getStatus(),
    ex == null ? "none" : ex.getClass().getSimpleName(), controller.calls);

이 코드를 별도로 실행한 출력은 400 / HttpMessageNotReadableException / calls=0이었다. 본문만 .content("{}")로 바꾸면 앞의 비교에서는 200 / none / calls=1이 됐다. 같은 방법으로 깨진 JSON과 정상 JSON도 비교한다. 자기 API를 검사할 때는 URL을 해당 매핑으로 바꾸고 DTO와 Jackson 설정을 유지한다. 400이 예상과 다르면 getResolvedException()의 메시지와 원인을 확인한다. {}가 거절됐다면 필드 검증이나 생성자 설정 등 이 예제에 없는 조건도 확인한다.

기본 예외 처리기 문서 (새 탭에서 열림)는 HttpMessageNotReadableException을 400으로 처리한다. 이번 실험도 기본 처리를 사용했고 오류 응답 본문은 비어 있었다. 실제 앱의 예외 처리기가 오류 JSON을 만들거나 상태를 바꾸는 경우에는 응답 모양만으로 같은 원인이라고 판단하지 않는다.

이 관찰은 해당 버전과 standalone MockMvc 구성의 본문 변환까지 확인한 결과다. MockMvc는 실행 중인 서버 없이 MVC 요청 처리를 검사한다 (새 탭에서 열림). 실제 HTTP 서버와 프록시, 인증 필터, 본문을 먼저 읽는 사용자 정의 필터, Bean Validation, 다른 Jackson 설정은 이번에 확인하지 않았다. 이 비교는 본문 존재와 변환을 구분하는 근거이며, 외부 API에서 발생하는 모든 400의 원인을 설명하지는 않는다.

← 전체 글 보기