Spring MVC는 반환값을 어떻게 HTTP 응답으로 바꾸는가
ReturnValueHandler 체인부터 HttpMessageConverter 선택, Content Negotiation 알고리즘, 커스텀 핸들러 작성까지 — 컨트롤러 반환값이 HTTP 응답이 되는 전 과정을 추적한다.
- 01 DispatcherServlet은 어떻게 HTTP 요청을 응답으로 만드는가
- 02 Spring MVC는 요청을 어떻게 핸들러에 연결하는가
- 03 Spring MVC는 파라미터를 어떻게 바인딩하는가
- 04 Spring MVC는 반환값을 어떻게 HTTP 응답으로 바꾸는가
- 05 Spring MVC 예외는 어떻게 응답이 되는가
- 06 Spring MVC 요청 파이프라인은 어떻게 설계됐는가
- 07 Spring MVC는 어떻게 요청 하나를 처리하는가
컨트롤러 메서드가 User 객체를 반환하면 브라우저는 JSON을 받는다. ResponseEntity.created(uri).body(user)를 반환하면 201 Created와 Location 헤더가 함께 온다. 이 변환은 어디서, 어떤 순서로 일어나는가? Spring MVC는 수십 가지 반환 타입을 처리하기 위해 Chain of Responsibility 패턴을 세 겹으로 쌓았다 — ReturnValueHandler 체인, MessageConverter 체인, Content Negotiation 전략 체인이 그것이다.
첫 번째 관문: ReturnValueHandler 체인
ServletInvocableHandlerMethod.invokeAndHandle()이 컨트롤러 메서드를 실행하고 나면, 반환값은 HandlerMethodReturnValueHandlerComposite에 위임된다. 이 Composite는 등록된 핸들러 목록을 순서대로 탐색해 supportsReturnType()이 true를 반환하는 첫 번째 핸들러를 선택한다.
기본 등록 순서의 핵심만 정리하면 다음과 같다.
ModelAndViewMethodReturnValueHandler → ModelAndView 타입
HttpEntityMethodProcessor → HttpEntity / ResponseEntity
RequestResponseBodyMethodProcessor → @ResponseBody (클래스 또는 메서드 레벨)
ViewNameMethodReturnValueHandler → String / void (폴백)
ServletModelAttributeMethodProcessor → 나머지 타입 (폴백)
@RestController에서 String을 반환해도 ViewNameMethodReturnValueHandler가 아닌 RequestResponseBodyMethodProcessor가 선택되는 이유가 여기 있다. 클래스 레벨 @ResponseBody(@RestController에 포함)를 먼저 확인하는 RequestResponseBodyMethodProcessor가 등록 순서상 훨씬 앞에 있기 때문이다.
핸들러가 응답 본문을 직접 쓰는 경우 — @ResponseBody, ResponseEntity — 반드시 mavContainer.setRequestHandled(true)를 선언해야 한다. 이 플래그가 true이면 DispatcherServlet은 View 렌더링을 건너뛴다. 누락하면 응답이 커밋된 뒤 ViewResolver가 추가로 실행되어 IllegalStateException이 발생한다.
두 번째 관문: MessageConverter 선택
@ResponseBody 경로에서 RequestResponseBodyMethodProcessor는 writeWithMessageConverters()를 호출한다. 여기서 두 번째 체인이 동작한다.
알고리즘은 세 단계다.
- producible 수집 — 등록된 각 Converter에 대해
canWrite(UserType, ?)를 확인한다.MappingJackson2HttpMessageConverter는User.class와application/json에 대해true를 반환한다. - acceptable 파싱 —
Accept헤더를 품질 계수(q값) 기준으로 정렬한다. - 교집합 선택 —
acceptable ∩ producible중 q값이 가장 높은 타입을 선택한다. 교집합이 비어 있으면406 Not Acceptable이다.
여기서 자주 마주치는 함정이 있다.
StringHttpMessageConverter는 text/plain과 */* 두 미디어 타입을 지원한다. */* 때문에 String 타입 반환 시 Accept: application/json 요청에도 canWrite(String.class, application/json)이 true를 반환한다. 선택되면 Content-Type: application/json이 설정되지만 응답 본문은 따옴표 없는 plain text다 — Jackson이 직렬화한 것이 아니다. JSON API에서 String을 직접 반환하지 말고, Map 또는 DTO를 반환하거나 produces = MediaType.APPLICATION_JSON_VALUE를 명시하라.
커스텀 Converter를 등록할 때는 extendMessageConverters()를 사용해야 한다. configureMessageConverters()를 구현하면 Spring Boot가 자동 등록한 기본 Converter 전체가 교체되어 Jackson Converter가 사라진다.
세 번째 관문: Content Negotiation
getAcceptableMediaTypes()가 Accept 헤더를 파싱할 때 ContentNegotiationManager가 개입한다. 기본 활성 전략은 HeaderContentNegotiationStrategy 하나뿐이다. URL 파라미터 방식(?format=json)은 명시적 설정으로 활성화할 수 있고, URL 확장자 방식(.json)은 Spring Boot 2.6부터 보안 이유(RFD 취약점)로 기본 비활성화됐다.
브라우저가 보내는 Accept: text/html,application/xhtml+xml,... 헤더는 text/html이 q=1.0으로 가장 높다. REST API가 이 요청을 받으면 producible 목록에 text/html이 없으므로 교집합에서 */*;q=0.8 부분이 살아남아 Jackson의 application/json이 선택된다. 그러나 XML Converter도 함께 등록된 환경에서는 application/xml;q=0.9가 먼저 선택될 수 있다. @GetMapping(produces = "application/json")을 명시하면 producible 목록이 [application/json]으로 고정되어 이 불확실성이 사라진다.
ResponseEntity와 @ResponseStatus의 처리 순서
ResponseEntity는 HttpEntityMethodProcessor가 처리한다. 처리 순서가 중요하다.
① setResponseStatus() — @ResponseStatus 어노테이션 값으로 status 설정
② HttpEntityMethodProcessor.handleReturnValue()
— ResponseEntity의 status로 덮어씀
@ResponseStatus(CREATED)와 ResponseEntity.ok(body)를 같이 쓰면 최종 응답은 200이다. ResponseEntity가 항상 우선한다. 반대로 @ResponseStatus가 유용한 경우는 두 가지다 — 상태 코드가 항상 고정인 단순 반환, 그리고 예외 클래스에 붙여 전역 상태 코드를 매핑할 때.
ResponseEntity는 런타임에 상태 코드, 헤더, 본문을 완전히 제어할 수 있지만 반환 타입이 ResponseEntity<T>로 고정되어 테스트에서 unwrap이 필요하다. @ResponseStatus는 선언적이고 간결하지만 동적 상태 코드가 불가능하다. 조건부 상태 코드나 Location 헤더가 필요하면 ResponseEntity, 고정 상태 코드면 @ResponseStatus를 선택하라.
커스텀 ReturnValueHandler가 필요한 순간
ResponseBodyAdvice는 Jackson 직렬화 직전에 body를 변환할 수 있다. 공통 래퍼(ApiResponse), 로깅, 압축이 여기에 맞는다. 그러나 Content-Type 자체를 바꾸거나 MessageConverter 체인을 완전히 우회해야 한다면 HandlerMethodReturnValueHandler를 직접 구현해야 한다.
@Override
public boolean supportsReturnType(MethodParameter returnType) {
return AnnotatedElementUtils.hasAnnotation(
returnType.getContainingClass(), EncryptedResponse.class)
|| returnType.hasMethodAnnotation(EncryptedResponse.class);
}
@Override
public void handleReturnValue(Object returnValue, MethodParameter returnType,
ModelAndViewContainer mavContainer, NativeWebRequest webRequest) throws Exception {
mavContainer.setRequestHandled(true); // 필수
String json = objectMapper.writeValueAsString(returnValue);
String encrypted = encryptionService.encrypt(json);
HttpServletResponse response = webRequest.getNativeResponse(HttpServletResponse.class);
response.setContentType("application/octet-stream");
response.getWriter().write(encrypted);
}
addReturnValueHandlers()로 등록하면 기본 핸들러 뒤에 위치한다. @ResponseBody가 없고 커스텀 어노테이션만 있는 경우에는 기본 핸들러가 supportsReturnType()에서 false를 반환하므로 뒤에 있어도 선택된다. 기본 핸들러보다 먼저 실행해야 한다면 @PostConstruct에서 adapter.getReturnValueHandlers() 목록 앞에 삽입하고 setReturnValueHandlers()로 교체해야 한다.
정리
invokeAndHandle()은 ReturnValueHandler 체인 → MessageConverter 체인 → Content Negotiation 세 단계로 반환값을 HTTP 응답으로 변환한다.mavContainer.setRequestHandled(true)는 “이 핸들러가 응답을 완전히 처리했다”는 선언이다. 응답을 직접 쓰는 핸들러라면 반드시 설정해야 한다.StringHttpMessageConverter의*/*지원과 Converter 등록 순서는 예상치 못한 Content-Type을 만들어낸다. REST API에서는produces명시로 방어하라.- 커스텀 Converter는
extendMessageConverters()로, 커스텀 ReturnValueHandler는 어노테이션 조건을 기준으로 설계하면 기본 핸들러와 충돌 없이 공존할 수 있다.
다음 글에서는 이 체인이 예외를 만났을 때 어떻게 동작하는지, HandlerExceptionResolver가 @ExceptionHandler와 @ResponseStatus를 어떻게 조율하는지 추적한다.