필드가 1개인 DTO를 Jackson - Deserialize 할 시 문제점

ifi9·2023년 6월 11일
0

환경

SpringBoot 3.1.0
OpenJDK Corretto-17.0.6

문제점 발견

간단한 프로젝트의 API를 만들 때에는 아래와 같은 Controller를 만들고는 했다.

@Slf4j
@RequiredArgsConstructor
@RestController
public class SampleController {

    private final SampleService sampleService;

    @PostMapping("/sample")
    public ResponseEntity<Void> createSample(@RequestBody SampleCreateRequest request) {
        log.info("=== SampleController - createSample() ===");
        log.info("name : {}", request.getName());
        return ResponseEntity
                .ok()
                .build();
    }
}

@Getter
public class SampleCreateRequest {

    private String name;

    public SampleCreateRequest(String name) {
        this.name = name;
    }
    
}

그런데 우연찮게 알게 된 것인데 시그니처의 매개변수로 받는 위와 같은 필드가 1개인 DTO에서 AbstractJackson2HttpMessageConverter에서 문제가 발생한다.
Exception의 내용은

Resolved [org.springframework.http.converter.HttpMessageNotReadableException: JSON parse error: Cannot construct instance of com.ifi.jdk17sample.Sample.web.dto.SampleCreateRequest (although at least one Creator exists): cannot deserialize from Object value (no delegate- or property-based Creator)]

기본 생성자가 없다는 내용인데, DTO 필드를 2개 이상으로 늘릴 시에는 아무런 문제가 없이 동작을 한다.

@Getter
public class SampleCreateRequest {

    private String name;
    private Long id;

    public SampleCreateRequest(String name, Long id) {
        this.name = name;
        this.id = id;
    }
}

더군다나 필드의 속성에 따라 출력되는 예외 처리 내용이 조금 다르기는 했는데

@Getter
public class SampleCreateRequest {

    private LocalDate createdAt;

    public SampleCreateRequest(LocalDate createdAt) {
        this.createdAt = createdAt;
    }
}

마찬가지로 기본 생성자가 없다는 것이 주요 내용이었다.
처음 발견했을 때는 LocalDate 타입으로만 했었어서 Stack Trace를 따라가며 내부 소스를 볼 때 한참 헤맸었다.

원인 찾기 및 해결 방법

원인 찾기

물론 기본 생성자를 달면 해결이 되기는 하지만 생성자 초기화가 아닌 기본 생성자와 Setter를 사용한다면 불변이 깨질 것이기에 기본 생성자를 만드는 방법은 피하고 싶었다.

오직 1개일 때만 동작을 하지 않으니 그와 관련된 내부 소스들을 디버깅해보았으나 잘 이해가 되지 않아서 깃 이슈를 찾아보았다.

내용을 간단하게 요약하자면 버전이 올라감에 따라 별다른 조치 없이 해결되는 것은 아니고, 추가적으로 무언가를 해줘야 한다는 것이다.

해결 방법

1. @JsonCreator

해당 깃 이슈에서는 @JsonCreator라는 애너테이션을 붙이는 방식을 안내한다.

@Getter
public class SampleCreateRequest {

    private LocalDate createdAt;

    @JsonCreator
    public SampleCreateRequest(LocalDate createdAt) {
        this.createdAt = createdAt;
    }
}

해당 애너테이션을 붙이고 실행하면 정상적으로 동작하는 것이 확인 가능하다. 하지만 사용하고자 한다면 jackson 라이브러리 버전이 2.12 이상이어야 한다.
적어도 SpringBoot 2.5.1 이상부터는 사용 가능한 것을 확인하였다. (사용 가능한 최소한의 버전은 아닐 수 있음)

2. @Jacksonized

Lombok의 @Jacksonized 애너테이션으로 @Builder 혹은 @SuperBuilder를 사용할 시에 같이 붙이면 된다.
@Builder는 기본 생성자나 다른 생성자가 없을 시에 자동으로 @AllArgsConstructor를 만드는데 결국 매개변수가 1개인 생성자인 조건을 충족하기 때문이다.

@Jacksonized
@Builder
@Getter
public class SampleCreateRequest {

    private LocalDate createdAt;

}

이것도 lombok 라이브러리 버전 1.18.14 이상에서 추가되었다고는 하는데 SpringBoot 2.4.1까지는 버전 조건이 충족하는 것을 확인을 하였다. (사용 가능한 최소한의 버전은 아닐 수 있음)

3. @JsonDeserialize와 @JsonPOJOBuilder

@JsonDeserialize는 JSON data를 자바 객체로 역직렬화할 때 사용하는데 불변 클래스의 빌더를 생성할 때 사용하는 @JsonPOJOBuilder와 함께 사용해도 해결이 가능하다. 자세한 사용 방법은 Jackson README.md에서 튜토리얼을 볼 수 있다.

@JsonDeserialize(
        builder = SampleCreateRequest.SampleCreateRequestBuilder.class
)
@Getter
public class SampleCreateRequest {

    private LocalDate createdAt;

    @Builder
    public SampleCreateRequest(LocalDate createdAt) {
        this.createdAt = createdAt;
    }

    @JsonPOJOBuilder(
            withPrefix = ""
    )
    public static class SampleCreateRequestBuilder {
    }

}

사실 이 방법은 @Jacksonized을 사용한 클래스를 디컴파일 했을 때의 볼 수 있는 애너테이션들이다. 하지만 사용이 좀 더 까다로워서 별다른 문제가 발견되지 않는다면 이 방법보다는 @Jacksonized 애너테이션의 사용을 하고자 할 것 같다.

4. 이외의 방법?

만약 위의 3가지 방법 모두 사용이 불가능하다면 어떻게 하면 될지 고민해 보았다.
만약 RequestBody에 더 이상의 필드 추가가 되지 않을 것이라면 굳이 Jackson objectmapper를 사용해야 할까? 싶다. 그리고 숨겨야 할 값도 아니라면 @PathVariable 혹은 @RequestParam 같은 것을 활용할 듯하다.

마치며

보통 문제가 발생하면 시간 여유가 있다면 내부 소스를 디버깅부터 하고 있었는데, 이 포스팅의 문제는 단순히 디버깅만으로는 찾기가 힘들었고 공식 문서에서 쉽게 발견이 가능했다.

이번에는 내부 소스를 뒤적거릴 때 오랫동안 헤매서 시간을 너무 낭비한 것 같은 느낌이 들어 아쉬웠다. 앞으로는 공식 문서부터 찾아보고 내부 소스를 보는 식으로 바꿔봐야겠다.

그리고 Jackson 내부 소스를 볼 때 소소한 오타를 발견해서 처음으로 PR을 날려보기도 했다.

0개의 댓글