스프링 MVC를 공부하다 @RequestBody
에 대해 궁금한 점이 생겼다. 예를 들어 다음과 같은 코드가 있다고 했을 때
@ResponseBody
@PostMapping("/api/v1/users/save")
public User saveUser(@RequestBody User user) {
log.info("username={}, age={}",
user.getUsername(), user.getAge());
return user;
}
스프링은 @RequestBody
를 보고 JSON - 자바 객체 간 변환을 처리할 수 있는 메시지 컨버터(MappingJackson2HttpMessageConverter)를 찾아 각각 처리된다.
이때 궁금한 점이 생겼다. 어떻게 컨버터는 JSON 요청을 받아 자바 객체로 역직렬화 할 수 있고, 반대로 자바 객체를 JSON 객체로 직렬화시킬 수 있을까?
그리고 몇 가지 실험해본 결고 흥미로운 현상을 발견하였다. 본래 필자는 @RequestBody
를 사용하면서 객체에 값이 설정될 때 생성자나 setter를 통해 들어오는 줄 알고 있었다.
그러나 다음과 같이 구조를 변경하여도 정상적으로 동작되는 것을 확인하였다.
@Getter
public class User {
private String username;
private int age;
}
위 구조에서는 각 필드에 대한 값을 설정하는 생성자도 없고, setter 메서드도 없는데 어떻게 값을 설정한 것일까? 오늘은 이에 대해 포스팅해보고자 한다.
코드에서 @RequestBody
를 명시하면 스프링은 내부적으로 관련된 메시지 컨버터를 찾아 해당 코드를 처리한다.
내부적인 동작 방식과 원리에 대해서는 아래 블로그에 대해 잘 정리되어 있으니 3개의 글을 모두 읽고 오자.
위 블로그 글을 요약하자면, 내부적으로 메시지 컨버터의 수행 과정에서 ObjectMapper
를 사용함을 확인할 수 있다.
위 그림과 같이 MappingJackson2HttpMessageConverter
라는 객체를 통해 직렬화/역직렬화를 수행하는데 이에 대한 주요 로직은 부모 클래스인 AbstractJackson2HttpMessageConverter
안의 정의된 readJavaType()
메소드에서 확인할 수 있다.
아래는 readJavaType()
의 일부를 가져온 것이다.
// private Object readJavaType(JavaType javaType,
// HttpInputMessage inputMessage) throws IOException { ...
boolean isUnicode = ENCODINGS.containsKey(charset.name()) ||
"UTF-16".equals(charset.name()) ||
"UTF-32".equals(charset.name());
try {
InputStream inputStream
= StreamUtils.nonClosing(inputMessage.getBody());
if (inputMessage instanceof MappingJacksonInputMessage) {
Class<?> deserializationView
= ((MappingJacksonInputMessage) inputMessage).getDeserializationView();
if (deserializationView != null) {
ObjectReader objectReader = objectMapper.readerWithView(deserializationView).forType(javaType);
if (isUnicode) {
return objectReader.readValue(inputStream);
} else {
Reader reader
= new InputStreamReader(inputStream, charset);
return objectReader.readValue(reader);
}
}
if (isUnicode) {
return objectMapper.readValue(inputStream, javaType);
} else {
Reader reader = new InputStreamReader(inputStream, charset);
return objectMapper.readValue(reader, javaType);
}
}
어느 정도 유추해볼 수 있는 것은 ObjectMapper
객체를 사용해서 메시지 본문의 내용을 변환시킨다는 것이다. (더 자세한 내용은 위 글을 참고하자)
결론을 말하자면 우선 우리가 작성한 @RequestBody
는 스프링이 JSON을 처리할 수 있는 메시지 컨버터를 통해 처리되며, 이때 메시지 컨버터 내부에서 ObjectMapper를 통해 JSON 데이터를 객체로 변환 및 생성하게 된다.
참고로 Object Mapper가 JSON 필드와 자바 객체의 필드를 매칭시키는 방식은 자바 클래스에 정의된 setter나 getter 메소드에서 앞부분 "set"/"get" 부분을 제거하고 앞글자로 소문자로 바꾼 필드명을 JSON 필드와 매칭시키는 방식이다. 즉, 객체의 getter/setter 메소드를 통해 JSON 필드와 매칭시키는 방식이다.
https://jenkov.com/tutorials/java-json/jackson-objectmapper.html#how-jackson-objectmapper-matches-json-fields-to-java-fields
그리고 중요한 것은 필드 값들을 넣어줄 때에는 setter가 아니라 reflection을 사용한다는 것이다. 따라서 아래와 같이 Getter 하나만 있어도 정상적으로 동작되었던 것이다.
@Getter
public class User {
private String username;
private int age;
}
앞에서 JSON 데이터를 통해 객체를 생성할 때 setter가 아닌 reflection 방식을 사용해 값을 대입해주는 것을 알 수 있었다. 여기서 주의할 점은 생성자이다. 예를 들어 아래와 같이 User
클래스의 구조를 수정했다고 가정해보자.
@Getter
public class User {
private String username;
private int age;
private String sex;
public User() {
System.out.println("User.User");
}
public User(final String username, final int age) {
this.username = username;
this.age = age;
this.sex = "male";
}
}
그리고 다음과 같이 요청이 온다고 가정해보자. 그렇다면 어느 생성자가 호출되는 것일까?
위 응답을 테스트해보면 예상과 달리 기본 생성자가 호출되고, 그 결과 sex
필드는 null 값으로 초기화 된 것을 확인할 수 있다.
이 부분과 관련되어서는 나도 직접 해보는 것이 좋을 것 같아 AbstractJackson2HttpMessageConverter
클래스의 readJavaType
메소드의 첫 줄에 중단점을 찍고 디버그를 수행하였다.
환경:
순서는 요약 하자면 다음과 같았다.
readJavaType
메소드에서 MineType과 Encoding 방식을 추출readValue
호출readValue
에서 _readMapAndClose
를 호출_readMapAndClose
메소드 내에서 readRootValue
메소드를 호출readRootValue
에서 BeanDeserializer
클래스의 메소드 deserialize
를 호출deserialize
메소드 내에서 deserializeFromObject
를 호출deserializeFromObject
메소드 내에서 createUsingDefault
를 호출사실 여기서부터가 핵심이다. 결국 메시지 컨버터가 내부적으로 생성자를 통해 객체를 생성하는 부분이 이 부분이기 때문이다.
위 코드를 보면 _defaultCreator
, 즉 기본 생성자가 정의되어 있지 않다면 부모의 기본 생성자를 호출함을 알 수 있다. User
객체의 경우 기본 생성자를 정의해두었으므로 try 문 안에 코드, 즉 기본 생성자가 호출될 것이다.
그러나 User
클래스의 기본 생성자는 어떻던가? 계속 디버그 과정을 진행해보니 아래와 같이 기본 생성자가 호출되었다.
이렇게 기본 생성자를 통해 객체를 생성하고 나면 다시 BeanDeserializer
의 deserializeFromObject
메서드가 진행되었으며, 마지막에는 reflection을 이용해 JSON 필드에 존재하는 값을 객체 필드 값으로 매핑한 후 해당 객체를 반환함을 알 수 있었다. (아래 그림 참고)
정리해보면 결국 기본 생성자가 존재한다면 이를 호출함을 알 수 있다. 따라서 위 User
클래스의 기본 생성자에 멤버를 모두 올바르게 초기화하려면 기본 생성자 안에 초기화 코드를 집어 넣는 등 다른 방법을 사용해야 한다.
즉
@RequestBody
를 통해 전달된 JSON 객체는 메시지 컨버터 내부에서 ObjectMapper를 통해 기본 생성자로 객체를 생성한 후 reflection을 이용해 JSON 필드에 존재하는 값을 객체 필드 값으로 매핑한 후 해당 객체를 반환하는 과정을 거친다.
그렇다면 기본 생성자를 애초에 정의해주지 않는다면 어떻게 될까? 만약 아래와 같이 기본 생성자를 정의해주지 않을 경우 앞에서 본 방식과 다르게 동작한다.
@Getter
public class User {
private String username;
private int age;
private String sex;
public User(final String username, final int age) {
this.username = username;
this.age = age;
this.sex = "male";
}
}
위 코드를 실행하면 아래와 같은 오류를 확인할 수 있다.
Caused by: com.fasterxml.jackson.databind.exc.InvalidDefinitionException: Cannot construct instance of
User
(no Creators, like default constructor, exist): cannot deserialize from Object value (no delegate- or property-based Creator)
at [Source: (org.springframework.util.StreamUtils$NonClosingInputStream); line: 1, column: 2]
그럼 이제 원인을 분석해보자. 위 코드를 디버깅해보면 이전에 살펴본BeanDeserializer
에서 deserializeFromObjectUsingNonDefault
를 호출한다.
이때 deserializeFromObjectUsingNonDefault
구현 코드는 아래와 같다.
위 코드를 요약하자면 객체를 생성할 수 있는 delegateSerializer를 찾고, 없다면 _propertyBasedCreator
를 찾게 된다.
이때 _propertyBasedCreator
는 property 기반 클래스(property 관련 어노테이션이 적용된)인 경우에만 null이 아니기 때문에 이 또한 넘어가게 되고 결국에는 아래와 같이 handleMissingInstantiator
를 호출하게 된다.
결국 기본 생성자를 명시적으로 정의하지 않는 경우,
@JsonProperty
,@JsonAutoDetect
등을 사용한 Property 기반 클래스이거나, 생성자가 위임된 경우가 아니라면 오류가 발생하게 된다.
출처: @RequestBody에 왜 기본 생성자는 필요하고, Setter는 필요 없을까? #2
이번 시간에는 메시지 컨버터가 내부적으로 어떻게 동작하는지와 @RequestBody
를 통해 JSON 객체를 자바 객체로 변환할 때 주의할 점을 알아보았다.
스프링 부트 환경은 많은 것이 추상화되고 자동화되어 있어 편리하기도 하지만 반대로 동작 방식을 모르고 사용했다가는 추후 애플리케이션 장애를 발생시킬 가능성이 클 것 같다.