POST 형식의 REST API 를 작성할 때, 요청 값을 주로 JSON 형태로 전달받는다. 이 데이터를 내부에서 사용할 수 있도록 맵핑 DTO 가 필요하고 주로 @RequestBody 어노테이션을 활용하여 전달받게 된다. 다음과 같이 코드를 작성할 수 있다.
@Getter
public class SampleRequestBody {
private final String sampleName;
private final Long sampleNumber;
public SampleRequestBody(String sampleName, Long sampleNumber) {
this.sampleName = sampleName;
this.sampleNumber = sampleNumber;
}
}
@PostMapping("/api/v1/sample")
public ResultResponse<Boolean> sample(
@RequestBody SampleRequestBody requestBody
) {
...
}
SampleRequestBody 에는 2개의 필드가 포함되어 있고 @Getter 어노테이션만 부여되어 있다. POST 형식으로 API 를 호출할 수 있고, @RequestBody 로 맵핑해두었다. POSTMAN 등 API 를 호출할 수 있는 툴을 사용하여 /api/v1/sample API 를 호출하면 다음과 같이 에러가 발생한다.
에러 메시지
Type definition error: [simple type, class sample.kdohyeon.blog.controller.blog.request.SampleRequestBody]; nested exception is com.fasterxml.jackson.databind.exc.InvalidDefinitionException: Cannot construct instance of `sample.kdohyeon.blog.controller.blog.request.SampleRequestBody` (no Creators, like default constructor, exist): cannot deserialize from Object value (no delegate- or property-based Creator)\n at [Source: (org.springframework.util.StreamUtils$NonClosingInputStream); line: 2, column: 5]
전달받은 JSON 데이터를 스프링 내부 객체로 변환하는 과정에서 발생하는 에러이다. 즉, 데이터 바인딩을 하는 과정에서 발생하는 에러인데 이에 대해 좀 더 살펴보자.
스프링 데이터 바인딩에 대한 이해
스프링부트를 활용하면 내부적으로 jackson 라이브러리 (JSON 데이터구조를 처리해주는 라이브러리)를 활용하여 JSON 데이터를 내부 객체로 데이터 바인딩을 시켜준다. 좀 더 자세히 들어가면, @RequestBody 어노테이션을 사용하게 되면 스프링은 입력받는 데이터의 형식에 맞는 메시지 컨버터를 골라 변환해준다. 여기서 Jackson 라이브러리는 JSON 데이터 처리를 위해 MappingJackson2HttpMessageConverter 를 제공하는데 스프링은 이를 활용하여 컨버팅을 한다. 그 과정에서 ObjectMapper 를 활용하여 JSON 데이터와 오브젝트 사이의 변환을 지원한다.
Jackson 라이브러리의 특성
스프링은 JSON 데이터 바인딩을 위해 MappingJackson2HttpMessageConverter 를 사용한다고 했는데 어떻게 동작하는걸까? JSON 데이터를 내부 객체로 어떻게 맵핑해주는걸까?
Jackson 은 기본적으로 프로퍼티 기반으로 동작하는데 프로퍼티는 Getter 와 Setter 기반으로 정해진다. Getter 또는 Setter 메소드 명의 prefix 를 지우고, 나머지 문자의 첫 문자를 소문자로 변환한 문자열을 참조한다. 위 예시에서는 SampleRequestBody 의 @Getter 어노테이션이 있으므로 getSampleName() 메소드가 생성되고 jackson 은 여기서 get 을 지우고 (SampleName) 첫 문자를 소문자로 변환한다 (sampleName). 따라서 SampleName 과 SampleNumber 가 그 프로퍼티이다.
그런데 잠깐 다시 위 예시로 돌아가보면 분명 @Getter 어노테이션으로 Getter 를 설정해주었다. 그럼 동작해야 하는게 아닐까? 왜 Cannot construct instance of... 에러가 나는걸까? 주의해야 할 점은 별도의 생성자를 직접 만들게 되면 jackson 에게 이를 알려줘야 한다. 위 예시에서는 SampleRequestBody 를 immutable 객체로 만들기 위해 final 키워드를 사용했는데 이렇게 되면 별도 생성자를 만들게 된다. jackson 에게 별도로 생성한 생성자를 활용하라고 알려줘야 하는데, 그 방법으로는 @JsonCreator + @JsonProperty 를 활용하는 방법과 @ConstructorProperties 를 활용하는 방법이 있다 (여기 참조).
@ConstructorProperties 의 동작 방식
먼저, @ConstructorProperties 어노테이션을 별도로 생성한 생성자 위에 부여해준다. 그리고 외부에서 요청할 파라미터 이름을 속성으로 입력해준다. 아래 예시에서는 name 과 number 를 입력받아 sampleName, sampleNumber 에 각각 할당하려고 한다. 따라서 1) 에서는 name 과 number 로 값을 입력받고, 그대로 @ConstructorProperties 를 활용해 그 값을 전달받는다. 그리고 2) 에서는 그 순서에 맞춰 name 과 number 를 생성자로 주입한다.
따라서, 2) 과정에서는 순서에 기반하여 주입을 하기 때문에 @ConstructorProperties 에 입력되는 프로퍼티 명과 생성자에 입력되는 변수 명이 꼭 동일하지 않아도 된다. 아래 처럼 @ConstructorProperties 에서는 name 을, 생성자에는 sampleName 으로 해도 정상 작동한다.
그렇기 때문에 @ConstructorProperties 로 입력받는 데이터와 주입하는 데이터의 순서가 꼬이지 않게 조심해야 한다. 아래처럼 @ConstructorProperties 에 number 와 name 의 순서를 바꿔두게 되면 number -> sampleName 으로 주입, name -> sampleNumber 로 주입하게 동작한다. 만약 이 때, 데이터의 유형이 맞지 않으면 에러가 발생한다.
참고로, 별도의 생성자를 만들지 않으면 (아래 예시처럼) jackson 은 잘 동작한다.
@Getter
public class SampleRequestBody {
private String sampleName;
private Long sampleNumber;
}
참고 자료
- https://joont92.github.io/spring/MessageConverter/
- https://mommoo.tistory.com/83
- https://blog.benelog.net/jackson-with-constructor.html
- https://www.baeldung.com/jackson-deserialize-immutable-objects
- https://github.com/FasterXML/jackson-databind/#annotations-using-custom-constructor
- https://tecoble.techcourse.co.kr/post/2021-05-11-requestbody-modelattribute/
'스프링' 카테고리의 다른 글
[스프링] @PostMapping 의 속성 알아보기 (headers, produces) (0) | 2023.02.27 |
---|---|
[스프링] AOP 와 @RestControllerAdvice (0) | 2023.02.22 |
[Gradle] Kotlin DSL 과 buildSrc 를 통한 버전 관리 (0) | 2023.02.20 |
[Gradle] Build Lifecycle (0) | 2023.02.16 |
[스프링] @CircuitBreaker 적용하기 (0) | 2023.02.16 |
댓글