Entity에 setter를 없애고 dto를 사용하는 방식으로 변경하다가
실수로 컨트롤러 메서드에 setter 없는 entity와 확인용 로그를 그대로 놔둔 적이 있었다.
하지만 콘솔창에 setter가 없는데도 바인딩된 값이 출력됐었다.
부족한 지식으로 판단하기에는
setter가 없으니 데이터를 dto에 set을 하지 못해서
로그에는 null이 찍힐 것이라 예상했는데 왜 값이 출력될까?
1. RequestDto
@Getter
public class RequestDto {
private String name;
private int age;
}
비슷한 상황을 연출해보기 위해
name과 age 필드를 가진 dto 클래스에 setter 메서드없이 오로지 @Getter만 작성했다.
2. SampleController
@Slf4j
@RestController
public class SampleController {
@PostMapping("/post")
public void post(@RequestBody RequestDto requestDto) {
log.info("NAME={}", requestDto.getName());
log.info("AGE={}", requestDto.getAge());
}
}
이렇게 setter 없는 RequestDto를 파라미터에 배치하고
@RequestBody 어노테이션을 사용했다.
3. 요청
{
"name" : "홍길동",
"age" : 20
}
Postman을 사용해서 JSON 형식으로 요청을 보냈다.
4. 결과
생략... : NAME=홍길동
생략... : AGE=20
그런데 이상하게도 로그에 값이 찍혔다.
setter가 없는데도 어떻게 값을 집어넣은 것일까
컨트롤러 파라미터에 클래스를 배치했을 때 어떻게 처리하는지
그리고 @RequestBody 어노테이션에 어떤 원리가 있는 지 알아봐야겠다.
우선 범위를 줄여보기 위해 다른 방식으로 시도해보았다.
GET 방식, POST 방식(query, x-www-form..)
@GetMapping("/get")
public void get(RequestDto requestDto) {
log.info("NAME={}", requestDto.getName());
log.info("AGE={}", requestDto.getAge());
}
GET 요청을 받을 컨트롤러를 만들고
localhost:9999/get?name=홍길동&age=20
쿼리파라미터 형식으로 같은 요청을 보내봤다.
생략... : NAME=null
생략... : AGE=0
우선 GET 요청은 예상했던 결과와 같았다. (바인딩 불가)
그래서 String은 null로, primitive type인 age는 default인 0으로 초기화된 모습이다.
JSON + @RequestBody와는 다른 방식으로 작동하는 것 같다.
GET 방식의 경우 setter를 필요로 한다.
전형적인 property 방식으로
파라미터에 배치된 dto 클래스의 private 필드에 public getter/setter를 이용하여 자동으로 바인딩하는 방식에서
setter를 없앴기 때문에 property 방식으로 접근을 못한 것이다.
GET뿐만 아니라 POST 방식의 쿼리파라미터, form도 property 방식을 사용해 값을 바인딩한다.
setter가 없으면 안 된다.
조금 느낌이 왔다.
JSON 형식의 요청 데이터를 처리하는 메세지 컨버터쪽을 알아보면 될 것 같다.
스프링은 요청(Request)과 응답(Response) 메시지의 데이터 변환을 처리하기 위해
HttpMessageConverter를 사용한다.
public interface HttpMessageConverter<T> {
boolean canRead(Class<?> clazz, @Nullable MediaType mediaType);
boolean canWrite(Class<?> clazz, @Nullable MediaType mediaType);
List<MediaType> getSupportedMediaTypes();
default List<MediaType> getSupportedMediaTypes(Class<?> clazz) {
return (canRead(clazz, null) || canWrite(clazz, null) ?
getSupportedMediaTypes() : Collections.emptyList());
}
T read(Class<? extends T> clazz, HttpInputMessage inputMessage)
throws IOException, HttpMessageNotReadableException;
void write(T t, @Nullable MediaType contentType, HttpOutputMessage outputMessage)
throws IOException, HttpMessageNotWritableException;
}
미디어 타입을 읽고 쓸 수 있는 지 확인하는 메서드
그리고 읽기, 쓰기 메서드가 정의되어 있다.
MediaType에는 스프링이 처리할 수 있는 다양한 데이터 타입들이 상수로 정의되어 있다.
APPLICATION_FORM_URLENCODED
APPLICATION_JSON
MULTIPART_FORM_DATA
TEXT_PLAIN
...
HttpMessageConverter는 정의된 데이터 타입 목록을 확인하여 처리 유무를 판단한다.
그중 MappingJackson2HttpMessageConverter는 HttpMessageConverter의 구현체로
JSON 데이터와 Java 객체간의 변환을 담당한다.
부모인 추상 클래스 AbstractGenericHttpMessageConverter의 readInternal의 일부
@Override
protected Object readInternal(Class<?> clazz, HttpInputMessage inputMessage)
throws IOException, HttpMessageNotReadableException {
JavaType javaType = getJavaType(clazz, null);
return readJavaType(javaType, inputMessage);
}
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);
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);
}
}
}
readInternal에서는 JSON 데이터를 Java 객체로 변환하여 요청 메시지의 내용을 처리한다.
내부적으로 ObjectMapper의 readValue()를 호출하여 JSON 문자열을 Java 객체로 변환한다.
ObjectMapper는 리플렉션을 사용하여 Java 객체의 필드에 접근할 수 있다.
리플렉션: 동적으로 클래스의 정보를 추출하여 접근 제어자에 상관 없이 변수, 메서드에 접근하는 API
정리
@RequestBody에 어떤 데이터가 들어왔을 때
그 데이터를 처리를 담당하는 메세지 컨버터가 동작하며
JSON의 경우 JSON 컨버터는 내부적으로 ObjectMapper를 사용한다.
ObjectMapper는 리플렉션을 사용해서
RequestDto에 setter가 없건(Field 클래스의 set을 이용)
생성자가 private로 막혀있건(접근 제어자를 우회할 수 있음)
RequestDto 필드에 값을 설정할 수 있게 된 것이다.
@Getter
public class RequestDto {
private String name;
private int age;
private RequestDto() {
}
private RequestDto(String name, int age) {
this.name = name;
this.age = age;
}
}
setter가 없고, 기본 생성자가 private임에도 값이 바인딩 된 모습
생략... : NAME=홍길동
생략... : AGE=20