Backend/Spring

HttpMessageConverter, ArgumentResolver, MappingJackson2HttpMessageConverter(JSON을 이용한 @RequestBody/@ResponseBody의 동작 원리)

olsohee 2024. 1. 10. 18:01

@RequestBody는 클라이언트 측에서 보낸 HTTP 요청 메시지 바디의 데이터를 자바 객체로 변환해준다. 그리고 @ResponseBody는 객체 반환시 객체가 JSON 형태로 변환되어 응답이 나가도록 해준다. 이들의 동작 원리를 이해하기 위해서는 직렬화/역직렬화의 개념과 스프링 MVC에서 제공하는 HttpMessageConverter에 대해 알아야 한다.

직렬화/역직렬화

직렬화(Serialization)는 객체를 문자열 또는 바이트 스트림으로 변환하는 것을 의미한다. 반대로 역직렬화(Deserialization)는 문자열 또는 바이트 스트림을 다시 객체로 변환하는 것을 의미한다. 

즉 @RequestBody는 클라이언트 측에서 보낸 HTTP 요청 메시지 바디의 데이터를 자바 객체로 변환하는 역직렬화를 담당하고, @ResponseBody는 자바 객체를 CSV, XML, JSON 등의 형태로 변환하는 직렬화를 담당한다.

HttpMessageConverter

HttpMessageConverter는 HTTP 요청 메시지 바디의 데이터를 자바 객체로 변환하거나(역직렬화), 자바 객체를 특정 형태로 변환하는(직렬화) 역할을 담당한다. HttpMessageConverter는 HTTP 요청과 응답 두 경우 모두 사용되고(양방향), 스프링 MVC는 다음 경우에 HttpMessageConverter를 적용한다.

  • HTTP 요청의 경우: @RequestBody, HttpEntity(RequestEntity)
  • HTTP 응답의 경우: @ResponseBody, HttpEntity(ResponseEntity)

HttpMessageConverter는 다음과 같이 인터페이스 형태이다.  

public interface HttpMessageConverter<T> {

	boolean canRead(Class<?> clazz, @Nullable MediaType mediaType);

	boolean canWrite(Class<?> clazz, @Nullable MediaType mediaType);

	List<MediaType> getSupportedMediaTypes();

	default List<MediaType> getSupportedMediaTypes(Class<?> clazz) {
		return (canRead(clazz, null) || canWrite(clazz, null) ?
				getSupportedMediaTypes() : Collections.emptyList());
	}

	T read(Class<? extends T> clazz, HttpInputMessage inputMessage)
			throws IOException, HttpMessageNotReadableException;

	void write(T t, @Nullable MediaType contentType, HttpOutputMessage outputMessage)
			throws IOException, HttpMessageNotWritableException;

}
  • canRead(), canWrite(): 메시지 컨버터가 해당 클래스, 미디어 타입을 지원하는지 체크
  • read(), write(): 메시지 컨버터를 통해 메시지를 읽고 쓰는 기능

그리고 스프링이 직렬화/역직렬화 데이터 형식에 따라 여러가지 메시지 컨버터를 제공하는데, 이들은 HttpMessageConverter 인터페이스를 구현한다. 스프링이 기본적으로 제공하는 메시지 컨버터는 다음과 같다. 

  • ByteArrayHttpMessageConverter
    • 클래스 타입: byte[]
    • 미디어 타입: */*
    • 요청 ex, @RequestBody byte[] data
    • 응답 ex, @ResponseBody return byte[]
  • StringHttpMessageConverter
    • 클래스 타입: String
    • 미디어 타입: */*
    • 요청 ex, @RequestBody String data
    • 응답 ex, @ResponseBody return "ok"
  • MappingJackson2HttpMessageConverter
    • 클래스 타입: 객체 또는 HashMap
    • 미디어 타입: application/json
    • 요청 ex, @RequestBody RequestDto requestDto
    • 응답 ex, @ResponseBody return responseDto
  • ...

스프링은 대상 클래스 타입과 미디어 타입(Content-Type, Accept)을 기반으로 여러 메시지 컨버터들 중 적절한 메시지 컨버터를 찾아서 사용한다(canRead(), canWrite() 메소드를 통해 해당 컨버터가 해당 클래스와 미디어 타입을 지원하는지 체크하여 적절한 메시지 컨버터를 찾는다).

동작 흐름

지금까지 살펴본 내용을 기반으로 스프링이 제공하는 HttpMessageConverter가 동작하는 흐름을 정리해보자.

 

HTTP 요청 데이터를 읽는 경우는 다음과 같다.

  1. HTTP 요청이 오고, 컨트롤러에서 @RequestBody 또는 HttpEntity를 사용한다.
  2. 메시지 컨버터가 요청 데이터를 읽을 수 있는지 확인하기 위해 canRead()를 호출한다.
    1. 대상 클래스 타입을 지원하는가(ex, @RequestBody의 대상 클래스)
    2. HTTP 요청의 Content-Type을 지원하는가
  3. 위 두 조건을 만족하여 canRead()를 만족하면 해당 메시지 컨버터가 사용되고, 메시지 컨버터의 read()를 호출해서 데이터를 읽어 객체를 생성하고 반환한다.

HTTP 응답 데이터를 생성하는 경우는 다음과 같다.

  1. 컨트롤러에서 @ResponseBody 또는 HttpEntity를 통해 값을 반환한다.
  2. 메시지 컨버터가 응답 데이터를 쓸 수 있는지 확인하기 위해 canWrite()를 호출한다.
    1. 대상 클래스 타입을 지원하는가(ex, return의 대상 클래스)
    2. HTTP 요청의 Accept를 지원하는가
  3. 위 두 조건을 만족하여 canWrite()를 만족하면 해당 메시지 컨버터가 사용되고, 메시지 컨버터의 write()를 호출해서 응답 메시지 바디에 데이터를 생성한다. 

RequestMappingHandlerAdapter가 HttpMessageConverter를 사용하는 방식

https://olsohee.tistory.com/61 해당 게시글에서 RequestMappingHandlerAdapter에 대해 알아봤다. 요약하자면, RequestMappingHandlerAdapter는 스프링이 가장 높은 우선순위로 등록하는 핸들러 어댑터로, 어노테이션 기반의 핸들러(컨트롤러)를 처리하는 핸들러 어댑터이다.

 

생각해보면 RequestMappingHandlerAdapter 핸들러 어댑터를 사용하여 호출하는 컨트롤러, 즉 어노테이션 기반의 컨트롤러는 파라미터로 다양한 형식을 받을 수 있었다(HttpServletRequest, Model, @ModelAttribute, @RequestBody, HttpEntity 등). 이게 가능한 이유는 HandlerMethodArgumentResolver 덕분이다(줄여서 ArgumentResolver라고 부른다).

ArgumentResolver(HandlerMethodArgumentResolver)

어노테이션 기반의 컨트롤러를 처리하는 RequestMappingHandlerAdapter는 ArgumentResolver를 통해 컨트롤러에서 필요로 하는 다양한 형식의 파라미터 값을 생성한다. 그리고 필요로 하는 파라미터 값이 모두 준비되면 컨트롤러를 호출하면서 파라미터로 넘겨주는 것이다.

HandlerMethodArgumentResolver는 다음과 같이 인터페이스 형태이고, 스프링은 다양한 파라미터 형식을 지원하는 여러 ArgumentResolver를 제공하는데, 이들은 HandlerMethodArgumentResolver 인터페이스를 구현하고 있다.

public interface HandlerMethodArgumentResolver {

	boolean supportsParameter(MethodParameter parameter);

	@Nullable
	Object resolveArgument(MethodParameter parameter, @Nullable ModelAndViewContainer mavContainer,
			NativeWebRequest webRequest, @Nullable WebDataBinderFactory binderFactory) throws Exception;

}
  • supportsParameter()를 호출해서 해당 파라미터를 지원하는지 체크하고, 지원하면 resolveArgument()를 호출해서 실제 객체를 생성한다. 그리고 이렇게 생성된 객체를 컨트롤러 호출시 파라미터로 넘겨준다.

ReturnValueHandler(HandlerMethodReturnValueHandler)

ReturnValueHandler는 ArgumentResolver와 유사하게 응답 값을 변환하고 처리해주는 역할을 한다. 컨트롤러에서 String으로 반환하면 뷰를 찾아서 반환하는 것이 ReturnValueHandler 덕분이다. 

public interface HandlerMethodReturnValueHandler {

	boolean supportsReturnType(MethodParameter returnType);

	void handleReturnValue(@Nullable Object returnValue, MethodParameter returnType,
			ModelAndViewContainer mavContainer, NativeWebRequest webRequest) throws Exception;

}
  • supportsReturnType()을 호출해서 해당 반환 타입을 지원하는지 체크하고, 지원하면 handleReturnValue()를 호출해서 실제 객체를 생성한다. 그리고 이렇게 생성된 객체를 반환한다.

HttpMessageConverter의 위치

  • RequestMappingHandlerAdapter 핸들러 어댑터는 ArgumentResolver를 통해 컨트롤러가 필요로 하는 파라미터 값을 생성한다. 그리고 이때 컨트롤러가 필요로 하는 파라미터가 @RequestBody 또는 HttpEntity(RequestEntity)이면 RequestMappingHandlerAdapter는 HttpMessageConverter를 사용해서 HTTP 요청 메시지 데이터를 읽어서 파라미터로 넘겨줄 객체(@RequestBody 또는 HttpEntity(RequestEntity))를 생성한다. 
  • 그리고 응답의 경우에도, ReturnValueHandler를 통해 응답 객체를 생성하는데, 이때 생성해야 하는 응답 객체가 @ResponseBody 또는 HttpEntity(ResponseEntity)이면 HttpMessageConverter를 사용해서 응답 객체를 생성한다.

JSON 데이터 변환시 사용되는 MappingJackson2HttpMessageConverter

스프링이 기본으로 제공하는 HttpMessageConverter 중 JSON 데이터의 직렬화/역직렬화를 담당하는 메시지 컨버터는 MappingJackson2HttpMessageConverter이다.

 

api 개발을 하다보면 @RequestBody를 통해 JSON 데이터가 자바 객체로 변환되고, @ResponseBody를 통해 자바 객체가 JSON 데이터로 변환되는 과정을 자주 접한다. 그렇다면 MappingJackson2HttpMessageConverter를 통해 어떻게 JSON 데이터와 자바 객체 간의 직렬화/역직렬화 과정이 일어나는지 알아보자.

ObjectMapper

MappingJackson2HttpMessageConverter는 내부적으로 ObjectMapper를 통해 JSON 데이터를 자바 객체로 역직렬화 한다. ObjectMapper는 JSON 데이터를 자바 객체로 역직렬화하거나 자바 객체를 JSON 데이터로 직렬화할 때 사용하는 Jackson 라이브러리이다.

ObjectMapper의 직렬화 방식(자바 객체 ➡️ JSON 데이터)

자바 객체로부터 JSON 데이터를 만들기 위해서는 필드 값을 알야아 한다. 이때 ObjectMapper는 public 필드 또는 public의 getter 메소드를 통해 필드 값을 알아낸다. 그리고 getter 메소드의 prefix(get)를 잘라내고 맨 앞을 소문자로 만들어서 필드를 식별한다. 따라서 직렬화를 위해서는 getter 메소드가 필요하다.

 

이러한 ObjectMapper의 직렬화 동작 방식을 알고 있으면 다음 예제의 문제점을 알 수 있다. 다음과 같이 DTO 클래스를 정의했다고 가정하자.

@Getter
@NoArgsConstructor
@AllArgsConstructor
public class RequestDto {

    private String name;
    private Integer age;

    public String getNameWithAge() {
        return name + "(" + age + ")";
    }

}

위 클래스 객체를 ObjectMapper로 직렬화하면 다음과 같은 JSON 데이터가 생성된다. 

{"name":"kim","age":20,"nameWithAge":"kim(20)"}

getNameWithAge() 메소드를 getter로 인식하여, 해당 메소드가 반환하는 값을 nameWithAge 필드 값으로 인식하기 때문이다. 따라서 ObjectMapper의 직렬화 동작 방식을 알고 잘못된 JSON 응답이 나가지 않도록 주의하자.

ObjectMapper의 역직렬화 방식(JSON 데이터 ➡️ 자바 객체)

JSON 데이터를 자바 객체로 변환하는 역직렬화를 위해서는 우선 자바 객체를 생성한다. 이때 해당 자바 클래스의 기본 생성자를 사용한다. 따라서 역직렬화를 위해서는 기본 생성자가 필요하다.

 

객체를 생성한 후에는 객체의 필드 값을 초기화해주어야 한다. 이때 필드명을 알아내기 위해 public 필드 또는 public의 getter/setter 메소드로 필드명을 찾고 초기화한다. 그리고 ObjectMapper는 setter 메소드를 통해 필드 값을 초기화하지 않는다. 리플렉션을 이용하기 때문에 setter 없이 getter만 있어도 ObjectMapper가 정상적으로 필드명을 찾고 필드 값을 초기화한다.

정리

  • HttpMessageConverter는 직렬화와 역직렬화를 담당한다. (HTTP 요청 데이터 ➡️ 자바 객체) (자바 객체 ➡️ HTTP 응답 데이터)
  • RequestMappingHandlerAdapter + ArgumentResolver
    • 어노테이션 기반의 컨트롤러를 처리하는 RequestMappingHandlerAdapter는 ArgumentResolver를 통해 컨트롤러에서 필요로 하는 다양한 형식의 파라미터 값을 생성한다.
    • 그리고 이때 생성해야 하는 값의 형태가 @RequestBody 또는 HttpEntity(RequestEntity)이면,HttpMessageConverter를 사용해서 값을 생성한다(스프링이 제공하는 여러 HttpMessageConverter 중 적절한 메시지 컨버터를 사용해서 HTTP 요청 메시지 바디의 데이터를 자바 객체로 생성하는 역직렬화를 진행).
    • RequestMappingHandlerAdapter는 ArgumentResolver를 통해 생성한 자바 객체를 컨트롤러의 파라미터로 넘겨준다.
  • MappingJackson2HttpMessageConverter
    • 스프링이 기본적으로 제공하는 HttpMessageConverter 중 JSON 데이터를 담당하는 메시지 컨버터는 MappingJackson2HttpMessageConverter이다.
    • MappingJackson2HttpMessageConverter는 내부적으로 ObjectMapper를 통해 직렬화/역직렬화를 진행한다.
    • ObjectMapper는 직렬화를 할 때, 자바 객체의 필드 값을 찾기 위해 getter 메소드를 사용한다. 그리고 역직렬화를 할 때, 자바 객체 생성을 위해 기본 생성자를 사용하고, 필드명을 알아내기 위해 getter 또는 setter 메소드를 사용한다.
    • 결론적으로 항상 DTO 클래스에는 @NoArgsConstructor, @Getter를 넣어주자! (setter 보다는 getter가 자주 사용되므로 역직렬화 시 필요한 getter, setter 중에 getter만 사용하자)

Reference