본격적인 응답 과정을 알아보기 이전에, @ResponseBody 어노테이션에 대해서 알아보자.
하지만 API를 구현할 때, 보통 @ResponseBody 보다는 @RestController를 클래스 레벨에서 사용하는 경우가 많다.
@RestController란?
@RestController 는 @Controller 를 기반으로 하는 어노테이션으로, @RestController 컨트롤러에 존재하는 모든 메서드에 @ResponseBody 를 적용한다.
즉, @RestController 는 @Controller 와 @ResponseBody 를 합친 어노테이션이라고 할 수 있다.
그렇다면 @ResponseBody는 무엇일까?
@ResponseBody의 역할
@Controller 어노테이션이 있는 컨트롤러(핸들러) 메소드에서 사용 가능하다. @ResponseBody 어노테이션을 Controller method에 달아주면 메소드의 return value가 Http 응답 본문(body)에 전달된다.
이때 @ResponseBody 어노테이션을 이용했을 때 어떻게 응답이 이루어지는가- 를 이해하기 위해서는, 애초에 Spring의 Http 응답 프로세스를 알고 있어야 한다.
간략한 Http 응답 프로세스
Controller 메소드의 반환값은 ReturnValueHandler가 처리한다.
Java에서 데이터를 HTTP로 전송하기 위해서는 데이터가 바이트코드 형태로 변환되어야 한다. 따라서 Controller 메소드가 반환한 값은 HTTP 응답 메시지에 담기기 전에 반드시 바이트코드로 직렬화(Serialization)되어야 한다. 이 변환 과정은 ReturnValueHandler가 사용하는 HttpMessageConverter를 통해 이루어진다. 이렇게 직렬화된 데이터가 HTTP 응답의 body에 담기게 되는 것이다.
➕ 참고로, 스프링은 Http request/response message를 역직렬화/직렬화하는 작업을 HttpMessageConverter에게 일임한다. 따라서 Http request 시 발생할 수 있는 역직렬화도, Http response 시 발생할 수 있는 직렬화도 HttpMessageConverter가 수행한다.
직렬화란
‘객체’가 ‘바이트 코드’로 변환되는 것을 의미한다. (참고링크)
직렬화를 통해 생겨난 바이트 코드는 응답 메시지의 본문에 담겨져 클라이언트에게 전송된다.
세부적인 직렬화 과정
- 객체를 JSON 형식의 문자열로 변환한다.
- JSON 문자열을 바이트 코드로 변환한다.
- 변환된 바이트 코드를 응답 본문에 담는다.
- 객체 → JSON 문자열 → 바이트 코드: 직렬화
- JSON 문자열 → 바이트 코드: 인코딩
현재 서버가 받은 Http 요청 메시지의 accept header 값이 application/json 타입이라고 가정한다. 따라서 클라이언트가 서버에게 받은 응답 메시지의 본문을 디코딩하여 Json 문자열의 형태로 읽을 수 있도록 하여야 한다. 이를 위해서는 응답 메시지를 만드는 과정에서 JSON 형식의 문자열을 바이트코드로 인코딩하는 과정이 필요해진다.
인코딩과 디코딩
이제 대략적인 응답 프로세스를 파악했으니, 지금부터는 @ResponseBody가 이전에 언급한 응답 프로세스에 어떠한 영향을 미치는지 알아보겠다.
Http 응답 프로세스
Controller 메소드의 반환값은 ReturnValueHandler가 처리한다.
Java에서 데이터를 HTTP로 전송하기 위해서는 데이터가 바이트코드 형태로 변환되어야 한다. 따라서 Controller 메소드가 반환한 값은 HTTP 응답 메시지에 담기기 전에 반드시 바이트코드로 직렬화(Serialization)되어야 한다. 이 변환 과정은 ReturnValueHandler가 사용하는 HttpMessageConverter를 통해 이루어진다.
@ResponseBody를 이용한 응답 과정 알아보기
다음과 같은 형태의 컨트롤러를 작성했다.
@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);
}
}
@RequestMapping과 @GetMapping을 이용하여 GET /api/tracks/liked 요청이 오면 LikeController 클래스의 getLikedTracks 메소드가 동작하도록 하였다. 이 메소드는 비즈니스 로직 실행 후 ApiResponse 인스턴스를 반환한다.
그런데, 응답 과정을 생각해보기 이전에 드는 궁금증이 있다.
애초에 우리가 작성한 컨트롤러 메소드는 어디서 어떻게 호출되는 것일까?
요청이 들어온 후 컨트롤러 메서드가 호출되는 과정
HTTP 요청이 서버로 들어온 뒤 컨트롤러 메서드가 실행되기까지의 흐름을 Dispatcher servlet 단계에서부터 스택 프레임으로 나타내었다.
위 그림은 request를 기반으로 실행시킬 컨트롤러 메소드를 찾은 뒤 HandlerAdapter를 통해 이를 실행하기까지의 과정이다. 나는 GET /api/tracks/liked 요청을 보냈으므로 LikeController의 getLikedTracks 메소드가 실행되는 것을 볼 수 있다.
*해당 그림에서 함수 파라미터 타입과 이름은 이해에 필요하다고 판단되는 경우에만 작성해주었다. 따라서 이것이 명시되어 있지 않다고 해서, 파라미터가 존재하지 않는 것은 아니다.
조금만 더 자세히 살펴보자.
DispatcherServlet은 Http 요청을 처리하기 위한 핵심 디스패처이다.
웹 요청을 처리하기 위해, 등록된 핸들러 메서드에게 요청을 전송하며 편리한 매핑과 예외 처리를 가능하게 한다.
어떻게?
1. request 를 기반으로 실행할 핸들러 메서드를 결정한다 : mappedHandler = getHandler(request)
2. 핸들러 메서드를 처리해 줄 어댑터를 찾는다 : ha = getHandlerAdapter(mappedHandler.getHandler())
3. 어댑터야 내가 찾은 핸들러 메서드 작업 좀 해줘 ~^^ : ha.handler(request, response, mappedHandler.getHandler())
참고로 mappedHanlder 자체가 아닌 mappedHanlder.getHandler를 인자로 넘겨주는 이유는 mappedHandler가 HandlerExecutionChain 타입이라서 그렇다. 사실 어댑터는 핸들러 메서드를 실행하기 이전에 메서드에 딸려있는 인터셉터의 preHandle 메서드를 먼저 실행한다. 이를 위해 핸들러 메서드와 이것의 인터셉터들을 멤버변수로 가지고 있는 HandlerExecutionChain 객체를 사용하는 것이다.
이제 핸들러 어댑터가 디스패처 서블릿으로부터 전달받은 핸들러 메서드를 처리할 차례이다.
가장 먼저 호출되는 메서드는 Abstract 핸들러 어댑터로, 구현체에게 다시 한 번 핸들러 메서드를 처리하도록 전달해주는 역할을 한다.
이때 구현체로는 RequestMappingHandlerAdapter가 선택되어있는데, 이유는 실행시킬 핸들러 메서드가 @RequestMapping을 사용하였기 때문이다.
RequestMappingHandlerAdapter 는 핸들러 메서드를 처리하기 위해 전달 받은 핸들러 메서드를 실행 시킨다.
(우리의)컨트롤러 메서드가 실행되었다.
그런데 이 실행 과정을 보아하니 ServletInvocableHandlerMethod 인스턴스의 메서드가 사용된다. 이 인스턴스는 HandlerMethod를 상속받아 구현된 것으로, 지금까지 계속해서 언급해왔던 핸들러 메서드이다.
핸들러 메서드는 LikeController의 getLikedTracks 메서드와 같은 컨트롤러 메서드에 대한 정보를 포함하며, 실제로 getLikedTracks 메서드는 이 핸들러 메서드를 통해 호출된다.
애초에 우리가 작성한 컨트롤러 메서드는 어디서 어떻게 호출되는 것일까?
라는 궁금증을 해결했다.
이제는 정말로 응답 과정을 알아보겠다.
@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;
}
}
해당 메서드는 핸들러 메서드를 실행시키고, 반환된 값을 ReturnValueHandler를 통해 handle하는 책임을 가지고 있기 때문이다.
따라서, 반환된 값을 처리할 적절한 ReturnValueHandler를 찾아 반환값을 처리할 차례이다. 이것은 다양한 반환 유형에 대응할 수 있도록 여러 ReturnValueHandler들을 리스트로 가지고 있는 HandlerMethodReturnValueHandlerComposite 를 이용하여 수행한다.
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 메서드가 먼저 실행되기 때문이다.
HttpEntityMethodProcessor는 index가 5이고, RequestResponseBodyMethodProcessor는 11이다. 그렇기 때문에 for문을 돌릴 때 HttpEntityMethodProcessor의 supportsReturnType 메서드가 먼저 실행되겠다.
두 방식의 차이점 정리
handler(controller) method의 returnType에 따라 returnValueHandler 가 달라지는데, @ResponseBody 를 쓴 경우 RequestResponseBodyMethodProcessor 가 선택되고, HttpEntity를 반환하는 경우 HttpEntityMethodProcessor 가 선택된다.
각 Processor 코드를 비교해보면, 응답해주는 값이 @ResponseBody의 경우 반환하는 값 그 자체이고,
HttpEntity를 반환하는 경우 전달받은 반환값(HttpEntity)로부터 직접 헤더와 본문을 메소드로 추출해 처리하는 정도의 차이로 느껴졌다. 그 외 응답 본문에 들어갈 값을 직렬화 해주기 위해서 컨버터를 호출하는 것은 동일했다.
다시 본론으로 돌아와서, 현재는 RequestResponseBodyMethodProcessor가 ReturnValueHandler이므로 RequestResponseBodyMethodProcessor에게 "저기... returnValue 좀 처리해줄래?" 라고 물어보면 된다.
returnValue를 처리해보자. 현재 returnValue는 ApiResponse 타입 객체로, 객체를 직렬화해서 OutputStream에 써주면 handlerReturnValue 역할이 끝난다.
이를 위해서 ServletServerHttpResponse 인스턴스를 생성한다.
//AbstractMessageConverterMethodProcessor
protected ServletServerHttpResponse createOutputMessage(NativeWebRequest webRequest) {
HttpServletResponse response = webRequest.getNativeResponse(HttpServletResponse.class);
Assert.state(response != null, "No HttpServletResponse");
return new ServletServerHttpResponse(response);
}
만약 ServletServetHttpResponse에 대해서 잘모르겠고, 왜 메서드명이 create"OutputMessage"인지 모르겠다면 아래글을 참고하자.
ServletServerHttpResponse와 HttpOuputMessage 비교
서블릿에서 응답 보내기
이렇게 생성된 ServletServerHttpResponse 인스턴스를 통해, OutputStream에 returnValue를 직렬화해서 써달라고 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();
}
}
}
마무리
이 글은 컨트롤러에서 반환하는 객체가 Http response message에 어떠한 과정을 거쳐 전달되는가? 담기는가?가 궁금해서 스프링 코드를 까보며 정리한 내용을 적은 글이다.
디버깅을 하며 새로 알게된 사실이 많았고, 내용이 상당히 길어 이를 정리하는데 시간이 꽤 걸렸다. 드디어 정리 끝!
'Spring' 카테고리의 다른 글
ServletResponse와 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 |
JPA(자바 ORM 표준 JPA 프로그래밍-기본편) { 1 } (0) | 2023.07.05 |