본문으로 건너뛰기
안동민 개발노트 아이콘

안동민 개발노트

본문 시작
6장 : Spring MVC 요청·응답

HTTP 메시지 변환기

Spring 프레임워크 7 Jackson 3 JSON 컨버터와 쓰기 전용 CSV 컨버터의 canWrite 선택을 구현하고 수락에 따른 200·406과 컨버터 등록 순서를 검증합니다.

@ResponseBody PostResponse가 JSON이 되는 것은 컨트롤러가 JSON을 직접 썼기 때문이 아닙니다. 반환 값 핸들러가 Java 타입과 협상된 미디어 타입을 쓸 수 있는 HttpMessageConverter를 찾고 컨버터가 본문 바이트를 만듭니다. 요청 본문은 반대로 Content-Type과 대상 Java 타입을 읽을 컨버터를 찾습니다.


컨버터 선택 조건

응답 흐름은 다음 질문을 통과합니다.

  1. 핸들러의 produces 조건과 클라이언트 Accept가 겹치는가.
  2. 가능한 미디어 타입 중 어느 표현을 선택하는가.
  3. 컨버터가 반환 Java 타입과 미디어 타입을 쓸 수 있는가.
  4. 선택된 컨버터가 헤더와 본문을 작성할 수 있는가.

JSON 컨버터는 Jackson 3 매퍼로 객체를 application/json에 쓰고, 문자열 컨버터는 문자열을 텍스트 미디어 타입에 씁니다. 같은 문자열이 JSON 텍스트인지 평문 텍스트인지 모호하게 만들지 않고 DTO와 produces로 의도를 드러냅니다.

요청에서는 consumesContent-Type이 맞아도 컨버터가 대상 DTO를 읽지 못하면 415 또는 메시지 변환 오류가 됩니다. 읽은 뒤 JSON 문법·타입 불일치는 400입니다.

CSV 응답 컨버터

PostResponsetext/csv로 쓰는 최소 컨버터입니다.

src/main/java/board/web/PostCsvHttpMessageConverter.java
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 처리가 사라질 수 있습니다. 기존 목록을 확장합니다.

src/main/java/board/config/WebConverterConfig.java
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 매퍼를 혼용하지 않습니다.

표현 후보 선언

src/main/java/board/web/NegotiatedPostController.java
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 협상 검증

src/test/java/board/web/MessageConverterSelectionTest.java
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/jsontext/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 컨트롤러 하나에 연결하고 리포지토리 변경 결과까지 수직으로 검증합니다.