Spring - @RequestBody 모델 매핑 원리
업데이트:
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
는 간단하게 말하면 Json과 Java 객체간의 직렬화/역직렬화를 도와주는 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");
}
❗Jackson과
getter
근데 테스트 도중
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
에는 이 밖에도 내용이 있으나 길어질 것 같아 다른 포스팅에서 자세히 다뤄보도록 하겠습니다.
결과
위의 결과 화면을 보시면 모든 매개변수 생성자만 있는 경우가 실패했음을 알 수 있습니다.
에러 추적
실패 메시지는 다음과 같습니다.
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;
}
}
그리고 나서 테스트를 돌려보면
테스트가 성공한 것을 볼 수 있습니다~
추가로 Spring
의 MessageConverter
는 어떻게 Json String을 객체로 매핑하는 알아보기 위해 계속해서 에러를 따라가 보도록 하겠습니다.
위 로그에 이어 아래에는 다음 로그 화면입니다.
여기서 나와있는 AbstractJackson2HttpMessageConverter
의 readJavaType
메소드를 보시면 다음과 같은데요.
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 테스트
Post
는 ObjectMapper
를 사용하는 것으로 확인되었으니, 이번엔 Get
요청은 어떻게 처리되는지 확인해보겠습니다.
테스트 코드는 Post
테스트처럼 MockMvc
를 사용했고 요청 메소드를 Post
→ Get
으로 변경했습니다.
그리고 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";
}
결과
테스트 결과는 다음과 같습니다.
이상하게 전부 성공했습니다!?
하지만 콘솔 로그를 보면 BoardDto
만 값이 제대로 바인딩이 안된 것을 확인할 수 있습니다.
BoardDto
는 NoArgsConstructor
/ 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 Class
의getDeclaredConstructor
를 호출하여 어떤 값이 들어있는지 확인해봤습니다.
getDeclaredConstructors
를 호출했을 경우에는 모든 생성자들을 가져옵니다.
하지만 getDeclaredConstructor
를 호출했을 때에는 기본 생성자만 나오는 것을 확인할 수 있습니다.
수정 후 테스트
NoArgsConstructor
제거 후 AllArgsConstructor
만 남기고 다시 테스트해봤습니다.
정상적으로 값이 바인딩 되는걸 확인할 수 있습니다.
마무리
개인적으로 불변객체를 좋아하기 때문에 setter
를 지양하자는 주의인데요.
따라서 setter
없이 Post
요청을 받을 경우에는 객체의 property에 접근하기 위해
Getter
가 필요(혹은 변수에 public
접근 제어자 사용)함을 알 수 있었고,
Get
요청에는 반드시 모든 매개변수를 받는 생성자, AllArgsConstructor
가 1개만 필요함을 알 수 있습니다.
그럼 많이 쓰는 Builder
의 경우는 어떻게 써야 할까요? 이는 다른 포스팅에서 다뤄보도록 하겠습니다.
생각보다 글이 길어졌네요😅
오늘도 제 긴글을 읽어주셔서 감사합니다😀
댓글남기기