← all posts
DEV 2026.05.02 · 12 min read Intermediate

Spring MVC는 반환값을 어떻게 HTTP 응답으로 바꾸는가

ReturnValueHandler 체인부터 HttpMessageConverter 선택, Content Negotiation 알고리즘, 커스텀 핸들러 작성까지 — 컨트롤러 반환값이 HTTP 응답이 되는 전 과정을 추적한다.


컨트롤러 메서드가 User 객체를 반환하면 브라우저는 JSON을 받는다. ResponseEntity.created(uri).body(user)를 반환하면 201 CreatedLocation 헤더가 함께 온다. 이 변환은 어디서, 어떤 순서로 일어나는가? 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 경로에서 RequestResponseBodyMethodProcessorwriteWithMessageConverters()를 호출한다. 여기서 두 번째 체인이 동작한다.

알고리즘은 세 단계다.

  1. producible 수집 — 등록된 각 Converter에 대해 canWrite(UserType, ?) 를 확인한다. MappingJackson2HttpMessageConverterUser.classapplication/json에 대해 true를 반환한다.
  2. acceptable 파싱Accept 헤더를 품질 계수(q값) 기준으로 정렬한다.
  3. 교집합 선택acceptable ∩ producible 중 q값이 가장 높은 타입을 선택한다. 교집합이 비어 있으면 406 Not Acceptable이다.

여기서 자주 마주치는 함정이 있다.

StringHttpMessageConverter의 */* 함정

StringHttpMessageConvertertext/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의 처리 순서

ResponseEntityHttpEntityMethodProcessor가 처리한다. 처리 순서가 중요하다.

① 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를 어떻게 조율하는지 추적한다.