빌드노트
application/json인데 Spring의 String 응답은 hello 그대로다
Spring MVC에서 application/json을 지정해도 String 반환은 원문으로 쓰일 수 있다. 실제 MockMvc에서 본문 바이트·선택된 converter·JSON 값 종류를 비교하고, DTO·JSON 문자열 값·평문 중 응답 계약에 맞는 반환 타입을 고른다.
Spring MVC의 @RestController에서 String을 반환하고 produces = "application/json"을 붙였는데, 클라이언트의 JSON 파서가 hello를 읽지 못할 수 있다. 응답의 Content-Type이 JSON이라는 사실만으로 반환 문자열에 JSON 따옴표가 붙지는 않는다. StringHttpMessageConverter가 선택되면 문자열 원문을 본문으로 쓴다. JSON 객체를 보내려는 API라면 문자열을 조립하기보다 DTO를 반환하고, 실제 본문과 파싱된 값의 종류까지 확인해야 한다.
아래는 이 증상을 만든 메서드다. 기존 @RestController 안에서 같은 형태의 반환값과 produces를 먼저 찾는다. 일반 @Controller에서 뷰 이름을 반환하는 경우와는 다르다.
@GetMapping(value = "/raw", produces = "application/json")
public String raw() {
return "hello";
}
Java 소스의 "hello"에서 따옴표는 문자열 리터럴의 표기다. 문자열 자체에 따옴표 문자가 들어 있는 것은 아니다. JSON 문자열 값으로 보내려면 본문 바이트에 실제 큰따옴표가 필요하다.
상태 코드 다음에 본문 바이트를 본다
기존 프로젝트에 Spring MVC와 Jackson 2를 사용하는 MockMvc 테스트가 있다면, 그 프로젝트 설정을 반영한 mockMvc와 objectMapper로 응답을 읽는다. JDK 17 이상에서 다음 코드는 응답 타입, 원문, 16진 바이트를 확인하고 마지막 줄에서 JSON 파싱을 시도한다. /raw는 자신의 문제가 있는 GET 경로로 바꾼다. get은 MockMvcRequestBuilders의 정적 메서드이고, MediaType은 Spring HTTP 타입이다. 문자 출력에는 java.nio.charset.StandardCharsets, 바이트 출력에는 java.util.HexFormat을 사용한다. 테스트 메서드는 예외를 전달할 수 있어야 한다.
var response = mockMvc.perform(get("/raw").accept(MediaType.APPLICATION_JSON))
.andReturn().getResponse();
var bytes = response.getContentAsByteArray();
System.out.println(response.getContentType());
System.out.println(new String(bytes, StandardCharsets.UTF_8));
System.out.println(HexFormat.ofDelimiter(" ").formatHex(bytes));
System.out.println(objectMapper.readTree(bytes).getNodeType());
이번 격리 실행에서는 상태가 200이었고, 앞의 세 출력은 application/json, hello, 68 65 6c 6c 6f였다. 본문은 5바이트였으며 따옴표 바이트 22가 없었다. 마지막 파싱은 Unrecognized token 'hello'라는 오류로 실패했다. 이 입력에서 발생하는 파싱 실패는 진단하려던 증상이다. 테스트를 통과시키려고 파싱 줄을 삭제하는 것으로 응답 계약이 고쳐지지는 않는다.
반대로 본문이 "hello"라면 바이트는 22 68 65 6c 6c 6f 22이고 파싱 결과는 STRING이다. 객체를 기대했다면 파싱 성공만으로 끝내지 말고 OBJECT인지도 본다. RFC 8259의 JSON 문법 (새 탭에서 열림)은 최상위 문자열도 허용한다. JSON은 반드시 객체여야 하는 형식이 아니므로, 문법상 유효성과 이 API가 약속한 값의 모양은 별도로 확인해야 한다.
같은 JSON 헤더로 반환값 세 가지를 비교했다
운영 장애 기록이 아니라 별도로 만든 MockMvc 실험이다. Windows, OpenJDK 17.0.2, Spring Framework 6.2.12, Jackson 2.18.4, Jakarta Servlet API 6.0.0으로 실행했다. Spring Boot 자동 설정은 사용하지 않았고, standaloneSetup이 이 클래스패스에서 등록한 converter를 먼저 관찰했다. 실제 서버 소켓은 열지 않았다. MockMvc 공식 설명 (새 탭에서 열림)처럼 DispatcherServlet을 거치는 MVC 처리를 검사한 것이다.
본문 없는 GET 요청에 Accept: application/json을 보내고 다음 반환값을 비교했다. 세 응답 모두 상태 200, 응답 Content-Type: application/json이었다. 아래 본문 표기는 Java 리터럴이 아니라 실제 응답 내용이다.
String값hello반환 → 본문hello, 5바이트, JSON 파싱 실패. 선택된 converter는StringHttpMessageConverter였다.- 이미 JSON 문법인
String값{"message":"hello"}반환 → 본문{"message":"hello"}, 19바이트, 파싱 결과OBJECT. 이때도StringHttpMessageConverter였다. Message객체 반환 → 본문{"message":"hello"}, 19바이트, 파싱 결과OBJECT. 이 경우에는MappingJackson2HttpMessageConverter였다.
두 번째와 세 번째의 본문은 같지만 생성 경로는 달랐다. 두 번째는 이미 만들어 둔 JSON 텍스트를 그대로 썼고, 세 번째는 객체를 Jackson이 JSON으로 직렬화했다. JSON 텍스트 String이 정상처럼 보였다고 해서 모든 String에 자동 직렬화가 적용된다고 판단할 수 없다.
ResponseEntity.ok().contentType(MediaType.APPLICATION_JSON).body("hello")도 따로 실행했다. 결과는 같은 5바이트 hello였고 파싱에 실패했다. 이 구성에서 ResponseEntity<String>로 바꾸고 헤더를 직접 지정하는 것만으로는 해결되지 않았다.
String을 쓸 수 있는 converter가 둘이었다
Spring의 메시지 변환 설명 (새 탭에서 열림)에서 HTTP 본문을 읽고 쓰는 주체는 HttpMessageConverter다. StringHttpMessageConverter API (새 탭에서 열림)는 기본 지원 미디어 타입을 */*로 설명한다. 기본 출력이 text/plain이라는 말은 JSON 미디어 타입에서 선택될 수 없다는 뜻이 아니다.
자신의 설정에서도 String과 JSON 조합을 쓸 수 있는 후보와 등록 순서를 확인할 수 있다. 앞서 사용한 mockMvc에서 아래 코드를 실행한다. RequestMappingHandlerAdapter의 패키지는 org.springframework.web.servlet.mvc.method.annotation이다.
var adapter = mockMvc.getDispatcherServlet().getWebApplicationContext()
.getBean(RequestMappingHandlerAdapter.class);
for (var converter : adapter.getMessageConverters()) {
System.out.println(converter.getClass().getSimpleName() + " "
+ converter.canWrite(String.class, MediaType.APPLICATION_JSON));
}
실행 출력에서 StringHttpMessageConverter true가 MappingJackson2HttpMessageConverter true보다 앞에 있었다. 전체 목록은 byte 배열 → String → Resource → ResourceRegion → 폼 → Jackson 순서였다. String converter의 지원 목록은 [text/plain, */*], Jackson의 지원 목록은 [application/json, application/*+json]이었다. 둘 다 String과 JSON 조합을 쓸 수 있었다.
이 조회는 String 기준 후보를 보여 준다. 실제 응답에 사용된 converter까지 확정하는 로그는 아니다. 이번 실험에서는 본문을 바꾸지 않는 ResponseBodyAdvice로 선택된 converter 타입도 기록해 앞선 결과를 확인했다. 6.2.12의 반환값 처리 코드 (새 탭에서 열림)는 미디어 타입을 정한 뒤 목록을 순회하며 쓸 수 있는 converter를 찾아 본문을 쓴다. 같은 버전의 String converter (새 탭에서 열림)는 문자열을 문자셋에 맞춰 복사하며 JSON용 따옴표를 덧붙이지 않는다.
순서의 영향을 확인하려고 격리 구성에서 Jackson → String 두 개만 명시적으로 등록해 다시 실행했다. 같은 hello 반환은 이번에 "hello"라는 7바이트 JSON 문자열이 됐다. 그런데 이미 JSON 텍스트인 String도 "{\"message\":\"hello\"}"라는 25바이트 본문으로 바뀌었다. 파싱에는 성공했지만 결과는 객체가 아닌 STRING이었다. Message 객체 반환은 두 구성에서 모두 19바이트 JSON 객체였다.
따라서 “Spring에서 String은 항상 원문으로 나간다”는 결론도 맞지 않는다. 위 결과는 선택된 converter에 따라 달랐다. 전역 순서를 바꾸면 기존에 JSON 텍스트를 반환하던 API의 값 모양까지 달라질 수 있으므로, 한 메서드를 고칠 때는 그 메서드의 반환 계약부터 정한다.
JSON 객체인지, JSON 문자열 값인지, 평문인지 정한다
클라이언트가 message 필드를 읽는 API라면 다음처럼 DTO를 반환한다. 아래 코드는 기존 @RestController 안에 두며, Jackson JSON converter가 등록되어 있어야 한다. 문자열 조립 대신 객체를 반환해 필드의 JSON 직렬화를 converter에 맡기는 변경이다.
public record Message(String message) {}
@GetMapping(value = "/object", produces = "application/json")
public Message object() {
return new Message("hello");
}
이 코드의 격리 실행 결과는 {"message":"hello"}였고, 선택된 converter는 Jackson이었다. MappingJackson2HttpMessageConverter API (새 탭에서 열림)는 Jackson 2의 ObjectMapper로 JSON을 읽고 쓴다고 설명한다. 실제 응답을 파싱한 뒤 객체 여부와 message 값까지 검사하면, 문자열 원문이나 JSON 문자열로 바뀌는 회귀도 구별할 수 있다.
API 계약이 필드 없는 JSON 문자열 값 "hello"라면 Jackson 2의 com.fasterxml.jackson.databind.JsonNode와 com.fasterxml.jackson.databind.node.TextNode를 사용해 그 값을 나타낼 수도 있다. 다음 코드는 String converter 대신 JSON converter가 처리할 값을 반환하는 방법이다.
@GetMapping(value = "/json-value", produces = "application/json")
public JsonNode jsonValue() {
return TextNode.valueOf("hello");
}
기본 구성에서 이 응답은 "hello", 7바이트, 파싱 결과 STRING이었다. 따옴표를 수동으로 양옆에 붙이는 방법은 값 안의 따옴표·역슬래시·줄바꿈까지 처리해야 한다. RFC 8259의 문자열 문법 (새 탭에서 열림)에 맞는 escape는 JSON 직렬화에 맡긴다.
반환 계약이 평문 hello라면 produces = "text/plain"으로 맞추고 클라이언트도 텍스트로 읽는다. 별도 비교에서 이 메서드는 text/plain;charset=ISO-8859-1과 5바이트 hello를 보냈다. 이를 JSON 파서에 넣으면 여전히 실패하지만, 평문 계약에서는 JSON 파싱 자체가 필요하지 않다.
자신의 결과가 다르면 테스트용 standaloneSetup과 실제 앱의 converter 목록이 같은지, 사용자 정의 converter나 ResponseBodyAdvice가 값을 바꾸는지 확인한다. 이번 관찰은 고정한 Spring 6.2.12·Jackson 2 구성의 ASCII 응답에 한정한다. 6.2 문서와 API 링크는 확인 당시 6.2.19를 가리켜 핵심 동작은 실험 버전의 태그 소스와 대조했다. Spring Boot 자동 설정, Spring 7·Jackson 3, 실제 Servlet 컨테이너·필터·프록시 이후의 바이트는 검증하지 않았다.