빌드노트
JSON을 보냈는데 Spring API가 415라면 Content-Type부터 본다
JSON처럼 보이는 본문도 요청 Content-Type이 맞지 않으면 415로 거절된다. 같은 입력의 헤더 누락·text/plain·application/json과 깨진 JSON을 실제 비교해, 매핑 실패와 DTO 변환 실패를 구분하고 헤더 수정 뒤 400이 나타나는 이유를 설명한다.
Spring MVC API에 JSON 본문을 보냈는데 415가 나고 컨트롤러 첫 줄의 로그도 찍히지 않는다면, 실제 요청의 Content-Type부터 확인한다. 본문이 JSON 모양이어도 text/plain으로 전송되면 JSON 전용 매핑에서 거절될 수 있다. 요청 타입이 허용되는지 확인하는 단계와 JSON을 읽어 DTO로 만드는 단계가 다르기 때문이다.
consumes는 요청의 Content-Type으로 매핑을 좁힌다. Accept는 받고 싶은 응답 타입이며 produces와 연결된다. JSON을 보내는 문제를 해결하려고 Accept: application/json만 추가해도 요청 타입은 바뀌지 않는다. 이 구분은 Spring의 요청 매핑 문서 (새 탭에서 열림)에 설명돼 있다.
같은 JSON에서 헤더만 바꿨다
별도 최소 구성에서 비교했다. Windows 11, OpenJDK 17.0.2, Spring Framework 6.2.12, Jackson 2.19.2를 사용했다. @EnableWebMvc의 기본 메시지 변환기를 쓰고, MockMvc로 요청을 처리했다. Spring Boot 자동 설정이나 별도 예외 처리기는 넣지 않았다.
컨트롤러는 @RequestBody Input을 받아 정상일 때 본문 없는 200을 돌려준다. 판단에 필요한 선언은 다음과 같다.
public record Input(String name, int count) {}
@PostMapping(path = "/strict", consumes = "application/json")
public ResponseEntity<Void> strict(@RequestBody Input input) {
return ResponseEntity.ok().build();
}
POST /strict에 {"name":"pen","count":2}를 그대로 넣고 요청 헤더만 바꿨다. 아래 호출 수는 매 요청마다 초기화한 카운터로 센 메서드 본문 실행 횟수다.
Content-Type: application/json→ 200, 예외 없음, 호출 1회.Content-Type: text/plain→ 415,HttpMediaTypeNotSupportedException, 호출 0회.Content-Type헤더 없음 → 415,HttpMediaTypeNotSupportedException, 호출 0회.
text/plain 요청에 Accept: application/json을 추가해도 415와 호출 0회가 그대로였다. 415였던 요청에서는 대상 메서드도 선택되지 않았다. 본문의 중괄호를 분석하기 전에 consumes 조건에서 막힌 것이다. 따라서 이 API가 JSON을 받기로 한 계약이라면, 클라이언트가 실제 JSON 본문과 Content-Type: application/json을 함께 보내도록 고친다.
헤더를 고친 뒤 400이 나오면
정상 JSON만으로는 “415 뒤에 가려진 본문 오류”를 볼 수 없다. 이번에는 값 하나를 빼서 {"name":"pen","count":}를 보냈다. 이 문자열을 유지하고 Content-Type만 바꾼 결과다.
text/plain → 415 / HttpMediaTypeNotSupportedException / 호출 0회
application/json → 400 / HttpMessageNotReadableException / 호출 0회
두 번째 요청은 메서드 선택까지 진행됐지만 인자를 읽다가 실패했다. 내부 원인은 Jackson의 JsonParseException이었고, 메시지에는 expected a value가 있었다. 헤더를 바로잡으면서 그동안 도달하지 못했던 JSON 구문 오류가 드러난 것이다.
JSON 문법이 맞아도 DTO로 변환할 수 없으면 같은 400이 나왔다. application/json으로 {"name":"pen","count":"many"}를 보내자 HttpMessageNotReadableException의 내부 원인은 InvalidFormatException이었다. 문자열 many를 int count로 만들 수 없었다. 이때도 메서드 본문 호출은 0회였다.
Spring은 메시지 변환기를 통해 요청 본문을 객체로 읽는다 (새 탭에서 열림). 이 구성에서는 매핑 뒤의 인자 변환까지 성공해야 메서드 본문이 실행된다. 그래서 첫 줄의 로그가 없다는 사실만으로 매핑 실패와 JSON 파싱 실패를 구분할 수 없다.
위 상태 코드는 6.2.12의 기본 예외 처리 규칙 (새 탭에서 열림)과도 일치한다. 다만 모든 400이 JSON 구문 오류라는 뜻은 아니다. 자기 요청에서는 예외 종류와 내부 원인을 함께 확인해야 한다. 검증 제약 위반 등 다른 400까지 이 실험에서 재현한 것은 아니다.
consumes를 지워도 남는 415
비교용 /open에서는 같은 DTO를 받되 consumes를 선언하지 않았다. 정상 JSON을 application/json으로 보내면 200이었다. 같은 본문을 text/plain으로 보내면 메서드는 선택됐지만 여전히 415였고, 본문 호출은 0회였다. 이번에는 매핑이 아니라 @RequestBody Input을 읽을 메시지 변환기를 찾는 단계에서 실패했다.
6.2.12의 Jackson 변환기 (새 탭에서 열림)는 기본으로 application/json과 application/*+json을 지원한다. consumes를 없애거나 넓힌다고 이 변환기가 text/plain을 JSON DTO로 읽도록 바뀌지는 않는다. 실제로 consumes = "*/*"인 /wildcard도 text/plain 요청에 415를 반환했다.
헤더 누락도 자동 JSON 판별로 이어지지 않았다. /open의 실제 요청에는 Content-Type이 없었는데, 예외 메시지에는 다음 타입이 나타났다.
Content-Type 'application/octet-stream' is not supported
클라이언트가 그 헤더를 보냈다는 증거는 아니다. 해당 버전은 타입이 없는 요청을 변환할 때 application/octet-stream으로 간주한다. 6.2.12의 본문 변환 코드 (새 탭에서 열림)에서도 이 처리를 확인했다. 요청 헤더와 예외 메시지를 구분해서 읽어야 한다.
내 요청에서 확인할 위치
브라우저라면 개발자 도구 Network에서 실패한 실제 POST 요청을 열고 Request Headers의 Content-Type과 Payload를 함께 본다. API 도구라면 편집창의 JSON 색상 표시보다 전송된 요청 헤더를 확인한다. 본문은 JSON인데 헤더가 text/plain이거나 없다면, 클라이언트의 JSON 본문 설정과 헤더를 맞춘 뒤 같은 URL·메서드·본문으로 다시 비교한다.
요청이 이미 application/json인데 415라면 클래스와 메서드의 유효한 consumes 조건, 등록된 메시지 변환기, @RequestBody의 DTO 타입을 확인한다. 메서드에 따로 선언한 consumes는 클래스 선언을 덮어쓰므로 두 위치를 함께 읽는다. 로그에 HttpMediaTypeNotSupportedException이 있다면 스택에서 매핑의 handleNoMatch인지 본문의 readWithMessageConverters인지가 구분에 도움이 된다. 이번 비교에서도 두 415는 예외 이름이 같았지만 발생 위치가 달랐다.
수정 후 400으로 바뀌고 HttpMessageNotReadableException이 확인되면, 다시 허용 타입을 넓히기보다 JSON 구문과 DTO 필드의 자료형을 본다. 위 입력이라면 빠진 count 값을 채우거나 "many"를 계약에 맞는 숫자로 바꾸는 단계다. 본문을 유지한 헤더 비교와 헤더를 고정한 본문 비교를 나누면 무엇을 고쳐야 하는지 좁힐 수 있다.
이 관찰은 Spring MVC와 해당 버전의 기본 변환기·예외 처리에 한정된다. MockMvc 안에서 실행했으므로 실제 HTTP 서버, 프록시, 인증 필터, 사용자 정의 변환기·예외 처리기가 있는 경로는 확인하지 않았다. 그런 구성에서는 외부 상태 코드와 서버 안의 원래 예외가 다르게 보일 수 있다.