@RequestBody는 어떻게 동작할까?
HttpMessageConverter가 알아서 잘 변환해주는 게 아니야!
Annotation indicating a method parameter should be bound to the body of the web request. The body of the request is passed through an
HttpMessageConverter
to resolve the method argument depending on the content type of the request.
@RequestBody
는 Client가 보낸 데이터를 Java 객체로 역직렬화해주는 어노테이션입니다. 이 어노테이션을 사용하면 HTTP 본문이 HttpMessageConverter
을 이용하여 알맞은 객체로 변환됩니다.
@Getter
@Setter
@AllArgsConstructor
@NoArgsConstructor
public class User {
private String name;
private int age;
}
@PostMapping
public ResponseEntity<User> post(@RequestBody User user) {
return ResponseEntity.ok(user);
}
@Test
void requestBody() throws Exception {
ObjectMapper objectMapper = new ObjectMapper();
User user = new User("Eve", 860);
String body = objectMapper.writeValueAsString(user);
mockMvc.perform(post("/users")
.contentType(MediaType.APPLICATION_JSON_VALUE)
.content(body))
.andExpect(status().isOk())
.andExpect(jsonPath("name").value("Eve"))
.andExpect(jsonPath("age").value(860));
}
requestBody()
테스트에서 User의 정보가 담긴 JSON을 POST 요청하여 @RequestBody
어노테이션이 붙은 User 객체로 매핑이 된 후 그대로 잘 반환하는 것을 확인할 수 있습니다. 그런데 정확히 @RequestBody
가 어떻게 user에 JSON을 역직렬화해주는 걸까요?
보통 'HttpMessageConverter
이 알아서 잘 변환해주겠지'라고 생각하고 DTO에 Lombok의 @Data
를 쓰는 경우가 있는데요, @Data
는 @EqualsAndHashCode
와 @RequiredArgsConstructor
을 포함해버립니다. 또한 저는 아무리 DTO라도 꼭 필요한 어노테이션만 붙이는 것이 클린 코드 측면에서나 객체지향적인 측면에서나 옳다고 생각하기 때문에 정확히 어떤 어노테이션만 들어가야 할지 고민하게 됐습니다.
💡@RequestBody
타겟 자체는 이렇게 써도 잘 매핑된다.
@Getter
@NoArgsConstructor(access = AccessLevel.PRIVATE)
public class User {
private String name;
private int age;
}
@Test
void requestBody() throws Exception {
String body = "{\"name\":\"Eve\",\"age\":860}";
mockMvc.perform(post("/users")
.contentType(MediaType.APPLICATION_JSON_VALUE)
.content(body))
.andExpect(status().isOk())
.andExpect(jsonPath("name").value("Eve"))
.andExpect(jsonPath("age").value(860));
}
User에는 위에서 보았던 Setter도 없고 전체 인자를 받는 생성자도 없으며, 심지어 기본 생성자의 접근제어자는 private입니다. requestBody() 테스트 결과도 성공적으로 잘 수행됩니다. 다만, 생성자가 private이라 User 객체를 생성하지 못하기 때문에 매퍼를 사용할 수 없습니다. 만약 매퍼를 쓰고 싶다면 @AllArgsConstructor
를 써도 되겠죠.
그런데 그 전에, 이렇게 쓰면 과연 아무 문제가 없을까요? 그걸 알기 위해 @RequestBody
가 어떻게 동작하는지 정확히 알아야 합니다.
💡@RequestBody
가 붙으면 이렇게 동작한다.
@Getter
@NoArgsConstructor(access = AccessLevel.PRIVATE)
public class User {
private String name;
private int age;
}
이전의 시나리오처럼 클라이언트로가 JSON으로 User를 서버로 보낸다고 가정해봅시다. 서버 내부 흐름은 다음과 같이 진행됩니다. 이 블로그를 참고해서 진행했는데, 차이점은 이 경우의 기본 생성자는 private이라는 점입니다. 제 개발 환경은 Spring Boot 3.1.1
, jackson-databind 2.15.2
입니다.
DispatcherServlet
이 해당 요청을 실행할 핸들러 어댑터를 찾습니다. 저희의 경우@RestController
를 사용했기 때문에RequestMappingHandlerAdapter
가 실행됩니다.RequestMappingHandlerAdapter
에서RequestResponseBodyMethodProcessor
를 획득합니다. (Spring MVC에서@RequestBody
,@ResponseBody
는RequestResponseBodyMethodProcessor
가 처리합니다.)RequestResponseBodyMethodProcessor
의supportsParameter()
가 실행됩니다.이후
RequestResponseBodyMethodProcessor
의resolveArgument()
가 실행됩니다.resolveArgument()
에서 오버라이딩된readWithMessageConverters()
를 호출합니다.readWithMessageConverters()
안에서 부모 클래스에 정의되어 있는readWithMessageConverters()
를 호출합니다.readWithMessageConverters()
내부에서 클라이언트 요청에 적합한 메시지 컨버터를 찾습니다.HttpMessageConverter
중에서도 저희는 JSON 요청을 했으므로MappingJackson2HttpMessageConverter
가 선택됩니다.그리고 이
MappingJackson2HttpMessageConverter
컨버터가 이 요청을 읽을 수 있는지 확인합니다.MappingJackson2HttpMessageConverter
의canRead()
에서 해당 MediaType을 읽을 수 있는지 검증합니다. JSON 타입을 읽을 수 있으므로 true를 반환합니다.canRead()
에서 true를 반환했으므로 조건문 안으로 진입하여read()
를 호출합니다.MappingJackson2HttpMessageConverter
의read()
에서JavaType
을 얻어내readJavaType()
을 호출합니다.readJavaType()
내부에서 ObjectReader의readValue()
를 호출하고readValue()
에서 null 체크 후_bindAndClose()
를 호출하고_bindAndClose()
에서readRootValue()
를 호출합니다.readRootValue()
에서BeanDeserializer
타입의 deser가 드디어 역직렬화를 시도합니다!deserialize()
에서 여러 검증을 거친 후deserializeFromObject()
를 호출합니다.그리고
deserializeFromObject()
내부에서createUsingDefault()
를 호출합니다.여기서부터가 핵심입니다.
createUsingDefault()
는 기본 생성자로 Object를 생성하여 반환하는 메서드입니다._defaultCreator
에 대한 null 체크를 진행하는데, 저희는 private 레벨이지만 기본 생성자가 있긴 하니까 해당 조건문을 지나치게 됩니다. 그 후call()
으로 기본 생성자를 통한 객체 생성을 시도합니다.call()
내부에선 자바의 리플렉션 API인newInstance()
를 사용하여 객체 생성을 합니다.call()
메서드 이후에User
객체가 잘 생성된 것을 확인할 수 있습니다.ObjectReader가 리플렉션을 통해 객체를 생성하기 때문에 기본 생성자가 필요하구나!
일단 왜 기본 생성자가 필요한 지는 이해했습니다. 그런데 다시 한번 말씀드리지만, 저희의 기본 생성자는 private 레벨입니다. 리플렉션으로 해당 인스턴스의 private 레벨에 접근하려면
setAccessible()
을 사용했어야 할 텐데요.setAccessible()
어느 시점에 쓰였던 걸까요?
디버깅을 쭉 해보니 InnerClassLambdaMetafactory
의 buildCallSite()
에서 setAccessible(true)
를 적용시켜주고 있었습니다. 결론적으로 생성자가 private이든 아니든 리플렉션 쓰는 데에 문제 없다는 뜻입니다. LambdaMetafactory
에 대해서는 해당 문서를 참고해주세요.
💡기본 생성자는 필요하네, 그럼 Getter는 왜 필요해?
객체를 생성해주는 것까진 이해했습니다. 그런데 객체의 값을 어떻게 바인딩할까요?
공식 문서에 따르면, Jackson ObjectMapper
는 JSON 객체의 필드를 Java 객체의 필드에 맵핑할 때 getter 혹은 setter 메서드를 사용한다고 합니다. getter나 setter 메서드 명의 접두사(get, set)를 지우고, 나머지 문자의 첫 문자를 소문자로 변환한 문자열을 참조하여 필드명을 알아냅니다. 이렇게 얻어낸 필드명으로 리플렉션 API를 통해 값을 바인딩하게 됩니다.
그래서인지 BasicDeserializerFactory
의 _findCreatorsFromProperties()
내부에서 findProperties()
를 통해 Getter나 Setter가 있는지 확인합니다.
만약 둘 다 없으면 ServletInvocableHandlerMethod
의 invokeAndHandle()
에서 HttpMediaTypeNotAcceptableException
이 발생합니다.
둘 중 하나 이상이 있으면 Getter나 Setter를 통해 바인딩을 하는 것이 아니라, 리플렉션의 set()
을 통해 우리가 생성했던 인스턴스에 값을 바인딩하게 됩니다.
어쨌거나 Getter나 Setter 둘 중 하나만 쓰면 되는데, 한 번 생성된 Request용 DTO를 다시 수정할 필요는 없으므로 불변성을 위해 Setter 대신 Getter만 쓰면 된다고 생각합니다.
💡저는 기본 생성자 없어도 잘 되던데요?
@Getter
@AllArgsConstructor
public class User {
private String name;
private int age;
}
이번엔 기본 생성자 없이, @AllArgsConstructor
를 써서 전체 필드를 인자로 받는 생성자를 생성하여 진행해 봅시다.
BeanDeserializer에서 기본 생성자가 있을 때에는 넘어갔던 if문에 걸리게 됩니다.
이후
deserializeFromObjectUsingNonDefault()
을 호출합니다.여기서 메서드 이름 그대로 기본 생성자가 아닌 것을 이용해서 역직렬화를 시도합니다.
첫 번째로
delegateDeser
가 null이 아닌지 체크합니다. Delegate-based creators인지 체크하는 건데, 이에 대한 내용은 이 블로그를 참고해주세요.이후에 Property-based creators인지 체크합니다. 근데 웬일인지 null이 아니네요!
저는
@AllArgsConstructor
만 붙여주고,@JsonCreator
는 안 붙였는데PropertyBasedCreator
가 생성돼있네요?PropertyBasedCreator
를 살펴보니, 다음과 같은 문서가 있었습니다.
Object that is used to collect arguments for the non-default creator (non-default-constructor, or argument-taking factory method) before creator can be called.
네, 기본 생성자 아니면 Jackson이 @JsonCreator
를 자동으로 붙여버립니다. 그래서 Property-based creators로 인식된 것이었습니다.
💡그래서 결국엔 이렇게 써도 되긴 됩니다만
@Getter
@NoArgsConstructor(access = AccessLevel.PRIVATE)
public class User {
private String name;
private int age;
}
이렇게 @RequestBody
타겟 객체를 immutable하게(엄밀히는 리플렉션 때문에 아니지만) 가져가면 좋지 않을까 싶지만, 테스트 환경에서 이 객체를 생성해야 하는 경우라면 조금 복잡해질 수 있지 않을까라는 생각이 드네요.
프로젝트 컨벤션에 따라 생성자의 접근제어자 레벨을 조정하고 Setter를 추가하거나(마음에 들진 않지만), 전체 인자를 받는 생성자를 만들거나, 아예 정적 팩토리 메서드 패턴을 쓰는 방법을 고려할 수 있을 것 같습니다.
🎯정리
@RequestBody
타겟 객체는 기본 생성자와 Getter 또는 Setter만 있어도 됩니다.기본 생성자가 없어도 다음과 같은 경우에 역직렬화가 가능합니다.
Delegate-based creators인 경우
Property-based creators인 경우
위 경우에 해당되지 않을 시
HttpMessageNotReadableException
이 발생합니다.Getter나 Setter 둘 중 하나만 써도 됩니다. 둘 다 안 쓰면
HttpMediaTypeNotAcceptableException
이 발생합니다.바인딩은 Setter가 아니라 리플렉션 API가 합니다.