잭슨은 JSON -> Java 클래스로 Deserialize, Java 클래스 -> JSON으로 Serialize 할 때 매우 유용한 라이브러리다.
하지만 잭슨이 나온 이후에 자바 8이 나왔는지 모르겠는데 LocalDate, LocalTime, LocalDateTime 등등의 클래스를 기본적으로 깔끔하게 처리해주지 못한다. 따라서 이번에는 어렵지는 않지만 새로 프로젝트 구성할 때마다 매번 까먹어서 찾아 헤매던 케이스들을 정리해봤다. 또한 예제의 설명은 스프링 부트를 기준으로 설명하겠다.
GET /?date=2011-11-11&time=11:11:11&dateTime=2017-11-11 11:11:11으로 요청을 날려보면 아래와 같은 응답을 받을 수 있다.
1 2 3 4 5 6 7 8
{ "timestamp":1516027261943, "status":400, "error":"Bad Request", "exception":"org.springframework.web.method.annotation.MethodArgumentTypeMismatchException", "message":"Failed to convert value of type 'java.lang.String' to required type 'java.time.LocalDate'; nested exception is org.springframework.core.convert.ConversionFailedException: Failed to convert from type [java.lang.String] to type [@org.springframework.web.bind.annotation.RequestParam java.time.LocalDate] for value '2011-11-11'; nested exception is java.lang.IllegalArgumentException: Parse attempt failed for value [2011-11-11]", "path":"/get" }
파라미터로 넘긴 값들을 String으로 인식해서 TypeMismatchException이 발생했다. 이럴 땐 @DateTimeFormat 어노테이션을 파라미터에 달아주면 된다.
{ "timestamp":1516036927928, "status":400, "error":"Bad Request", "exception":"org.springframework.validation.BindException", "errors":[ { "codes":[ "typeMismatch.dateType.date", "typeMismatch.date", "typeMismatch.java.time.LocalDate", "typeMismatch" ], "arguments":[ { "codes":[ "dateType.date", "date" ], "arguments":null, "defaultMessage":"date", "code":"date" } ], "defaultMessage":"Failed to convert property value of type 'java.lang.String' to required type 'java.time.LocalDate' for property 'date'; nested exception is org.springframework.core.convert.ConversionFailedException: Failed to convert from type [java.lang.String] to type [java.time.LocalDate] for value '2011-11-11'; nested exception is java.lang.IllegalArgumentException: Parse attempt failed for value [2011-11-11]", "objectName":"dateType", "field":"date", "rejectedValue":"2011-11-11", "bindingFailure":true, "code":"typeMismatch" }, { "codes":[ "typeMismatch.dateType.dateTime", "typeMismatch.dateTime", "typeMismatch.java.time.LocalDateTime", "typeMismatch" ], "arguments":[ { "codes":[ "dateType.dateTime", "dateTime" ], "arguments":null, "defaultMessage":"dateTime", "code":"dateTime" } ], "defaultMessage":"Failed to convert property value of type 'java.lang.String' to required type 'java.time.LocalDateTime' for property 'dateTime'; nested exception is org.springframework.core.convert.ConversionFailedException: Failed to convert from type [java.lang.String] to type [java.time.LocalDateTime] for value '2017-11-11 11:11:11'; nested exception is java.lang.IllegalArgumentException: Parse attempt failed for value [2017-11-11 11:11:11]", "objectName":"dateType", "field":"dateTime", "rejectedValue":"2017-11-11 11:11:11", "bindingFailure":true, "code":"typeMismatch" }, { "codes":[ "typeMismatch.dateType.time", "typeMismatch.time", "typeMismatch.java.time.LocalTime", "typeMismatch" ], "arguments":[ { "codes":[ "dateType.time", "time" ], "arguments":null, "defaultMessage":"time", "code":"time" } ], "defaultMessage":"Failed to convert property value of type 'java.lang.String' to required type 'java.time.LocalTime' for property 'time'; nested exception is org.springframework.core.convert.ConversionFailedException: Failed to convert from type [java.lang.String] to type [java.time.LocalTime] for value '11:11:11'; nested exception is java.lang.IllegalArgumentException: Parse attempt failed for value [11:11:11]", "objectName":"dateType", "field":"time", "rejectedValue":"11:11:11", "bindingFailure":true, "code":"typeMismatch" } ], "message":"Validation failed for object='dateType'. Error count: 3", "path":"/" }
역시나 String으로 인식해서 발생하는 문제다. @DateTimeFormat을 사용하자.
{ "timestamp":1516031758629, "status":400, "error":"Bad Request", "exception":"org.springframework.http.converter.HttpMessageNotReadableException", "message":"JSON parse error: Can not construct instance of java.time.LocalDate: no String-argument constructor/factory method to deserialize from String value ('2011-11-11'); nested exception is com.fasterxml.jackson.databind.JsonMappingException: Can not construct instance of java.time.LocalDate: no String-argument constructor/factory method to deserialize from String value ('2011-11-11')\n at [Source: java.io.PushbackInputStream@405079af; line: 2, column: 10] (through reference chain: com.example.demo.DateType[\"date\"])", "path":"/" }
웬일인지 모르겠지만 문제가 발생한다.
JSR-310 (Java Specification Request - Date and Time API)
Spring Jpa java8 date (LocalDateTime) 와 Jackson을 참고했을 때 Java8이 나오기 전에는 Date 클래스가 좀 허접했다고 한다. 그 이전에는 Joda Time이라는 라이브러리를 사용했다고 한다. 이 JSR-310 스펙은 조다 타임의 창시자도 같이 제정했다고 하니 아주 믿을만(?)한 스펙인 거 같다. 이 스펙의 구현체가 LocalDate, LocalTime, LocalDateTime 등등인 것 같다.
잭슨에서 제대로 저런 날짜/시간 관련 클래스를 (De)Serialize 하려면 Jackson Datatype: JSR310을 Dependency에 추가해줘야한다. Maven이나 Gradle에 추가해주자.
그리고 나서 다시 서버를 띄워보면 다음과 같은 응답이 날아온다.
1 2 3 4 5 6 7 8
{ "timestamp":1516032507565, "status":400, "error":"Bad Request", "exception":"org.springframework.http.converter.HttpMessageNotReadableException", "message":"JSON parse error: Can not deserialize value of type java.time.LocalDateTime from String \"2011-11-11 11:11:11\": Text '2011-11-11 11:11:11' could not be parsed at index 10; nested exception is com.fasterxml.jackson.databind.exc.InvalidFormatException: Can not deserialize value of type java.time.LocalDateTime from String \"2011-11-11 11:11:11\": Text '2011-11-11 11:11:11' could not be parsed at index 10\n at [Source: java.io.PushbackInputStream@c126518; line: 4, column: 14] (through reference chain: com.example.demo.DateType[\"dateTime\"])", "path":"/" }
어떤 이유에선지 LocalDateTime만 제대로 Deserialize 못 하고 있다. 아래와 같이 request body를 수정해주면 된다.
사실 Custom Deserializer를 쓰면 jackson-datatype-jsr310은 필요 없긴 하다. (하지만 나중에 Serialize를 위해서는 또 필요하기 때문에 지우진 말자.) 이렇게 하면 이제 @DateTimeFormat이나 @JsonFormat은 무력화되는 것 같다.
만약 특정 필드만 오버라이딩한 Deserializer를 안 쓰려면 아래와 같이 하면 된다.