Spring MVC HttpMessageConverter — @RequestBody/@ResponseBody는 어떻게 JSON이 되나
들어가며
컨트롤러에 @RestController를 붙이고 메서드에서 DTO 객체
하나 return하면 클라이언트는 JSON을 받는다. 반대로
@RequestBody User user 파라미터에 요청 바디가 자동으로
매핑된다. 중간에서 누가 바이트 스트림을 객체로 바꿨고, 누가 객체를
바이트 스트림으로 내보냈는가. ObjectMapper가
한다로 답하면 절반만 맞는 말이다. 진짜 주인공은
HttpMessageConverter라는 인터페이스이고, Jackson의
ObjectMapper는 그 구현체 중 하나가 내부적으로 들고 있는
도구일 뿐이다.
스프링 MVC는 요청/응답 바디를 다룰 때 컨버터 목록을
순회하면서 처음으로 매치되는 하나를 골라 쓴다. 매치 기준은 두
가지다. 대상 자바 타입을 읽거나 쓸 수
있는가(canRead/canWrite), 그리고 HTTP
Content-Type/Accept 헤더의 미디어 타입이
컨버터가 선언한 supportedMediaTypes와 맞물리는가. 이 두
조건을 함께 이해하지 못하면 406·415 에러 앞에서 "왜 뜨는지 모르겠다"가
된다. 다음 세 개는 실무에서 자주 밟는다.
@ResponseBody리턴값이 어떻게 JSON 바디가 되는지 한 번도 궁금했던 적 없다면, 이 글은 그것부터 시작한다configureMessageConverters를 덮어쓰면 기본 컨버터가 통째로 사라진다 —extendMessageConverters와 역할이 다르다@Bean ObjectMapper직접 등록은 Boot Customizer 체인을 우회한다 —Jackson2ObjectMapperBuilderCustomizer가 정답이다
이 글은 계약 → 기본 컨버터 → 등록 확장 → ObjectMapper → 디버깅 순으로, Spring Framework 6.x / Spring Boot 3.x / Jackson 2.x 기준으로 정리한다.
목차
- 1) HttpMessageConverter 계약: canRead·canWrite·read·write
- 2) GenericHttpMessageConverter: 제네릭 타입을 잃지 않기
- 3) RequestResponseBodyMethodProcessor: 인자와 반환을 동시에 다루는 한 조각
- 4) Spring Boot 기본 컨버터 10종
- 5) 컨버터 선택 흐름: 요청 읽기와 응답 쓰기
- 6) configureMessageConverters vs extendMessageConverters
- 7) add(0, ...) 우선순위 패턴: 끝에 붙이면 밀린다
- 8) ObjectMapper 등록 패턴: Customizer vs @Bean
- 9) Content Negotiation: produces/consumes와 Accept 헤더
- 10) 406·415 디버깅 흐름
- 11) Spring 6 RestClient와 컨버터 공유
- 12) 실무에서 이렇게 읽고 쓴다
- 13) 한 줄 정리
1) HttpMessageConverter 계약: canRead·canWrite·read·write
HttpMessageConverter<T>는 이름 그대로 HTTP
바디와 자바 객체를 양방향으로 변환하는 인터페이스다. Spring
MVC의 요청 바디 바인딩(@RequestBody)과 응답 바디
직렬화(@ResponseBody)는 전부 이 인터페이스의 구현체 목록을
순회하며 동작한다. 메서드는 다섯 개 — 변환 질의·실행 메서드 넷에 지원
미디어 타입 조회 하나가 더 붙는다. 의미를 정확히 분리하면 전체
파이프라인이 한 그림으로 보인다.
public interface HttpMessageConverter<T> {
boolean canRead(Class<?> clazz, @Nullable MediaType mediaType);
boolean canWrite(Class<?> clazz, @Nullable MediaType mediaType);
List<MediaType> getSupportedMediaTypes();
T read(Class<? extends T> clazz, HttpInputMessage inputMessage);
void write(T t, @Nullable MediaType contentType, HttpOutputMessage outputMessage);
}canRead/canWrite는 필터다. "나 이
타입을, 이 미디어 타입으로 처리할 수 있다"고 손을 드는 자리다. 스프링은
등록된 컨버터를 등록된 순서대로 돌며 처음으로
true를 반환한 컨버터를 선택한다. 이 "순서"가 6~7번 섹션에서
다룰 핵심 논점이다. read/write는 실제 변환을 수행한다.
read는 HttpInputMessage의
InputStream에서 바이트를 읽어 객체를 만들고,
write는 객체를 HttpOutputMessage의
OutputStream에 직렬화한다.
1-1)
canRead와 canWrite는 왜 분리돼 있나
같은 타입이라도 읽을 수 있는 컨버터와 쓸 수 있는 컨버터가 다를 수
있기 때문이다. 예를 들어 StringHttpMessageConverter는
문자열을 양쪽 다 다루지만, ResourceHttpMessageConverter는
응답으로 파일을 내려주는 경우(Resource write)와 요청으로
받는 경우(Resource read)가 현실적으로 비대칭이다.
SourceHttpMessageConverter나
FormHttpMessageConverter도 미디어 타입 매칭이 read/write
방향에 따라 달라진다. 설계상 두 방향을 따로 질의할 수 있어야 하기 때문에
메서드가 두 벌 있다.
1-2) 미디어 타입이
null로 넘어올 때
요청에 Content-Type이 없거나 응답 협상 결과가 특정되지
않은 경우 mediaType이 null로 들어온다. 이때
컨버터들은 보통 "내 supportedMediaTypes 중 아무거나"로
간주해 자기 타입을 지원한다고 답한다. 이 동작이 기본
폴백을 만든다. Accept 헤더 없이 호출된 API가
JSON을 돌려주는 이유가 여기 있다.
MappingJackson2HttpMessageConverter가 목록에 있고, 대상
타입이 자기 canWrite를 통과하면 그대로 JSON을 쓴다.
2) GenericHttpMessageConverter: 제네릭 타입을 잃지 않기
HttpMessageConverter<T>의
canRead(Class<?> clazz, ...)에는 타입
파라미터가 없다. List<User>와
List<Order>를 구별하지 못한다는 뜻이다. 이걸
해결하려고 서브 인터페이스가 하나 더 있다.
public interface GenericHttpMessageConverter<T> extends HttpMessageConverter<T> {
boolean canRead(Type type, @Nullable Class<?> contextClass, @Nullable MediaType mediaType);
boolean canWrite(@Nullable Type type, Class<?> clazz, @Nullable MediaType mediaType);
T read(Type type, @Nullable Class<?> contextClass, HttpInputMessage inputMessage);
void write(T t, @Nullable Type type, @Nullable MediaType contentType, HttpOutputMessage outputMessage);
}Type은 리플렉션의 ParameterizedType까지
받는다. 스프링 MVC는 컨트롤러 메서드의 MethodParameter에서
getGenericParameterType()으로 Type을 뽑아 이
인터페이스 경로로 넘긴다. 덕분에
@RequestBody List<User> users에서 Jackson이
List<User>를 역직렬화할 때 원소 타입을
User로 정확히 특정할 수 있다.
Jackson 계열(MappingJackson2HttpMessageConverter,
MappingJackson2XmlHttpMessageConverter)을 포함해 JSON 계열
대부분(Gson, JSON-B 등)이 이 인터페이스를 구현한다. 제네릭을
쓰는 API를 설계한다면 거의 자동으로 이 경로를 탄다고 봐도 된다.
여기서 틀리기 쉬운 지점 하나. 커스텀 컨버터를 만들 때 제네릭을 다룬다면
HttpMessageConverter만 구현해서는 안 된다.
GenericHttpMessageConverter까지 구현해야
List<T> 같은 시그니처가 제대로 동작한다.
3) RequestResponseBodyMethodProcessor: 인자와 반환을 동시에 다루는 한 조각
컨버터를 실제로 호출하는 주체는 컨트롤러 어댑터가 아니라,
HandlerMethodArgumentResolver +
HandlerMethodReturnValueHandler 조합이다. 둘을
동시에 구현한 단 하나의 클래스가
RequestResponseBodyMethodProcessor다. 이름이 긴 만큼 책임이
두 개다. @RequestBody 파라미터를 해석하는 resolver 역할과,
@ResponseBody 반환값을 쓰는 handler 역할을 겸한다.
// 개념 요약 (실제 소스 구조를 따라간 의사 흐름)
public class RequestResponseBodyMethodProcessor
extends AbstractMessageConverterMethodProcessor
implements HandlerMethodArgumentResolver, HandlerMethodReturnValueHandler {
public Object resolveArgument(MethodParameter parameter, ...) {
// @RequestBody가 붙은 파라미터를 readWithMessageConverters(...)로 변환
}
public void handleReturnValue(Object returnValue, MethodParameter returnType, ...) {
// @ResponseBody 반환값을 writeWithMessageConverters(...)로 직렬화
}
}readWithMessageConverters와
writeWithMessageConverters는 상위 추상 클래스에 있고, 이 두
메서드가 컨버터 목록을 순회하며 첫 매치를 찾는 로직의
실제 구현이다. 요청 쪽은 Content-Type으로, 응답 쪽은
Accept 협상 결과로 골라낸 미디어 타입을 가지고
canRead/canWrite를 호출한다. 같은 컨버터
목록을 양방향이 공유한다는 점이 중요하다. 컨버터를 한 군데에서 관리하면
요청·응답 양쪽 동작이 동시에 바뀐다.
한편
HttpEntity<T>·ResponseEntity<T>
파라미터/반환은 별도의 HttpEntityMethodProcessor가
담당한다. 이 클래스도
HandlerMethodArgumentResolver와
HandlerMethodReturnValueHandler를 동시에 구현한 독립
resolver/handler 쌍이고,
@RequestBody/@ResponseBody 어노테이션이 전혀
없어도 파라미터/반환 타입이
HttpEntity·ResponseEntity이기만 하면
트리거된다. 내부적으로 같은
writeWithMessageConverters/readWithMessageConverters를
쓰기 때문에 컨버터 선택 로직은 완전히 동일하다. 어노테이션 기반과 타입
기반이라는 진입 경로만 갈릴 뿐이다.
4) Spring Boot 기본 컨버터 10종
Spring Framework의
WebMvcConfigurationSupport#addDefaultHttpMessageConverters가
무조건 등록하는 코어 컨버터에, Spring Boot의
HttpMessageConvertersAutoConfiguration이 클래스패스 감지
기반으로 JSON/XML 계열을 얹어 최종 목록을 완성한다. 이렇게 조립된
리스트가 RequestMappingHandlerAdapter에 주입되기 때문에,
실제로 동작 중인 컨버터를 확인하는 가장 간단한 방법은 그 빈에서
getMessageConverters()를 꺼내는 것이다. 아래 표는 등록
순서대로 정리한 것이며, 순서는 그대로 canRead/canWrite의
매칭 우선순위가 된다.
| # | 컨버터 클래스 | 주요 미디어 타입 | 등록 조건 | 역할 |
|---|---|---|---|---|
| 1 | ByteArrayHttpMessageConverter |
application/octet-stream, */* |
항상 | byte[] 바이트 배열 |
| 2 | StringHttpMessageConverter |
text/plain, */* |
항상 | String — 문자셋은 기본 UTF-8 (5.x부터) |
| 3 | SourceHttpMessageConverter |
application/xml, text/xml,
application/*+xml |
항상 | javax.xml.transform.Source (DOM/SAX/StAX) |
| 4 | AllEncompassingFormHttpMessageConverter |
application/x-www-form-urlencoded,
multipart/form-data |
항상 | 폼·멀티파트 (내부적으로 JSON/XML 파트를 위임) |
| 5 | MappingJackson2HttpMessageConverter |
application/json, application/*+json |
Jackson(com.fasterxml.jackson.databind) classpath |
JSON — 실무에서 가장 자주 쓰는 컨버터 |
| 6 | GsonHttpMessageConverter |
application/json, application/*+json |
Gson만 있고 Jackson 없을 때 | JSON 대체 구현 |
| 7 | JsonbHttpMessageConverter |
application/json, application/*+json |
JSON-B만 있고 Jackson/Gson 없을 때 | JSON-B(EE) 대체 구현 |
| 8 | MappingJackson2XmlHttpMessageConverter |
application/xml, text/xml,
application/*+xml |
jackson-dataformat-xml classpath |
Jackson 기반 XML |
| 9 | Jaxb2RootElementHttpMessageConverter |
application/xml, text/xml,
application/*+xml |
JAXB classpath, Jackson XML 없을 때 | JAXB 기반 XML 대체 |
| 10 | ProtobufHttpMessageConverter |
application/x-protobuf, text/plain |
protobuf-java classpath |
Protocol Buffers |
1~4번은 Framework 코어가 무조건 등록하는 그룹이고, 5번 이후는 전부
classpath 조건부다. Boot의
HttpMessageConvertersAutoConfiguration이 이 조건부 조립을
대신 해주고, 결과 리스트를 RequestMappingHandlerAdapter에
주입한다. 그래서 Jackson XML이 있으면 8번이 추가되고 없으면 9번이 XML을
맡는 식으로 목록이 유동적이다. 디버깅 시 실제 등록 목록을 확인하는 코드
한 줄을 남겨두는 편이 좋다.
Jackson Smile/CBOR(
MappingJackson2Smile/CborHttpMessageConverter)도 해당jackson-dataformat-*이 있으면 추가되지만, REST API 실무에서 접할 빈도가 낮아 표에선 생략했다.ResourceHttpMessageConverter/ResourceRegionHttpMessageConverter는 파일 다운로드·Range 요청 시 별도로 등록돼 동작한다.
@Autowired RequestMappingHandlerAdapter adapter;
@EventListener(ApplicationReadyEvent.class)
void dumpConverters() {
adapter.getMessageConverters().forEach(c ->
log.info("converter={} supports={}", c.getClass().getSimpleName(), c.getSupportedMediaTypes()));
}4-1) Kotlin serialization의 자동 등록은 Boot 버전에 따라 갈린다
Jackson은 Boot가 자동 구성에서 케어한다. Kotlin
kotlinx.serialization의
KotlinSerializationJsonHttpMessageConverter는 버전에 따라
다르다. Spring Boot 3.2 미만에서는 자동 등록되지 않아,
WebMvcConfigurer#extendMessageConverters에
add(0, ...)로 수동 등록해야 JSON 응답이 그쪽으로 간다.
Spring Boot 3.2 이상에서는
kotlinx-serialization-json이 classpath에 있으면
HttpMessageConvertersAutoConfiguration이 자동으로 등록해
준다. Kotlin 프로젝트에서 "분명 @Serializable 붙였는데 왜
Jackson이 직렬화하지?" 싶으면 Boot 버전과 의존성부터 확인한다.
5) 컨버터 선택 흐름: 요청 읽기와 응답 쓰기
컨버터 선택은 선언된 목록을 앞에서부터 훑는 단순한 선형 탐색이다. 그림으로 보면 한 번에 이해된다.
요청 쪽은 비교적 단순하다. Content-Type이 명시돼 있고,
그 미디어 타입으로 대상 타입을 읽을 수 있는 첫 번째 컨버터가 이긴다.
응답 쪽은 더 복잡하다. Accept로부터 여러 개의 수용
가능한 미디어 타입 리스트가 나오고, 각
(컨버터, 미디어타입) 조합을 순회한다. 컨버터가 자신이
선언한 producible 타입과 acceptable 타입의
교집합 중 하나라도 canWrite로 허용하면 그걸로 쓴다.
이 경로에서 실패한 경우의 예외 이름이 바로
415(HttpMediaTypeNotSupportedException)와
406(HttpMediaTypeNotAcceptableException)이다. 10번 섹션에서
이 둘을 다시 다룬다.
6) configureMessageConverters vs extendMessageConverters
커스텀 컨버터를 추가하려면 WebMvcConfigurer의 두 메서드
중 하나를 오버라이드한다. 이름이 비슷해서 자주 헷갈리는데,
완전히 다른 동작이다.
| 항목 | configureMessageConverters |
extendMessageConverters |
|---|---|---|
| 기본 컨버터 | 이 메서드에 뭔가 추가하면 기본 컨버터가 등록되지 않는다 | 기본 컨버터가 이미 등록된 목록을 넘겨준다 |
| 용도 | 기본 목록을 통째로 교체하고 직접 구성할 때 | 기본 목록에 추가하거나 순서를 조정할 때 |
| 실무 권장 | 거의 쓰지 않음 | 대부분의 경우 이걸 쓴다 |
| Boot와의 궁합 | Boot의 Jackson 자동 구성이 증발함 | Boot 자동 구성과 공존 |
핵심 한 줄. configureMessageConverters를
오버라이드해서 converters.add(...)를 한 번이라도 호출하면,
Spring은 "사용자가 목록을 직접 제공했다"고 판단하고 기본 컨버터를
추가하지 않는다. 이게 Boot 환경에서 특히 무섭다.
Jackson2ObjectMapperBuilderCustomizer로 곱게 조립돼 있던
MappingJackson2HttpMessageConverter가 통째로 사라지고, JSON
바디가 더 이상 직렬화되지 않는 사고가 난다.
// 피하기 — 기본 컨버터가 사라진다
@Configuration
class BadConfig implements WebMvcConfigurer {
@Override
public void configureMessageConverters(List<HttpMessageConverter<?>> converters) {
converters.add(new MyCustomConverter());
// 이 순간 Jackson·String·ByteArray 전부 증발
}
}// 선호 — 기본 목록에 추가
@Configuration
class GoodConfig implements WebMvcConfigurer {
@Override
public void extendMessageConverters(List<HttpMessageConverter<?>> converters) {
converters.add(0, new MyCustomConverter()); // 앞에 꽂기
}
}configureMessageConverters를 써야 할 상황은
Boot의 자동 구성을 전면 부정하고 완전히 내가 구성한다는
확신이 있을 때 뿐이다. 그런 확신이 필요할 일은 거의 없다.
7) add(0, ...) 우선순위 패턴: 끝에 붙이면 밀린다
extendMessageConverters를 쓰더라도 또 하나의 함정이
남는다. 목록에 그냥 add(converter)로 붙이면 맨 뒤에
들어간다. 앞쪽에 이미 같은 미디어 타입이나 같은 타입을 처리하는
컨버터가 있으면, 뒤에 붙은 커스텀 컨버터는 영원히 선택되지 않는다.
예를 들어 JSON 응답에 대해 내가 만든 MyJsonConverter를
기본 Jackson 컨버터보다 먼저 쓰게 하고 싶다면, 반드시 목록의 앞에
삽입해야 한다.
@Override
public void extendMessageConverters(List<HttpMessageConverter<?>> converters) {
converters.add(0, new MyJsonConverter()); // 0번 인덱스에 삽입
}"가운데 어디"에 넣고 싶다면 대상 클래스를 찾아서 그 앞에 넣는 패턴이 깔끔하다.
@Override
public void extendMessageConverters(List<HttpMessageConverter<?>> converters) {
int jacksonIdx = -1;
for (int i = 0; i < converters.size(); i++) {
if (converters.get(i) instanceof MappingJackson2HttpMessageConverter) {
jacksonIdx = i;
break;
}
}
if (jacksonIdx >= 0) {
converters.add(jacksonIdx, new MyJsonConverter()); // Jackson 바로 앞
} else {
converters.add(0, new MyJsonConverter());
}
}기존 컨버터의 설정만 바꾸고 싶다면 제거 후 다시 꽂는 편이 더 명확하다. "같은 클래스 두 개가 다른 설정으로 존재하는" 상황은 디버깅을 고통스럽게 만든다.
8) ObjectMapper 등록 패턴: Customizer vs @Bean
Jackson의 ObjectMapper 설정은 MVC에서만이 아니라
RestClient·RestTemplate·@JsonTest
등 여러 곳에서 공유된다. 그래서 어디에 설정을 거느냐가 전체
애플리케이션의 JSON 동작을 좌우한다. 여기서 두 갈래가 있다.
8-1) 피하기:
@Bean ObjectMapper를 직접 등록
// 피하기 — Boot의 Customizer 체인을 우회한다
@Bean
ObjectMapper objectMapper() {
return new ObjectMapper()
.registerModule(new JavaTimeModule())
.disable(SerializationFeature.WRITE_DATES_AS_TIMESTAMPS);
}겉보기엔 문제없어 보이지만 함정이 두 개다. 첫째, Boot가
Jackson2ObjectMapperBuilder를 거쳐 적용하던 기본 구성(언어
모듈 자동 탐색, failOnUnknownProperties=false, 기본
Locale/TimeZone 등)이 전부 사라진다. 둘째, 다른
라이브러리가 제공하는
Jackson2ObjectMapperBuilderCustomizer가 무시된다. 예컨대
Spring Data REST나 Springdoc OpenAPI가 기여하는 설정이 증발한다.
8-2) 선호:
Jackson2ObjectMapperBuilderCustomizer
// 선호 — Boot의 빌더 체인에 기여
@Bean
Jackson2ObjectMapperBuilderCustomizer jacksonCustomizer() {
return builder -> builder
.modules(new JavaTimeModule())
.featuresToDisable(SerializationFeature.WRITE_DATES_AS_TIMESTAMPS)
.serializationInclusion(JsonInclude.Include.NON_NULL);
}이 방식은 Boot가 내부적으로
Jackson2ObjectMapperBuilder에 모든 커스터마이저를 적용한 뒤
하나의 ObjectMapper를 만들어
MappingJackson2HttpMessageConverter에 주입하는 흐름에
추가로 한 줄을 얹는 것에 가깝다. 여러 모듈이 각자의
Customizer 빈을 등록할 수 있고 설정이 합성된다. 기본값도 그대로
살아있다.
8-3) application.yml로 끝낼 수 있는 건 거기서 끝낸다
많은 설정은 아예 코드 없이 application.yml로
해결된다.
spring:
jackson:
serialization:
write-dates-as-timestamps: false
indent-output: false
deserialization:
fail-on-unknown-properties: false
default-property-inclusion: non_null
time-zone: Asia/SeoulCustomizer는 yml로 표현 안 되는 것(커스텀 시리얼라이저, 모듈 직접 등록 등)에만 쓰는 게 깔끔하다.
9) Content Negotiation: produces/consumes와 Accept 헤더
컨버터가 아무리 많아도 그중 어떤 걸 쓸지는 콘텐츠
협상(Content Negotiation) 이 정한다.
입력(Content-Type)과 출력(Accept)을 양쪽에서
제한할 수 있다.
| 항목 | 요청 바디(입력) | 응답 바디(출력) |
|---|---|---|
| 클라이언트 헤더 | Content-Type |
Accept |
| 서버 매핑 제한 | @RequestMapping(consumes = ...) |
@RequestMapping(produces = ...) |
| 불일치 시 에러 | HTTP 415 Unsupported Media Type | HTTP 406 Not Acceptable |
| 미지정 시 동작 | 서버의 기본 컨버터 중 매치되는 것 | Accept: */*로 간주하고 가장 적합한 것 |
consumes/produces는 이 엔드포인트가
무엇을 받고 내보내는지 스스로 선언하는 필터다. 협상의 후보가
그만큼 좁아진다. 예를 들어
produces = "application/json"으로 선언한 엔드포인트에
Accept: application/xml로 요청이 오면 406이 난다. 컨버터
목록에 XML 컨버터가 있더라도 그 엔드포인트의 선언과 맞지 않기 때문이다.
여기서 틀리기 쉽다. "컨버터가 있으니 XML도 된다"가 아니라,
엔드포인트가 선언하지 않으면 안 된다.
Spring Boot 3.x의 기본값은 URL 확장자·파라미터 기반 협상은 꺼져 있고
Accept 헤더 기반 협상만 활성화되어 있다.
예전처럼 /users.json 같은 확장자로 포맷을 바꾸려면
ContentNegotiationConfigurer에서 명시적으로 켜야 한다. 보안
이슈 때문에 기본이 꺼진 것이므로, 굳이 켤 이유가 없다면 그대로 두는 게
낫다.
10) 406·415 디버깅 흐름
실무에서 컨버터와 관련된 에러는 대부분 둘 중 하나다. 흐름을 외워두면 10분 걸릴 걸 1분 만에 잡는다.
10-1) HTTP 415 Unsupported Media Type — 요청을 못 읽는다
서버가 요청 바디의 Content-Type을 해석할
컨버터를 못 찾았다는 뜻이다. 흔한 원인을 현상·원인·확인 순으로
정리하면 이렇다.
| 현상 | 원인 | 확인 방법 |
|---|---|---|
Content-Type이 비어있거나 text/plain인데
@RequestBody DTO |
Jackson 컨버터의 canRead가
application/json이 아니라서 매치 실패 |
클라이언트 요청 헤더를 curl -v/브라우저 개발자 도구로
덤프 |
consumes = "application/json" 엔드포인트에
application/xml 요청 |
어노테이션 선언이 협상 후보를 좁혀 415 확정 | 컨트롤러의 @RequestMapping(consumes=...) 값 확인 |
| Jackson 의존성이 빠진 프로젝트 | JSON 컨버터 자체가 등록되지 않음 (예: Kotlin serialization만 쓰는 경우) | adapter.getMessageConverters() 덤프 |
매치는 됐는데 read 단계에서 터짐 |
대상 타입에 기본 생성자·매핑 가능한 시그니처 없음 | 보통 400으로 내려감. 스택트레이스에서
MismatchedInputException 검색 |
10-2) HTTP 406 Not Acceptable — 응답을 못 쓴다
서버가 클라이언트의 Accept 헤더를 만족시키는
응답을 만들 컨버터를 못 찾았다는 뜻이다. Boot 3.x의 기본 오류
응답은 대개 이런 모양으로 내려온다.
{
"timestamp": "2026-04-13T10:21:33.482+00:00",
"status": 406,
"error": "Not Acceptable",
"path": "/api/users/1"
}체크 순서.
- 클라이언트
Accept가 뭔가.application/xml인데 서버에 XML 컨버터가 없으면 406. - 엔드포인트에
produces제약이 걸려 있나.produces = "application/json"인데Accept: application/xml이면 406. - 반환 타입을
canWrite할 수 있는 컨버터가 등록 목록에 있나. 커스텀 타입을 반환하면서 그걸 처리하는 컨버터를 안 넣었으면 여기 걸린다. - 커스텀 컨버터를
add(converter)로 끝에 붙여 순서에 밀리고 있는 건 아닌가 (§7 참조).
10-3) 디버깅 시 즉시 실행하는 스니펫
이 한 줄이면 실제로 어떤 컨버터가 어떤 미디어 타입을 지원하도록 등록돼 있는지 즉시 확인된다.
@RestController
class DebugController {
@Autowired RequestMappingHandlerAdapter adapter;
@GetMapping("/_debug/converters")
public List<Map<String, Object>> converters() {
return adapter.getMessageConverters().stream()
.map(c -> Map.<String, Object>of(
"class", c.getClass().getName(),
"mediaTypes", c.getSupportedMediaTypes()))
.toList();
}
}운영 환경에서는 노출하지 말고 로컬/스테이지에서만 잠깐 열었다 닫는다.
11) Spring 6 RestClient와 컨버터 공유
Spring Framework 6.1이 도입한 RestClient는
RestTemplate의 동기 API를 대체하는 새 인터페이스다.
흥미로운 건 이 클라이언트도 같은
HttpMessageConverter 목록을 그대로 재사용한다는
점이다. Boot 3.2 이상에서는 RestClient.Builder 빈이 자동
구성되고, 그 빌더가 서버 측에서 쓰는 것과 동일한 컨버터
목록(정확히는 HttpMessageConverters 오토컨피규어
결과)을 공유한다. 즉 서버 응답 직렬화 설정을 한 번 바꾸면 클라이언트
요청 직렬화도 함께 바뀐다. 이것이 ObjectMapper 설정을
Jackson2ObjectMapperBuilderCustomizer로 몰아 넣어야 하는 또
하나의 이유다. 한 군데만 건드리면 된다.
커스텀 컨버터도 동일하다.
WebMvcConfigurer#extendMessageConverters에 꽂은 컨버터는
MVC 서버 쪽에서 쓰이고, RestClient.Builder에 기여하고
싶으면 RestClientCustomizer를 따로 등록한다. 둘 다 같은
컨버터 인스턴스를 공유할 필요가 없고, 보통은 별도로 설정해도 된다. 다만
양쪽에서 동일한 Jackson 설정을 기대한다는 점만 기억하면
혼란이 적다.
12) 실무에서 이렇게 읽고 쓴다
- 커스텀 컨버터는
extendMessageConverters에add(0, ...)로 넣는다.configureMessageConverters는 거의 쓸 일이 없고, 끝에 그냥add하면 앞의 기본 컨버터에 가려진다. ObjectMapper설정은Jackson2ObjectMapperBuilderCustomizer또는spring.jackson.*yml로 한다.@Bean ObjectMapper직접 등록은 Boot 자동 구성을 우회하므로 피한다.- 엔드포인트에
produces/consumes를 명시하면 협상 후보가 좁아져 의도치 않은 직렬화가 줄고, 에러도 일찍 드러난다. 특히 API 버저닝이나 vendor media type(application/vnd.myapp.v2+json) 설계 시 필수. - 406/415가 났으면 먼저
getMessageConverters()를 덤프한다. 컨버터 누락, 순서 문제, 미디어 타입 불일치 중 하나다. - 제네릭 반환
타입(
ResponseEntity<List<User>>등)이 잘 안 풀리면 커스텀 컨버터가GenericHttpMessageConverter를 구현했는지 확인한다. - Kotlin
kotlinx.serialization을 쓴다면 Boot 버전을 먼저 확인한다. 3.2 미만이면KotlinSerializationJsonHttpMessageConverter를add(0, ...)로 수동 등록하고, 3.2 이상이면kotlinx-serialization-jsonclasspath만 맞추면 자동 등록된다. - 테스트 할 때는
MockMvc대신WebTestClient나 실제 포트로TestRestTemplate/RestClient를 써서 협상까지 포함해 검증한다.MockMvc는Accept헤더 생략 시 기본 동작이 실제 톰캣과 다를 수 있다. - 운영에서 JSON 스키마가 갑자기 바뀐 것 같다면 Boot
버전 업그레이드로 인한 Jackson 모듈 변경, 혹은 누가
@Bean ObjectMapper를 추가해 Customizer가 무시된 경우를 먼저 의심한다.
13) 한 줄 정리
@RequestBody/@ResponseBody의 주체는
Jackson이 아니라 HttpMessageConverter 목록의 선형
탐색이다. 첫 매치가 이기므로 순서와 등록 방법이 동작
전부를 결정한다. configureMessageConverters로
덮어쓰지 말고 extendMessageConverters에
add(0, ...)로 끼워 넣고, ObjectMapper는
Jackson2ObjectMapperBuilderCustomizer로 한
군데에서만 조립한다.
태그: Spring MVC, HttpMessageConverter, Jackson, ObjectMapper, @RequestBody, @ResponseBody, Content Negotiation, Spring Boot 3, RestClient, 406 415