Jackson deserialized error

영은·2023년 7월 12일

앱 출시 직후, 앱에서 회원가입을 하려고 하는데 유저 종류 두 가지 중 하나에서 회원가입이 되지 않는 오류가 발생했다. 개발 서버로 api를 호출할 때는 잘 되던 것이 운영 서버로 api 호출하면 에러가 나니 확인해달라는 것이 프론트엔드의 요청이었다. 모달로 확인할 수 있는 에러는 HttpMessageNotReadable 였다.

요청 받은 시점의 개발서버와 운영서버는 cicd 워크플로우를 제외하고서는 완전히 같은 어플리케이션이 배포되어 돌아가고 있는데 운영서버에서만 API가 작동하지 않는다는 것이 의아했지만 곧장 테스트를 해보았다. 직접 운영서버로 요청을 보내서 테스트 해보니 해당 기능에 문제가 없었다! 개발서버와 운영서버 간 프론트엔드단 환경이나 소스코드의 차이가 없는지 물었지만 동일하는 대답이 돌아왔다.

이럴 때는 바로 서버 로그를 직접 까보아야 한다. 서버에 접속하여 해당 에러 로그를 찾아보니 다음과 같았다.

  • 에러로그
2023-07-10 06:58:30,744 WARN  F.i.e.e.c.GlobalControllerAdvice - request type mapping error :
org.springframework.http.converter.HttpMessageNotReadableException: JSON parse error: Cannot deserialize value of type `java.lang.Integer` from Array value (token `JsonToken.START_ARRAY`); nested exception is com.fasterxml.jackson.databind.exc.MismatchedInputException: Cannot deserialize value of type `java.lang.Integer` from Array value (token `JsonToken.START_ARRAY`)
 at [Source: (org.springframework.util.StreamUtils$NonClosingInputStream); line: 1, column: 536] (through reference chain: FIS.iLUVit.dto.parent.SignupParentRequest["interestAge"])

...

Caused by: com.fasterxml.jackson.databind.exc.MismatchedInputException: Cannot deserialize value of type `java.lang.Integer` from Array value (token `JsonToken.START_ARRAY`)
 at [Source: (org.springframework.util.StreamUtils$NonClosingInputStream); line: 1, column: 536] (through reference chain: FIS.iLUVit.dto.parent.SignupParentRequest["interestAge"])
  • 문제의 dto
package ..dto.parent;

import ..domain.Parent;
import ..domain.embeddable.Theme;
import ..domain.enumtype.Auth;
import lombok.*;
import javax.validation.constraints.Email;
import javax.validation.constraints.NotNull;
import javax.validation.constraints.Pattern;
import javax.validation.constraints.Size;

@Getter
@Builder
@NoArgsConstructor
@AllArgsConstructor
public class SignupParentRequest {
    @Size(min=5, message = "아이디는 5자 이상이어야합니다.")
    private String loginId;
    @Pattern(regexp = "^(?=.*[A-Za-z])(?=.*\\d)(?=.*[@$!%*#?&])[A-Za-z\\d@$!%*#?&]{8,}$", message = "비밀번호는 문자, 숫자, 특수문자를 최소 한개씩 포함한 8자 이상이어야합니다.")
    private String password;
    @NotNull(message = "입력하지 않은 목록이 있습니다.")
    private String passwordCheck;
    @NotNull(message = "입력하지 않은 목록이 있습니다.")
    private String phoneNum;
    @Size(min = 2, max = 10, message = "닉네임은 2글자 이상 10글자 이하여야 합니다.")
    private String nickname;
    @NotNull(message = "입력하지 않은 목록이 있습니다.")
    private String name;
    @Email(message = "유효하지 않은 이메일 주소입니다.")
    private String emailAddress;
    @NotNull(message = "입력하지 않은 목록이 있습니다.")
    private String address;
    @NotNull(message = "입력하지 않은 목록이 있습니다.")
    private String detailAddress;
    private Theme theme;
    @NotNull(message = "입력하지 않은 목록이 있습니다.")
    private Integer interestAge;

    public Parent createParent(String pwd) {
        return Parent.builder()
                .loginId(loginId)
                .password(pwd)
                .name(name)
                .nickName(nickname)
                .phoneNumber(phoneNum)
                .emailAddress(emailAddress)
                .address(address)
                .detailAddress(detailAddress)
                .theme(theme)
                .interestAge(interestAge)
                .auth(Auth.PARENT)
                .readAlarm(true)
                .build();
    }
}

여기에서 중점적으로 봐야할 로그는 다음과 같다.

org.springframework.http.converter.HttpMessageNotReadableException: JSON parse error: Cannot deserialize value of type `java.lang.Integer` from Array value (token `JsonToken.START_ARRAY`); nested exception is com.fasterxml.jackson.databind.exc.MismatchedInputException: Cannot deserialize value of type `java.lang.Integer` from Array value (token `JsonToken.START_ARRAY`) at [Source: (org.springframework.util.StreamUtils$NonClosingInputStream); line: 1, column: 536] (through reference chain: FIS.iLUVit.dto.parent.SignupParentRequest["interestAge"])

Json parse error 라고? jackson이 해당 api의 requestDto에서 Integer 자료형인 interestAge을 역직렬화 할 수 없다는 것이 그 이유였다. (여기에서 좀 더 유심히 에러 로그를 봤어야하는데 deserialize와 Integer에만 꽂혀서 제대로 에러 로그를 읽지를 않은 것이 불필요한 시간을 소모하게한 요소이다 반성반성..)

Spring은 기본적으로 객체와 JSON 간의 변환에 Jackson spring library를 사용하고 있다. JSON 데이터를 Java 객체로 역직렬화하거나, Java 객체를 JSON 형식으로 직렬화하는 작업을 수행하는 데 사용되는 잭슨 라이브러리는 기본 생성자가 없으면 동작하지 않는다. 그러나 해당 dto에는 기본 생성자를 만들어주는 어노테이션 @NoArgsConstructor 이 잘만 붙어있었다.

역직렬화 문제가 있는데 기본 생성자가 없다면 기본 생성자를 만들어주자!


Integer는 자바 기본 자료형 int를 객체로 wrapping하여 처리할 수 있도록 하는 래퍼 클래스니까 불변 클래스로서의 성질을 갖는다. setter도 닫혀있고 객체가 한 번 생성되면 내부의 값을 변경할 수 없는 특성 때문에 Integer 자료형인 interestAge 속성만 잭슨이 역직렬화를 못 하고 있는 것이 아닌지까지 생각이 다다랐다.

그렇다고 setter를 여는 것은 객체지향적 설계에 어긋나니 그러고 싶지 않고, 그렇다고 자바 기본 자료형 int를 쓰고 싶지도 않으니 다른 방법을 찾아보았다. Jackson에게 해당 필드를 역직렬화할 때 setter 대신 다른 메서드를 사용하도록 알려주는 방식으로 접근해보려 하여 찾은 결과, 아래의 코드를 dto에 추가했다.

Jackson에서 @JsonCreator 어노테이션과 @JsonProperty 어노테이션을 함께 쓰면 Jackson은 생성자를 사용하여 객체를 생성하고, interetAge 속성의 값을 해당 생성자의 매개변수로 전달할 수 있게 된다.

@JsonCreator
public SignupParentRequest(@JsonProperty("interestAge") Integer interestAge) {
	this.interestAge = interestAge;
}

속성의 이름을 지정하기 위해 @JsonProperty를 사용해야하는데, 그 이유는 다음과 같다. 생성자를 포함한 함수는 컴파일 되면 파라미터의 이름은 var0, var1 처럼 임의적으로 바뀌어 버린다. 그래서 jackson이 아무리 열심히 json을 파싱해서 key value를 얻어도 이걸 @JsonCreator가 붙은 함수에 어떤 순서로 호출해야 하는지 알 수 없다. 그래서 jackson이 역직렬화할 속성에 @JsonProperty(name=“property”)를 넣어줘야 한다.

@JsonCreator는 jackson에게 어떤 값을 사용하여 객체를 생성해야 하는지 알려줌으로써 해당 함수를 통해 객체를 생성하고 필드를 생성함과 동시에 값을 채울 수 있게 도와준다. 이를 통해 jackson은 JSON 데이터를 해당 생성자의 매개변수로 변환하여 객체를 생성할 수 있게 된다.

여기까지 한 뒤 해당 에러의 해결을 위해 분기한 hotfix branch를 master branch에 merge했는데, cicd 완료 이후 다시 확인해보니 동일한 에러가 계속 발생하고 있었다. 🤯


이 지점에서 다시 에러 메세지를 확인해보자.

Cannot deserialize value of type java.lang.Integer from Array value (token JsonToken.START_ARRAY)

즉, Jackson이 JSON 데이터의 interestAge 필드 값을 Integer 객체로 역직렬화하는 중에 Array 타입의 값을 발견하여 변환할 수 없다는 것을 에러 메시지에서 친절하게 알려주고 있었다. 에러가 발생하는 이유는 interestAge 필드가 JSON 데이터에서 Array 값으로 전달되고 있는데, Jackson은 해당 값을 Integer로 변환할 수 없기 때문이라는 from Array value 이 부분을 놓치고 있었다.

Integer 객체를 역직렬화 하겠다고 애꿏은 어노테이션만 잔뜩 달았는데도 해결이 되지 않자 다시 에러 로그로 돌아와보니 그제서야 저 문장에 제대로 보였다.

프론트엔드가 해당 API를 호출하며 request body에 interestAge를 실어 보낼 때 단일 데이터가 아니라 배열로 보내주고 있지 않은지 확인을 재요청하자 제대로 된 원인을 알 수 있었다! 회원가입 할 때 기입하는 아이 연령대 설정 UI가 바 형태로 되어 있는데, 이때 슬라이더를 움직이는 순간 Array로 바뀐다는 것이었다.

프론트엔드에서 해당 문제를 인지하고 interestAge를 단일 데이터로 보내주니 HttpMessageNotReadable 오류는 더 이상 발생하지 않았다.

오늘의 결론 : 에러 메세지를 꼼꼼히 읽자. 에러 메세지에 답이 다 있다..! 허무하다면 허무한 결말이지만 디버깅의 핵심인 Locate error의 난이도와 중요성을 다시 체감할 수 있었기에 글을 남긴다. 에러의 위치를 정확히 알아내어 해당 에러가 어디에서 뭐 때문에 발생하는지를 꼬집을 수 있는 능력은 경험과 기술이 축적되어 쌓이는 것임을 기억하자.

0개의 댓글