빌드노트
컨트롤러가 있어도 Accept가 맞지 않으면 406이 난다
JSON API가 Accept: text/plain에서 406이 되는 이유를 실제 Spring MVC로 비교한다. produces에서 메서드 선택이 막힌 경우와 DTO 반환 뒤 converter에서 막힌 경우를 구분하고, 클라이언트가 요구하는 응답 형식을 맞추는 방법을 설명한다.
Spring MVC의 JSON API가 어떤 클라이언트에서는 성공하고, Accept: text/plain을 보내는 클라이언트에서만 406 Not Acceptable로 실패할 수 있다. 컨트롤러와 URL이 있어도 응답 형식에 관한 조건까지 맞아야 요청을 처리할 수 있다. JSON을 읽을 클라이언트라면 먼저 실제 전송된 Accept를 application/json으로 맞춘다. 요청의 Content-Type을 JSON으로 바꾸는 것과는 다른 수정이다.
실패한 요청을 브라우저 개발자 도구의 Network나 API 도구의 전송 기록에서 열어 본다. 정상 요청과 URL·HTTP 메서드를 같게 두고 Accept를 비교한다. 이어 컨트롤러의 클래스와 메서드에 붙은 produces, 반환하는 DTO 타입을 확인한다. 메서드에 따로 선언한 produces는 클래스의 선언을 덮어쓰므로 두 위치를 함께 읽어야 한다.
보내는 본문과 받고 싶은 응답은 따로 정한다
JSON을 주고받는 POST에서는 다음 두 요청 헤더가 함께 보일 수 있지만, 가리키는 방향이 다르다.
- 요청
Content-Type: application/json은 보내는 본문이 JSON이라는 뜻이다.consumes와 요청 본문 읽기에 관련된다. - 요청
Accept: application/json은 JSON 응답을 받을 수 있다는 뜻이다.produces와 응답 형식 선택에 관련된다. - 응답의
Content-Type: application/json은 서버가 실제 보낸 본문의 형식이다.
Spring의 요청 매핑 문서 (새 탭에서 열림)는 consumes를 요청 Content-Type 조건으로, produces를 Accept와 연결된 조건으로 설명한다. 따라서 JSON을 보냈다는 사실만으로 JSON 응답도 요구했다고 판단하면 안 된다.
같은 메서드에 Accept만 바꿔 보았다
운영 장애 사례가 아니라 새로 만든 작은 MVC 구성에서 비교했다. Windows 11, OpenJDK 17.0.2, Spring Framework 6.2.11, Jackson 2.19.2를 사용했다. @EnableWebMvc로 만든 웹 컨텍스트에 MockMvc를 연결했다. 협상 전략은 HeaderContentNegotiationStrategy 하나로 고정했고, converter는 UTF-8의 StringHttpMessageConverter와 new ObjectMapper()를 받는 MappingJackson2HttpMessageConverter 두 개만 명시적으로 등록했다. Spring Boot 자동 설정과 사용자 정의 예외 처리기는 넣지 않았다.
먼저 JSON만 제공한다고 선언한 메서드를 본다. 아래는 @RestController 내부에서 응답 계약과 반환값을 결정하는 부분이다. 실행 횟수를 세는 계측은 생략했다.
public record Item(String name, int count) {}
@GetMapping(path = "/strict", produces = "application/json")
public Item strict() {
return new Item("pen", 2);
}
본문 없는 GET /strict에서 Accept만 바꾼 실제 결과다. 호출 수는 매 요청 직전에 초기화한 카운터로 확인했다.
application/json→ 200,strict선택, 본문 실행 1회, 응답Content-Type: application/json.text/plain→ 406, 선택된 메서드 없음, 본문 실행 0회, 응답Content-Type없음.*/*→ 200,strict선택, 본문 실행 1회, 응답Content-Type: application/json.
성공한 두 응답의 본문은 모두 {"name":"pen","count":2}였다. 여기서 */*는 어떤 미디어 타입도 받을 수 있다는 요청이다. 서버가 JSON을 보낼 수 있으므로 JSON으로 응답했다. 와일드카드로 성공했다고 그 API가 평문 응답도 지원하는 것은 아니다.
406 요청에는 HttpMediaTypeNotAcceptableException이 기록됐다. 예외가 시작된 위치는 RequestMappingInfoHandlerMapping.handleNoMatch였다. 이 경우에는 메서드가 실행되기 전에 produces 조건이 맞지 않았다. 6.2.11의 매핑 코드 (새 탭에서 열림)에서도 응답 타입 조건 불일치 시 이 예외를 발생시키는 분기를 확인할 수 있다.
같은 GET에 Content-Type: application/json을 추가해도 Accept: text/plain이면 406 그대로였다. 이 메서드는 요청 본문을 읽지 않으며 consumes도 없다. 이 결과를 JSON 본문을 받는 POST의 Content-Type을 생략해도 된다는 뜻으로 확대해서는 안 된다.
produces를 지워도 406이 남는 이유
비교용 /open은 같은 Item을 반환하고 produces만 선언하지 않았다. Accept: application/json과 */*에서는 모두 200과 JSON 본문을 얻었다. text/plain에서는 여전히 406이었지만, 이번에는 open이 선택됐고 메서드 본문도 1회 실행됐다.
예외 이름은 같았고 발생 위치는 AbstractMessageConverterMethodProcessor.writeWithMessageConverters로 바뀌었다. 매핑 조건은 통과했으나 반환한 Item을 클라이언트가 요구한 평문으로 쓸 수 없었던 것이다. 해당 버전의 응답 처리 코드 (새 탭에서 열림)는 허용된 응답 타입과 제공 가능한 타입을 비교하고, 공통 타입이 없으면 이 예외를 발생시킨다.
등록된 converter를 조회하자 Jackson converter의 지원 타입은 application/json, application/*+json이었다. Item을 JSON으로 쓸 수 있는지 묻는 canWrite는 참, text/plain으로 쓸 수 있는지는 거짓이었다. 이 지원 범위는 Jackson converter 문서 (새 탭에서 열림)에도 설명돼 있다.
StringHttpMessageConverter도 등록돼 있었지만 Item을 평문으로 쓰지는 못했다. 같은 converter가 String을 평문으로 쓸 수 있는지는 참이었다. 지원 미디어 타입 목록뿐 아니라 실제 반환 타입까지 맞아야 한다. produces를 지우거나 목록에 평문을 추가하는 선언만으로 DTO의 평문 표현이 생기지는 않는다.
따라서 406이라는 상태만 보고 “컨트롤러가 호출되지 않았다”고 단정할 수 없다. 이번 두 요청은 하나는 선택 전, 다른 하나는 반환값 처리 중에 실패했다. 실제 메서드 호출과 응답 전송 성공은 따로 확인해야 한다.
자기 MVC 설정으로 확인할 때
이미 MockMvc를 사용하는 프로젝트라면, 문제가 난 컨트롤러와 MVC 설정을 로드한 테스트에서 세 Accept를 비교한다. 아래 코드는 Java 17 기준이며 context는 로드된 WebApplicationContext다. MockMvc, MockMvcBuilders, java.util.List를 가져오고, get은 MockMvcRequestBuilders, print는 MockMvcResultHandlers에서 정적 import한다. 테스트 메서드는 throws Exception을 선언한다. /strict는 자신의 조회 API 경로로 바꾸고 필요한 인증·쿼리 조건은 정상 요청과 같게 유지한다.
다음 블록은 상태뿐 아니라 선택된 handler와 응답 Content-Type을 함께 출력하기 위한 것이다. 위 합성 구성에서도 그대로 실행했다. 이 생성 코드에는 보안 필터 적용이 없으므로, 필터를 포함한 기존 MockMvc가 있다면 그 인스턴스에 반복문만 적용한다.
MockMvc mockMvc = MockMvcBuilders.webAppContextSetup(context).build();
for (String accept : List.of("application/json", "text/plain", "*/*")) {
mockMvc.perform(get("/strict").header("Accept", accept))
.andDo(print());
}
출력에서 Handler의 Type·Method, Resolved Exception의 Type, MockHttpServletResponse의 Status·Content type을 함께 읽는다. 이번 JSON 요청은 Mvc406Lab$DemoController의 strict()와 200, application/json을 보였다. 평문 요청은 handler의 Type = null, 406, Content type = null이었다. 다른 결과가 나오면 이 예제의 converter 목록을 자기 프로젝트의 목록으로 간주하지 말고 실제 설정을 확인한다. MockMvc 설정 문서 (새 탭에서 열림)가 설명하듯, 웹 컨텍스트 연결 방식은 그 컨텍스트의 MVC 구성을 사용한다. 별도 standalone 설정으로만 성공시킨 결과와 구분해야 한다.
JSON API인데 클라이언트만 평문을 요구했다면 클라이언트가 JSON을 처리하도록 하고 Accept: application/json을 보낸다. 평문 응답이 실제 요구 사항이라면 먼저 평문 본문의 모양을 정하고, 해당 반환 타입을 그 형식으로 쓰는 처리와 converter 지원을 갖춘 뒤 produces에 반영한다. 오류를 없애려고 기존 DTO를 임의의 문자열로 바꾸면 응답 계약 자체가 달라진다.
application/json 요청도 실패하거나 메서드 실행 뒤 406이 난다면 RequestMappingHandlerAdapter.getMessageConverters()에서 실제 converter를 조회한다. 이번 반환 타입으로는 canWrite(Item.class, MediaType.APPLICATION_JSON)과 canWrite(Item.class, MediaType.TEXT_PLAIN)을 비교했다. 자기 DTO 타입과 요구 미디어 타입으로 바꿔 확인한다. 협상 설정도 함께 본다. Spring MVC의 Content Types 문서 (새 탭에서 열림)는 기본으로 Accept를 확인한다고 설명하지만, 프로젝트가 쿼리 파라미터 등 다른 전략을 설정했을 수 있다.
이 실험은 지정한 converter와 본문이 있는 DTO 응답을 MockMvc 안에서 처리한 결과다. 실제 HTTP 서버·프록시·보안 필터, 사용자 정의 converter·예외 처리기는 확인하지 않았다. 여러 타입에 가중치를 준 Accept나 JSON 직렬화 자체의 오류도 재현하지 않았다. 참고한 6.2 문서 경로는 확인 당시 6.2.19를 표시했으며, 예외 발생 위치는 실험 버전인 6.2.11 소스와 대조했다.