본문 바로가기
Java/Spring

@RequestBody로 지정한 DTO에 기본생성자가 필요한 이유

by yongckim 2022. 10. 11.
728x90
반응형

@RequestBody로 요청 DTO를 매핑시킨 상태로 HTTP 요청을 보냈는데 다음과 같은 에러가 발생했었습니다.

org.springframework.web.util.NestedServletException: Request processing failed; nested exception is org.springframework.http.converter.HttpMessageConversionException: Type definition error: [simple type, class org.devcourse.voucher.application.customer.controller.dto.CustomerRequest]; nested exception is com.fasterxml.jackson.databind.exc.InvalidDefinitionException: Cannot construct instance of `org.devcourse.voucher.application.customer.controller.dto.CustomerRequest` (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]
 ...
 ...

위의 예외 메시지를 계속 읽어보면 생성자가 존재하지 않는다는 메시지를 볼 수 있습니다.

...
...
nested exception is com.fasterxml.jackson.databind.exc.InvalidDefinitionException: 
Cannot construct instance of `org.devcourse.voucher.application.customer.controller.dto.CustomerRequest` (no Creators, like default constructor, exist): 
cannot deserialize from Object value (no delegate- or property-based Creator)
...
...

하지만 DTO를 확인해보았을 때 생성자가 존재하는 상태였습니다.

public class CustomerRequest {

  private String name;

  private String email;

  public CustomerRequest(String name, String email) {
    this.name = name;
    this.email = email;
  }

  public String getName() {
    return name;
  }

  public String getEmail() {
    return email;
  }

}

왜 생성자가 있는데 생성자가 존재하지 않는 에러가 발생하는 걸까요?

먼저 @RequestBody가 무엇인지 먼저 알아봅시다.

@RequestBody란?

@RequestBody를 지정한 객체의 경우 컨트롤러에 온 요청을 해당 객체에 매핑시켜 변환하는 역할을 해줍니다.

 

요청 본문 변환 과정

컨트롤러에 @RequestBody로 지정한 객체는 본문 형식을 확인한 후 자바객체로 변환하게 됩니다.

 

이 변환은 HttpMethodConverter를 사용하여 변환하게 됩니다.

 

RequestMappingHandlerAdpater에는 HttpMessageConverter 타입의 메시지 변환기가 여러개 등록되어 있습니다.

 

여러개의 Converter 중에 Content-Type에 따라 적합한 Converter 사용되게 됩니다.

 

저같은 경우 JSON 요청이기 때문에 MappingJackson2HttpMessageConverter를 사용해서 변환하게 됩니다.

 

해당 클래스는 Jackson 라이브러리의 ObjectMapper를 사용해서 요청 본문을 변환합니다.

 

ObjectMapper

ObjectMapper는 기본 POJO 또는 범용 Json Tree Model(JsonNode)에서 JSON을 읽고 쓰는 기능과 변환 수행을 위한 기능을 제공합니다.

쉽게 말해, Java Object ↔ JSON 파싱을 해주는 클래스입니다.

 

자바 객체에서 JSON으로 변환하는 것을 직렬화(Serialize)라고 하며, JSON을 자바 객체로 변환하는 것을 역직렬화라고 합니다.

(정확히는 JSON이 아니라 byte 형태)

 

요청은 어떻게 처리되어 반환되는지?

delegatingSerializer를 통해서 반환합니다. 만약, deletgate를 하지 않았거나 Property로 명시해주지 않았다면 기본적으로 기본생성자를 통해 변환하게 됩니다.

 

이때문에 DTO 클래스는 @JsonProperty나 @JsonAutoDetect 통해 명시해주거나 생성자를 위임하는 방법(delegate)을 사용하거나 기본생성자를 통해 자동으로 변환해줄 수 있게끔 구현해야 합니다.

 

DTO에 setter가 없는데 기본생성자로만 생성이 되는 이유

이를 알기 위해서는 먼저 ObjectMapper가 JSON Field와 Java Field를 어떻게 매핑하는지 알아야 합니다.

 

기본적으로 Jackson은 Json 필드의 이름을 Java 객체의 getter 및 setter 메서드와 일치시켜 JSON 객체 필드를 Java 객체의 필드에 매핑시킵니다.

 

Jackson은 getter 및 setter 메서드 이름의 “get” 및 “set” 부분을 제거하고 나머지 이름의 첫문자를 소문자로 변환시켜 사용합니다.

getName -> name

이를 요약하면 동일한 이름의 필드를 직접 찾는 것이 아닌 getter와 setter를 찾아 매핑하게 됩니다.

 

이때문에 DTO에 getter와 setter 둘다 존재하지 않는다면 UnrecognizedPropertyException 예외가 발생하게 됩니다.

 

DTO에 실제로 값을 주입하는 방법

DTO에 getter와 setter의 이름을 통해 필드를 매핑시킨다는 사실을 알았습니다.

 

그렇다면 실제 값은 어떻게 주입될까요?

만약 setter를 이용해서 한다면 setter가 존재하지 않는다면 에러가 발생할 것입니다.

하지만 다음과 같이 getter만 존재하는 DTO로 테스트해봐도 값이 잘 들어가는 것을 확인할 수 있습니다.

public class CustomerTestRequest {
  private String name;

  private String email;

  public String getName() {
    return name;
  }

  public String getEmail() {
    return email;
  }

}

이게 가능한 이유는 Jackson에서 값을 주입할때 setter를 사용하는 것이 아닌 reflection을 이용해서 값을 주입하기 때문입니다.

 

그렇기 때문에 setter를 넣어주지 않고 getter만 추가해도 문제가 발생하지 않습니다.

그리고 값의 변경 위험이 있는 setter보다는 getter를 사용하는 것이 더 안전할 것입니다.

정리

  • @RequestBody로 요청에 대한 값(text, json, etc...)을 객체에 매핑시켜 반환해줍니다.
    • 요청을 DTO로 변환할때 따로 프로퍼티를 명시하거나 위임(delegate)하지 않았다면 기본생성자를 통해 변환하게 됩니다. 
  • @RequestBody로 변환할때 요청에 대한 값을 HttpMethodConveter를 사용해 변환하게 되며, Content-Type에 따라 적합한 Converter가 선택됩니다.
  • JSON 요청의 경우 MappingJackson2HttpMessageConverter를 사용해 변환하며, 해당 클래스는 Jackson 라이브러리의 ObjectMapper를 사용해서 요청 본문을 변환합니다.
  • JSON 요청의 경우 Jackson 라이브러리를 사용하는데 이때, Json 필드의 이름을 동일한 이름의 필드를 찾는 것이 아닌 getter와 setter를 찾아 매핑하게 됩니다.
    • getter와 setter 메서드 이름의 get, set 부분을 제거하고 나머지 이름의 첫문자를 소문자로 변환시켜 사용합니다.
    • 이 때문에 반드시 getter 혹은 setter가 존재해야 합니다.
  • Jackson에서 실제로 값을 주입할때는 setter를 사용하는 것이 아닌 리플렉션을 사용해서 값을 넣습니다. (이때문에 setter 사용이 강제되지 않음)

해당 내용을 정리하며 @RequestBody를 사용할때 왜 기본생성자가 필요한지 알게되었고 실제 요청이 어떻게 파싱되어 DTO로 변환되는지 과정을 알 수 있었습니다.

이에 대해 잘 몰라서 처음에는 @JsonProperty 애노테이션으로 프로퍼티 지정을 통해 해당 문제를 해결했으나 기본생성자를 만들고 getter를 추가하면 해결된다는 것을 알게되었습니다. (DTO의 경우 들어온 후로 불변객체로 사용하기 때문에 setter를 만드는 것보다는 getter로 값만 가져올 수 있도록 처리하는게 좋을 것 같다고 생각합니다.)

 

참고

https://velog.io/@conatuseus/RequestBody에-기본-생성자는-왜-필요한가

https://velog.io/@conatuseus/RequestBody에-왜-기본-생정자는-필요하고-Setter는-필요-없을까-2-ejk5siejhh

https://velog.io/@conatuseus/RequestBody에-왜-기본-생성자는-필요하고-Setter는-필요-없을까-3-idnrafiw

https://jojoldu.tistory.com/407

https://jenkov.com/tutorials/java-json/jackson-objectmapper.html#how-jackson-objectmapper-matches-json-fields-to-java-fields

반응형