본문 바로가기

Programing/JVM(Java, Kotlin)

[JSON] LocalDateTime to JSON by Gson, Jackson2

이전 [Java] Date vs LocalDateTime 글에서 Date 클래스를 LocalDateTime으로 변경을 하게 된 배경에 대해 설명을 했다.

 

예고한대로 이번에는 변경을 했을 때 겪을 수 있는 것을 정리한다.

 

1. JPA 문제

낮은 버전의 JPA에서는 문제가 발생할 수 있다.

다행히 내가 사용한 JPA 2.0.2 와 구현체 하이버네이트(hibernate) 5.2.17에서는 Java8 의 시간/날짜에 대한 지원(JSR-310)이 추가되어서 문제가 없었다.

문제가 없었던 이유는 스프링 부트가 스프링 데이터의  org.springframework.data.jpa.convert.threeten.Jsr310JpaConverters 를 컨버터로 자동으로 등록을 해주었기 때문이다.

하지만, 초기에 이 클래스가 나왔을 때는 설정을 해주어야 추가가 되었다.

@EntityScan(basePackageClasses = { Application.class, Jsr310JpaConverters.class })
@SpringBootApplication
class Application { … }

이유는 Java8을 쓰지 않는 사용자도 선택적으로 사용을 해야 하기 때문에 이런 결정을 한 것으로 추측된다.

하지만 이런 것이 불편하다는 것을 스프링 커뮤니티에도 나오게 되었고 나중에는 자동으로 추가가 되게 변경된 것 같다.

spring-boot #2721 - Add Spring Data Jsr310JpaConverters (JDK8 dates) automatically to @EntityScan if applicable

spring-framework #15884 - Add Converter implementations that convert legacy Date instances into JDK 8 date/time types [SPR-11259

spring-boot #2763 Upgrade to Hibernate 5.1

 

2. JSON으로 변환 문제

Date와 LocalDateTime 의 경우 JSON으로 변환되면 라이브러리에 따라 다르게 변환이 된다. (기본 설정)

샘플로 정리한다.

타입 Gson 사용시 Jackson 사용시
java.util.Date "May 15, 2019 10:55:06 AM" "1557885389440"
java.time.LocalDateTime

{
   "date":{
      "year":2019,
      "month":5,
      "day":15
   },
   "time":{
      "hour":10,
      "minute":57,
      "second":11,
      "nano":837000000
   }
}

{
   "second":25,
   "dayOfYear":135,
   "year":2019,
   "month":"MAY",
   "dayOfMonth":15,
   "dayOfWeek":"WEDNESDAY",
   "monthValue":5,
   "hour":10,
   "minute":57,
   "nano":679000000,
   "chronology":{
      "id":"ISO",
      "calendarType":"iso8601"
   }
}

따라서 포맷을 맞추어 주는 것이 현명하다.

우선 Jackson의 경우가 간단하므로 먼저 알아보자.

with Jackson 

Date with Jackson

ObjectMapper에 설정을 해주는 것이 아닌 객체에 애노테이션을 달아 포맷을 정해줄 수 있다.

public class ItemDate {
    private Integer id;
    private String name;
    private String createBy;
    @JsonFormat(shape= JsonFormat.Shape.STRING, pattern="yyyy-MM-dd'T'HH:mm:ss.SSSZ", timezone="Asia/Seoul")
    private Date createAt;
}

이렇게 하면 JSON에서는 "2019-05-15T11:23:10.108+0900" 와 같은 문자열로 변환이된다.

LocalDatetime with Jackson

LocalDateTime의 경우는 좀 더 많은 애노테이션을 붙여주어야 한다.

public class ItemLocalDateTime {

    private Integer id;
    private String name;
    private String createBy;

    @JsonFormat(shape = JsonFormat.Shape.STRING, pattern = "yyyy-MM-dd'T'HH:mm:ss.SSS")
    @JsonDeserialize(using = LocalDateTimeDeserializer.class)
    @JsonSerialize(using = LocalDateTimeSerializer.class)
    private LocalDateTime createAt;
}

이렇게 하면 JSON에서는 "2019-05-15T11:24:46.223" 와 같은 문자열로 변환이된다.

참고로, TimeZone을 생략했는데, Z를 붙이면 Unsupported field: OffsetSeconds 라는 예외가 발생한다.

지역 시간은 시간대 필드를 가지고 있지 않기 때문이다. 이것은 ZoneZonedDateTime에 대응하기 때문이다.

with Gson

Gson은 애노테이션을 따로 지원하지 않는다.

대신 GsonBuilder를 통해 타입에 대한 지원을 설정해주면 된다.

Date with Gson

두 가지 방법이 있다.

1. setDateFormat 을 통해 포맷을 지정

2. registerTypeAdapter 을 통해 Date.class에 대한 JsonSerializer, JsonDeserializer 를 지정해준다.

첫 번째 방법은 Date에 대해서만 지정이 가능하다. 두 번째 방법은 Date과 LocalDateTime 둘다 적용이 가능하다.

Gson getGsonWithBuilder() {
	return new GsonBuilder().setDateFormat("yyyy-MM-dd'T'HH:mm:ss.SSSZ").create()
}
class GsonDateConverter implements JsonSerializer<Date>, JsonDeserializer<Date> {
    private static final String FORMAT = "yyyy-MM-dd'T'HH:mm:ss.SSSZ";

    @Override
    public JsonElement serialize(Date src, Type typeOfSrc, JsonSerializationContext context) {
        SimpleDateFormat simpleDateFormat = new SimpleDateFormat(FORMAT);
        return src == null ? null : new JsonPrimitive(simpleDateFormat.format(src));
    }

    @Override
    public Date deserialize(JsonElement json, Type typeOfT, JsonDeserializationContext context) throws JsonParseException {
        SimpleDateFormat simpleDateFormat = new SimpleDateFormat("yyyy-MM-dd'T'HH:mm:ss.SSSZ");
        try {
            return json == null ? null : simpleDateFormat.parse(json.getAsString());
        } catch (ParseException e) {
            throw new JsonParseException(e);
        }
    }
}

// groovy
Gson getGsonWithTypeAdapter() {
	return new GsonBuilder().registerTypeAdapter(Date.class, new GsonDateConverter()).create()
}

LocalDatetime with Gson

public class GsonLocalDateTimeAdapter implements JsonSerializer<LocalDateTime>, JsonDeserializer<LocalDateTime> {
    @Override
    public JsonElement serialize(LocalDateTime localDateTime, Type srcType, JsonSerializationContext context) {
        return new JsonPrimitive(DateTimeFormatter.ISO_LOCAL_DATE_TIME.format(localDateTime));
    }

    @Override
    public LocalDateTime deserialize(JsonElement json, Type typeOfT, JsonDeserializationContext context)
            throws JsonParseException {
        return LocalDateTime.parse(json.getAsString(), DateTimeFormatter.ISO_LOCAL_DATE_TIME);
    }
}

// spring bean
@Configuration
public class GsonConfig {

    @Bean
    public List<GsonBuilderCustomizer> customizers() {
        return Lists.newArrayList(
                (GsonBuilderCustomizer) gsonBuilder ->
                        gsonBuilder.registerTypeAdapter(LocalDateTime.class, new GsonLocalDateTimeAdapter()));
    }
}

어떤 포맷을 써야 좋을지는 상황에 맞게 정하면 될 것 같다. 만약 node.js 나 JavaScript랑 데이터 교환을 하게 된다면 날짜/시간 포맷을 어떤 것을 하면 좋을까?

개인적으로는 자바스크립트의 Date 타입도 지원이 되는 ISO date-time을 권장한다. 왜냐면 파싱해서 사용하기가 편하기 때문이다. 물론 대부분은 moment 를 쓰겠지만 말이다.

ISO 날짜 시간은 JavaScript에서도 쉽게 파싱해서 쓸 수 있다.

여담이지만 JavaScript의 Date 객체는 Netscape의 켄 스미스(Ken Smith)가 자바의 java.util.Date를 참고해서 만들었다고 한다.

나중에 자바의 Date가 말이많아 Joda Time을 쓰다가 자바8에서 새로운 클래스가 만들어졌으리라는 미래를 모르고 그랬을 것이다.

따라서 자바스크리븥와 자바가 아무 상관이 없다는 말은 거짓. 왜냐면 공통된 Date 클래스가 있으니까...