프로그래밍/Java

Jackson 라이브러리 기본기능 정리 - json 직렬화와 역직렬화

Jay22 2020. 2. 27. 18:15
반응형

Jackson Annotation 예제

본 글은 다음 원문을 번역 및 수정한 글입니다.
https://www.baeldung.com/jackson-annotations

 

전체 코드는 다음 레파지토리에 있습니다. 필요하다면 클론 받으셔서 test 패키지에 있는 JacksonTest.class 를 보시면 한 눈에 볼 수 있습니다.

https://github.com/JunHoPark93/jackson-practice

 

JunHoPark93/jackson-practice

Practice for jackson library. Contribute to JunHoPark93/jackson-practice development by creating an account on GitHub.

github.com

Jackson Serialization Annotation

이번 내용은 Baeldung 기술 블로그의 Jackson 관련 내용을 정리하려고 합니다. 정리하게 된 계기는 사내에서도 주로 쓰는 라이브러리이고 제대로 모르고 지나친다면 문제가 될 수 있는 부분들이 존재하기 때문입니다.

 

참고로 예시에서 접근제한자가 public 인 것들이 있는 이유는 (직렬화, 역직렬화 할때 필요하기도 하고) getter setter 넣을 시 코드가 길어지고, Lombok을 쓰자니 Jackson 애노테이션과 자칫 혼동할 수 있기 때문에 그렇게 하였습니다.

 

여기서 말하는 직렬화란 객체를 전송가능한 형태로 말아주는걸 의미하고 역직렬화란 그 데이터들을 다시 자바 객체로 변환해주는 것으로 이해하시면 됩니다. 

@JsonAnyGetter

JsonAnyGetter 는 Map 필드를 다루는데 유연성을 제공합니다.

Member 클래스가 Map으로 속성을 가지고 있다고 가정 해보겠습니다.

public class Member {
    public String name;
    private Map<String, String> properties;

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

    @JsonAnyGetter
    public Map<String, String> getProperties() {
        return properties;
    }

    public void add(String attr, String val) {
        this.properties.put(attr, val);
    }
}

getProperties 메서드에 @JsonAnyGetter 가 붙어있습니다. 이 속성은 ObjectMapper를 이용해서 json String 으로 변환해보겠습니다.

@Test
public void json_any_getter() throws JsonProcessingException {
    Member member = new Member("Jay");
    member.add("favorite", "chicken");
    member.add("hobby", "tennis");

    String result = new ObjectMapper().writeValueAsString(member);

    assertThat(result, containsString("favorite"));
    assertThat(result, containsString("hobby"));
}

테스트는 통과합니다. 결과가 어떻게 나오는지 살펴보면,

{"name":"Jay","favorite":"chicken","hobby":"tennis"}

모든 속성이 동일한 depth로 있는것을 볼 수 있습니다. 즉 Map 타입이 전부 key-value 형식으로 json에 들어있는 것을 볼 수 있습니다. 그렇다면 @JsonAnyGetter 애노테이션을 제거하고 실행해볼까요?

{"name":"Jay","properties":{"favorite":"chicken","hobby":"tennis"}}

그러면 Map 필드는 다음과 같이 properties 로 한 번 감싸진 형태의 json으로 나오게 됩니다. 자바단에서 Map을 쓴다면 @JsonAnyGetter를 이용해서 필요에 맞게 커스텀 할 수 있겠죠.

 

@JsonGetter

@JsonGetter는 메서드 이름을 getter 메서드로 표현하기 위해 @JsonProperty 애노테이션의 대안으로 쓸 수 있습니다.

public class Member2 {
    public int id;
    private String name;

    public Member2(int id, String name) {
        this.id = id;
        this.name = name;
    }

    @JsonGetter("name")
    public String getTheName() {
        return name;
    }
}

@JsonGetter 의 이름으로 name을 썼는데요. 저 name 필드를 get 하는 것으로 쉽게 이해할 수 있습니다.

@Test
public void json_getter() throws JsonProcessingException {
    Member2 member = new Member2(1, "Jay");

    String result = new ObjectMapper().writeValueAsString(member);

    assertThat(result, containsString("Jay"));
    assertThat(result, containsString("1"));
}

매우 직관적으로 알 수 있겠네요.

@JsonPropertyOrder

@JsonPropertyOrder 를 통해서 직렬화 하는 속성의 순서를 정할 수 있습니다.

@JsonPropertyOrder({ "name", "id" })
public class Member3 {
    public int id;
    public String name;

    public Member3(int id, String name) {
        this.id = id;
        this.name = name;
    }
}

다음과 같이 순서를 지정하면 직렬화 시 필드의 순서를 결정할 수 있게 됩니다.

{"name":"My bean","id":1}

저 애노테이션이 없다면 id name 순으로 나오게 되겠죠.

@JsonValue

@JsonValue 는 전체 인스턴스를 직렬화할 때 사용하는 단일 메서드를 나타냅니다. 예를들어, enum에서 getName 메서드에 @JsonValue를 넣어주어 이름을 통해 직렬화하게 합니다.

말이 조금 어려울 수 있는데 예시를 볼까요.

public enum TypeEnumWithValue {
    TYPE1(1, "치킨"), TYPE2(2, "피자");

    private Integer id;
    private String name;

    TypeEnumWithValue(Integer id, String name) {
        this.id = id;
        this.name = name;
    }

    @JsonValue
    public String getName() {
        return name;
    }
}

이 enum 은 TYPE1 과 TYPE2가 있습니다. 하단의 getName에 @JsonValue 애노테이션이 붙어있죠. 저 애노테이션이 없다면, 이 enum을 json 으로 직렬화를 할 때 enum 이름으로 직렬화가 됩니다.

new ObjectMapper().writeValueAsString(TypeEnumWithValue.TYPE1);

즉 이 결과가 다음과 같이 나오게 됩니다 (애노테이션 없을 시)

TYPE1

enum의 name 필드로 직렬화를 하고 싶다면 (치킨, 피자..) 저렇게 enum의 name이 나오면 원하는 결과를 얻을 수 없습니다. @JsonValue 애노테이션을 붙여준다면 직렬화 시 나오는 필드의 이름을 결정합니다. 결과적으로 다음과 같은 내용을 얻을 수 있습니다.

@JsonValue
public String getName() {
    return name;
}

치킨

@JsonRootName

@JsonRootName 애노테이션은 root wrapper의 이름을 설정할 수 있습니다. 아래와 같은 json을,

{
  "id": 1,
  "name":"Jay"
}

{
  "Member": {
    "id": 1,
    "name": "Jay"
  }
}

위와 같이 Root 이름을 지정할 수 있습니다.

@JsonRootName(value = "member")
public class Member4 {
    public int id;
    public String name;

    public Member4(int id, String name) {
        this.id = id;
        this.name = name;
    }
}

위와 같이 JsonRootName 을 member로 지정해 놓고 결과를 출력한다면 wrapping 된 결과를 얻을 수 있습니다.

@Test
public void json_root_name() throws JsonProcessingException {
    Member4 user = new Member4(1, "Jay");

    ObjectMapper mapper = new ObjectMapper();
    mapper.enable(SerializationFeature.WRAP_ROOT_VALUE);
    String result = mapper.writeValueAsString(user);

    System.out.println(result);
    assertThat(result, containsString("Jay"));
    assertThat(result, containsString("member"));
}

한 가지 주의 점이 있다면 ObjectMapper 설정을 변경 해주어야 합니다.

mapper.enable(SerializationFeature.WRAP_ROOT_VALUE);

@JsonSerialize

@JsonSerialize 는 엔티티를 marshalling 할 때 사용자가 지정한 커스텀 serializer를 쓸 수 있게 해줍니다. 무슨 말인지 하나씩 살펴봅시다. 직렬화를 어떻게 할지 사용자가 커스텀을 한다고 했을 때는 StdSerializer를 이용해서 구현할 수 있습니다.

 

 

StdSerializer를 상속받아서 커스텀한 클래스를 만듭니다.

public class CustomDateSerializer extends StdSerializer<Date> {
    private static SimpleDateFormat formatter = new SimpleDateFormat("dd-MM-yyyy hh:mm:ss");

    public CustomDateSerializer() {
        this(null);
    }

    public CustomDateSerializer(Class<Date> t) {
        super(t);
    }

    @Override
    public void serialize(Date value, JsonGenerator gen, SerializerProvider arg2) throws IOException {
        gen.writeString(formatter.format(value));
    }
}

커스텀할 부분을 serialize 라는 메서드를 재정의해서 만듭니다. 예시에는 날짜 데이터를 포매팅하고 있습니다.

@Test
public void json_serialize() throws ParseException, JsonProcessingException {
    SimpleDateFormat df = new SimpleDateFormat("dd-MM-yyyy hh:mm:ss");

    String toParse = "20-12-2014 02:30:00";
    Date date = df.parse(toParse);
    Event event = new Event("party", date);
    String result = new ObjectMapper().writeValueAsString(event);
    assertThat(result, containsString(toParse));
}

다음과 같이 객체 필드의 포맷은 변경하지 않고 직렬화 시 변경해주고 싶은 필드만 커스텀하게 변경할 수 있습니다.

Jackson Deserialization Annotations

이제는 역직렬화에 대해서 알아보겠습니다.

@JsonCreator

@JsonCreator를 이용해서 역직렬화 할 때 생성자나 팩토리를 수정할 수 있습니다.

{
    "id":1,
    "theName":"Jay"
}

이런 json이 있다고 할 때 역직렬화 할 객체에는 (이 json을 객체로 매핑하는 과정을 의미합니다) theName이라는 필드를 쓰기 싫습니다. 그냥 name이라는 필드를 쓰고 싶다고 가정한다면 다음과 같이 할 수 있습니다.

unmarshalling 과정의 조금의 절차를 넣어주면 됩니다. @JsonCreator 와 @JsonProperty를 사용함으로써 해결할 수 있습니다.

public class Member5 {
    public int id;
    public String name;

    @JsonCreator
    public Member5(@JsonProperty("id") int id, @JsonProperty("theName") String name) {
        this.id = id;
        this.name = name;
    }
}

@JsonCreator를 생성자 위에 달아주고 프로퍼티르 이름을 json key 에 해당하는 값을 넣어줍니다. 그리고 원하는 변수명에 넣어주면 됩니다.

@Test
public void json_creator() throws JsonProcessingException {
    String json = "{\n" +
            "    \"id\":1,\n" +
            "    \"theName\":\"Jay\"\n" +
            "}";
    Member5 member = new ObjectMapper().readerFor(Member5.class).readValue(json);

    assertThat(member.name, is(member.name));
}

다음과 같이 objectmapper의 readFor과 readValue의 조합으로 원하는 객체로 역직렬화를 할 수 있습니다.

혹은 @JsonSetter 로 다음과 같이 만들 수 있습니다.

// 중략...
private String name;

@JsonSetter("name")
public void setTheName(String name) {
    this.name = name;
}

@JacksonInject

@JacksonInject 는 json 데이터가 아닌 곳에서 값을 주입할 때 쓰입니다.

public class Member6 {
    @JacksonInject
    public int id;

    public String name;
}

@Test
public void json_inject() throws JsonProcessingException {
    String json = "{\"name\":\"Jay\"}";

    InjectableValues injectableValues = new InjectableValues.Std().addValue(int.class, 5);
    Member6 member = new ObjectMapper().reader(injectableValues).forType(Member6.class).readValue(json);

    assertThat(member.id, is(5));
}

@JsonAnySetter

@JsonAnySetter 는 Map 을 다룰 때 유용합니다. 역직렬화 과정에서 json 프로퍼티의 값들을 map 에 손쉽게 담을 수 있습니다.

public class Member7 {
    public String name;
    public Map<String, String> properties = new HashMap<>();

    @JsonAnySetter
    public void add(String key, String value) {
        properties.put(key, value);
    }
}

@Test
public void json_any_setter() throws IOException {
    String json = "{\"name\":\"Jay\",\"favorite\":\"chicken\",\"hobby\":\"tennis\"}";

    Member7 member = new ObjectMapper().readerFor(Member7.class).readValue(json);

    assertThat(member.properties.get("favorite"), is("chicken"));
}

그러면 위와 같은 프로퍼티들이 map 에 하나씩 담기게 됩니다.

@JsonDeserialize

@JsonDeserialize는 커스텀 역직렬화를 만들 수 있습니다.

public class Event2 {
    public String name;

    @JsonDeserialize(using = CustomDateDeserializer.class)
    public Date eventDate;
}

커스텀 역직렬화 클래스는 다음과 같이 만들 수 있습니다.

public class CustomDateDeserializer extends StdDeserializer<Date> {
    private static SimpleDateFormat formatter = new SimpleDateFormat("dd-MM-yyyy hh:mm:ss");

    public CustomDateDeserializer() {
        this(null);
    }

    public CustomDateDeserializer(Class<?> vc) {
        super(vc);
    }

    @Override
    public Date deserialize(JsonParser parser, DeserializationContext ctxt) throws IOException {
        String date = parser.getText();
        try {
            return formatter.parse(date);
        } catch (ParseException e) {
            throw new RuntimeException(e);
        }
    }
}

StdDeserializer를 상속받은 직렬화기를 만들었는데 StdDeserializer의 상위 클래스인 JsonDeserializer를 상속받아서 만들어도 됩니다.

@Test
public void json_deserializer() throws JsonProcessingException {
    String json = "{\"name\":\"Jay event\",\"eventDate\":\"20-12-2020 01:01:00\"}";

    SimpleDateFormat df = new SimpleDateFormat("dd-MM-yyyy hh:mm:ss");
    Event2 event = new ObjectMapper()
            .readerFor(Event2.class)
            .readValue(json);

    assertThat(df.format(event.eventDate), is("20-12-2020 01:01:00"));
}

@JsonAlias

@JsonAlias는 역직렬화를 할 때 한 개 이상의 이름을 한 객체 필드에 매핑되게 설정할 수 있습니다.

public class Member8 {
    @JsonAlias({"name", "his_name", "her_name"})
    public String name;
    public String hobby;
}

Member8 클래스는 name, his_name, her_name 중 어떤 것으로 들어오든 name에 매핑 시킬 수 있습니다.

@Test
public void json_alias() throws JsonProcessingException {
    String json1 = "{\"name\":\"Jay\",\"hobby\":\"tennis\"}";
    String json2 = "{\"his_name\":\"Jay\",\"hobby\":\"tennis\"}";
    String json3 = "{\"her_name\":\"Jay\",\"hobby\":\"tennis\"}";

    Member8 member1 = new ObjectMapper().readerFor(Member8.class).readValue(json1);
    Member8 member2 = new ObjectMapper().readerFor(Member8.class).readValue(json2);
    Member8 member3 = new ObjectMapper().readerFor(Member8.class).readValue(json3);

    assertThat(member1.name, is("Jay"));
    assertThat(member2.name, is("Jay"));
    assertThat(member3.name, is("Jay"));
}

Jackson Property Inclusion Annotations

@JsonIgnoreProperties

@JsonIgnoreProperties 애노테이션은 클래스 레벨에 붙일 수 애노테이션이며 Jackson이 무시할 필드를 설정할 수 있습니다.

@JsonIgnoreProperties({"id"})
public class Member9 {
    public int id;
    public String name;

    public Member9(int id, String name) {
        this.id = id;
        this.name = name;
    }
}

@Test
public void json_ignore_properties() throws JsonProcessingException {
    Member9 member = new Member9(1, "Jay");
    String result = new ObjectMapper().writeValueAsString(member);

    assertThat(result, containsString("Jay"));
    assertThat(result, not(containsString("1")));
}

위와 같이 객체의 id 필드는 직렬화시 제외 시킨 결과가 나옵니다.

혹은 클래스레벨에서 쓰기 싫다면 필드에 붙이는 애노테이션도 존재합니다.

@JsonIgnore

@JsonIgnore
public int id;

public String name;

@JsonIgnoreProperties와 같이 id 가 직렬화시 제외됩니다.

혹은 이너 클래스 전부를 제외시키고 싶다면 @JsonIgnoreType을 쓰면 됩니다.

// 클래스 내부
        @JsonIgnoreType
    public static class Name {
        public String firstName;
        public String lastName;
    }

@JsonInclude

@JsonInclude NON_NULL 은 null을 직렬화시 제외시킵니다.

@JsonInclude(JsonInclude.Include.NON_NULL)
public class Member10 {
    public int id;
    public String name;

    public Member10(int id, String name) {
        this.id = id;
        this.name = name;
    }
}

@Test
public void json_include() throws JsonProcessingException {
    Member10 member = new Member10(1, null);
    String result = new ObjectMapper().writeValueAsString(member);

    assertThat(result, containsString("1"));
    assertThat(result, not(containsString("name")));
}

@JsonAutoDetect

@JsonAutoDetect를 이용하여 필드의 직렬화 대상을 정할 수 있습니다. 서두에 필드 접근제한자에 대해서 신경쓰지 말라고 했는데 요번 파트는 잘 보셔야 합니다.

@JsonAutoDetect(fieldVisibility = JsonAutoDetect.Visibility.ANY)
public class Member11 {
    private int id;
    private String name;

    public Member11(int id, String name) {
        this.id = id;
        this.name = name;
    }
}

id 와 name은 private 입니다. 접근할 수 있는 경로가 없습니다. 물론 getter 메서드를 만들면 해결되는 문제이긴 하지만, 없다고 가정하겠습니다.

그래서 @JsonAutoDetect의 가시성 범위를 설정합니다. ANY로 준다면 private 필드까지 모두 직렬화시 접근 가능합니다.

@Test
public void json_auto_detect_visibility_any() throws JsonProcessingException {
    Member11 member = new Member11(1, "Jay");

    String result = new ObjectMapper().writeValueAsString(member);
    assertThat(result, containsString("1"));
    assertThat(result, containsString("Jay"));
}

JsonAutoDetect의 가시성 범위는 ANY, NON_PRIVATE, PROTECTED_AND_PUBLIC 등등 여러 속성이 있으니 필요한 것들을 골라 쓰면 될 것 같습니다.

@JsonProperty

직렬화시 설정할 수 있는 이름을 지정하는 애노테이션 입니다.

private String name;

@JsonProperty("name")
public void setTheName(String name) {
    this.name = name;
}

@JsonProperty("name")
public String getTheName() {
    return name;
}

Jackson Annotation 비활성화

모든 애노테이션을 비활성화하려면 다음과 같은 옵션을 준 ObjectMapper를 이용하면 됩니다.

@Test
public void disable_jackson_annotation() {
    ObjectMapper mapper = new ObjectMapper();
    mapper.disable(MapperFeature.USE_ANNOTATIONS);
    // 중략
    // 이 mapper를 사용하면, 애노테이션이 전부 비활성화 됩니다
}

제가 실제로 겪었던 문제

한 모듈에서 json 데이터를 가져올 때 어떤 DTO를 사용하고 있었습니다. 그 DTO 에서 @JsonDeserialize로 한 필드를 Hash 하면서 가져오고 있었죠. 당연히 중요하고 민감한 값이었습니다. 문제는 다른 모듈로 데이터를 전송하고 같은 DTO로 또 역직렬화를 한게 문제였습니다. 다른 필드들은 값이 같았지만 Hash 한 필드는 한 번 더 Hash가 진행되는 참사가 일어났습니다. DTO를 다른 것을 쓰면 되었지만 클래스가 너무 많아져서 하나의 DTO로 통신했던 것이 문제였습니다.

결국 한쪽에서 역직렬화할 때 사용하는 ObjectMapper를

mapper.disable(MapperFeature.USE_ANNOTATIONS);

설정을 통해 @JsonDeserialize를 비활성화 시켜서 해결을 하였지만 더 좋은 방법이 있는지 고민중입니다.

 

지금 까지 Jackson Annotation의 기본 사용방법을 알아봤는데요. 고급기능들은 업무나 사이드에서 맞닥뜨리게 될 때 정리를 하려고 합니다.

반응형