본 내용은 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)해서 응답값을 확인해야 하는 경우가 있다. 예를 들어 목록에 새로운 값을 추가했을 때 리스트로 받은 응답에 해당 값이 잘 들어있는지 확인해야 하는 경우다. 이렇게 역직렬화를 하는 경우 ObjectMapper
의 readValue
메서드와 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) 클래스는 역직렬화할 수 없는 것일까?
다행히 그렇지 않다.
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
어노테이션으로 명시.@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이 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의 가장 큰 장점인 것 같다.
좋은 글 잘 읽고 갑니다!
특히 공식 문서와 디버깅을 통해 직접 확인해주시는 글 내용이
너무 좋았어용 감사합니다 좋은 하루 되세요~