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 소스에 그대로 드러납니다.
등록된 컨버터 목록이 요청 read와 응답 write를 좁은 조건으로 연결한다
본문 없는 JSON GET의 매핑·반환·writer 출력 경로만 추적합니다. echo POST의 request read와 GET write는 별도 실행이며 원표에 함께 보존합니다.
FLOWCHART · HTTP MESSAGE CONVERTER · READ / WRITE
등록된 컨버터 목록이 요청 read와 응답 write를 좁은 조건으로 연결한다
이 문서의 예제에서는 요청의 Content-Type과 대상 타입으로 reader를, 응답의 Accept·produces와 반환 타입으로 writer를 고른다.
두 영역은 read와 write의 서로 다른 선택입니다. 이 fixture의 XML Accept 매핑 실패는 handler 전에 406이며, 고정 응답 미디어 타입의 writer 부재는 handler 뒤 500입니다. CSV writer가 있다고 CSV reader까지 생기는 것은 아닙니다.
WRITE · 200 JSON · CONTROLLER 1
같은 PostResponse를 Jackson 3로
GET /api/converter/posts/42 + Accept: application/json → Jackson writer가 id·title·content의 JSON을 씁니다. controller는 1회입니다.
WRITE · 200 CSV · CONTROLLER 1
좁은 custom writer로 CSV를
같은 GET에 Accept: text/csv → custom writer가 한 행을 씁니다. 정확히 PostResponse와 text/csv의 write만 지원하므로 JSON을 가로채지 않습니다.
MAPPING · 406 · CONTROLLER 0
표현 후보가 없는 XML Accept
같은 GET의 Accept: application/xml은 mapping의 JSON·CSV produces와 겹치지 않습니다. 이 fixture는 controller 진입 전에 406입니다. writer 실패와 순서를 혼동하지 않습니다.
READ · 415 · CONTROLLER 0
CSV mapping은 있지만 reader가 없다
POST /api/converter/posts/echo + Content-Type: text/csv 매핑은 존재합니다. custom converter의 canRead는 false이므로 PostResponse를 읽을 reader가 없어 415입니다.
READ · 400 · CONTROLLER 0
선택된 JSON reader의 문법 실패
같은 POST에 JSON Content-Type을 보내면 reader는 선택됩니다. 깨진 JSON을 읽지 못해 400이 되고 handler 인자가 완성되지 않습니다.
WRITE · 500 · CONTROLLER 1
고정 produces를 쓸 writer가 없다
GET /api/converter/posts/unwritable은 고정 application/x-board-post로 handler가 값을 반환합니다. 해당 타입·표현의 writer가 없어 HttpMessageNotWritableException과 500입니다.
ServerBuilder.addCustomConverter로 CSV writer를 앞에 더해도 Jackson·String·Resource converter가 남습니다. 호환 후보가 여럿이면 순서가 관여합니다. 별도 잘못된 standalone fixture의 선두 Object + */* converter는 JSON 요청을 intercepted 문자열로 가로챕니다.
ServerBuilder.addCustomConverter는 기본 목록을 보존한 채
custom writer를 추가한다. 목록 앞에 둘 때도 지원 범위를 정확히
PostResponse + text/csv write로 제한해야 JSON을 가로채지
않는다.
이 예의 조건
- 두 박스는 같은 실행의 진행 정보와 자료·결과를 관찰하는 고정 역할입니다. 박스의 좌우·위아래 위치를 별도 호출 순서로 해석하지 않습니다.
- 조건별 종료는 대안입니다. 실패 뒤 정상 후속 단계를 실행하거나 독립 비교 사례를 같은 요청으로 연결하지 않습니다.
- 요청은 Content-Type과 대상 타입으로 reader를 골라 bytes에서 Java 값을 만든 뒤 handler로 넘깁니다. 응답은 handler 반환 뒤 반환 타입과 선택된 media type에 맞는 writer가 JSON 또는 CSV를 씁니다. Accept와 mapping produces가 불일치하는 이 fixture의 406은 handler 실행 전에 발생하며 아래에서 별도로 비교합니다.
- 원 데스크톱의 여섯 노드와 네 관계를 두 영역으로 재배치한다. 요청 읽기는 Content-Type과 대상 타입으로 reader를 골라 인자를 준비하고 핸들러에 들어간다. 응답 쓰기는 핸들러 반환 타입과 선택된 미디어 타입으로 writer를 골라 JSON 또는 CSV를 쓴다. 두 영역 사이에는 연결선이 없다. GET의 응답 쓰기와 echo POST의 요청 읽기를 비교하며 모든 요청이 두 경로를 연속 실행한다고 주장하지 않는다. 실제 실패 사례의 위치·호출 횟수는 아래 설명을 따른다.
관계도의 보충 설명
| 순번 | 원문 표시 |
|---|---|
| 2 | 요청 읽기는 Content-Type과 대상 타입, 응답 쓰기는 반환 타입과 선택한 media type으로 converter를 선택합니다. 독립 fixture에서 JSON/CSV GET은 200·controller 1, XML Accept mapping 406·CSV reader 없음 415·깨진 JSON 400은 controller 0입니다. 고정 custom produces의 writer 없음 500은 controller 1입니다. CSV custom은 PostResponse와 text/csv의 write만 지원하고 기본 목록을 보존합니다. |
| 6 | 요청은 Content-Type과 대상 타입으로 reader를 골라 bytes에서 Java 값을 만든 뒤 handler로 넘깁니다. 응답은 handler 반환 뒤 반환 타입과 선택된 media type에 맞는 writer가 JSON 또는 CSV를 씁니다. Accept와 mapping produces가 불일치하는 이 fixture의 406은 handler 실행 전에 발생하며 아래에서 별도로 비교합니다. |
| 장면 | 관찰 값 | 전환 이유 |
|---|---|---|
| GET 표현 매핑 본문 없는 GET /api/converter/posts/42가 Accept: application/json으로 JSON produces 매핑을 선택합니다. | 선택 경로: JSON GET 응답 쓰기 · 요청 reader: GET에 요청 본문이 없어 실행하지 않음 | echo POST의 request read는 별도 endpoint이며 이 GET의 선행 단계로 연결하지 않습니다. |
| controller 반환 선택된 GET controller가 한 번 실행되어 PostResponse를 반환합니다. | controller: 1회 · 반환 값: PostResponse · 응답 표현: application/json | 반환 타입과 선택된 미디어 타입이 응답 writer 선택의 입력이 됩니다. |
| JSON writer 출력 등록된 Jackson writer가 PostResponse의 JSON을 써 200 응답을 만듭니다. | writer: PostResponse·application/json 호환 후보 · 응답: 200·JSON · CSV custom: JSON을 가로채지 않는 좁은 write 범위 | Java 반환값이 JSON 응답 바이트로 바뀝니다. 독립 POST read와 다른 실패 조건은 원표에서 확인합니다. |
등록된 컨버터 목록이 요청 read와 응답 write를 좁은 조건으로 연결한다
본문 없는 JSON GET의 매핑·반환·writer 출력 경로만 추적합니다. echo POST의 request read와 GET write는 별도 실행이며 원표에 함께 보존합니다.
FLOWCHART · HTTP MESSAGE CONVERTER · READ / WRITE
등록된 컨버터 목록이 요청 read와 응답 write를 좁은 조건으로 연결한다
이 문서의 예제에서는 요청의 Content-Type과 대상 타입으로 reader를, 응답의 Accept·produces와 반환 타입으로 writer를 고른다.
두 영역은 read와 write의 서로 다른 선택입니다. 이 fixture의 XML Accept 매핑 실패는 handler 전에 406이며, 고정 응답 미디어 타입의 writer 부재는 handler 뒤 500입니다. CSV writer가 있다고 CSV reader까지 생기는 것은 아닙니다.
WRITE · 200 JSON · CONTROLLER 1
같은 PostResponse를 Jackson 3로
GET /api/converter/posts/42 + Accept: application/json → Jackson writer가 id·title·content의 JSON을 씁니다. controller는 1회입니다.
WRITE · 200 CSV · CONTROLLER 1
좁은 custom writer로 CSV를
같은 GET에 Accept: text/csv → custom writer가 한 행을 씁니다. 정확히 PostResponse와 text/csv의 write만 지원하므로 JSON을 가로채지 않습니다.
MAPPING · 406 · CONTROLLER 0
표현 후보가 없는 XML Accept
같은 GET의 Accept: application/xml은 mapping의 JSON·CSV produces와 겹치지 않습니다. 이 fixture는 controller 진입 전에 406입니다. writer 실패와 순서를 혼동하지 않습니다.
READ · 415 · CONTROLLER 0
CSV mapping은 있지만 reader가 없다
POST /api/converter/posts/echo + Content-Type: text/csv 매핑은 존재합니다. custom converter의 canRead는 false이므로 PostResponse를 읽을 reader가 없어 415입니다.
READ · 400 · CONTROLLER 0
선택된 JSON reader의 문법 실패
같은 POST에 JSON Content-Type을 보내면 reader는 선택됩니다. 깨진 JSON을 읽지 못해 400이 되고 handler 인자가 완성되지 않습니다.
WRITE · 500 · CONTROLLER 1
고정 produces를 쓸 writer가 없다
GET /api/converter/posts/unwritable은 고정 application/x-board-post로 handler가 값을 반환합니다. 해당 타입·표현의 writer가 없어 HttpMessageNotWritableException과 500입니다.
ServerBuilder.addCustomConverter로 CSV writer를 앞에 더해도 Jackson·String·Resource converter가 남습니다. 호환 후보가 여럿이면 순서가 관여합니다. 별도 잘못된 standalone fixture의 선두 Object + */* converter는 JSON 요청을 intercepted 문자열로 가로챕니다.
ServerBuilder.addCustomConverter는 기본 목록을 보존한 채
custom writer를 추가한다. 목록 앞에 둘 때도 지원 범위를 정확히
PostResponse + text/csv write로 제한해야 JSON을 가로채지
않는다.
이 예의 조건
- 두 박스는 같은 실행의 진행 정보와 자료·결과를 관찰하는 고정 역할입니다. 박스의 좌우·위아래 위치를 별도 호출 순서로 해석하지 않습니다.
- 조건별 종료는 대안입니다. 실패 뒤 정상 후속 단계를 실행하거나 독립 비교 사례를 같은 요청으로 연결하지 않습니다.
- 요청은 Content-Type과 대상 타입으로 reader를 골라 bytes에서 Java 값을 만든 뒤 handler로 넘깁니다. 응답은 handler 반환 뒤 반환 타입과 선택된 media type에 맞는 writer가 JSON 또는 CSV를 씁니다. Accept와 mapping produces가 불일치하는 이 fixture의 406은 handler 실행 전에 발생하며 아래에서 별도로 비교합니다.
- 원 데스크톱의 여섯 노드와 네 관계를 두 영역으로 재배치한다. 요청 읽기는 Content-Type과 대상 타입으로 reader를 골라 인자를 준비하고 핸들러에 들어간다. 응답 쓰기는 핸들러 반환 타입과 선택된 미디어 타입으로 writer를 골라 JSON 또는 CSV를 쓴다. 두 영역 사이에는 연결선이 없다. GET의 응답 쓰기와 echo POST의 요청 읽기를 비교하며 모든 요청이 두 경로를 연속 실행한다고 주장하지 않는다. 실제 실패 사례의 위치·호출 횟수는 아래 설명을 따른다.
관계도의 보충 설명
| 순번 | 원문 표시 |
|---|---|
| 2 | 요청 읽기는 Content-Type과 대상 타입, 응답 쓰기는 반환 타입과 선택한 media type으로 converter를 선택합니다. 독립 fixture에서 JSON/CSV GET은 200·controller 1, XML Accept mapping 406·CSV reader 없음 415·깨진 JSON 400은 controller 0입니다. 고정 custom produces의 writer 없음 500은 controller 1입니다. CSV custom은 PostResponse와 text/csv의 write만 지원하고 기본 목록을 보존합니다. |
| 6 | 요청은 Content-Type과 대상 타입으로 reader를 골라 bytes에서 Java 값을 만든 뒤 handler로 넘깁니다. 응답은 handler 반환 뒤 반환 타입과 선택된 media type에 맞는 writer가 JSON 또는 CSV를 씁니다. Accept와 mapping produces가 불일치하는 이 fixture의 406은 handler 실행 전에 발생하며 아래에서 별도로 비교합니다. |
| 장면 | 관찰 값 | 전환 이유 |
|---|---|---|
| GET 표현 매핑 본문 없는 GET /api/converter/posts/42가 Accept: application/json으로 JSON produces 매핑을 선택합니다. | 선택 경로: JSON GET 응답 쓰기 · 요청 reader: GET에 요청 본문이 없어 실행하지 않음 | echo POST의 request read는 별도 endpoint이며 이 GET의 선행 단계로 연결하지 않습니다. |
| controller 반환 선택된 GET controller가 한 번 실행되어 PostResponse를 반환합니다. | controller: 1회 · 반환 값: PostResponse · 응답 표현: application/json | 반환 타입과 선택된 미디어 타입이 응답 writer 선택의 입력이 됩니다. |
| JSON writer 출력 등록된 Jackson writer가 PostResponse의 JSON을 써 200 응답을 만듭니다. | writer: PostResponse·application/json 호환 후보 · 응답: 200·JSON · CSV custom: JSON을 가로채지 않는 좁은 write 범위 | Java 반환값이 JSON 응답 바이트로 바뀝니다. 독립 POST read와 다른 실패 조건은 원표에서 확인합니다. |
그림은 이 문서의 예제 경로만 보여 줍니다. 요청에서는 Content-Type과 메서드 인자의 대상 타입으로 reader를 고르고, 응답에서는 Accept와 produces가 만든 표현 후보를 반환 타입과 함께 writer에 대조합니다. 호환 후보가 여럿이면 목록 순서가 관여하므로 사용자 정의 converter는 지원 범위를 좁혀야 합니다.
요청을 읽는 형식과 응답받을 형식은 독립입니다. Content-Type은 보낸 본문의 형식이고 Accept는 받고 싶은 응답 표현입니다. consumes·produces는 해당 컨트롤러 매핑이 허용하는 입력·출력 범위를 먼저 제한합니다. produces에 형식을 적었다고 그 형식을 쓸 converter가 자동으로 생성되는 것은 아닙니다. 반대로 GET에 요청 본문이 없다면 응답 writer를 고르려고 request reader부터 실행해야 하는 것도 아닙니다.
이 문서는 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 구성을 classes로 지정하므로 테스트 실행에 별도의 메인 클래스 선택이 필요하지 않습니다.
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 방언, 스프레드시트 수식 주입 방어, 파일 다운로드 정책을 구현했다고 주장하지 않습니다.
필드 a,"b"를 넣으면 내부 따옴표를 두 번 쓰고 필드 전체를 인용하므로 결과는 "a,""b"""입니다. 쉼표가 포함된 필드를 단순히 쉼표로 나누면 원래 한 값을 두 필드로 오해합니다. 이 예제의 줄 구분은 \n으로 고정되어 있으며, RFC 4180의 CRLF 레코드 구분과 인용 규칙을 전부 구현한 파일 형식으로 확대해서 주장하지 않습니다.
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 계약으로 연결합니다.
기존 연습 풀이와 표현 선택 조건 확인하기
기존 연습의 구체 조건. WeeklyPostStats(String week, int posts)의 고정 조회 값은 ("2026-W40", 3)으로 둡니다. 새 GET 매핑은 /api/stats/weekly에서 JSON·CSV를 허용하고, POST echo 매핑은 같은 URI에서 CSV를 입력받아 JSON으로 반환하도록 선언합니다. 각 요청 전 controller probe는 0으로 초기화합니다. CSV POST는 Content-Type: text/csv, Accept: application/json과 비어 있지 않은 week,posts\n2026-W40,3\n 본문을 보냅니다.
풀이. PostCsvHttpMessageConverter 구조를 따라 통계용 converter를 만들되 supports는 정확히 WeeklyPostStats.class, 지원 미디어 타입은 text/csv, canRead는 항상 false로 둡니다. writeInternal은 week,posts\n 헤더 뒤에 Csv.field(stats.week()) + "," + stats.posts() + "\n"을 UTF-8로 씁니다. 따라서 이 고정 값의 CSV 본문은 week,posts\n2026-W40,3\n입니다. 여기서 \n은 실제 줄바꿈을 나타내는 표기입니다. writer가 통계 타입을 지원해도 기존 게시물 타입까지 지원하는 것은 아닙니다.
ServerBuilder.addCustomConverter로 이 converter를 추가한 뒤 Jackson·String·Resource converter가 남았는지 실제 목록을 검사합니다. GET의 JSON·CSV 요청은 각각 200과 controller 1회이고, XML만 허용하는 Accept는 produces와 겹치지 않아 406·0회입니다. CSV POST echo는 매핑이 존재해도 @RequestBody WeeklyPostStats를 읽을 reader가 없으므로 415·0회입니다. POST 매핑을 아예 만들지 않고 415를 기대하면 reader가 아닌 매핑 경계를 검사하는 잘못된 테스트가 됩니다.
조건 2. 원래 PostCsvHttpMessageConverter의 제목 필드에 a,"b"를 넣고 내용 필드는 plain으로 둡니다. id가 42일 때 헤더 다음 한 행을 구하세요.
풀이. 제목은 내부 따옴표를 두 번 쓴 뒤 전체를 인용하므로 행은 42,"a,""b""",plain\n입니다. 인용된 제목 안의 쉼표는 새 열의 구분자가 아닙니다. 같은 정책에서 제목이 빈 문자열이면 그 필드는 비어 있지만 행의 열은 그대로 유지되어 42,,plain\n이 됩니다. 원문의 Csv.field와 UTF-8 문자열 계산을 직접 확인할 수 있습니다.
조건 3. 원래 /api/converter/posts/echo에 문법이 올바른 JSON PostResponse를 보내되 Content-Type: application/json, Accept: text/csv로 지정합니다. 기존 매핑과 converter 목록을 그대로 두면 CSV writer가 요청을 처리할까요?
풀이. JSON 입력용 echo 매핑의 produces는 application/json뿐이므로 CSV Accept와 겹치지 않습니다. 이 조건에서는 매핑 단계의 406과 controller 0회를 기대하며, JSON을 읽어 반환값을 CSV writer에 넘기는 단계까지 가지 않습니다. GET의 JSON·CSV produces와 POST echo의 JSON 전용 produces를 구별해야 합니다. 입력이 JSON이라는 사실만으로 JSON 응답을 받기로 한 것은 아닙니다.