Study/Spring

JSON 필드 하나면 왜 Jackson 파업함?

dev_kong 2023. 5. 9. 01:53
728x90
728x90

이번 장바구니 미션 2단계 를 진행하면서,
도저히 이해할 수 없는 에러가 하나 생겼었는데 왜 이러한 에러가 생겼고, 어떻게 해결 했는지 기록을 남겨보려 한다.

예외 발생

user 별로 장바구니를 추가하는 기능을 만들다 발생한 에러였다.

다음은 CartController의 일부이다.

 

@PostMapping  
public ResponseEntity<Void> addProductToCart(  
        @AuthenticationPrincipal User user,  
        @RequestBody CartCreateRequest cartCreateRequest  
) {  
    final Long cartId = cartService.addProduct(user, cartCreateRequest.getProductId());  
    return ResponseEntity.created(URI.create("/cart/product/" + cartId)).build();  
}

 

위의 method에서 @ResponseBody로 받아오는 객체는 다음과 같다.

 

public class CartCreateRequest {  
    private Long productId;  

    public CartCreateRequest(Long productId) {  
        this.productId = productId;  
    }  

    public Long getProductId() {  
        return productId;  
    }
}

 

그리고 이 상태로 요청을 보냈더니, 에러가 발생하더라.

 

error : JSON parse error: Cannot construct instance of `cart.dto.cart.CartCreateRequest` (although at least one Creator exists): cannot deserialize from Object value (no delegate- or property-based Creator); nested exception is com.fasterxml.jackson.databind.exc.MismatchedInputException: Cannot construct instance of `cart.dto.cart.CartCreateRequest` (although at least one Creator exists): cannot deserialize from Object value (no delegate- or property-based Creator)
 at [Source: (org.springframework.util.StreamUtils$NonClosingInputStream); line: 2, column: 5]

 

발생한 예외 MismatchedInputException를 구글링 해보니 기본 생성자를 만들어줘야 한단다.

이상하다... 다른 @ResponseBody를 통해 받아오는 다른 API에서 사용하는 DTO는 기본생성자를 만들어준 기억이 없는 것 같아서 바로 확인을 해보았다.

 

@PostMapping("/product")  
public ResponseEntity<Void> saveProduct(@Valid @RequestBody final ProductCreateRequest productCreateRequest) {  
    final Long id = productService.save(productCreateRequest);  
    return ResponseEntity.created(URI.create("/admin/product/" + id)).build();  
}

 

위의 코드는 admin controller에서 상품을 등록하기 위해 사용하는 api이다.
그리고 @RequestBody로 받아오는 dto는 다음과 같다.

 

public class ProductCreateRequest {  
    @NotBlank(message = "상품의 이름이 입력되지 않았습니다.")  
    @Size(min = 1, max = 20, message = "{min}글자 이상 {max}글자 이하로만 입력가능 합니다.")  
    private final String name;  

    @NotNull(message = "상품의 가격을 입력해주세요.")  
    @Range(min = 10, max = 1_000_000, message = "상품 금액은 {min}원 이상 {max}이하의 정수만 입력가능 합니다.")  
    private final Integer price;  

    @NotNull(message = "상품의 사진을 등록해주세요.")  
    private final String imgUrl;  

    public ProductCreateRequest(String name, Integer price, String imgUrl) {  
        this.name = name;  
        this.price = price;  
        this.imgUrl = imgUrl;  
    }  
    public String getName() {  
        return name;  
    }  
    public Integer getPrice() {  
        return price;  
    }  
    public String getImgUrl() {  
        return imgUrl;  
    }
}

 

위의 dto를 확인해보면, 기본 생성자가 없는데 해당 api는 잘 동작한다.

 

두 가지 dto의 차이라고 한다면,
필드의 갯수말고는 딱히 차이가 없어보인다.

 

킹리적 갓심으로 추론을 해보자면, JSON 필드의 갯수가 하나인 경우에는 모종의 이유로 우리의 친구 Jackson이 일을 제대로 안하는 것 같다.

 

혹시 모르니까 테스트를 한번해보자.

 

@PostMapping("/test")  
public ResponseEntity<String> asdf(@RequestBody MyRequest myRequest) {  
    return ResponseEntity.ok("됨??");  
}  

public class MyRequest{  
    private final String myField;  

    public MyRequest(String myField) {  
        this.myField = myField;  
    }  

    public String getMyField() {  
        return myField;  
    }
}

 

테스트코드 작성은 좀 귀찮아서 그냥 PostMan으로 요청을 날려봤다.

 

{
    "message": "JSON parse error: Cannot construct instance of `cart.controller.HomeController$MyRequest` (although at least one Creator exists): cannot deserialize from Object value (no delegate- or property-based Creator); nested exception is com.fasterxml.jackson.databind.exc.MismatchedInputException: Cannot construct instance of `cart.controller.HomeController$MyRequest` (although at least one Creator exists): cannot deserialize from Object value (no delegate- or property-based Creator)\n at [Source: (org.springframework.util.StreamUtils$NonClosingInputStream); line: 2, column: 5]"
}

 

마찬가지로 에러가 발생했다.

 

public static class MyRequest{  
    private final String myField;  
    private final String myField2;  

    public MyRequest(String myField, String myField2) {  
        this.myField = myField;  
        this.myField2 = myField2;  
    }  

    public String getMyField() {  
        return myField;  
    }  

    public String getMyField2() {  
        return myField2;  
    }    
}

 

이번엔 필드를 두개로 늘린 상태로 요청을 보냈다.

 

{

"myField": "test",

"myField2": "test2"

}

 

이렇게 했더니 응답이 너무 잘온다.


아무런 예외도 발생하지 않고 정상적으로 요청을 처리하고 응답까지 잘 보내준다.

왜.. 필드가 하나면, JSON을 객체로 변환해주지 못하는 걸까.. 우리의 Jackson 씨..

원인

Jackson 라이브러리의 깃헙에서 사람들이 남겨놓은 이슈를 검색해봤다.


single field 라는 키워드로 검색을 했던 것 같다.

그렇게 어렵지 않게 위와같은 상황에 대한 이슈를 발견할 수 있었다.

 

https://github.com/FasterXML/jackson-databind/issues/3085

 

Jackson fails to deserialize 1 field POJO · Issue #3085 · FasterXML/jackson-databind

Describe the bug When trying to deserialize JSON to POJO it fails when POJO has only one field. Version information 2.11.4 To Reproduce POJO: @AllArgsConstructor //lombok, if i add constructor manu...

github.com

마찬가지로 필드가 하나인 경우 왜 JSON에서 객체로 deserialize 가 안되냐는 내용이었다.

해당 질문에 달린 답변은 다음과 같다.

This is a well-known issue due to ambiguity of the 1-arg constructor case: single-argument could match either:

  1. Delegating case like "string-value" OR
  2. Properties-based case like {"name" : "value"}

and if user does not specify mode with

@JsonCreator(mode = JsonCreator.Mode.PROPERTIES) // or Mode.DELEGATING

Jackson will try to guess which one to use. In your case it likely guesses that DELEGATING mode is to be used and expects a String, not Object value.

I think Lombok has a way to get appropriate @JsonCreator (or @ContructorProperties which also implies properties-based approach).

Or... Jackson 2.12 does allow configuration to default to Properties, always. This is probably the best way

 

대충 요약하면 필드가 하나인 경우 Delegating 방식과 Properties 방식 중 뭘 써야 할지 몰라서 발생하는 에러라고 한다.

그럼 뭐 둘중 뭐쓸지 정해주면 에러 해결할 수 있지 않을까.

Delegating vs Properties

근데 일단 두가지 방식이 뭔질 알아야 둘중에 하날 선택하든지 말든지 할것 같다.

 

Delegating 방식은 (역)직렬화 하는 방법을 커스텀하는 방식이라고 한다. 복잡한 데이터가 있거나 데이터 처리방법을 변경해야 할때 사용한다고 한다.

 

반면, Properties 방식은 Jackson 씨의 기본적인 변환 방식이다.
필드가 두개이상인 경우 별 탈 없이 객체로의 역직렬화가 이뤄졌는데, 이 때 사용된 방식이 Properties 방식이라고 한다.

 

그렇다면, 당연히 나는 Properties 방식을 사용하면 될 듯 하다.

해결

내가 아는 한도에서는 세가지 해결 방법이 있다. 하나씩 알아보자.

Default constructor

처음 부터 말했듯 기본생성자를 생성해주면 해결된다.

하지만 기본생성자 덕에 final 선언이 안되다 보니, 그냥 기분이 별로 안좋다.

public static class MyRequest{  
    private String myField;  

    public MyRequest() {  
    }  

    public MyRequest(String myField) {  
        this.myField = myField;  
    }  

    public String getMyField() {  
        return myField;  
    }
}

 

@JsonConstructor

public static class MyRequest{  
    private final String myField;  

    @JsonCreator  
    public MyRequest(String myField) {  
        this.myField = myField;  
    }  
    public String getMyField() {  
        return myField;  
    }
}

생성자 위에 @JsonCreator 를 붙여주면, 손쉽게 처리가능하다.
파라미터에는 @JsonProperty 라는 어노테이션을 사용할 수 있다.

 

@JsonCreator  
public MyRequest(@JsonProperty("myField") String myField) {  
    this.myField = myField;  
}

 

이런 식으로 JSON의 키 값을 입력해주면 해당 value를 매핑해주는 역할을 한다.
주의할 점은 key와 일치하지 않는 값을 입력하면, null 로 값이 매핑된다.

 

@ConstructorProperties

이 어노테이션 역시 생성자에 붙여주면 된다.

 

public static class MyRequest{  
    private final String myField;  

    @ConstructorProperties(value = {"myField"})  
    public MyRequest(String myField) {  
        this.myField = myField;  
    }  

    public String getMyField() {  
        return myField;  
    }
}

 

위와 같이 사용하면된다.
이 어노테이션은 value 값을 지정해주는 것을 필수로 해줘야 한다.(안하면 컴파일 에러 뜸)


value에 들어오는 값은 JSON의 키 값이다.

우선순위는 일치하는 이름인듯 하나, 일치하는 키가 없는 경우는 그냥 순서대로 매핑되는 듯 하다.

728x90
728x90