HTTP 메시지 변환기
요청 read와 응답 write에서 Java 타입·미디어 타입·등록 순서가 HttpMessageConverter 선택과 400·406·415·500 경계를 어떻게 만드는지 실제 Boot MVC 문맥으로 검증합니다.
@RequestBody가 요청 바이트를 Java 객체로 바꾸고 @ResponseBody가 Java 객체를 응답 바이트로 바꾸는 작업은 컨트롤러 메서드가 직접 수행하지 않습니다. Spring MVC는 등록된 HttpMessageConverter 가운데 Java 타입과 미디어 타입을 함께 지원하는 후보를 찾고, 요청에서는 read, 응답에서는 write를 호출합니다. 이 양방향 계약은 Spring Framework 7.0.9의 HttpMessageConverter 소스에 그대로 드러납니다.
FLOWCHART · HTTP MESSAGE CONVERTER · READ / WRITE
등록된 컨버터 목록이 요청 read와 응답 write를 좁은 조건으로 연결한다
이 문서의 예제에서는 요청의 Content-Type과 대상 타입으로 reader를, 응답의 Accept·produces와 반환 타입으로 writer를 고른다.
-
REQUEST · READ
Content-Type과 대상 타입을 함께 지원하는 reader가 있는가?
reader 있음 → 선택된 converter가 bytes를 읽습니다. parse·타입 read가 실패하면 controller 진입 전
400 Bad Request로 끝나고, 성공하면PostResponse가 되어 controller가 같은 Java 값을 반환합니다.reader 없음 → 이 예제의 CSV request는 controller 진입 전
415 Unsupported Media Type으로 끝납니다. -
RESPONSE · NEGOTIATION
Accept와 produces가 겹치는 응답 표현을 만드는가?
예 → 반환 타입과 함께 writer 선택으로 진행합니다.
아니오 → controller 진입 전
406 Not Acceptable로 끝납니다. -
RESPONSE · WRITE
반환 타입과 미디어 타입을 쓸 converter가 있는가?
예 → JSON은 Jackson, CSV는 좁은
PostCsvHttpMessageConverter가 Content-Type과 bytes를 씁니다.아니오 → controller 반환 뒤 독립된
500 Internal Server Error종착점으로 갑니다.
- 415·400·406·500 종착 실패
- 선택된 write 경로
- request 방향
ServerBuilder.addCustomConverter는 기본 목록을 보존한 채
custom writer를 추가한다. 목록 앞에 둘 때도 지원 범위를 정확히
PostResponse + text/csv write로 제한해야 JSON을 가로채지
않는다.
그림은 이 문서의 예제 경로만 보여 줍니다. 요청에서는 Content-Type과 메서드 인자의 대상 타입으로 reader를 고르고, 응답에서는 Accept와 produces가 만든 표현 후보를 반환 타입과 함께 writer에 대조합니다. 호환 후보가 여럿이면 목록 순서가 관여하므로 사용자 정의 converter는 지원 범위를 좁혀야 합니다.
이 문서는 converter 선택과 표현 협상을 소유합니다. JSON 필드 바인딩과 Bean Validation의 상세 계약은 ch6-4, 컨트롤러 반환값 자체의 뷰·본문·리다이렉트 의미는 ch6-5, CRUD와 ETag는 ch6-7, 전체 장애 역추적 절차와 ProblemDetail은 ch6-8에서 다룹니다. 다운로드·Range·스트리밍과 완전한 CSV 표준 구현은 이 예제가 증명하지 않습니다.
세 문서가 공유할 실행 그래프를 고정한다
ch6-6·ch6-7·ch6-8의 실행 예제는 하나의 Gradle 프로젝트를 공유합니다. Java 25와 Gradle 9.5.1에서 Spring Boot 4.1.1 BOM을 사용하며, 이 그래프가 Spring Framework 7.0.9, Jackson 3.1.5, JUnit 6.0.3을 고정합니다. Boot 플러그인이나 실행 메인 클래스를 두지 않고 Java 테스트 그래프로만 구성하므로 세 장의 격리된 Boot 구성이 서로 메인 클래스를 차지하지 않습니다.
rootProject.name = 'mvc-boundary-contracts'plugins { id 'java' }
java { toolchain { languageVersion = JavaLanguageVersion.of(25) } }
repositories { mavenCentral() }
dependencies {
implementation platform('org.springframework.boot:spring-boot-dependencies:4.1.1')
implementation 'org.springframework.boot:spring-boot-starter-webmvc'
implementation 'org.springframework.boot:spring-boot-starter-validation'
testImplementation platform('org.springframework.boot:spring-boot-dependencies:4.1.1')
testImplementation 'org.springframework.boot:spring-boot-starter-webmvc-test'
testRuntimeOnly 'org.junit.platform:junit-platform-launcher'
}
tasks.withType(JavaCompile).configureEach {
options.encoding = 'UTF-8'
options.release = 25
options.compilerArgs += ['-parameters']
}
tasks.named('test') { useJUnitPlatform() }이 장은 board.converter 패키지만 자신의 테스트 애플리케이션에 명시적으로 가져옵니다. 뒤 장의 board.crud, board.pipeline과 같은 URI나 단순 클래스 이름을 사용하더라도 하나의 ApplicationContext에 함께 스캔되지 않습니다.
package board.converter;
import org.springframework.boot.SpringBootConfiguration;
import org.springframework.boot.autoconfigure.EnableAutoConfiguration;
import org.springframework.context.annotation.Import;
@SpringBootConfiguration(proxyBeanMethods = false)
@EnableAutoConfiguration
@Import({
ConverterProbe.class,
FixedPostQuery.class,
NegotiatedPostController.class,
WebConverterConfig.class
})
public class ConverterTestApplication {
}같은 Java 값에 여러 표현을 허용한다
컨트롤러가 반환할 표현 타입은 하나입니다. JSON용 객체와 CSV용 문자열을 별도 핸들러에서 만들지 않고, 같은 PostResponse를 선택된 writer에 넘깁니다.
package board.converter;
import java.util.Objects;
public record PostResponse(long id, String title, String content) {
public PostResponse {
if (id < 1) {
throw new IllegalArgumentException("id must be positive");
}
Objects.requireNonNull(title, "title");
Objects.requireNonNull(content, "content");
}
}조회 경계도 정확히 같은 PostResponse를 반환해 표와 구현의 타입이 어긋나지 않습니다.
package board.converter;
@FunctionalInterface
public interface PostQuery {
PostResponse required(long id);
}조회 값이 테스트 순서나 외부 상태에 흔들리지 않도록 한 게시글만 가진 고정 조회를 사용합니다.
package board.converter;
public final class FixedPostQuery implements PostQuery {
private static final PostResponse POST = new PostResponse(
42L,
"JSON, CSV",
"같은 게시글을 CSV로 표현");
@Override
public PostResponse required(long id) {
if (id != POST.id()) {
throw new IllegalArgumentException("unknown post: " + id);
}
return POST;
}
}응답 상태만 보면 converter 이전에 컨트롤러가 실행됐는지 구분할 수 없습니다. 테스트 전용 probe를 별도 객체로 두고 각 사례가 자신의 호출 횟수를 초기화합니다.
package board.converter;
import java.util.concurrent.atomic.AtomicInteger;
public final class ConverterProbe {
private final AtomicInteger controllerCalls = new AtomicInteger();
void controllerEntered() {
controllerCalls.incrementAndGet();
}
public int controllerCalls() {
return controllerCalls.get();
}
public void reset() {
controllerCalls.set(0);
}
}대표 converter의 역할은 그림보다 실제 표가 더 정확합니다.
| Converter | 이 예제에서 보는 Java 타입 | 대표 미디어 타입 | Request read | Response write |
|---|---|---|---|---|
StringHttpMessageConverter | String | 기본 응답 text/plain | 지원 | 지원 |
JacksonJsonHttpMessageConverter | record·객체 | application/json, application/*+json | 지원 | 지원 |
PostCsvHttpMessageConverter | 정확히 PostResponse | text/csv | 미지원 | 지원 |
Spring Framework 7의 JacksonJsonHttpMessageConverter는 Jackson 3 JsonMapper 기반입니다. 신규 Boot 4 예제에 Jackson 2용 converter나 수동 mapper를 섞지 않습니다.
CSV writer의 범위를 타입과 미디어 타입으로 좁힌다
CSV 구분자와 따옴표를 최소한으로 다루는 작은 정책을 먼저 분리합니다. 이 함수는 쉼표·따옴표·CR·LF가 있는 필드를 인용하고 내부 따옴표를 두 번 씁니다. 완전한 CSV 방언, 스프레드시트 수식 주입 방어, 파일 다운로드 정책을 구현했다고 주장하지 않습니다.
package board.converter;
import java.util.Objects;
public final class Csv {
private Csv() {
}
static String field(String value) {
Objects.requireNonNull(value, "value");
var quoted = value.replace("\"", "\"\"");
var needsQuotes = value.indexOf(',') >= 0
|| value.indexOf('\"') >= 0
|| value.indexOf('\r') >= 0
|| value.indexOf('\n') >= 0;
return needsQuotes ? "\"" + quoted + "\"" : quoted;
}
}사용자 정의 converter는 응답 쓰기만 지원합니다. canRead를 false로 고정하므로 text/csv 요청을 PostResponse로 읽는 계약은 생기지 않습니다. readInternal 예외는 정상 선택 경로에서 호출되지 않는 방어입니다. supports도 Object나 임의의 컬렉션이 아니라 정확히 PostResponse만 허용합니다.
package board.converter;
import static java.nio.charset.StandardCharsets.UTF_8;
import java.io.IOException;
import org.springframework.http.HttpInputMessage;
import org.springframework.http.HttpOutputMessage;
import org.springframework.http.MediaType;
import org.springframework.http.converter.AbstractHttpMessageConverter;
public final class PostCsvHttpMessageConverter
extends AbstractHttpMessageConverter<PostResponse> {
public static final MediaType TEXT_CSV =
new MediaType("text", "csv", UTF_8);
public PostCsvHttpMessageConverter() {
super(UTF_8, TEXT_CSV);
}
@Override
public boolean canRead(Class<?> type, MediaType mediaType) {
return false;
}
@Override
protected boolean supports(Class<?> type) {
return type == PostResponse.class;
}
@Override
protected PostResponse readInternal(
Class<? extends PostResponse> type,
HttpInputMessage input) {
throw new UnsupportedOperationException("CSV request is not supported");
}
@Override
protected void writeInternal(
PostResponse post,
HttpOutputMessage output) throws IOException {
var csv = "id,title,content\n"
+ post.id() + ","
+ Csv.field(post.title()) + ","
+ Csv.field(post.content()) + "\n";
output.getBody().write(csv.getBytes(UTF_8));
}
}기본 converter 목록을 보존하며 확장한다
사용자 정의 writer만 추가하려면 기본 목록을 다시 조립할 이유가 없습니다. Spring Framework 7.0.9의 WebMvcConfigurer.configureMessageConverters(HttpMessageConverters.ServerBuilder)는 기본 등록을 유지하면서 custom converter를 추가하는 현행 확장점을 제공합니다.
CSV converter를 앞에 두되, 지원 타입과 미디어 타입을 좁혔으므로 JSON은 계속 Jackson converter가 처리합니다. Object + */*처럼 넓은 converter를 앞에 두면 순서가 의미를 바꾸므로 마지막 테스트에서 그 실패도 실제로 재현합니다.
package board.converter;
import org.springframework.context.annotation.Configuration;
import org.springframework.http.converter.HttpMessageConverters;
import org.springframework.web.servlet.config.annotation.WebMvcConfigurer;
@Configuration(proxyBeanMethods = false)
public class WebConverterConfig implements WebMvcConfigurer {
@Override
public void configureMessageConverters(
HttpMessageConverters.ServerBuilder builder) {
builder.addCustomConverter(new PostCsvHttpMessageConverter());
}
}컨트롤러는 표현 문자열이 아니라 객체를 반환한다
GET은 JSON과 CSV를 응답 후보로 선언합니다. 두 echo 경로는 요청 방향의 실패를 분리하기 위한 테스트 표면입니다. text/csv 매핑은 존재하지만 reader가 없으므로 요청은 컨트롤러 진입 전에 415가 됩니다. 고정된 사용자 미디어 타입에 writer가 없는 /unwritable은 컨트롤러가 반환한 뒤 500이 되는 조건부 사례입니다.
package board.converter;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
@RestController
@RequestMapping("/api/converter/posts")
public final class NegotiatedPostController {
private final PostQuery query;
private final ConverterProbe probe;
public NegotiatedPostController(PostQuery query, ConverterProbe probe) {
this.query = query;
this.probe = probe;
}
@GetMapping(
path = "/{id}",
produces = {"application/json", "text/csv"})
public PostResponse get(@PathVariable long id) {
probe.controllerEntered();
return query.required(id);
}
@PostMapping(
path = "/echo",
consumes = "application/json",
produces = "application/json")
public PostResponse echoJson(@RequestBody PostResponse post) {
probe.controllerEntered();
return post;
}
@PostMapping(
path = "/echo",
consumes = "text/csv",
produces = "application/json")
public PostResponse echoCsv(@RequestBody PostResponse post) {
probe.controllerEntered();
return post;
}
@GetMapping(
path = "/unwritable",
produces = "application/x-board-post")
public UnwritablePayload unwritable() {
probe.controllerEntered();
return new UnwritablePayload("writer 없음");
}
public record UnwritablePayload(String message) {
}
}406은 Accept와 produces가 겹치지 않아 응답 표현 후보를 만들지 못한 경우입니다. 415는 이 예제에서 text/csv 요청을 PostResponse로 읽을 converter가 없는 경우입니다. 선택된 JSON reader가 본문 문법을 해석하지 못하면 400이고, 고정된 응답 미디어 타입을 쓸 writer가 없으면 컨트롤러 실행 뒤 500이 됩니다. 이 네 상태를 모든 애플리케이션 오류의 보편적 원인으로 일반화하지 않습니다.
실제 Boot MVC 문맥에서 선택 경계를 증명한다
테스트는 @AutoConfigureMockMvc의 Boot 4.1.1 소스를 사용합니다. 실제 RequestMappingHandlerAdapter의 converter 목록을 읽고 실제 MockMvc 요청으로 200·400·406·415·500과 컨트롤러 호출 경계를 검증합니다. 마지막 한 사례만 의도적으로 잘못된 standalone 목록을 만들어 넓은 converter의 가로채기를 재현합니다.
package board.converter;
import static java.nio.charset.StandardCharsets.UTF_8;
import static org.assertj.core.api.Assertions.assertThat;
import static org.springframework.http.MediaType.APPLICATION_JSON;
import static org.springframework.http.MediaType.APPLICATION_XML;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.content;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
import java.io.IOException;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.boot.webmvc.test.autoconfigure.AutoConfigureMockMvc;
import org.springframework.http.HttpInputMessage;
import org.springframework.http.HttpOutputMessage;
import org.springframework.http.MediaType;
import org.springframework.http.converter.AbstractHttpMessageConverter;
import org.springframework.http.converter.HttpMessageNotWritableException;
import org.springframework.http.converter.ResourceHttpMessageConverter;
import org.springframework.http.converter.StringHttpMessageConverter;
import org.springframework.http.converter.json.JacksonJsonHttpMessageConverter;
import org.springframework.test.web.servlet.MockMvc;
import org.springframework.test.web.servlet.client.RestTestClient;
import org.springframework.web.servlet.mvc.method.annotation.RequestMappingHandlerAdapter;
@SpringBootTest(classes = ConverterTestApplication.class)
@AutoConfigureMockMvc
class MessageConverterContractTest {
private static final String POST_URI = "/api/converter/posts/42";
private static final String ECHO_URI = "/api/converter/posts/echo";
private static final MediaType UNWRITABLE =
MediaType.parseMediaType("application/x-board-post");
@Autowired
private MockMvc mvc;
@Autowired
private ConverterProbe probe;
@Autowired
private RequestMappingHandlerAdapter adapter;
@BeforeEach
void resetProbe() {
probe.reset();
}
@Test
void Boot_기본_converter와_CSV_converter를_함께_유지한다() {
var converters = adapter.getMessageConverters();
assertThat(converters.getFirst())
.isInstanceOf(PostCsvHttpMessageConverter.class);
assertThat(converters.stream()
.filter(PostCsvHttpMessageConverter.class::isInstance)
.count())
.isEqualTo(1);
assertThat(converters)
.anyMatch(JacksonJsonHttpMessageConverter.class::isInstance)
.anyMatch(StringHttpMessageConverter.class::isInstance)
.anyMatch(ResourceHttpMessageConverter.class::isInstance);
}
@Test
void JSON_Accept는_Jackson3로_200_JSON을_쓴다() throws Exception {
mvc.perform(get(POST_URI).accept(APPLICATION_JSON))
.andExpect(status().isOk())
.andExpect(content().contentTypeCompatibleWith(APPLICATION_JSON))
.andExpect(jsonPath("$.id").value(42))
.andExpect(jsonPath("$.title").value("JSON, CSV"))
.andExpect(jsonPath("$.content")
.value("같은 게시글을 CSV로 표현"));
assertThat(probe.controllerCalls()).isEqualTo(1);
}
@Test
void CSV_Accept는_custom_converter로_200_CSV를_쓴다() throws Exception {
mvc.perform(get(POST_URI).accept(PostCsvHttpMessageConverter.TEXT_CSV))
.andExpect(status().isOk())
.andExpect(content().contentTypeCompatibleWith(
PostCsvHttpMessageConverter.TEXT_CSV))
.andExpect(content().string(
"id,title,content\n"
+ "42,\"JSON, CSV\",같은 게시글을 CSV로 표현\n"));
assertThat(probe.controllerCalls()).isEqualTo(1);
}
@Test
void XML_Accept는_406이고_controller는_0회다() throws Exception {
mvc.perform(get(POST_URI).accept(APPLICATION_XML))
.andExpect(status().isNotAcceptable());
assertThat(probe.controllerCalls()).isZero();
}
@Test
void 읽기_미지원_CSV는_415이고_controller는_0회다() throws Exception {
mvc.perform(post(ECHO_URI)
.contentType(PostCsvHttpMessageConverter.TEXT_CSV)
.accept(APPLICATION_JSON)
.content("id,title,content\n42,title,content\n"))
.andExpect(status().isUnsupportedMediaType());
assertThat(probe.controllerCalls()).isZero();
}
@Test
void 선택된_JSON_reader의_문법_실패는_400이고_controller는_0회다()
throws Exception {
mvc.perform(post(ECHO_URI)
.contentType(APPLICATION_JSON)
.accept(APPLICATION_JSON)
.content("{\"id\":42,"))
.andExpect(status().isBadRequest());
assertThat(probe.controllerCalls()).isZero();
}
@Test
void 고정_produces에_writer가_없으면_controller_후_500이다()
throws Exception {
var result = mvc.perform(get("/api/converter/posts/unwritable")
.accept(UNWRITABLE))
.andExpect(status().isInternalServerError())
.andReturn();
assertThat(result.getResolvedException())
.isInstanceOf(HttpMessageNotWritableException.class);
assertThat(probe.controllerCalls()).isEqualTo(1);
}
@Test
void 넓은_선두_converter는_JSON을_가로챈다() {
var client = RestTestClient
.bindToController(new NegotiatedPostController(
new FixedPostQuery(), new ConverterProbe()))
.configureServer(builder -> builder.setMessageConverters(
new GreedyHttpMessageConverter(),
new JacksonJsonHttpMessageConverter()))
.build();
client.get()
.uri(POST_URI)
.accept(APPLICATION_JSON)
.exchange()
.expectStatus().isOk()
.expectHeader().contentTypeCompatibleWith(APPLICATION_JSON)
.expectBody(String.class).isEqualTo("intercepted");
assertThat(new PostCsvHttpMessageConverter()
.canWrite(PostResponse.class, APPLICATION_JSON))
.isFalse();
}
private static final class GreedyHttpMessageConverter
extends AbstractHttpMessageConverter<Object> {
private GreedyHttpMessageConverter() {
super(MediaType.ALL);
}
@Override
protected boolean supports(Class<?> type) {
return true;
}
@Override
protected Object readInternal(
Class<? extends Object> type,
HttpInputMessage input) {
throw new UnsupportedOperationException("read is not used");
}
@Override
protected void writeInternal(
Object value,
HttpOutputMessage output) throws IOException {
output.getBody().write("intercepted".getBytes(UTF_8));
}
}
}이 테스트가 고정하는 사실은 좁습니다.
| 요청 | 선택 경계 | 기대 결과 | Controller 호출 |
|---|---|---|---|
GET + Accept: application/json | Jackson writer | 200 JSON | 1 |
GET + Accept: text/csv | PostCsvHttpMessageConverter | 200 CSV | 1 |
GET + Accept: application/xml | 표현 후보 없음 | 406 | 0 |
POST + Content-Type: text/csv | reader 없음 | 415 | 0 |
| POST + 깨진 JSON | 선택된 reader 파싱 실패 | 400 | 0 |
GET + 고정 custom produces | writer 없음 | 500 | 1 |
String 응답이 항상 JSON으로 이중 인코딩된다는 식의 규칙은 없습니다. 반환 타입, 애노테이션, 선택된 미디어 타입과 converter를 함께 봐야 합니다. 이 장의 CSV writer는 교육용 한 행 정책이며, 운영 CSV·파일·스트리밍 API의 완전성을 대신하지 않습니다.
연습 문제
WeeklyPostStats record를 만들고 응답 전용 text/csv converter를 추가하세요. 먼저 Object + */* converter를 맨 앞에 둔 실패 테스트로 JSON 가로채기를 재현한 뒤 다음 조건을 모두 만족하도록 범위를 좁힙니다.
- Java 타입은 정확히
WeeklyPostStats만 지원한다. - 미디어 타입은
text/csv만 지원한다. - request read는 지원하지 않아 CSV 요청이 415다.
- 기본 Jackson·String·Resource converter는 목록에 남는다.
- JSON·CSV·XML 요청은 각각 200·200·406이고 호출 횟수도 함께 검증한다.
다음 문서에서는 같은 공유 Gradle 그래프의 board.crud Boot 문맥에서 201·204·ETag·조건부 갱신을 하나의 CRUD 계약으로 연결합니다.