@RequestBody 사용시 유의할 점

이승훈·2023년 3월 3일
0

TIL

목록 보기
1/1

스프링 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 응답

이때 궁금한 점이 생겼다. 어떻게 컨버터는 JSON 요청을 받아 자바 객체로 역직렬화 할 수 있고, 반대로 자바 객체를 JSON 객체로 직렬화시킬 수 있을까?

그리고 몇 가지 실험해본 결고 흥미로운 현상을 발견하였다. 본래 필자는 @RequestBody를 사용하면서 객체에 값이 설정될 때 생성자나 setter를 통해 들어오는 줄 알고 있었다.

그러나 다음과 같이 구조를 변경하여도 정상적으로 동작되는 것을 확인하였다.

@Getter
public class User {

    private String username;

    private int age;
}

위 구조에서는 각 필드에 대한 값을 설정하는 생성자도 없고, setter 메서드도 없는데 어떻게 값을 설정한 것일까? 오늘은 이에 대해 포스팅해보고자 한다.

Object Mapper

코드에서 @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 메소드의 첫 줄에 중단점을 찍고 디버그를 수행하였다.

환경:

  • Java 11 (Azul Zulu 11.0.18)
  • Spring boot 2.7.8

순서는 요약 하자면 다음과 같았다.

  1. readJavaType 메소드에서 MineType과 Encoding 방식을 추출
  2. 인코딩 방식이 일치(?)한다면 objectMapper 의 readValue 호출
  3. readValue에서 _readMapAndClose를 호출
  4. _readMapAndClose 메소드 내에서 readRootValue 메소드를 호출
  5. readRootValue에서 BeanDeserializer 클래스의 메소드 deserialize를 호출
  6. deserialize 메소드 내에서 deserializeFromObject를 호출
  7. deserializeFromObject 메소드 내에서 createUsingDefault를 호출

사실 여기서부터가 핵심이다. 결국 메시지 컨버터가 내부적으로 생성자를 통해 객체를 생성하는 부분이 이 부분이기 때문이다.

위 코드를 보면 _defaultCreator, 즉 기본 생성자가 정의되어 있지 않다면 부모의 기본 생성자를 호출함을 알 수 있다. User 객체의 경우 기본 생성자를 정의해두었으므로 try 문 안에 코드, 즉 기본 생성자가 호출될 것이다.

그러나 User 클래스의 기본 생성자는 어떻던가? 계속 디버그 과정을 진행해보니 아래와 같이 기본 생성자가 호출되었다.

이렇게 기본 생성자를 통해 객체를 생성하고 나면 다시 BeanDeserializerdeserializeFromObject 메서드가 진행되었으며, 마지막에는 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 객체를 자바 객체로 변환할 때 주의할 점을 알아보았다.

스프링 부트 환경은 많은 것이 추상화되고 자동화되어 있어 편리하기도 하지만 반대로 동작 방식을 모르고 사용했다가는 추후 애플리케이션 장애를 발생시킬 가능성이 클 것 같다.

profile
학생

0개의 댓글