본문 바로가기

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.10의 HttpMessageConverter 소스에 그대로 드러납니다.

등록된 컨버터 목록이 요청 read와 응답 write를 좁은 조건으로 연결한다

본문 없는 JSON GET의 매핑·반환·writer 출력 경로만 추적합니다. echo POST의 request read와 GET write는 별도 실행이며 원표에 함께 보존합니다.

요청읽기는Content-Type과대상타입,응답쓰기는반환타입과선택한media type으로converter를선택합니다. 독립fixture에서JSON/CSV GET은200·controller1,XMLAccept mapping406·CSVreader없음415·깨진JSON400은controller0입니다. 고정customproduces의writer없음500은controller1입니다. CSVcustom은PostResponse와text/csv의write만지원하고기본목록을보존합니다.

FLOWCHART · HTTP MESSAGE CONVERTER · READ / WRITE

등록된 컨버터 목록이 요청 read와 응답 write를 좁은 조건으로 연결한다

이 문서의 예제에서는 요청의 Content-Type과 대상 타입으로 reader를, 응답의 Accept·produces와 반환 타입으로 writer를 고른다.

메시지 converter의 읽기와 쓰기요청은Content-Type과대상타입으로reader를골라bytes에서Java값을만든뒤handler로넘깁니다. 응답은handler반환뒤반환타입과선택된media type에맞는writer가JSON또는CSV를씁니다. Accept와mappingproduces가불일치하는이fixture의406은handler실행전에발생하며아래에서별도로비교합니다.REQUEST READ · handler 실행 전Content-Type대상 Java 타입호환 readerbytes → Java 값argument 준비handler 진입RESPONSE WRITE · handler 반환 뒤handler 반환PostResponse반환 타입선택한 media type호환 writerJSON 또는 CSV
컨버터의 요청 읽기와 응답 쓰기 영역원 데스크톱의 여섯 노드와 네 관계를 두 영역으로 재배치한다. 요청 읽기는 Content-Type과 대상 타입으로 reader를 골라 인자를 준비하고 핸들러에 들어간다. 응답 쓰기는 핸들러 반환 타입과 선택된 미디어 타입으로 writer를 골라 JSON 또는 CSV를 쓴다. 두 영역 사이에는 연결선이 없다. GET의 응답 쓰기와 echo POST의 요청 읽기를 비교하며 모든 요청이 두 경로를 연속 실행한다고 주장하지 않는다. 실제 실패 사례의 위치·호출 횟수는 아래 설명을 따른다.요청 read · 실행 전응답 write · 반환 뒤Content-Type대상 Java 타입호환 readerbytes → Java 값argument 준비handler 진입handler 반환PostResponse반환 타입선택한 media type호환 writerJSON 또는 CSV

두 영역은 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는 별도 실행이며 원표에 함께 보존합니다.

요청읽기는Content-Type과대상타입,응답쓰기는반환타입과선택한media type으로converter를선택합니다. 독립fixture에서JSON/CSV GET은200·controller1,XMLAccept mapping406·CSVreader없음415·깨진JSON400은controller0입니다. 고정customproduces의writer없음500은controller1입니다. CSVcustom은PostResponse와text/csv의write만지원하고기본목록을보존합니다.

FLOWCHART · HTTP MESSAGE CONVERTER · READ / WRITE

등록된 컨버터 목록이 요청 read와 응답 write를 좁은 조건으로 연결한다

이 문서의 예제에서는 요청의 Content-Type과 대상 타입으로 reader를, 응답의 Accept·produces와 반환 타입으로 writer를 고른다.

메시지 converter의 읽기와 쓰기요청은Content-Type과대상타입으로reader를골라bytes에서Java값을만든뒤handler로넘깁니다. 응답은handler반환뒤반환타입과선택된media type에맞는writer가JSON또는CSV를씁니다. Accept와mappingproduces가불일치하는이fixture의406은handler실행전에발생하며아래에서별도로비교합니다.REQUEST READ · handler 실행 전Content-Type대상 Java 타입호환 readerbytes → Java 값argument 준비handler 진입RESPONSE WRITE · handler 반환 뒤handler 반환PostResponse반환 타입선택한 media type호환 writerJSON 또는 CSV
컨버터의 요청 읽기와 응답 쓰기 영역원 데스크톱의 여섯 노드와 네 관계를 두 영역으로 재배치한다. 요청 읽기는 Content-Type과 대상 타입으로 reader를 골라 인자를 준비하고 핸들러에 들어간다. 응답 쓰기는 핸들러 반환 타입과 선택된 미디어 타입으로 writer를 골라 JSON 또는 CSV를 쓴다. 두 영역 사이에는 연결선이 없다. GET의 응답 쓰기와 echo POST의 요청 읽기를 비교하며 모든 요청이 두 경로를 연속 실행한다고 주장하지 않는다. 실제 실패 사례의 위치·호출 횟수는 아래 설명을 따른다.요청 read · 실행 전응답 write · 반환 뒤Content-Type대상 Java 타입호환 readerbytes → Java 값argument 준비handler 진입handler 반환PostResponse반환 타입선택한 media type호환 writerJSON 또는 CSV

두 영역은 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)를 골라 바이트에서 Java 값을 만들고 핸들러에 넘깁니다. 아래쪽의 응답 쓰기는 핸들러가 반환한 뒤, 반환 타입과 선택된 미디어 타입에 맞는 라이터(writer)가 JSON이나 CSV를 씁니다. 두 영역은 서로 다른 선택이며 모든 요청이 두 경로를 이어서 거친다는 뜻은 아닙니다. 호환되는 후보가 여럿이면 목록의 순서가 관여하므로, 직접 만든 컨버터는 지원 범위를 좁혀야 합니다.

요청을 읽는 형식과 응답받을 형식은 서로 독립입니다. Content-Type은 보낸 본문의 형식이고 Accept는 받고 싶은 응답 표현입니다. consumes와 produces는 그 컨트롤러 매핑이 허용하는 입력과 출력의 범위를 먼저 제한합니다. produces에 형식을 적는다고 그 형식을 쓸 컨버터가 저절로 생기지는 않으며, 반대로 요청 본문이 없는 GET이라면 응답 라이터를 고르려고 요청 리더부터 실행할 필요도 없습니다.

이 글은 컨버터 선택과 표현 협상(content negotiation)을 다룹니다. JSON 필드 바인딩과 Bean Validation의 자세한 규칙은 요청 본문·JSON 변환에서, 컨트롤러 반환 값의 뷰·본문·리다이렉트 의미는 뷰·리다이렉트·응답 본문에서, CRUD와 ETag는 게시판 CRUD 웹에서, 장애를 거슬러 추적하는 절차와 ProblemDetail은 MVC 실패 경계 진단에서 다룹니다. 다운로드와 Range, 스트리밍, 완전한 CSV 표준 구현은 이 예제의 범위가 아닙니다.


세 글이 함께 쓸 실행 그래프를 정합니다

6장 6편부터 8편까지의 예제는 하나의 Gradle 프로젝트를 함께 씁니다. Java 25와 Gradle 9.8.2에서 Spring Boot 4.1.2 BOM을 쓰며, 이 그래프가 Spring Framework 7.0.10과 Jackson 3.1.7, JUnit 6.0.3을 정합니다. Boot 플러그인 없이 Java 테스트 그래프로 구성하고, 각 테스트가 쓸 Boot 구성을 classes로 지정하므로 테스트를 실행할 때 메인 클래스를 따로 고를 필요가 없습니다.

settings.gradle
rootProject.name = 'mvc-boundary-contracts'
build.gradle
plugins { id 'java' }

java { toolchain { languageVersion = JavaLanguageVersion.of(25) } }

repositories { mavenCentral() }

dependencies {
    implementation platform('org.springframework.boot:spring-boot-dependencies:4.1.2')
    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.2')
    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에 함께 스캔되지 않습니다.

src/main/java/board/converter/ConverterTestApplication.java
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를 선택된 라이터에 넘깁니다.

src/main/java/board/converter/PostResponse.java
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의 생성자는 식별자가 양수인지와 두 문자열이 null이 아닌지만 검사하며, 자신이 JSON으로 쓰일지 CSV로 쓰일지는 알지 못합니다. 표현 형식은 뒤에서 선택된 컨버터가 정합니다. 조회도 정확히 같은 PostResponse를 돌려주므로, 표에 적은 타입과 구현이 어긋나지 않습니다.

src/main/java/board/converter/PostQuery.java
package board.converter;

@FunctionalInterface
public interface PostQuery {
    PostResponse required(long id);
}

메서드 이름 required는 게시글이 반드시 있어야 한다는 뜻이므로, 아래 고정 구현은 42가 아닌 식별자에 Optional이나 null 대신 IllegalArgumentException을 던집니다. 조회 값이 테스트 순서나 외부 상태에 흔들리지 않도록, 게시글 하나만 가진 고정 조회를 씁니다.

src/main/java/board/converter/FixedPostQuery.java
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;
    }
}

응답 상태만 보면 컨버터 이전에 컨트롤러가 실행되었는지 구분할 수 없습니다. 그래서 테스트 전용 프로브(probe)를 별도 객체로 두고, 사례마다 자신의 호출 횟수를 초기화합니다.

src/main/java/board/converter/ConverterProbe.java
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);
    }
}

대표적인 컨버터의 역할을 표로 정리하면 다음과 같습니다.

컨버터이 예제에서 보는 Java 타입대표 미디어 타입요청 읽기응답 쓰기
StringHttpMessageConverterString기본 응답 text/plain지원지원
JacksonJsonHttpMessageConverter레코드와 객체application/json, application/*+json지원지원
PostCsvHttpMessageConverter정확히 PostResponsetext/csv미지원지원

Spring Framework 7의 JacksonJsonHttpMessageConverter는 Jackson 3의 JsonMapper를 기반으로 합니다. 새로 만드는 Boot 4 예제에는 Jackson 2용 컨버터나 직접 만든 매퍼를 섞지 않습니다.


CSV 라이터의 범위를 타입과 미디어 타입으로 좁힙니다

먼저 CSV의 구분자와 따옴표를 최소한으로 다루는 작은 정책을 따로 둡니다. 이 함수는 쉼표나 따옴표, CR, LF가 있는 필드를 따옴표로 감싸고 안쪽의 따옴표를 두 번 씁니다. 완전한 CSV 방언이나 스프레드시트 수식 주입 방어, 파일 다운로드 정책을 구현한 것은 아닙니다.

필드 a,"b"를 넣으면 안쪽 따옴표를 두 번 쓰고 필드 전체를 감싸므로 결과는 "a,""b"""입니다. 쉼표가 들어 있는 필드를 단순히 쉼표로 나누면 원래 한 값을 두 필드로 잘못 읽게 됩니다. 이 예제의 줄 구분은 \n으로 고정되어 있으므로, RFC 4180의 CRLF 레코드 구분과 인용 규칙을 모두 구현한 파일 형식으로 보지 않습니다.

src/main/java/board/converter/Csv.java
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;
    }
}

직접 만든 컨버터는 응답 쓰기만 지원합니다. canRead를 false로 고정하므로 text/csv 요청을 PostResponse로 읽는 경로는 생기지 않으며, readInternal의 예외는 정상적인 선택 경로에서 호출되지 않는 방어 코드입니다. supports도 Object나 임의의 컬렉션이 아니라 정확히 PostResponse만 허용합니다.

src/main/java/board/converter/PostCsvHttpMessageConverter.java
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));
    }
}

기본 컨버터 목록을 보존하며 확장합니다

라이터 하나만 더하려면 기본 목록을 다시 조립할 이유가 없습니다. Spring Framework 7.0.10의 WebMvcConfigurer.configureMessageConverters(HttpMessageConverters.ServerBuilder)는 기본 등록을 유지한 채 직접 만든 컨버터를 더하는 현재의 확장점입니다.

CSV 컨버터를 앞에 두되 지원 타입과 미디어 타입을 좁혔으므로, JSON은 계속 Jackson 컨버터가 처리합니다. Object와 */*처럼 넓은 컨버터를 앞에 두면 순서가 의미를 바꾸므로, 마지막 테스트에서 그 실패도 실제로 재현합니다.

src/main/java/board/converter/WebConverterConfig.java
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());
    }
}

더 깊이: Spring 7에서 컨버터 구성 API가 바뀐 이유

Spring 6까지의 확장점은 목록을 직접 받는 configureMessageConverters(List)와 extendMessageConverters(List)였고, 7.0에서는 둘 다 제거 예정(deprecated for removal)으로 바뀌었습니다. WebMvcConfigurer API 문서와 WebMvcConfigurationSupport 소스를 보면, 목록을 받는 옛 configureMessageConverters에서 목록에 컨버터를 하나라도 넣으면 기본 컨버터 등록이 꺼졌습니다. 컨버터 하나를 더하려다 JSON 컨버터까지 사라지는 실수가 쉬웠던 이유이며, 그래서 기본 목록에 손을 대려면 extendMessageConverters를 써야 했습니다.

새 HttpMessageConverters.Builder는 이 규칙을 메서드 이름으로 드러냅니다. registerDefaults()는 클래스 경로를 살펴 기본 컨버터를 등록하고, withJsonConverter 같은 형식별 메서드는 자동으로 찾은 그 형식의 기본값을 대신하며, 이 글이 쓴 addCustomConverter는 컨버터를 기본 목록보다 앞에 넣습니다. 7.0.4에는 기본 등록을 끄는 disableDefaults()가, 7.0.3에는 개별 구성 전에 목록 전체를 고칠 수 있는 configureMessageConvertersList가 더해졌습니다.

같은 7.0에서 Jackson 2용 MappingJackson2HttpMessageConverter도 Jackson 3용 JacksonJsonHttpMessageConverter로 대체되며 제거 예정이 되었습니다. Boot 애플리케이션에서는 JacksonJsonHttpMessageConverter 빈을 직접 정의하면 MVC 구성의 기본값을 대신하고, ServerHttpMessageConvertersCustomizer 빈으로 컨버터를 더하거나 특정 기본 컨버터를 바꿀 수 있습니다.


컨트롤러는 표현 문자열이 아니라 객체를 돌려줍니다

GET은 JSON과 CSV를 응답 후보로 선언합니다. 두 echo 경로는 요청 방향의 실패를 따로 확인하기 위한 테스트용 표면입니다. text/csv 매핑은 있지만 리더가 없으므로 요청은 컨트롤러에 들어가기 전에 415가 됩니다. 고정한 사용자 정의 미디어 타입에 라이터가 없는 /unwritable은 컨트롤러가 반환한 뒤에 500이 되는 경우입니다.

src/main/java/board/converter/NegotiatedPostController.java
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로 읽을 컨버터가 없는 경우입니다. 선택된 JSON 리더가 본문의 문법을 해석하지 못하면 400이고, 고정된 응답 미디어 타입을 쓸 라이터가 없으면 컨트롤러 실행 뒤에 500이 됩니다. 이 네 상태를 모든 애플리케이션 오류의 공통 원인으로 일반화하지 않습니다.


실제 Boot MVC 컨텍스트에서 선택 과정을 확인합니다

테스트는 @AutoConfigureMockMvc의 Boot 4.1.2 소스를 씁니다. 실제 RequestMappingHandlerAdapter의 컨버터 목록을 읽고, 실제 MockMvc 요청으로 200·400·406·415·500과 컨트롤러의 호출 여부를 확인합니다. 마지막 한 사례만 독립형(standalone) 설정에 일부러 잘못된 컨버터 목록을 넣어, 넓은 컨버터가 요청을 가로채는 상황을 재현합니다.

src/test/java/board/converter/MessageConverterContractTest.java
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));
        }
    }
}

이 테스트가 고정하는 사실은 다음과 같이 좁습니다.

요청선택 단계기대 결과컨트롤러 호출
GET과 Accept: application/jsonJackson 라이터200 JSON1
GET과 Accept: text/csvPostCsvHttpMessageConverter200 CSV1
GET과 Accept: application/xml표현 후보 없음4060
POST와 Content-Type: text/csv리더 없음4150
POST와 깨진 JSON선택된 리더의 파싱 실패4000
GET과 고정된 사용자 정의 produces라이터 없음5001

String 응답이 항상 JSON으로 한 번 더 인코딩된다는 규칙은 없습니다. 반환 타입과 애노테이션, 선택된 미디어 타입과 컨버터를 함께 봐야 합니다. 이 글의 CSV 라이터는 한 행만 쓰는 학습용 정책이며, 운영 환경의 CSV나 파일, 스트리밍 API가 갖춰야 할 것을 모두 대신하지는 않습니다.


정리

  • HttpMessageConverter는 요청에서는 Content-Type과 대상 타입으로, 응답에서는 반환 타입과 선택된 미디어 타입으로 고르며, 후보가 여럿이면 목록의 순서가 관여합니다.
  • produces는 응답 후보를 제한할 뿐 컨버터를 만들지 않으므로, 라이터가 없으면 컨트롤러 실행 뒤에 500이 됩니다.
  • Accept가 produces와 겹치지 않으면 406, 읽을 리더가 없으면 415, 선택된 리더가 본문을 해석하지 못하면 400이며, 세 경우 모두 컨트롤러는 호출되지 않습니다.
  • 직접 만든 컨버터는 ServerBuilder.addCustomConverter로 기본 목록을 보존한 채 더하고, 지원 타입과 미디어 타입, 읽기·쓰기 방향을 좁힙니다.
  • Object와 */*를 지원하는 넓은 컨버터를 앞에 두면 JSON 요청까지 가로챕니다.

연습 문제

1. 주간 통계 CSV 컨버터

WeeklyPostStats(String week, int posts) 레코드를 만들고, 고정 조회 값은 ("2026-W40", 3)으로 둡니다. 새 GET 매핑 /api/stats/weekly는 JSON과 CSV를 허용하고, 같은 URI의 POST echo 매핑은 CSV를 입력받아 JSON으로 돌려주도록 선언합니다. 요청마다 컨트롤러 프로브는 0으로 초기화하고, CSV POST는 Content-Type: text/csv와 Accept: application/json, 본문 week,posts\n2026-W40,3\n을 보냅니다. 먼저 Object와 */*를 지원하는 컨버터를 맨 앞에 둔 실패 테스트로 JSON 가로채기를 재현한 뒤, 응답 전용 text/csv 컨버터를 어떻게 만들어야 하는지, JSON과 CSV, XML 요청과 CSV POST의 결과가 어떻게 되는지 설명하세요.

해설 보기

PostCsvHttpMessageConverter의 구조를 따라 통계용 컨버터를 만들되, 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은 실제 줄바꿈을 나타냅니다. 이 라이터가 통계 타입을 지원한다고 기존 게시글 타입까지 지원하지는 않습니다.

ServerBuilder.addCustomConverter로 이 컨버터를 더한 뒤 Jackson, String, Resource 컨버터가 남아 있는지 실제 목록을 검사합니다. GET의 JSON과 CSV 요청은 각각 200과 컨트롤러 1회이고, XML만 허용하는 Accept는 produces와 겹치지 않아 406과 0회입니다. CSV POST echo는 매핑이 있어도 @RequestBody WeeklyPostStats를 읽을 리더가 없으므로 415와 0회입니다. POST 매핑을 아예 만들지 않고 415를 기대하면, 리더가 아니라 매핑을 검사하는 잘못된 테스트가 됩니다.

2. CSV 한 행 계산하기

원래의 PostCsvHttpMessageConverter에서 제목 필드에 a,"b"를 넣고 내용 필드는 plain으로 둡니다. id가 42일 때 헤더 다음의 한 행을 구하세요. 제목이 빈 문자열이라면 어떻게 되나요?

해설 보기

제목은 안쪽 따옴표를 두 번 쓴 뒤 전체를 감싸므로 행은 42,"a,""b""",plain\n입니다. 따옴표로 감싼 제목 안의 쉼표는 새 열의 구분자가 아닙니다. 같은 정책에서 제목이 빈 문자열이면 그 필드는 비지만 열의 개수는 그대로여서 42,,plain\n이 됩니다. 이 계산은 Csv.field의 코드만으로 확인할 수 있습니다.

3. JSON을 보내고 CSV를 요청하기

원래의 /api/converter/posts/echo에 문법이 올바른 JSON PostResponse를 보내되, Content-Type: application/json과 Accept: text/csv를 지정합니다. 매핑과 컨버터 목록을 그대로 두면 CSV 라이터가 응답을 쓸까요?

해설 보기

JSON 입력용 echo 매핑의 produces는 application/json뿐이므로 CSV Accept와 겹치지 않습니다. 따라서 매핑 단계에서 406과 컨트롤러 0회가 예상되며, JSON을 읽어 반환 값을 CSV 라이터에 넘기는 단계까지 가지 않습니다. GET의 JSON·CSV produces와 POST echo의 JSON 전용 produces를 구별해야 하며, 입력이 JSON이라고 해서 JSON 응답을 받기로 한 것은 아닙니다.

다음 글 게시판 CRUD 웹에서는 같은 Gradle 그래프의 board.crud Boot 컨텍스트에서 201과 204, ETag, 조건부 갱신을 하나의 CRUD 흐름으로 연결합니다.