업데이트:

Spring 프로젝트에서 API 엔드포인트인 Controller를 만들다 보면 값이 넘어 올 때 setter나 생성자가 없다고 하는 오류를 자주 접할 수 있습니다. 어떤 경우에 이런 오류가 발생하고 어떤 경우에 객체로 매핑이 잘될 수 있는지를 알아보도록 하겠습니다.

Dto


@RequestBody를 통해 매핑될 Dto 케이스는 총 3개로 정했습니다.

1. AllArgsConstructor

모든 매개변수를 받는 생성자만 있는 경우입니다.

@AllArgsConstructor
public class OrderDto {

    private String name;
    private long amount;

}

2. NoArgsConstructor / AllArgsConstructor

기본 생성자와 모든 매개변수를 받는 생성자가 있는 경우입니다.

@NoArgsConstructor
@AllArgsConstructor
public class BoardDto {

    private String title;
    private String content;

}

3. getter / setter

Lombok으로 생성자는 지정하지 않고(그럼 기본 생성자만 존재하겠죠?)

getter/setter가 있는 경우 입니다.

@Getter
@Setter
public class BookDto {

    private String name;
    private long amount;

}

테스트


Post 테스트

Controller는 단순합니다.

@Slf4j
@RestController
public class MappingController {

    @PostMapping("/order")
    public String createOrder(@RequestBody OrderDto orderDto) {
        log.info("createOrder, orderDto : {}", orderDto);
        return "Success";
    }
}

위에는 orderDto 매핑 API “/order”만 있으나, 다른 BookDto, BoardDto에 관련된 API도 동일합니다.

Controller@RequestBody가 제대로 매핑 되는지만 확인하기 위함입니다.

테스트 코드는 역시 모두 동일합니다.

MockMvc를 사용했으며, ObjectMapper를 통해 객체를 Json String으로 변환하여 API를 호출합니다.

💡ObjectMapper는 간단하게 말하면 JsonJava 객체간의 직렬화/역직렬화를 도와주는 Jackson의 라이브러리입니다.

@DisplayName("모든 매개변수만 있는 Order Post 호출")
@Test
void callPost() throws Exception {
    /** Given **/
    String content = objectMapper.writeValueAsString(new OrderDto("order1", 1_000L));

    /** When **/
    String actual = mockMvc.perform(post("/order")
            .content(content)
            .contentType(MediaType.APPLICATION_JSON)
        )
        .andExpect(status().isOk())
        .andReturn()
        .getResponse()
        .getContentAsString();

    assertThat(actual).isEqualTo("Success");
}

Jacksongetter

근데 테스트 도중 Controller에 들어오기도 전에 에러가 발생했습니다.

com.fasterxml.jackson.databind.exc.InvalidDefinitionException: 
No serializer found for class com.citizen.board.domain.BoardDto 
and no properties discovered to create BeanSerializer 
(to avoid exception, disable SerializationFeature.FAIL_ON_EMPTY_BEANS)

무슨 에러냐면 objectMapper로 직렬화하기 위해선 객체 property에 접근하기 위해 멤버변수가 public이거 getter/setter가 존재해야 합니다. 1번, 2번 케이스에 getter를 추가하여 에러를 해결하겠습니다.

💡objectMapper에는 이 밖에도 내용이 있으나 길어질 것 같아 다른 포스팅에서 자세히 다뤄보도록 하겠습니다.

결과

Post_Result

위의 결과 화면을 보시면 모든 매개변수 생성자만 있는 경우가 실패했음을 알 수 있습니다.

에러 추적

실패 메시지는 다음과 같습니다.

org.springframework.web.util.NestedServletException: 
Request processing failed; 
nested exception is org.springframework.http.converter.HttpMessageConversionException: 
Type definition error: 
[simple type, class com.citizen.board.domain.OrderDto]; 
nested exception is com.fasterxml.jackson.databind.exc.InvalidDefinitionException: 
Cannot construct instance of `com.citizen.board.domain.OrderDto` 
(no Creators, like default constructor, exist): 
cannot deserialize from Object value (no delegate- or property-based Creator)
 at [Source: (PushbackInputStream); line: 1, column: 2]

중요한 부분은 (no Creators, like default constructor, exist) 입니다.

like default constructor 즉, 기본 생성자가 없다는 뜻입니다.

이번엔 반대로 OrderDto@AllArgsConstructor대신 @NoArgsConstructor를 추가해보도록 하겠습니다.

Setter가 아닌 값을 세팅해주기 위해 setVariable이란 메소드를 추가해줬습니다.

@Getter
@NoArgsConstructor
public class OrderDto {

    private String name;
    private Long amount;

    public void setVariable(String name, Long amount) {
        this.name = name;
        this.amount = amount;
    }

}

그리고 나서 테스트를 돌려보면

Post_Refactoring_Result

테스트가 성공한 것을 볼 수 있습니다~

추가로 SpringMessageConverter는 어떻게 Json String을 객체로 매핑하는 알아보기 위해 계속해서 에러를 따라가 보도록 하겠습니다.

위 로그에 이어 아래에는 다음 로그 화면입니다.

Post_Fail_Log

여기서 나와있는 AbstractJackson2HttpMessageConverterreadJavaType 메소드를 보시면 다음과 같은데요.

private Object readJavaType(JavaType javaType, HttpInputMessage inputMessage) throws IOException {
   MediaType contentType = inputMessage.getHeaders().getContentType();
   Charset charset = getCharset(contentType);

   ObjectMapper objectMapper = selectObjectMapper(javaType.getRawClass(), contentType);
   Assert.state(objectMapper != null, "No ObjectMapper for " + javaType);

   boolean isUnicode =ENCODINGS.containsKey(charset.name()) ||
         "UTF-16".equals(charset.name()) ||
         "UTF-32".equals(charset.name());
   try {
      if (inputMessage instanceof MappingJacksonInputMessage) {
         Class<?> deserializationView = ((MappingJacksonInputMessage) inputMessage).getDeserializationView();
         if (deserializationView != null) {
            ObjectReader objectReader = objectMapper.readerWithView(deserializationView).forType(javaType);
            if (isUnicode) {
               return objectReader.readValue(inputMessage.getBody());
            }
            else {
               Reader reader = new InputStreamReader(inputMessage.getBody(), charset);
               return objectReader.readValue(reader);
            }
         }
      }
      if (isUnicode) {
         return objectMapper.readValue(inputMessage.getBody(), javaType);
      }
      else {
         Reader reader = new InputStreamReader(inputMessage.getBody(), charset);
         return objectMapper.readValue(reader, javaType);
      }
   }
   catch (InvalidDefinitionException ex) {
      throw new HttpMessageConversionException("Type definition error: " + ex.getType(), ex);
   }
   catch (JsonProcessingException ex) {
      throw new HttpMessageNotReadableException("JSON parse error: " + ex.getOriginalMessage(), ex, inputMessage);
   }
}

보시면 결국 ObjectMapper를 이용하여 객체로 매핑해주는걸 확인하실 수 있습니다.

Get 테스트

PostObjectMapper를 사용하는 것으로 확인되었으니, 이번엔 Get 요청은 어떻게 처리되는지 확인해보겠습니다.

테스트 코드는 Post 테스트처럼 MockMvc를 사용했고 요청 메소드를 PostGet으로 변경했습니다.

그리고 query parameter로 보내기 위해 MultiValueMap을 사용했습니다.

@DisplayName("모든 매개변수만 있는 Order Get 호출")
@Test
void callGet() throws Exception {
    /** Given **/
    MultiValueMap<String, String> params = new LinkedMultiValueMap<>();
    params.put("name", Arrays.asList("order2"));
    params.put("amount", Arrays.asList("2000"));

    /** When **/
    String actual = mockMvc.perform(get("/order")
                    .params(params)
                    .contentType(MediaType.APPLICATION_JSON)
            )
            .andExpect(status().isOk())
            .andReturn()
            .getResponse()
            .getContentAsString();

    assertThat(actual).isEqualTo("Success");

Controller는 다음과 같습니다.

@GetMapping("/order")
public String getOrder(OrderDto orderDto) {
    log.info("getOrder, orderDto : {}", orderDto);
    return "Success";
}

결과

테스트 결과는 다음과 같습니다.

Get_Result1

이상하게 전부 성공했습니다!?

하지만 콘솔 로그를 보면 BoardDto만 값이 제대로 바인딩이 안된 것을 확인할 수 있습니다.

Get_Result2

BoardDtoNoArgsConstructor / AllArgsConstructor 모두 존재하는 Dto 입니다.

이유가 뭘까요?

에러 추적

향로(jojoldu) 님의 블로그 글을 참고하여 WebDataBinder를 따라가본 결과

ModelAttributeMethodProcessor라는 곳에서 값을 바인딩 해주고 있는 것을 알게 되었는데요.

그 메소드는 다음과 같습니다.

protected Object createAttribute(String attributeName, MethodParameter parameter,
      WebDataBinderFactory binderFactory, NativeWebRequest webRequest) throws Exception {

    MethodParameter nestedParameter = parameter.nestedIfOptional();
    Class<?> clazz = nestedParameter.getNestedParameterType();

    Constructor<?> ctor = BeanUtils.getResolvableConstructor(clazz);
    Object attribute = constructAttribute(ctor, attributeName, parameter, binderFactory, webRequest);
    if (parameter != nestedParameter) {
        attribute = Optional.of(attribute);
    }
    return attribute;
}

중간 BeanUtils.getResolvableConstructor를 보시면 Class를 인자로 받아 생성자를 넘겨주는 메소드인데 확인해보시면

public static <T> Constructor<T> getResolvableConstructor(Class<T> clazz) {
    Constructor<T> ctor = findPrimaryConstructor(clazz);
    if (ctor == null) {
        Constructor<?>[] ctors = clazz.getConstructors();
        if (ctors.length == 1) {
            ctor = (Constructor<T>) ctors[0];
        } else {
            try {
                ctor = clazz.getDeclaredConstructor();
            } catch (NoSuchMethodException ex) {
                throw new IllegalStateException("No primary or single public constructor found for " +
                    clazz + " - and no default constructor found either");
            }
        }
    }
    return ctor;
}

clazz.getConstructors를 통해 생성자들을 가져와 배열 ctors에 선언하는데, ctors의 길이가 1이면 그 생성자를 사용하지만, 여러개 있을 경우 clazz.getDeclaredConstructor기본 생성자를 사용합니다. 따라서, 값이 바인딩이 안되는거라 생각하시면 됩니다.

💡혹시나 싶어 BoardDto ClassgetDeclaredConstructor를 호출하여 어떤 값이 들어있는지 확인해봤습니다.

getDeclaredConstructors를 호출했을 경우에는 모든 생성자들을 가져옵니다.

getDeclaredConstructors

하지만 getDeclaredConstructor를 호출했을 때에는 기본 생성자만 나오는 것을 확인할 수 있습니다.

getDeclaredConstructor

수정 후 테스트

NoArgsConstructor 제거 후 AllArgsConstructor만 남기고 다시 테스트해봤습니다.

Get_Refactoring_Result

정상적으로 값이 바인딩 되는걸 확인할 수 있습니다.

마무리


개인적으로 불변객체를 좋아하기 때문에 setter를 지양하자는 주의인데요.

따라서 setter 없이 Post 요청을 받을 경우에는 객체의 property에 접근하기 위해

Getter가 필요(혹은 변수에 public 접근 제어자 사용)함을 알 수 있었고,

Get 요청에는 반드시 모든 매개변수를 받는 생성자, AllArgsConstructor1개만 필요함을 알 수 있습니다.

그럼 많이 쓰는 Builder의 경우는 어떻게 써야 할까요? 이는 다른 포스팅에서 다뤄보도록 하겠습니다.

생각보다 글이 길어졌네요😅

오늘도 제 긴글을 읽어주셔서 감사합니다😀

📌참고


@Request Body에서는 Setter가 필요없다?

@RequestBody 모델에 기본생성자, setter/getter가 필요한가?

댓글남기기