Spring HttpMessageConverter 심화

HttpMessageConverter란?

Spring MVC에서 HTTP 요청 본문을 Java 객체로 변환하고(@RequestBody), Java 객체를 HTTP 응답 본문으로 직렬화(@ResponseBody)하는 핵심 인터페이스가 HttpMessageConverter입니다. Content-TypeAccept 헤더를 기반으로 적절한 컨버터를 자동 선택합니다.

public interface HttpMessageConverter<T> {
    boolean canRead(Class<?> clazz, MediaType mediaType);
    boolean canWrite(Class<?> clazz, MediaType mediaType);
    List<MediaType> getSupportedMediaTypes();
    T read(Class<? extends T> clazz, HttpInputMessage inputMessage);
    void write(T t, MediaType contentType, HttpOutputMessage outputMessage);
}

Spring Boot는 클래스패스에 따라 Jackson, Gson, JAXB 등의 컨버터를 자동 등록합니다. 하지만 CSV 내보내기, Protocol Buffers, 커스텀 바이너리 포맷 등은 직접 구현해야 합니다.

기본 등록 컨버터 동작 순서

Spring Boot가 자동 등록하는 컨버터들과 우선순위를 이해해야 예상치 못한 직렬화 문제를 디버깅할 수 있습니다.

컨버터 MediaType 용도
ByteArrayHttpMessageConverter */* byte[] 처리
StringHttpMessageConverter text/plain String 처리 (UTF-8)
ResourceHttpMessageConverter */* Resource (파일 다운로드)
MappingJackson2HttpMessageConverter application/json JSON 직렬화/역직렬화
Jaxb2RootElementHttpMessageConverter application/xml XML (JAXB 존재 시)

컨버터는 등록 순서대로 canRead()/canWrite()를 호출하여 첫 번째로 매칭되는 컨버터를 사용합니다. 커스텀 컨버터의 우선순위가 중요한 이유입니다.

실전: CSV HttpMessageConverter 구현

엑셀 대신 CSV 내보내기 API를 만들 때, 커스텀 컨버터로 깔끔하게 구현할 수 있습니다.

@Component
public class CsvHttpMessageConverter<T>
        extends AbstractHttpMessageConverter<List<T>> {

    private static final MediaType TEXT_CSV =
        new MediaType("text", "csv", StandardCharsets.UTF_8);

    public CsvHttpMessageConverter() {
        super(TEXT_CSV);
    }

    @Override
    protected boolean supports(Class<?> clazz) {
        return List.class.isAssignableFrom(clazz);
    }

    @Override
    protected List<T> readInternal(
            Class<? extends List<T>> clazz,
            HttpInputMessage inputMessage) {
        throw new UnsupportedOperationException("CSV 읽기 미지원");
    }

    @Override
    protected void writeInternal(
            List<T> data,
            HttpOutputMessage outputMessage) throws IOException {

        outputMessage.getHeaders().setContentType(TEXT_CSV);
        outputMessage.getHeaders().set(
            "Content-Disposition",
            "attachment; filename="export.csv""
        );

        try (var writer = new OutputStreamWriter(
                outputMessage.getBody(), StandardCharsets.UTF_8)) {

            if (data.isEmpty()) return;

            // 헤더 추출 (리플렉션)
            Field[] fields = data.get(0).getClass().getDeclaredFields();
            String header = Arrays.stream(fields)
                .map(Field::getName)
                .collect(Collectors.joining(","));
            writer.write(header + "n");

            // 데이터 행
            for (T item : data) {
                String row = Arrays.stream(fields)
                    .peek(f -> f.setAccessible(true))
                    .map(f -> {
                        try {
                            Object val = f.get(item);
                            return val != null ? escapeCsv(val.toString()) : "";
                        } catch (IllegalAccessException e) {
                            return "";
                        }
                    })
                    .collect(Collectors.joining(","));
                writer.write(row + "n");
            }
        }
    }

    private String escapeCsv(String value) {
        if (value.contains(",") || value.contains(""") || value.contains("n")) {
            return """ + value.replace(""", """") + """;
        }
        return value;
    }
}

컨트롤러에서는 produces로 MediaType만 지정하면 자동으로 CSV 컨버터가 선택됩니다.

@GetMapping(value = "/users/export", produces = "text/csv")
public List<UserDto> exportUsers() {
    return userService.findAll();
}
// Accept: text/csv → CsvHttpMessageConverter 자동 선택

Content Negotiation 전략

같은 엔드포인트에서 JSON과 CSV를 모두 지원하려면 Content Negotiation을 활용합니다.

@GetMapping(value = "/users",
    produces = { "application/json", "text/csv" })
public List<UserDto> getUsers() {
    return userService.findAll();
}
// Accept: application/json → Jackson 컨버터
// Accept: text/csv         → CSV 컨버터

URL 확장자 기반 협상도 가능합니다.

@Configuration
public class WebConfig implements WebMvcConfigurer {

    @Override
    public void configureContentNegotiation(
            ContentNegotiationConfigurer configurer) {
        configurer
            .favorParameter(true)
            .parameterName("format")
            .defaultContentType(MediaType.APPLICATION_JSON)
            .mediaType("json", MediaType.APPLICATION_JSON)
            .mediaType("csv", MediaType.valueOf("text/csv"))
            .mediaType("xml", MediaType.APPLICATION_XML);
    }
}
// GET /users?format=csv → CSV 응답
// GET /users?format=json → JSON 응답

Protocol Buffers 컨버터

마이크로서비스 간 통신에서 Protobuf는 JSON 대비 직렬화 크기 3~10배 감소, 파싱 속도 5~100배 향상을 제공합니다. Spring은 ProtobufHttpMessageConverter를 기본 제공합니다.

// build.gradle
implementation 'com.google.protobuf:protobuf-java:3.25.0'
implementation 'com.google.protobuf:protobuf-java-util:3.25.0'

// 설정
@Configuration
public class ProtobufConfig {
    @Bean
    public ProtobufHttpMessageConverter protobufConverter() {
        return new ProtobufHttpMessageConverter();
    }
}

// 컨트롤러
@GetMapping(value = "/users/{id}",
    produces = { "application/x-protobuf", "application/json" })
public UserProto.User getUser(@PathVariable Long id) {
    User user = userService.findById(id);
    return UserProto.User.newBuilder()
        .setId(user.getId())
        .setName(user.getName())
        .setEmail(user.getEmail())
        .build();
}

클라이언트가 Accept: application/x-protobuf로 요청하면 바이너리 Protobuf로, Accept: application/json이면 JSON으로 응답합니다. 동일 엔드포인트에서 두 포맷을 모두 지원하는 패턴입니다.

컨버터 등록과 우선순위 제어

커스텀 컨버터의 등록 위치에 따라 동작이 달라집니다.

@Configuration
public class WebConfig implements WebMvcConfigurer {

    // 방법 1: 기존 컨버터 뒤에 추가
    @Override
    public void extendMessageConverters(
            List<HttpMessageConverter<?>> converters) {
        converters.add(new CsvHttpMessageConverter<>());
    }

    // 방법 2: 기존 컨버터 앞에 추가 (우선순위 높음)
    @Override
    public void extendMessageConverters(
            List<HttpMessageConverter<?>> converters) {
        converters.add(0, new CsvHttpMessageConverter<>());
    }

    // 방법 3: 기존 컨버터 전부 교체 (주의!)
    @Override
    public void configureMessageConverters(
            List<HttpMessageConverter<?>> converters) {
        converters.add(new MappingJackson2HttpMessageConverter());
        converters.add(new CsvHttpMessageConverter<>());
        // 기본 컨버터가 모두 사라짐!
    }
}
  • extendMessageConverters: 기본 컨버터 유지 + 추가 (권장)
  • configureMessageConverters: 기본 컨버터 제거 후 수동 등록 (세밀한 제어 필요 시)
  • 인덱스 0에 추가하면 canWrite()에서 먼저 매칭되어 우선순위가 높아짐

디버깅: 406 Not Acceptable 해결

클라이언트의 Accept 헤더와 매칭되는 컨버터가 없으면 406 에러가 발생합니다. 디버깅 방법:

// 현재 등록된 컨버터 목록 확인
@RestController
public class DebugController {

    @Autowired
    private RequestMappingHandlerAdapter adapter;

    @GetMapping("/debug/converters")
    public List<String> listConverters() {
        return adapter.getMessageConverters().stream()
            .map(c -> c.getClass().getSimpleName()
                + " → " + c.getSupportedMediaTypes())
            .toList();
    }
}

// 로그 레벨 조정
// application.yml
logging:
  level:
    org.springframework.web.servlet.mvc.method.annotation: DEBUG
    org.springframework.http.converter: TRACE

TRACE 로그에서 “Writing […]” 또는 “No converter found” 메시지로 어떤 컨버터가 선택(또는 실패)되었는지 확인할 수 있습니다.

관련 글

위로 스크롤
WordPress Appliance - Powered by TurnKey Linux