# @RequestBody는 어떻게 동작할까?

> 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`을 이용하여 알맞은 객체로 변환됩니다.

```java
@Getter
@Setter
@AllArgsConstructor
@NoArgsConstructor
public class User {
	private String name;
	private int age;
}
```

```java
@PostMapping
public ResponseEntity<User> post(@RequestBody User user) {
    return ResponseEntity.ok(user);	
}
```

```java
@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` **타겟 자체는 이렇게 써도 잘 매핑된다.**

```java
@Getter
@NoArgsConstructor(access = AccessLevel.PRIVATE)
public class User {
	private String name;
	private int age;
}
```

```java
@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`가 붙으면 이렇게 동작한다.

```java
@Getter
@NoArgsConstructor(access = AccessLevel.PRIVATE)
public class User {
	private String name;
	private int age;
}
```

이전의 시나리오처럼 클라이언트로가 JSON으로 User를 서버로 보낸다고 가정해봅시다. 서버 내부 흐름은 다음과 같이 진행됩니다. [이 블로그](https://blogshine.tistory.com/445)를 참고해서 진행했는데, 차이점은 이 경우의 기본 생성자는 private이라는 점입니다. 제 개발 환경은 `Spring Boot 3.1.1`, `jackson-databind 2.15.2`입니다.

1. `DispatcherServlet`이 해당 요청을 실행할 핸들러 어댑터를 찾습니다. 저희의 경우 `@RestController`를 사용했기 때문에 `RequestMappingHandlerAdapter`가 실행됩니다.
    
2. `RequestMappingHandlerAdapter`에서 `RequestResponseBodyMethodProcessor`를 획득합니다. (Spring MVC에서 `@RequestBody`, `@ResponseBody`는 `RequestResponseBodyMethodProcessor`가 처리합니다.)
    
3. `RequestResponseBodyMethodProcessor`의 `supportsParameter()`가 실행됩니다.
    
    ![](https://cdn.hashnode.com/res/hashnode/image/upload/v1689842128082/d7690bac-6448-4037-a335-41262c60b895.png align="center")
    
4. 이후 `RequestResponseBodyMethodProcessor`의 `resolveArgument()`가 실행됩니다. `resolveArgument()`에서 오버라이딩된 `readWithMessageConverters()`를 호출합니다.
    
    ![](https://cdn.hashnode.com/res/hashnode/image/upload/v1689901071163/92bf48d6-9e52-4fa1-a33a-c0ab20219a6c.png align="center")
    
5. `readWithMessageConverters()` 안에서 부모 클래스에 정의되어 있는 `readWithMessageConverters()`를 호출합니다.
    
    ![](https://cdn.hashnode.com/res/hashnode/image/upload/v1689900695338/e9b705c0-7886-46a0-8cc0-14d3189c96ae.png align="center")
    
6. `readWithMessageConverters()` 내부에서 클라이언트 요청에 적합한 메시지 컨버터를 찾습니다. `HttpMessageConverter` 중에서도 저희는 JSON 요청을 했으므로 `MappingJackson2HttpMessageConverter`가 선택됩니다.
    
    ![](https://cdn.hashnode.com/res/hashnode/image/upload/v1689901418543/b09abc96-00da-4fef-9d48-15ec9f2cf1b3.png align="center")
    
    그리고 이 `MappingJackson2HttpMessageConverter` 컨버터가 이 요청을 읽을 수 있는지 확인합니다.
    
    ![](https://cdn.hashnode.com/res/hashnode/image/upload/v1689902803519/b0264a06-adca-4a3e-ab24-dde299a8462b.png align="center")
    
7. `MappingJackson2HttpMessageConverter`의 `canRead()`에서 해당 MediaType을 읽을 수 있는지 검증합니다. JSON 타입을 읽을 수 있으므로 true를 반환합니다.
    
    ![](https://cdn.hashnode.com/res/hashnode/image/upload/v1689902441049/0c7fd28b-4421-4298-a4a8-b8294042f267.png align="center")
    
8. `canRead()`에서 true를 반환했으므로 조건문 안으로 진입하여 `read()`를 호출합니다.
    
    ![](https://cdn.hashnode.com/res/hashnode/image/upload/v1689902925818/4da0ee74-88b7-4a90-8309-500d2efb14ba.png align="center")
    
9. `MappingJackson2HttpMessageConverter`의 `read()`에서 `JavaType`을 얻어내 `readJavaType()`을 호출합니다.
    
    ![](https://cdn.hashnode.com/res/hashnode/image/upload/v1689903214854/018e8470-b5c0-4cf4-9529-661d0d6da0a8.png align="center")
    
10. `readJavaType()` 내부에서 ObjectReader의 `readValue()`를 호출하고
    
    ![](https://cdn.hashnode.com/res/hashnode/image/upload/v1689903740918/b2237fe4-9982-44d8-8299-6bbd8d2e3f77.png align="center")
    
    `readValue()`에서 null 체크 후 `_bindAndClose()`를 호출하고
    
    ![](https://cdn.hashnode.com/res/hashnode/image/upload/v1689904247681/d225ea10-32ae-42d5-ab55-2e03beed1044.png align="center")
    
    `_bindAndClose()`에서 `readRootValue()`를 호출합니다.
    
    ![](https://cdn.hashnode.com/res/hashnode/image/upload/v1689904324449/0a7f8d5c-2126-48dd-bcc2-f557c41c4173.png align="center")
    
11. `readRootValue()`에서 `BeanDeserializer` 타입의 deser가 드디어 역직렬화를 시도합니다!
    
    ![](https://cdn.hashnode.com/res/hashnode/image/upload/v1689904696049/8d93bb32-a2b1-40ec-8fb1-57e8d3b670e0.png align="center")
    
12. `deserialize()`에서 여러 검증을 거친 후 `deserializeFromObject()`를 호출합니다.
    
    ![](https://cdn.hashnode.com/res/hashnode/image/upload/v1689904900036/51118d34-d0cf-4f12-894a-2d7af0e29b82.png align="center")
    
    그리고 `deserializeFromObject()` 내부에서 `createUsingDefault()`를 호출합니다.
    
    ![](https://cdn.hashnode.com/res/hashnode/image/upload/v1689905042603/35bf8a05-aec7-4a00-9efa-42adcc4445b3.png align="center")
    
13. 여기서부터가 핵심입니다. `createUsingDefault()`는 **기본 생성자로 Object를 생성하여 반환하는 메서드**입니다. `_defaultCreator`에 대한 null 체크를 진행하는데, 저희는 private 레벨이지만 기본 생성자가 있긴 하니까 해당 조건문을 지나치게 됩니다. 그 후 `call()`으로 기본 생성자를 통한 객체 생성을 시도합니다.
    
    ![](https://cdn.hashnode.com/res/hashnode/image/upload/v1689905178968/5e126401-3415-47b8-8b62-856ebb624e4a.png align="center")
    
    `call()` 내부에선 자바의 리플렉션 API인 `newInstance()`를 사용하여 객체 생성을 합니다.
    
    ![](https://cdn.hashnode.com/res/hashnode/image/upload/v1689905477948/377bcbc2-bae0-4819-ba9a-a7c73b83b4c2.png align="center")
    
    ![](https://cdn.hashnode.com/res/hashnode/image/upload/v1689906920199/37d5601e-0eaf-47dc-96f2-3bf5d0d0ad1b.png align="center")
    
    `call()` 메서드 이후에 `User` 객체가 잘 생성된 것을 확인할 수 있습니다.
    
    *ObjectReader가 리플렉션을 통해 객체를 생성하기 때문에 기본 생성자가 필요하구나!*
    
    일단 왜 기본 생성자가 필요한 지는 이해했습니다. **그런데 다시 한번 말씀드리지만, 저희의 기본 생성자는 private 레벨입니다.** 리플렉션으로 해당 인스턴스의 private 레벨에 접근하려면 `setAccessible()`을 사용했어야 할 텐데요. `setAccessible()` 어느 시점에 쓰였던 걸까요?
    

![](https://cdn.hashnode.com/res/hashnode/image/upload/v1689908406591/f6a556e6-9386-4ee1-9c2f-52d3a0850573.png align="center")

디버깅을 쭉 해보니 `InnerClassLambdaMetafactory`의 `buildCallSite()`에서 `setAccessible(true)`를 적용시켜주고 있었습니다. 결론적으로 생성자가 private이든 아니든 리플렉션 쓰는 데에 문제 없다는 뜻입니다. `LambdaMetafactory`에 대해서는 [해당 문서](https://docs.oracle.com/javase/8/docs/api/java/lang/invoke/LambdaMetafactory.html)를 참고해주세요.

### 💡기본 생성자는 필요하네, 그럼 Getter는 왜 필요해?

객체를 생성해주는 것까진 이해했습니다. 그런데 객체의 값을 어떻게 바인딩할까요?

[공식 문서](https://jenkov.com/tutorials/java-json/jackson-objectmapper.html#how-jackson-objectmapper-matches-json-fields-to-java-fields)에 따르면, `Jackson ObjectMapper`는 JSON 객체의 필드를 Java 객체의 필드에 맵핑할 때 getter 혹은 setter 메서드를 사용한다고 합니다. getter나 setter 메서드 명의 접두사(get, set)를 지우고, 나머지 문자의 첫 문자를 소문자로 변환한 문자열을 참조하여 필드명을 알아냅니다. 이렇게 얻어낸 필드명으로 리플렉션 API를 통해 값을 바인딩하게 됩니다.

![](https://cdn.hashnode.com/res/hashnode/image/upload/v1689911240564/b3690537-b89e-46af-b161-491361354915.png align="center")

그래서인지 `BasicDeserializerFactory`의 `_findCreatorsFromProperties()` 내부에서 `findProperties()`를 통해 Getter나 Setter가 있는지 확인합니다.

만약 둘 다 없으면 `ServletInvocableHandlerMethod`의 `invokeAndHandle()`에서 `HttpMediaTypeNotAcceptableException`이 발생합니다.

둘 중 하나 이상이 있으면 Getter나 Setter를 통해 바인딩을 하는 것이 아니라, 리플렉션의 `set()`을 통해 우리가 생성했던 인스턴스에 값을 바인딩하게 됩니다.

어쨌거나 Getter나 Setter 둘 중 하나만 쓰면 되는데, 한 번 생성된 Request용 DTO를 다시 수정할 필요는 없으므로 불변성을 위해 Setter 대신 Getter만 쓰면 된다고 생각합니다.

### 💡저는 기본 생성자 없어도 잘 되던데요?

```java
@Getter
@AllArgsConstructor
public class User {
	private String name;
	private int age;
}
```

이번엔 기본 생성자 없이, `@AllArgsConstructor`를 써서 전체 필드를 인자로 받는 생성자를 생성하여 진행해 봅시다.

1. BeanDeserializer에서 기본 생성자가 있을 때에는 넘어갔던 if문에 걸리게 됩니다.
    
    ![](https://cdn.hashnode.com/res/hashnode/image/upload/v1689913554247/9da59fac-8d51-420b-b43d-0410f4555bc7.png align="center")
    
2. 이후 `deserializeFromObjectUsingNonDefault()`을 호출합니다.
    
    ![](https://cdn.hashnode.com/res/hashnode/image/upload/v1689913731754/4e8e98c4-29ff-495d-ba9a-827a5771a834.png align="center")
    
    여기서 메서드 이름 그대로 기본 생성자가 아닌 것을 이용해서 역직렬화를 시도합니다.
    
    ![](https://cdn.hashnode.com/res/hashnode/image/upload/v1689913852779/cbd4304d-bc91-4711-842b-614055e16594.png align="center")
    
    첫 번째로 `delegateDeser`가 null이 아닌지 체크합니다. Delegate-based creators인지 체크하는 건데, 이에 대한 내용은 [이 블로그](https://www.cowtowncoder.com/blog/archives/2011/07/entry_457.html)를 참고해주세요.
    
    ![](https://cdn.hashnode.com/res/hashnode/image/upload/v1689914223804/c36e601d-5b29-4992-978d-09dbca75d37b.png align="center")
    
    이후에 Property-based creators인지 체크합니다. 근데 웬일인지 null이 아니네요!
    
    저는 `@AllArgsConstructor`만 붙여주고, `@JsonCreator`는 안 붙였는데 `PropertyBasedCreator`가 생성돼있네요?
    
    ![](https://cdn.hashnode.com/res/hashnode/image/upload/v1689914230101/da8dfa89-da30-4e42-bbfc-dd44e9c678d9.png align="center")
    
    `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로 인식된 것이었습니다.

### 💡그래서 결국엔 이렇게 써도 되긴 됩니다만

```java
@Getter
@NoArgsConstructor(access = AccessLevel.PRIVATE)
public class User {
	private String name;
	private int age;
}
```

이렇게 `@RequestBody` 타겟 객체를 immutable하게(엄밀히는 리플렉션 때문에 아니지만) 가져가면 좋지 않을까 싶지만, 테스트 환경에서 이 객체를 생성해야 하는 경우라면 조금 복잡해질 수 있지 않을까라는 생각이 드네요.

프로젝트 컨벤션에 따라 생성자의 접근제어자 레벨을 조정하고 Setter를 추가하거나(마음에 들진 않지만), 전체 인자를 받는 생성자를 만들거나, 아예 정적 팩토리 메서드 패턴을 쓰는 방법을 고려할 수 있을 것 같습니다.

---

### 🎯**정리**

* `@RequestBody` 타겟 객체는 기본 생성자와 Getter 또는 Setter만 있어도 됩니다.
    
* 기본 생성자가 없어도 다음과 같은 경우에 역직렬화가 가능합니다.
    
    1. Delegate-based creators인 경우
        
    2. Property-based creators인 경우
        
* 위 경우에 해당되지 않을 시 `HttpMessageNotReadableException`이 발생합니다.
    
* Getter나 Setter 둘 중 하나만 써도 됩니다. 둘 다 안 쓰면 `HttpMediaTypeNotAcceptableException`이 발생합니다.
    
* 바인딩은 Setter가 아니라 리플렉션 API가 합니다.
    

---

### 🔖**참고**

* [https://docs.spring.io/spring-framework/docs/current/javadoc-api/org/springframework/web/bind/annotation/RequestBody.html](https://docs.spring.io/spring-framework/docs/current/javadoc-api/org/springframework/web/bind/annotation/RequestBody.html)
    
* [https://blogshine.tistory.com/445](https://blogshine.tistory.com/445)
    
* [https://blogshine.tistory.com/446](https://blogshine.tistory.com/446)
    
* [http://tutorials.jenkov.com/java-json/jackson-objectmapper.html#how-jackson-objectmapper-matches-json-fields-to-java-fields](http://tutorials.jenkov.com/java-json/jackson-objectmapper.html#how-jackson-objectmapper-matches-json-fields-to-java-fields)
    
* [https://www.cowtowncoder.com/blog/archives/2011/07/entry\_457.html](https://www.cowtowncoder.com/blog/archives/2011/07/entry_457.html)
