@ResponseBody
@Controller 어노테이션이 있는 컨트롤러(핸들러) 메소드에서 사용 가능하다. @ResponseBody 어노테이션을 Controller method에 달아주면 메소드의 return value가 Http 응답 본문(body)에 전달된다.
하지만 API를 구현할 때, 보통 @ResponseBody 보다는 @RestController를 클래스 레벨에서 사용하는 경우가 많다.
@RestController
@RestController 는 @Controller 를 기반으로 하는 어노테이션으로, @RestController 컨트롤러에 존재하는 모든 메서드에 @ResponseBody 를 적용한다.
즉, @RestController 는 @Controller 와 @ResponseBody 를 합친 어노테이션이라고 할 수 있다.
스프링의 응답 과정
Controller 메소드의 반환값은 ReturnValueHandler가 처리한다.
데이터를 HTTP로 전송하기 위해서는 데이터가 바이트코드 형태로 변환되어야 한다. 따라서 Controller 메소드가 반환한 값은 HTTP 응답 메시지에 담기기 전에 반드시 바이트코드로 직렬화(Serialization)되어야 한다. 이 변환 과정은 ReturnValueHandler가 들고 있는 HttpMessageConverter를 통해 이루어진다. HttpMessageConverter 에 의해 직렬화된 데이터가 HTTP 응답의 body에 담기게 되는 것이다.
➕ 스프링에서 Http request/response message를 역직렬화/직렬화하는 작업은 HttpMessageConverter가 담당한다. 따라서 Http request 시 발생할 수 있는 역직렬화도, Http response 시 발생할 수 있는 직렬화도 HttpMessageConverter가 수행한다는 것이다.
직렬화
직렬화란
‘객체’가 ‘바이트 코드’로 변환되는 것을 의미한다. (참고링크)
직렬화를 통해 생겨난 바이트 코드는 응답 메시지의 본문에 담겨져 클라이언트에게 전송된다.
직렬화 과정
서버가 받은 Http 요청 메시지의 accept header 값이 application/json 타입이라고 가정해보자.
- 객체를 JSON 형식의 문자열로 변환한다.
- JSON 문자열을 바이트 코드로 변환한다.
- 변환된 바이트 코드를 응답 본문에 담는다.
- 직렬화: 객체 → JSON 문자열 → 바이트 코드
+ 문자열을 바이트 코드로 변환하는 것을 문자 인코딩이라고 한다.
인코딩과 디코딩
컨트롤러까지 유저의 요청이 도달하는 과정
다음과 같은 컨트롤러가 정의되어 있다.
@RestController
@RequiredArgsConstructor
@RequestMapping("/api/tracks")
@Tag(name = "like", description = "Like 관련 api")
public class LikeController {
private final LikeService likeService;
@GetMapping("/liked")
@Operation(summary = "좋아요한 음악 조회")
public ApiResponse<?> getLikedTracks (
@UserId Long userId,
@RequestParam(required = false) Long cursor,
@RequestParam(required = false, defaultValue = "20") int size
) {
GetLikedTrackListResponseDto dto = likeService.getLikedTracks(userId, cursor, size);
return ApiResponse.ok(dto);
}
}
GET /api/tracks/liked 요청이 오면 위 컨트롤러 메소드가 동작할 것이다.
HTTP 요청이 서버로 들어온 뒤 컨트롤러 메서드가 실행되기까지의 흐름을 Dispatcher servlet 단계에서부터 스택 프레임형식으로 표현하였다.
request를 기반으로 실행시킬 컨트롤러 메소드를 찾은 뒤 HandlerAdapter를 통해 이를 실행하기까지의 과정이다. 이전에 언급한 컨트롤러 메서드가 호출되어, 가장 상단의 스택 프레임에 위치해있다.
조금만 더 자세히 살펴보자.
DispatcherServlet은 Http 요청을 처리하기 위한 핵심 디스패처이다.
웹 요청을 처리하기 위해, 등록된 핸들러 메서드에게 요청을 전송하며 편리한 매핑과 예외 처리를 가능하게 한다.
일단 핸들러 메서드는 컨트롤러 메서드와 같은 개념으로 생각하면 된다.
DispatcherServlet 동작
1. request를 기반으로 핸들러 메서드를 찾는다.
2. 핸들러 메서드를 실행해 줄 어댑터를 찾는다.
3. 핸들러 메서드를 실행하기 전, 인터셉터를 실행한다.
4. 어댑터를 통해 핸들러 메서드를 실행한다.
이제 핸들러 어댑터가 디스패처 서블릿으로부터 전달받은 핸들러 메서드를 처리할 차례이다.
가장 먼저 호출되는 메서드는 Abstract 핸들러 어댑터로, 구현체에게 다시 한 번 핸들러 메서드를 처리하도록 전달해주는 역할을 한다.
이때 구현체로는 RequestMappingHandlerAdapter가 선택되어있는데, 이유는 실행시킬 핸들러 메서드가 @RequestMapping을 사용하였기 때문이다.
RequestMappingHandlerAdapter 는 핸들러 메서드를 처리하기 위해 전달 받은 핸들러 메서드를 실행 시킨다.
(우리의)컨트롤러 메서드가 실행되었다.
그런데 이 실행 과정을 보아하니 ServletInvocableHandlerMethod 인스턴스의 메서드가 사용된다. 이 인스턴스는 HandlerMethod를 상속받아 구현된 것으로, 지금까지 계속해서 언급해왔던 핸들러 메서드이다.
핸들러 메서드는 실제 컨트롤러 메서드에 대한 정보를 포함하며, 실제로 getLikedTracks 컨트롤러 메서드는 이 핸들러 메서드를 통해 호출된다.
@ResponseBody를 이용한 응답 프로세스
@GetMapping("/liked")
@Operation(summary = "좋아요한 음악 조회")
public ApiResponse<?> getLikedTracks (
@UserId Long userId,
@RequestParam(required = false) Long cursor,
@RequestParam(required = false, defaultValue = "20") int size
) {
GetLikedTrackListResponseDto dto = likeService.getLikedTracks(userId, cursor, size);
return ApiResponse.ok(dto);
}
컨트롤러 메서드, 즉 getLikedTracks 메소드가 실행되면 ApiResponse 객체를 응답으로 반환하는 것으로 메서드가 종료된다. 따라서 ServletInvocableHandlerMethod 인스턴스의 메서드들이 종료되기 시작한다. 다만 아직 ServletInvocableHandlerMethod의 invokeAndHandler 메소드는 종료되지 않는다.
public void invokeAndHandle(ServletWebRequest webRequest, ModelAndViewContainer mavContainer,
Object... providedArgs) throws Exception {
Object returnValue = invokeForRequest(webRequest, mavContainer, providedArgs);
setResponseStatus(webRequest);
//...
try {
this.returnValueHandlers.handleReturnValue(
returnValue, getReturnValueType(returnValue), mavContainer, webRequest);
}
catch (Exception ex) {
//...
throw ex;
}
}
invokeAndHandler 메서드는 핸들러 메서드를 실행시키고, 핸들러 메서드가 반환한 값을 ReturnValueHandler를 통해 handle하는 책임도 가지고 있기 때문이다.
핸들러 메서드는 이미 실행하였으므로, 이제 반환값을 처리할 수 있는 ReturnValueHandler를 찾을 차례이다.
컨트롤러 메서드가 반환하는 값의 유형은 다양할 수 있다. 따라서 여러 ReturnValueHandler들 중에서, 해당 반환 타입을 처리할 수 있는 고마운 친구를 찾아 보아야한다. 이것을 찾기 위한 과정으로 HandlerMethodReturnValueHandlerComposite 를 이용한다. HandlerMethodReturnValueHandlerComposite 은 ReturnValueHandler 를 List 멤버변수로 가지고 있다.
HandlerMethodReturnValueHandlerComposite 은 다양한 returnValueHandler 구현체들을 가지고 있다.
우리는 @ResponseBody 어노테이션을 사용하여 객체를 return 하였기 때문에, RequestResponseBodyMethodProcessor가 returnValueHandler로 선택되겠다.
🤔 HandlerMethodReturnValueHandlerComposite은 어떻게 ReturnValueHandler을 고를까?
비밀은 바로 selectHandler 메서드에 있었다.
private HandlerMethodReturnValueHandler selectHandler(@Nullable Object value, MethodParameter returnType) {
for (HandlerMethodReturnValueHandler handler : this.returnValueHandlers) {
if (handler.supportsReturnType(returnType)) {
return handler;
}
}
인자로 받은 return 타입이 과연 ReturnValueHandler에서 처리 가능한 타입인지 supportReturnType 메서드를 통해 확인한다.
RequestResponseBodyMethodProcessor의 supportsReturnType 메서드는 다음과 같이 구현되어 있다.
@Override
public boolean supportsReturnType(MethodParameter returnType) {
return (AnnotatedElementUtils.hasAnnotation(
returnType.getContainingClass(), ResponseBody.class) ||
returnType.hasMethodAnnotation(ResponseBody.class));
}
우리는 @RestController 어노테이션을 클래스 level에 작성해주었으니, 해당 메서드의 반환값이 true를 반환하여 RequestResponseBodyMethodProcessor가 ReturnValueHandler가 되었을 것이다.
참고로, @ResponseBody를 이용하여 응답 본문에 담길 객체를 컨트롤러에서 바로 반환하는 방식말고도 HttpEntity(ResponseEntity)를 반환하는 방식도 많이 사용되는 것으로 알고 있다. HttpEntity(ResponseEntity)를 이용하면 응답 코드, 쿠키 등을 직접 세팅할 수 있는 등 low level의 접근이 가능해지기 때문이다. 그럴 경우 HttpEntityMethodProcessor가 returnValueHandler로 선택된다.
이것은 모두 각 returnValueHandler가 supportReturnType 메서드를 구현해놓았기 때문에 가능하다.
다음은 HttpEntityMethodProcessor의 supportsReturnType 메서드이다.
@Override
public boolean supportsReturnType(MethodParameter returnType) {
Class<?> type = returnType.getParameterType();
return ((HttpEntity.class.isAssignableFrom(type) &&
!RequestEntity.class.isAssignableFrom(type)) ||
ErrorResponse.class.isAssignableFrom(type) ||
ProblemDetail.class.isAssignableFrom(type));
}
만일 @ResponseBody + HttpEntity 와 같이 두 가지 방식을 동시에 사용했다면, HttpEntityMethodProcessor가 returnValueHandler로 채택된다. 왜냐하면 HttpEntityMethodProcessor의 supportsReturnType 메서드가 먼저 true를 반환하기 때문이다.
HttpEntityMethodProcessor는 index가 5이고, RequestResponseBodyMethodProcessor는 11이다. 그렇기 때문에 for문을 돌릴 때 HttpEntityMethodProcessor의 supportsReturnType 메서드가 먼저 실행되겠다.
@ResponseBody vs HttpEntity 차이점 정리
handler(controller) method의 returnType에 따라 returnValueHandler가 달라진다.
@ResponseBody 를 쓴 경우 RequestResponseBodyMethodProcessor 가 선택되고, HttpEntity를 반환하는 경우 HttpEntityMethodProcessor 가 선택된다.
각 Processor 코드를 비교해보면, 응답해주는 값이 @ResponseBody의 경우 반환하는 값 그 자체이고,
HttpEntity를 반환하는 경우 전달받은 반환값(HttpEntity)로부터 직접 헤더와 본문을 메소드로 추출해 처리하는 정도의 차이로 느껴졌다. 그 외 응답 본문에 들어갈 값을 직렬화 해주기 위해서 컨버터를 호출하는 것은 동일했다.
이렇게 returnValueHandler가 정해진 이후, 이를 시작으로 직렬화가 수행되는 단계까지의 과정을 살펴보자.
현재는 RequestResponseBodyMethodProcessor가 ReturnValueHandler이다. 따라서 RequestResponseBodyMethodProcessor가 응답을 처리하게 된다. 이름값한다.
다음 그림은 RequestResponseBodyMethodProcessor를 통해 returnValue를 처리하는 과정이다. 앞의 그림의 일부분이다.
returnValue를 처리해보자. 현재 returnValue는 ApiResponse 타입 객체로, 객체를 직렬화해서 OutputStream에 써주면 handleReturnValue 메서드의 역할이 끝난다.
이를 위해서, 가장 먼저 ServletServerHttpResponse 인스턴스를 생성하게 된다.
//AbstractMessageConverterMethodProcessor
protected ServletServerHttpResponse createOutputMessage(NativeWebRequest webRequest) {
HttpServletResponse response = webRequest.getNativeResponse(HttpServletResponse.class);
Assert.state(response != null, "No HttpServletResponse");
return new ServletServerHttpResponse(response);
}
ServletServerHttpResponse은 HttpOutputMessage를 부모로 가진다. 이것이 ServletServerHttpResponse 인스턴스를 생성하는 메서드의 이름이 createOutputMessage인 이유이다.
createOutputMessage 메서드의 내부를 자세히 살펴보면, ServletServerHttpResponse가 있고, HttpServletResponse가 있다. 이 둘은 이름이 상당히 유사하지만, ServletServerHttpResponse는 스프링이, HttpServletResponse는 서블릿이 제공한다는 차이점이 있다.
ServletServerHttpResponse는 내부적으로 HttpServletResponse 를 사용하여 Http 응답에 필요한 메서드를 정의해두고 있다.
이렇게 생성된 ServletServerHttpResponse 인스턴스를 통해 OutputStream을 얻을 수 있다. 이 OutputStream에 반환값을 직렬화하여 전달하는 것은 또 다른 객체의 도움을 받는다. 바로 HttpMessageConverter이다.
🤔 사용할 메시지 컨버터 찾기
어떠한 종류의 MessageConverter가 returnValue를 직렬화해줄 수 있는 지 찾아야 한다. 이를 위해서 writeWithMessageConverter메서드를 호출한다.
다음은 writeWithMessageConverter의 코드 중 일부를 가져온 것이다.
Object body = value;
if (selectedMediaType != null) {
for (HttpMessageConverter<?> converter : this.messageConverters) {
GenericHttpMessageConverter genericConverter =
(converter instanceof GenericHttpMessageConverter ghmc ? ghmc : null);
if(converter.canWrite(valueType, selectedMediaType){
if (body != null) {
body = getAdvice().beforeBodyWrite(body, returnType, selectedMediaType,
(Class<? extends HttpMessageConverter<?>>) converter.getClass(),
inputMessage, outputMessage);
genericConverter.write(body, targetType, selectedMediaType, outputMessage);
}
}
}
여러 메시지 컨버터들 중에서도 return type을 처리할 수 있는 컨버터를 골라 write 메소드를 호출하게 된다.
그 결과로, MappingJackson2HttpMessageConverter가 메시지 컨버터로 선택된다. returnValue를 json 형식의 바이트 코드로 바꾸어야 하기 때문이다.
따라서 MappingJackson2HttpMessageConverter는 ObjectMapper를 이용하여 직렬화를 수행하고 본인의 역할을 다한다.
objectWriter의 writeValue 메서드 코드
/**
* Method that can be used to serialize any Java value as
* JSON output, using provided {@link JsonGenerator}.
*<p>
* Note that the given {@link JsonGenerator} is not closed;
* caller is expected to handle that as necessary.
*/
public void writeValue(JsonGenerator g, Object value) throws IOException{
_assertNotNull("g", g);
_configureGenerator(g);
if (_config.isEnabled(SerializationFeature.CLOSE_CLOSEABLE)
&& (value instanceof Closeable)) {
Closeable toClose = (Closeable) value;
try {
_prefetch.serialize(g, value, _serializerProvider());
if (_config.isEnabled(SerializationFeature.FLUSH_AFTER_WRITE_VALUE)) {
g.flush();
}
} catch (Exception e) {
ClassUtil.closeOnFailAndThrowAsIOE(null, toClose, e);
return;
}
toClose.close();
} else {
_prefetch.serialize(g, value, _serializerProvider());
if (_config.isEnabled(SerializationFeature.FLUSH_AFTER_WRITE_VALUE)) {
g.flush();
}
}
}
이렇게 returnValueHandler가 MessageConverter를 이용하여 객체를 직렬화하고, 이를 OutputStream에 작성하는 전반적인 과정을 살펴보았다.
마무리
이 글은 컨트롤러에서 반환하는 객체가 Http response message에 어떠한 과정을 거쳐 전달되는가? 담기는가?가 궁금해서 스프링 코드를 까보며 정리한 내용을 적은 글이다.
디버깅을 하며 새로 알게된 사실이 많았고, 내용이 상당히 길어 이를 정리하는데 시간이 꽤 걸렸다. 드디어 정리 끝이다~~
'Spring' 카테고리의 다른 글
외부 API 비동기 호출로 성능 개선하기 (0) | 2025.03.04 |
---|---|
HttpServletResponse와 HttpOutputMessage (0) | 2024.09.11 |
서블릿에서 HTTP Response 보내기 (0) | 2024.08.31 |
영속성 관리(ORM 표준 기술 {3} ) (0) | 2023.07.06 |
JPA(자바 ORM 표준 JPA 프로그래밍-기본편) { 2 } (0) | 2023.07.05 |