Jackson ObjectMapper에서 기본 생성자 없이 Deserialization 하기

하루히즘·2022년 1월 2일
13

Spring Framework

목록 보기
12/15

서론

본 내용은 Spring Boot 2.x의 Jackson을 기반으로 작성하였다.

나는 API 응답 클래스를 getter 메서드와 final 필드의 조합으로 작성하는 편이다.

@Getter
public class ChatUsernameResponse {

	@JsonProperty("username")
	private final String username;

	@JsonProperty("alias")
	private final String alias;

	private ChatUsernameResponse(ChatUser chatUser) {
		this.username = chatUser.getUsername();
		this.alias = chatUser.getAlias();
	}

	public static ChatUsernameResponse of(ChatUser chatUser) {
		return new ChatUsernameResponse(chatUser);
	}
}

이 경우 한 번 생성한 응답이 중간에 실수로라도 변경될 일이 없고 어떤 데이터를 표현한다는 목적 자체에 잘 부합한다고 생각하기 때문에 습관적으로 이처럼 작성해왔다.

그러나 테스트 코드를 작성할 때 가끔씩 API 응답(JSON)을 다시 자바 클래스로 역직렬화(deserialization)해서 응답값을 확인해야 하는 경우가 있다. 예를 들어 목록에 새로운 값을 추가했을 때 리스트로 받은 응답에 해당 값이 잘 들어있는지 확인해야 하는 경우다. 이렇게 역직렬화를 하는 경우 ObjectMapperreadValue 메서드와 TypeReference 등을 사용할 수 있는데 대신 조건이 있다.

Cannot construct instance of com.example.simplechat.domains.room.bind.JoinedUsersResponse (no Creators, like default constructor, exist): cannot deserialize from Object value (no delegate- or property-based Creator)

즉 Jackson은, 사실 Jackson 뿐 아니라 Gson 같은 다른 직렬화/역직렬화 라이브러리들이 보통 그렇지만 기본 생성자나 getter/setter 를 기반으로 값을 읽거나 쓸 수 있다. 그러면 위처럼 getter와 final 필드로만 이루어진 불변(immutable) 클래스는 역직렬화할 수 없는 것일까?

다행히 그렇지 않다.

본론

Using Custom Constructor

FasterXML/Jackson의 공식 문서에서는 다음처럼 @JsonCreator 라는 어노테이션을 이용하여 Jackson이 별도의 생성자를 이용하도록 지정할 수 있다.

// https://github.com/FasterXML/jackson-databind/#annotations-using-custom-constructor

public class CtorBean
{
  public final String name;
  public final int age;

  @JsonCreator // constructor can be public, private, whatever
  private CtorBean(@JsonProperty("name") String name,
    @JsonProperty("age") int age)
  {
      this.name = name;
      this.age = age;
  }
}

마치 Jackson에게 이 생성자를 이용하여 CtorBean이라는 클래스를 JSON에서 역직렬화하라고 명시하는 것이다. 이를 위해서 각 파라미터가 어떤 필드를 뜻하는지 @JsonProperty 어노테이션을 이용하여 명시할 수 있다.

어노테이션의 설명에는 다음과 같은 내용이 적혀있다.

Marker annotation that can be used to define constructors and factory methods as one to use for instantiating new instances of the associated class.
NOTE: when annotating creator methods (constructors, factory methods), method must either be:
Single-argument constructor/factory method without JsonProperty annotation for the argument: if so, this is so-called "delegate creator", in which case Jackson first binds JSON into type of the argument, and then calls creator. This is often used in conjunction with JsonValue (used for serialization).
Constructor/factory method where every argument is annotated with either JsonProperty or JacksonInject, to indicate name of property to bind to

이를 요약하면 다음과 같다.

  • 기본 생성자, 팩토리 메서드 등 클래스의 인스턴스를 생성하는 메서드에 붙일 수 있음.
    • final 필드용 생성자에도 적용할 수 있다는 것이다.
  • 생성 메서드는 단 하나의 파라미터만 가지거나 모든 파라미터를 @JsonProperty 어노테이션으로 명시.
    • 만약 하나의 파라미터만 가진다면 "delegate creator"로 취급되어 해당 파라미터 타입으로 JSON 문자열을 역직렬화한 후 생성자를 호출한다.
    • @JsonProperty가 붙은 여러 파라미터를 가지는 경우 해당 어노테이션에서 명시한 필드를 기반으로 역직렬화를 수행한다.

즉 JSON 객체의 어떤 값을 이 클래스의 필드에 주입할 지 @JsonProperty 어노테이션으로 지정하고 그런 값들을 이용하여 인스턴스를 생성하는 과정, 즉 생성자(또는 팩토리 메서드)를 @JsonCreator 어노테이션으로 지정하여 역직렬화할 수 있는 것이다.

그래서 실제로 다음과 같은 불변 클래스에 생성자와 어노테이션을 적용한 결과 잘 역직렬화되는 것을 확인할 수 있었다.

@Getter
public class JoinedUserResponse {

    @JsonProperty("username")
    private final String username;

    @JsonProperty("alias")
    private final String alias;

    @JsonProperty("joinedAt")
    private final LocalDateTime joinedAt;

    @JsonCreator(mode = JsonCreator.Mode.PROPERTIES)
    private JoinedUserResponse(
        @JsonProperty("username") String username,
        @JsonProperty("alias") String alias,
        @JsonProperty("joinedAt") LocalDateTime joinedAt) {
        this.username = username;
        this.alias = alias;
        this.joinedAt = joinedAt;
    }

    public static JoinedUserResponse of(UserRoomRegistration registration) {
        ChatUser user = registration.getUser();
        return new JoinedUserResponse(
            user.getUsername(),
            user.getAlias(),
            registration.getUpdatedAt());
    }
}

@JsonCreator 어노테이션의 mode 속성은 언급했던 한 개의 파라미터냐(DELEGATING), 여러 개의 파라미터냐(PROPERTIES)에 따라 선택하거나 자동으로 설정되도록 기본값(DEFAULT)으로 둘 수 있다.

관련해서 비슷한 포스팅으로 이곳이 있는데 마찬가지로 setter가 없는 불변 클래스에서 Jackson을 어떻게 활용할 수 있는지에 대해 다루고 있다.

Jackson과 Reflection

그러면 왜 이런 방식으로 처리해야 하는 것일까? 이는 기본적으로 Jackson이 POJO, Plain Old Java Object를 기준으로 동작하기 때문이다. POJO는 기본 생성자, getter, setter를 가진 자바 클래스를 의미한다. 그래서 XXX라는 필드가 있다면 직렬화 시 getXXX 메서드로 필드 값을 읽어서 JSON으로 구축하거나 역직렬화 시 기본 생성자로 객체를 생성하고 setXXX 메서드로 필드에 값을 주입하는 것이다.

public class SampleObject {
    private String firstName;
    private String lastName;

    public void setFirstName(String firstName) {
        this.firstName = firstName;
    }

    public void setLastName(String lastName) {
        this.lastName = lastName;
    }

    ...
}

예를 들어 위와 같은 SampleObject 라는 클래스가 있을 때 이를 핸들러 메서드의 파라미터로 사용한다면 Jackson의 ObjectMapper가 역직렬화하는 과정을 볼 수 있다.

스프링은 자체적으로 Jackson을 의존성으로 등록해두고 있기 때문에 스프링에서 제공하는 맨 아래의 AbstractJackson2HttpMessageConverter에서 Jackson의 ObjectMapper, BeanDeserializer, 최종적으로 Java Reflection의 Constructor까지 호출하면서 SampleObject의 생성자를 호출하는 것을 볼 수 있다.

그 다음에는 setFirstName, setLastName 같은 setter 메서드를 호출하여 값을 주입하여 역직렬화를 수행한다. 그러나 기본 생성자가 없으면 어떨까? Jackson은 기본 생성자가 없더라도 해당 필드의 이름을 가진 파라미터를 받는 생성자가 있다면 역직렬화를 수행할 수 있다. 이 경우 맞는 이름을 찾지 못한 필드에 대해서만 setter 메서드를 Reflection으로 호출하고 나머지는 생성자에서 값을 주입한다.

그렇기 때문에 Jackson이 객체를 어떻게 생성하고 어떻게 값을 주입하거나 읽을 수 있는지 명시하기 위해 @JsonSetter, @JsonProperty 같은 어노테이션을 활용해야 하는 것이다. 관련 어노테이션의 목록은 GitHub 문서에서 제공하고 있다.

직렬화, 역직렬화 시 여러가지 상황에 따른 Jackson의 동작은 별도의 포스트에서 작성할 것이다.

결론

이전에는 동일한 필드를 가진 POJO 클래스를 테스트 용으로 하나 더 작성하는 방식으로 좀 무식하게 진행했었다. 하지만 아무리 생각해봐도 개발중에는 클래스가 자주 변경될 텐데 이를 항상 테스트 코드용 클래스에도 반영하기란 여간 번거로운 일이 아닐 것이다.

아직 Jackson 같은 직렬화/역직렬화 라이브러리의 내부 동작에 대해서 학습이 부족하기 때문에 그리고 모든 문제의 근원인 '공식 문서를 읽지 않았다'는 점 때문에 적절하지 못한 방식으로 진행했던 것 같다.

확실히 이런 식으로 어노테이션을 이용하여 동작을 커스텀할 수 있다는 것이 Jackson의 가장 큰 장점인 것 같다.

profile
YUKI.N > READY?

2개의 댓글

comment-user-thumbnail
2023년 12월 16일

좋은 글 잘 읽고 갑니다!
특히 공식 문서와 디버깅을 통해 직접 확인해주시는 글 내용이
너무 좋았어용 감사합니다 좋은 하루 되세요~

1개의 답글