HTTP 메시지 변환기
Spring 프레임워크 7 Jackson 3 JSON 컨버터와 쓰기 전용 CSV 컨버터의 canWrite 선택을 구현하고 수락에 따른 200·406과 컨버터 등록 순서를 검증합니다.
@ResponseBody PostResponse가 JSON이 되는 것은 컨트롤러가 JSON을 직접 썼기 때문이 아닙니다.
반환 값 핸들러가 Java 타입과 협상된 미디어 타입을 쓸 수 있는 HttpMessageConverter를 찾고 컨버터가 본문 바이트를 만듭니다.
요청 본문은 반대로 Content-Type과 대상 Java 타입을 읽을 컨버터를 찾습니다.
컨버터 선택 조건
응답 흐름은 다음 질문을 통과합니다.
- 핸들러의
produces조건과 클라이언트Accept가 겹치는가. - 가능한 미디어 타입 중 어느 표현을 선택하는가.
- 컨버터가 반환 Java 타입과 미디어 타입을 쓸 수 있는가.
- 선택된 컨버터가 헤더와 본문을 작성할 수 있는가.
JSON 컨버터는 Jackson 3 매퍼로 객체를 application/json에 쓰고, 문자열 컨버터는 문자열을 텍스트 미디어 타입에 씁니다.
같은 문자열이 JSON 텍스트인지 평문 텍스트인지 모호하게 만들지 않고 DTO와 produces로 의도를 드러냅니다.
요청에서는 consumes와 Content-Type이 맞아도 컨버터가 대상 DTO를 읽지 못하면 415 또는 메시지 변환 오류가 됩니다.
읽은 뒤 JSON 문법·타입 불일치는 400입니다.
CSV 응답 컨버터
PostResponse를 text/csv로 쓰는 최소 컨버터입니다.
package board.web;
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 =
MediaType.parseMediaType(
"text/csv;charset=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 PostResponse.class
.isAssignableFrom(type);
}
@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.escape(post.title()) + ","
+ Csv.escape(post.content()) + "\\n";
output.getBody().write(
csv.getBytes(UTF_8));
}
}읽기를 지원하지 않는다는 규칙을 canRead=false로 명시합니다.
readInternal 예외는 방어일 뿐 정상 선택 경로에서 호출되지 않습니다.
실제 CSV는 따옴표·줄바꿈·수식 주입·열 스키마를 검증된 라이브러리와 명시적 정책으로 다룹니다.
기본 컨버터 확장
WebMvcConfigurer.configureMessageConverters를 잘못 재정의해 기본 목록을 비우면 JSON과 문자열, Resource 처리가 사라질 수 있습니다.
기존 목록을 확장합니다.
package board.config;
import java.util.List;
import org.springframework.context.annotation.Configuration;
import org.springframework.http.converter
.HttpMessageConverter;
import org.springframework.web.servlet.config.annotation
.WebMvcConfigurer;
import board.web
.PostCsvHttpMessageConverter;
@Configuration(proxyBeanMethods = false)
class WebConverterConfig implements WebMvcConfigurer {
@Override
public void extendMessageConverters(
List<HttpMessageConverter<?>>
converters) {
converters.add(
0,
new PostCsvHttpMessageConverter());
}
}컨버터 순서는 겹치는 canWrite 후보가 있을 때 영향을 줄 수 있습니다.
사용자 정의 컨버터가 Object.class와 */*를 지원하면 거의 모든 응답을 가로챕니다.
가장 좁은 Java 타입과 미디어 타입을 선언합니다.
Boot 4의 JSON 컨버터는 Spring 프레임워크 7의 Jackson 3 지원을 사용합니다.
예전 MappingJackson2HttpMessageConverter를 신규 코드의 기본으로 추가해 Jackson 2와 3 매퍼를 혼용하지 않습니다.
표현 후보 선언
package board.web;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
@RestController
@RequestMapping("/api/posts")
class NegotiatedPostController {
private final PostQuery query;
NegotiatedPostController(PostQuery query) {
this.query = query;
}
@GetMapping(
path = "/{id}",
produces = {
"application/json",
"text/csv"
})
PostResponse get(
@PathVariable long id) {
return PostResponse.from(
query.required(id));
}
}컨트롤러는 CSV 문자열을 만들지 않습니다.
같은 PostResponse를 반환하고 선택된 컨버터가 표현을 작성합니다.
인가와 쿼리가 표현별 핸들러에 중복되지 않습니다.
JSON·CSV·XML 협상 검증
package board.web;
import static org.springframework.http.MediaType
.APPLICATION_JSON;
import org.junit.jupiter.api.Test;
import org.springframework.http.MediaType;
import org.springframework.http.converter.json
.JacksonJsonHttpMessageConverter;
import org.springframework.test.web.servlet.client
.RestTestClient;
class MessageConverterSelectionTest {
@Test
void Accept와_반환_type으로_converter를_선택한다() {
var controller =
new NegotiatedPostController(
new FixedPostQuery(
post(
42L,
"JSON, CSV",
50)));
var client = RestTestClient
.bindToController(controller)
.configureServer(builder ->
builder.setMessageConverters(
new PostCsvHttpMessageConverter(),
new JacksonJsonHttpMessageConverter()))
.build();
client.get()
.uri("/api/posts/42")
.accept(APPLICATION_JSON)
.exchange()
.expectStatus().isOk()
.expectHeader()
.contentTypeCompatibleWith(APPLICATION_JSON)
.expectBody()
.jsonPath("$.id").isEqualTo(42);
client.get()
.uri("/api/posts/42")
.accept(MediaType.parseMediaType("text/csv"))
.exchange()
.expectStatus().isOk()
.expectHeader()
.contentTypeCompatibleWith("text/csv")
.expectBody(String.class)
.isEqualTo("""
id,title,content
42,"JSON, CSV","같은 게시글을 CSV로 표현"
""");
client.get()
.uri("/api/posts/42")
.accept(MediaType.APPLICATION_XML)
.exchange()
.expectStatus().isEqualTo(406);
}
}MessageConverterSelectionTest
> Accept와_반환_type으로_converter를_선택한다() PASSED
application/json -> JacksonJsonHttpMessageConverter
text/csv -> PostCsvHttpMessageConverter
application/xml -> 406
BUILD SUCCESSFUL컨버터 오류 진단
| 증상 | 후보 단계 | 확인 |
|---|---|---|
| 406 | 응답 미디어 타입 없음 | 수락·produces |
| 415 | 요청 리더 없음 | Content-Type·consumes·canRead |
| 400 JSON | 리더 선택 후 파싱 실패 | 본문 문법·필드 타입 |
| 500 쓰기 지원 없음 | 반환 타입 작성기 부재 | 컨버터 등록·canWrite |
| 텍스트인데 JSON 헤더 | 넓은 사용자 정의 컨버터 | 지원 미디어 타입·순서 |
컨버터 로그를 트레이스로 오래 켜기보다 실패 요청을 작은 MVC 테스트로 재현하고 컨버터 목록과 canRead/canWrite 조건을 확인합니다. 애플리케이션 오류를 컨버터 500으로 숨기지 않습니다.
스트리밍·반복 쓰기
일부 컨버터는 콘텐츠 길이 계산이나 조건부 응답을 위해 반복 가능한 쓰기를 고려합니다. 큰 내보내기를 메모리에 완성해 길이를 계산할지 스트리밍할지 선택해야 합니다. 스트리밍은 첫 바이트 커밋 뒤 오류 상태로 전환할 수 없습니다.
파일 다운로드에는 ResourceHttpMessageConverter와 범위 요청 지원을 검토합니다.
대용량 바이트 배열을 DTO로 감싸 JSON 기반64로 보내지 않습니다.
연습 문제
주간 게시글 통계 DTO에 application/json과 text/csv 컨버터를 추가하세요.
CSV 컨버터는 응답만 지원하고 요청 CSV는 415여야 합니다.
잘못 만든 Object + */* 컨버터가 JSON을 가로채는 실패를 재현한 뒤 지원 타입과 미디어 타입을 좁힙니다.
해설 보기
실패 컨버터를 목록 맨 앞에 두고 canWrite(Object.class, application/json)이 true가 되는지 확인합니다.
그 결과 JSON 요청이 사용자 정의 텍스트 작성기로 처리되는 것을 테스트로 재현합니다.
수정 컨버터는 정확한 DTO와 text/csv만 지원합니다.
@Override
protected boolean supports(Class<?> type) {
return WeeklyPostStats.class
.isAssignableFrom(type);
}
@Override
protected boolean canWrite(MediaType mediaType) {
return mediaType != null
&& TEXT_CSV.isCompatibleWith(mediaType);
}기본 컨버터를 삭제하지 않고 extendMessageConverters로 추가합니다.
JSON·CSV·지원하지 않는 XML 세 회귀 테스트를 함께 둡니다.
다음 문서에서는 지금까지의 매핑·바인딩·검증·컨버터·상태를 실제 게시판 CRUD 컨트롤러 하나에 연결하고 리포지토리 변경 결과까지 수직으로 검증합니다.