본문 바로가기

Spring/Spring

Jackson custom serializer, deserializer 적용했던 사례 공유, LocalDateTime JSON 파싱하는 방법

반응형

Jackson custom serializer, deserializer example

JSON은 프로그래밍에서 굉장히 많이 쓰이는 데이터 포맷이다.

특히 스프링부트를 이용한 프로젝트에서는 'spring-boot-starter-web'을 dependency에 추가하기만 하면 jackson이라는 json 라이브러리가 자동으로 추가될 정도다.

심지어 jackson이 제공해주는 objectMapper 객체는 스프링 빈으로 제공하기까지 한다.

그러나 이렇게 기본 제공해주는 objectMapper 객체의 경우, 그냥 사용하기에는 모든 요구사항을 충족하기 어렵다.

요구 사항을 충족시키시 위해서는 Object to JSON(Serialize/직렬화), JSON to Object(Deserialize/역직렬화)를 입맛에 맞게 조절할 줄 알아야한다.

필자의 경우는 스프링부트 프로젝트에서 XmlGregorianCalendar 객체를 ISO8601 즉, "yyyy-MM-dd'T'hh:mm:ss.SSSZ" 포맷으로 데이터를 저장하고 읽어올 필요가 있었다.

그 해결 과정을 다른 예제로 간단하게 공유하고자 한다.

serialize, deserialize에 대해서 궁금한 사람은 맨 아래쪽으로 내려서 그 부분을 읽어보면 되고 경험을 공유해볼 사람은 순서대로 읽어보면 될 것이다.

1차 시도 간단한 테스트

테스트를 위해 아래의 코드들을 작성했다. (프로젝트 구성 참조)

//FileUtils.java
package com.tistory.jeongpro.util;

import java.io.File;
import java.io.IOException;

import com.fasterxml.jackson.databind.MapperFeature;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.fasterxml.jackson.databind.SerializationFeature;
import com.fasterxml.jackson.databind.json.JsonMapper;
import com.fasterxml.jackson.datatype.jsr310.JavaTimeModule;

public class FileUtils {
  public static ObjectMapper objectMapper;
  static {
      objectMapper = new ObjectMapper();
      //대소문자 구분 안하는 설정
      objectMapper.configure(MapperFeature.ACCEPT_CASE_INSENSITIVE_PROPERTIES, true);
      //pretty
      objectMapper.enable(SerializationFeature.INDENT_OUTPUT);
      //timestamp로 저장하지 않기로 설정
      objectMapper.disable(SerializationFeature.WRITE_DATES_AS_TIMESTAMPS);
  }
  public static boolean writeObjectToJson(String fileName, Object object) {
      boolean result = false;
      try {
          objectMapper.writeValue(new File(fileName), object);
          result = true;
      } catch (IOException e) {
          //에러 처리...
          e.printStackTrace();
      }        
      return result;
  }
  public static <T> Object readJsonToObject(String fileName, Class<T> clazz) {
      try {
          return objectMapper.readValue(new File(fileName), clazz);
      } catch (IOException e) {
          //에러 처리...
          e.printStackTrace();
      }
      return null;
  }
}

objectMapper를 간단히 구성하고 읽고 쓰는 메서드를 작성했다.

//Book.java
package com.tistory.jeongpro.domain;

import java.time.LocalDateTime;

public class Book {
  private String title;
  private String author;
  private User user;
  private LocalDateTime expiredTime;
  //getter,setter
  //constructor
  //toString...
}
--------------------------------------------------------------
//User.java
package com.tistory.jeongpro.domain;

import java.time.LocalDateTime;
public class User {
  private long id;
  private LocalDateTime created;
  //getter,setter
  //constructor
  //toString...
}

domain 클래스들도 만들었다. 이 클래스들로 Serialize, Deserialize 테스트를 진행한다.

package com.tistory.jeongpro.controller;

import java.io.File;
import java.time.LocalDateTime;

import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RestController;

import com.tistory.jeongpro.domain.Book;
import com.tistory.jeongpro.domain.User;
import com.tistory.jeongpro.util.FileUtils;

@RestController
public class UserController {
  @GetMapping("/")
  public Object test() {
      String basePath = System.getProperty("user.dir");
      String fileName = basePath + File.separator + "book.txt";
      Book book1 = new Book("Frozen", "jeongpro", new User(1, LocalDateTime.now()), LocalDateTime.now());
      FileUtils.writeObjectToJson(fileName, book1);

      Book book2 = (Book) FileUtils.readJsonToObject(fileName, Book.class);
      return book2;
  }
}

위와 같이 코드를 짠 후에 테스트를 진행했다.

야심찬 기본 예제로 ObjectMapper를 이용해서 파일에 쓰고 다시 해당 파일로부터 값을 읽은 후 객체를 api로 노출하는 코드다.

어이없겠지만 위의 예제는 에러가 난다.

어디가 에러가 나나 봤더니 파일에서 읽는 부분에서 에러가 난다.

아니? jackson 자기가 쓰고 자기가 뭔 포맷인지 몰라서 못 읽는건 무슨 경우지? 하는 생각이 들었다.

{
  "title" : "Frozen",
  "author" : "jeongpro",
  "user" : {
    "id" : 1,
    "created" : {
      "nano" : 771925000,
      "year" : 2019,
      "monthValue" : 12,
      "dayOfMonth" : 23,
      "hour" : 20,
      "minute" : 43,
      "second" : 55,
      "month" : "DECEMBER",
      "dayOfWeek" : "MONDAY",
      "dayOfYear" : 357,
      "chronology" : {
        "id" : "ISO",
        "calendarType" : "iso8601"
      }
    }
  },
  "expiredTime" : {
    "nano" : 771925000,
    "year" : 2019,
    "monthValue" : 12,
    "dayOfMonth" : 23,
    "hour" : 20,
    "minute" : 43,
    "second" : 55,
    "month" : "DECEMBER",
    "dayOfWeek" : "MONDAY",
    "dayOfYear" : 357,
    "chronology" : {
      "id" : "ISO",
      "calendarType" : "iso8601"
    }
  }
}

처음보는 포맷으로 저장하고 있었다. 예상에는 그냥 timestamp 형식으로 저장하고있거나 최소한 "yyyy-MM-dd hh:mm:ss" 이런 포맷으로는 저장할거라고 생각했는데 아주 의외였다.

이 부분을 어떻게 개선 하는지 찾아보았다.

jackson에서는 친절하게도 자바에서 사용하는 대부분의 Date,Time 포맷에 대해 serialize, deserialize가 가능하도록 모듈 구성을 미리 해놨고 그것을 적용하면 된다고 한다.

이것을 적용해봤다.

2차 시도 JavaTimeModule 추가

package com.tistory.jeongpro.util;

import java.io.File;
import java.io.IOException;

import com.fasterxml.jackson.databind.MapperFeature;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.fasterxml.jackson.databind.SerializationFeature;
import com.fasterxml.jackson.databind.json.JsonMapper;
import com.fasterxml.jackson.datatype.jsr310.JavaTimeModule;

public class FileUtils {
    public static ObjectMapper objectMapper;
    static {
        // objectMapper = new ObjectMapper();
        // objectMapper.registerModules(new JavaTimeModule());
        objectMapper = JsonMapper.builder()
                .addModule(new JavaTimeModule())
                .build();
        //대소문자 구분 안하는 설정
        objectMapper.configure(MapperFeature.ACCEPT_CASE_INSENSITIVE_PROPERTIES, true);
        //pretty
        objectMapper.enable(SerializationFeature.INDENT_OUTPUT);
        //timestamp로 저장하지 않기로 설정
        objectMapper.disable(SerializationFeature.WRITE_DATES_AS_TIMESTAMPS);
    }

    public static boolean writeObjectToJson(String fileName, Object object) {
        //...
    }
    public static <T> Object readJsonToObject(String fileName, Class<T> clazz) {
        //...
    }
}

Fileutils 클래스에서 objectMapper에 JavaTimeModule을 추가해줬다.

jackson 2.10이상 또는 3.0에서는 위와같이 JsonMapper의 builder패턴으로 모듈을 추가하고, 2.9 이하버전에서는 ObjectMapper를 생성하고 registerModules로 모듈을 추가하라고 한다.

필자는 2.10으로 테스트했는데 두 방식 모두 모듈이 잘 적용되었다.

결과는 아래와 같다.

{
  "title" : "Frozen",
  "author" : "jeongpro",
  "user" : {
    "id" : 1,
    "created" : "2019-12-23T22:21:53.6874212"
  },
  "expiredTime" : "2019-12-23T22:21:53.6874212"
}

간단한 설정만으로도 많이 온 것 같다.

여기서 만약에 밀리초를 소수점 이하 세자리까지만 표기하라는 요구사항이 있으면 어떻게 해야할까?

custom serializer를 등록해서 쓸 때 소수점이하 세자리 까지만 쓰도록하면 될 것이다. 해보자.

custom serializer

방법은 간단하다.

objectMapper에 모듈을 추가해주는데 그 모듈에 custom serializer를 추가해주기만 하면 된다.

package com.tistory.jeongpro.util;

import java.io.IOException;
import java.time.LocalDateTime;
import java.time.format.DateTimeFormatter;

import com.fasterxml.jackson.core.JsonGenerator;
import com.fasterxml.jackson.databind.SerializerProvider;
import com.fasterxml.jackson.databind.ser.std.StdSerializer;

public class LocalDateTimeSerializer extends StdSerializer {
  protected LocalDateTimeSerializer(Class<LocalDateTime> t) {
      super(t);
  }
  public LocalDateTimeSerializer() {
      this(null);
  }
  /**
   * 
   */
  private static final long serialVersionUID = -1636482041003741854L;

  @Override
  public void serialize(LocalDateTime value, JsonGenerator gen, SerializerProvider provider) throws IOException {
      gen.writeString(value.format(DateTimeFormatter.ofPattern("yyyy-MM-dd'T'HH:mm:ss.SSS")));
  }
}

새로운 코드를 작성했다. StdSerializer 클래스를 상속받는 custom serializer를 만들었다.

LocalDateTime 값(value)을 쓸 때 JsonGenerator한테 어떻게 할지를 지정하는 메서드만 구현해주면 된다.

{
  "title" : "Frozen",
  "author" : "jeongpro",
  "user" : {
    "id" : 1,
    "created" : "2019-12-23T23:48:29.142"
  },
  "expiredTime" : "2019-12-23T23:48:29.142"
}

결과도 원하는대로 밀리초 단위가 세자리까지 나오는걸 확인할 수 있다.

deserialize도 한 번 해보자.

만약 user의 생성일인 "created"는 초 단위까지 필요없고 년월일시분만 읽어와야한다면 어떻게할까?

custom deserializer

방법은 serialize와 유사하다.

package com.tistory.jeongpro.util;

import java.io.IOException;
import java.time.LocalDateTime;
import java.time.format.DateTimeFormatter;

import com.fasterxml.jackson.core.JsonParser;
import com.fasterxml.jackson.core.JsonProcessingException;
import com.fasterxml.jackson.databind.DeserializationContext;
import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.deser.std.StdDeserializer;
import com.tistory.jeongpro.domain.User;

public class UserDeserializer extends StdDeserializer{
  public UserDeserializer() {
      this(null);
  }
  protected UserDeserializer(Class<?> vc) {
      super(vc);
  }
  private static final long serialVersionUID = -7683857719562990174L;

  @Override
  public User deserialize(JsonParser p, DeserializationContext ctxt) throws IOException, JsonProcessingException {
      JsonNode jsonNode = p.getCodec().readTree(p);
      long id = jsonNode.get("id").asLong();
      String createdString = jsonNode.get("created").asText();
      LocalDateTime created = LocalDateTime.parse(createdString, DateTimeFormatter.ISO_LOCAL_DATE_TIME);
      LocalDateTime modifiedCreated = LocalDateTime.of(created.getYear(), created.getMonth(), created.getDayOfMonth(), created.getHour(), created.getMinute()); 
      return new User(id, modifiedCreated);
  }
}

StdDeserializer를 상속받는 클래스를 만들고, deserialize 메서드에서 원하는 조작을 하면 된다. (예제가 적절하지는 않아서 억지스러운 경향이 있다. 원리만 이해하도록하자...)

그리고 이 Deserializer를 module에 등록하고 objectMapper가 이 모듈을 다시 등록하면 된다.

package com.tistory.jeongpro.util;

import java.io.File;
import java.io.IOException;
import java.time.LocalDateTime;

import com.fasterxml.jackson.databind.MapperFeature;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.fasterxml.jackson.databind.SerializationFeature;
import com.fasterxml.jackson.databind.json.JsonMapper;
import com.fasterxml.jackson.databind.module.SimpleModule;
import com.fasterxml.jackson.datatype.jsr310.JavaTimeModule;
import com.tistory.jeongpro.domain.User;

public class FileUtils {
    public static ObjectMapper objectMapper;
    static {
        objectMapper = JsonMapper.builder()
                .addModule(new JavaTimeModule())
                .build();
        //대소문자 구분 안하는 설정
        objectMapper.configure(MapperFeature.ACCEPT_CASE_INSENSITIVE_PROPERTIES, true);
        //pretty
        objectMapper.enable(SerializationFeature.INDENT_OUTPUT);
        //timestamp로 저장하지 않기로 설정
        objectMapper.disable(SerializationFeature.WRITE_DATES_AS_TIMESTAMPS);
        //신규 모듈 설정
        SimpleModule simpleModule = new SimpleModule();
        simpleModule.addSerializer(LocalDateTime.class, new LocalDateTimeSerializer());
        simpleModule.addDeserializer(User.class, new UserDeserializer());
        objectMapper.registerModule(simpleModule);
    }
    public static boolean writeObjectToJson(String fileName, Object object) {
        //...
    }
    public static <T> Object readJsonToObject(String fileName, Class<T> clazz) {
        //...
    }
}

기존에 만들었던 simpleModule에 추가했다.

{
  "title": "Frozen",
  "author": "jeongpro",
  "user": {
    "id": 1,
    "created": "2019-12-24T16:47:00"
  },
  "expiredTime": "2019-12-24T16:47:02.292"
}

결과로 User객체의 created 값만 뒤의 분단위 이하는 제거됐다.

→ Deserialize에서 경험은 XmlGregorianCalendar라는 객체를 사용했었는데 직렬화할때는 TimeZone값이 들어갔으나 deserialize할 때는 TimeZone값이 사라졌었다.

그래서 XmlGregorianCalendar를 Deserializer를 커스텀하게 만들었고, 그 과정에서 StringToXmlGregorianCalendar라는 메서드를 만들어서 TimeZone값을 무사히 읽어왔던 경험이 있다.

반응형