Java Technical

Java의 새로운 날짜 및 시간 관리 방법과 JSON 통신 방법

시작하며…

Java8에서 새롭게 추가된 강력한 날짜 관련 클래스 LocalDate, LocalDateTime의 등장으로 Date 객체에 쌓여있던 수 많은 단점이 개선되었다. 그러나, 이런 개선은 일부분에서 또다른 불편함을 초래하였는데, 이는 JSON을 REST API를 통신함에 있어서 년,월,일,시,분,초 항목이 JSON Array Object로 전달 되고 이를 다시 Java에서 처리해야 하는 불편한 과정이 그 내용들이다.

Java8 이전에는 Calendar 클래스와 Joda Time 과 같은 또 다른 날짜 함수에 의존하여 다양한 날짜 계산의 오류나 한계를 극복해 왔으나, 이제는 완벽한 수준의 자유자제의 날짜 API의 등장으로 더이상 또 다른 날짜 클래스에 의존 하지 않고도 기능을 구현을 할 수 있게 되었다. 그러나, 이는 Java 시스템 내부에서의 이야기이고, 또 다른 시스템과 시간을 처리 할때는 기존과 동일한 변환이라는 문제점이 남아 있는다. (Remote Call 방식이 아닌, JSON, XML 등 Serialize, Deserialize 방식일 경우)

물론, 이와 같은 과정은 기존의 Date 클래스를 사용하던 시절에도 존재하였고, 이에 대한 전통적인 해결 방법으로는 시간을 문자열로 보내고 수신 후 처리자의 언어 방식으로 변환 하는 것이 관례였다.

하지만, 이제는 REST API를 통한 시스템간의 서비스 호출이 손쉽게 이뤄지고 있고, 다양한 Front-End 기술의 발전으로 상호간에 주고받는 방식에 표준날짜 포멧을 선호하는 추세에 따라, 시간 문자열을 ISO 표준[^1] 방식으로 기록하고 이를 Java에서 손쉽게 처리 하는 방식에 대해 Java Code와 함께 손쉬운 방법을 제시 하고자 한다. [^1]: https://en.wikipedia.org/wiki/ISO_8601

이전 방식(Date, Calendar Class)의 문제점과 불편한 사항들

본론으로 넘어가기 전에, Java 이전 버전에서 제공되는 시간관련 Class들의 역사와 문제점을 짚어보고 시작하자.

Date는 Java 탄생 버전 1.0 부터 출발하였고, Calendar 는 Date 다음에 등장하여 Java 1.1부터 등장하게 되었지만, 이 둘은 생각보다 많은 문제점 가지고 있다.

1월은 0부터 시작…

    @Test
    public void calendarTest() {
        Calendar calendar = Calendar.getInstance();
        calendar.set(2020, 2, 23);
        SimpleDateFormat format = new SimpleDateFormat("yyyy-MM-dd");
        assert(format.format(calendar.getTime())).equals("2020-02-23");
    }

Test 코드가 정상일 것 같지만, 그렇지 않다. Calendar의 월의 의미는 1 ~ 12가 아닌, 0부터 시작이다. 따라서, 3월과 2월의 비교가 된다. 그렇기 때문에 Calendar를 쓸때는 Calendar 상수를 사용해야 명시적이다. Calendar Class를 설계한 개발자의 마음을 느낄수 있다. “Index는 0부터지..”

게다가 남발된 상수만큼 불편한 것도 없다. 매번 쓸때 마다 상수, 상수, 상수…

calendar.set(2020, Calendar.FEBRUARY, 23);

일관성 없는 요일 개념

더욱 황당한 것은 Calendar와 Date간에 요일에 대한 상수 정의가 일관성이 없다는 것이다. Calendar는 일요일이 1부터 해서 토요일이 7로 끝나지만, Date는 0부터 시작이다. Calendar와 Date는 아주 빈번하게 호출하면서 사용하는데 이것은 버그가 양산 되기에 아주 훌륭한 구조다.

getTime() 을 사용하여, Date 객체로 변환하였지만 이 때부터 요일은 이제 0부터 일요일이다.

@Test public void calendarToDateTest() 
{ 
  Calendar calendar = Calendar.getInstance(); 
  calendar.set(2020, Calendar.FEBRUARY, 23); 
  assertThat(calendar.getTime().getDay()).isEqualTo(1); 
}

불변이 아니라서 불편해 질수 있다.

불변이란 말은 코드구조적으로는 Thread safe하다는 의미가 된다. C#, Python에서는 한번 정의한 날짜 객체는 속성을 바꿀 수 없는 것과 같이 되었어야 했으나, Date, Calendar는 당당하게 set을 수행하면, 내부 인스턴스 변수가 확확 바뀐다. 그래서 항상 다시 new Date() 와 같이 객체를 새로 생성해서 전달해야만 했다.

@SpringBootTest
class DateApplicationTests {

    private final Date date;

    DateApplicationTests(Date date) {
        this.date = new Date(date.getTime());
    }

    @Test
    public Date getDate() {
        return this.date;
    }

    @Test
    public Date getCurrentDate() {
        return new Date(this.date.getTime());
    }

}

2개의 Test 함수 getDate(), getCurrentDate()는 동일한 값을 전달하는 기능이지만, 다른 객체를 전달한다. getDate()에서는 스스로를 던져 준 모양새가 되어, 누구든 조작할 수 있게 되고 스스로 사용하는 코드가 있다면, 2차 문제가 발생할 수 있다.

그 외에도 알려진 문제점들…

윤초(윤년이 아니다). 하루의 마지막 1분이 60초가 아닌 61초로 계산하며, 주로 12월 31일이나 6월 30일에 추가한다. 1분 1초에 민감하지 않는 경우 별거 없다 할 수 있지만, 시스템에서 1초는 수많은 데이터가 생성될 수 있고, 1초가 바뀜에 따라 데이터 순서가 바뀔 수 도 있으며, 때에 따라서는 커트라인에 포함되느냐 마느냐에 결정될 시간일 수 있다.(사사오입?)

윤초는 59초 이후 60초로 표현된 1초가 하나 삽입되는 형태로 추가된다. 한국시간에서는 8시 59분 59초 다음에 추가됨.
(출처=APNIC)

윤초는 1972년 이후 지금까지 25회 적용되었으며, 2012년 6월 30일에 최근 적용되었다. Java7u60 이전에는 위와 같은 윤초 개념이 없으니 Date, Calendar를 사용하였을 경우 1초의 차이가 발생된다.

새로운 time package 등장!!!

지금까지의 Java의 날짜와 시간 관련 Class는 얻는 것에 목적이 있다면, 계산을 함에는 다양한 변수가 고려되지 않았다고 정리 할 수 있다. Java 8부터는 java.time package가 등장하면서 획득부터 계산까지 마무리 할 수 있는 package가 등장하였다.

Java PackageDescriptionRemarks
java.timeThe main API for dates, times, instants, and durations.
java.time.chronoGeneric API for calendar systems other than the default ISO.
java.time.formatProvides classes to print and parse dates and times.
java.time.temporalAccess to date and time using fields and units, and date time adjusters.
java.time.zoneSupport for time-zones and their rules.
java.time 패키지를 기본으로 해서, 다른 달력시스템을 고려한 패키지부터, 포멧팅과 타임존등 세부적인 항목으로 구분되어서 제공한다.
참고 : https://docs.oracle.com/javase/8/docs/api/java/time/package-frame.html

영역별로 세분된 Package 정리도 깔끔하고, 사용해야 하는 메인 Class도 가시적으로 명확하게 변경되었다.

새로운 Class

  • LocalDate : 로컬 날짜 클래스
  • LocalTime : 로컬 시간 클래스
  • LocalDateTime : 로컬 날짜 및 시간 클래스
  • ZonedDateTime : 특정 타임존을 지정한 날짜 및 시간 클래스
  • Instant : 특정 시점의 TimeStamp 클래스

사용 목적 별로 날짜, 시간, 날짜+시간으로 Class를 사용가능하며, now() 라는 명시적 API로 현재를 얻고, of() 라는 API로 지정 정보를 기준으로 Class를 획득한다. Local로 시작하는 의미에 부합하게 얻은 시간은 나의 현제 Local에 따르며 다른 지역의 시간을 얻고 싶을 땐, ZonedDateTime을 사용하여 지정한 타임존에서 얻을 수 있다. Instant 는 Date Class와 매우 유사하지만, UTC를 기준으로 동일한 시점을 갖는다. 전세계 동일한 기준으로 비교할 때 매우 유용하다.

LocalDate currentDate = LocalDate.now();    // 현재 날짜 정보. 2020-03-01
LocalDate setDate = LocalDate.of(2020, 3, 1);   // 지정한 날짜 정보

LocalTime currentTime = LocalTime.now();    // 현재 시간 정보. 09:52:13.906
LocalTime setTime = LocalTime.of(9, 52, 13); // 지정된 시간 정보

LocalDateTime currentDateTime = LocalDateTime.now();    // 현재 날짜와 시간 정보. 2020-03-01T09:52:13.906
LocalDateTime setDateTime = LocalDateTime.of(2020, 3, 1, 9, 52, 13); // 지정된 날짜와 시간 정보

ZonedDateTime utcDateTime = ZonedDateTime.now(ZoneId.of("UTC")); //2020-03-01T00:52:13.906+09:00[UTC]
ZonedDateTime seoulDateTime = ZonedDateTime.now(ZoneId.of("Asia/Seoul")); // Zone 정보가 추가된 현재 날짜와 시간 정보. 2020-03-01T09:52:13.906+09:00[Asia/Seoul]

날짜와 시간 계산 방법들

년, 월, 일, 주등 명시적인 API명으로 더하고 빼는 연산을 제공하며, 제공되는 결과는 이전에 값에 영향을 미치지 않는다.

LocalDateTime currentDateTime = LocalDateTime.now();
LocalDateTime newDateTime = currentDateTime
                .plusYears(1)       // 1년 더하기
                .minusYears(1)      // 1년 빼기
                .plusMonths(1)      // 1개월 더하기
                .minusMonths(1)     // 1갸월 빼기
                .plusDays(1)        // 1일 더하기
                .minusDays(1)       // 1일 빼기
                .plusWeeks(1)       // 1주 더하기
                .minusWeeks(1);     // 1주 빼기
log.info("current:{}, new:{}", currentDateTime, newDateTime); // 결과는 같다. (더하고, 빼고...)

현재 날짜와 시간을 기준으로 다양한 연산을 쉽게 획득 할 수 있다. 달력이나 날짜 계산 등 스케쥴을 개발할때는 많은 일을 덜어 낼 수 있다. 개발자가 계산하는 날짜 계산방식에서 사용자적인 계산방식으로 변화 했다는 느낌이다. 매달 마지막 일, 매주 마지막 금요일등 현실적인 접근이 필요한 계산에 적용 할 수 있게 제공하고 있다. 실제 “BOS-일정관리”에서도 이와 같은 기능을 사용하고 있다.

LocalDateTime currentDateTime = LocalDateTime.now();
LocalDateTime newDateTime = currentDateTime
        .with(TemporalAdjusters.firstDayOfMonth())              // 이번 달의 첫 번째 일(1일)
        .with(TemporalAdjusters.lastDayOfMonth())               // 이번 달의 마지막 일
        .with(TemporalAdjusters.firstDayOfNextMonth())          // 다음 달의 첫 번째 일(1일)
        .with(TemporalAdjusters.firstInMonth(DayOfWeek.MONDAY)) // 이번 달의 첫 번째 요일
        .with(TemporalAdjusters.lastInMonth(DayOfWeek.FRIDAY))  // 이번 달의 마지막 요일
        .with(TemporalAdjusters.previous(DayOfWeek.FRIDAY))     // 지난주 금요일
        .with(TemporalAdjusters.previousOrSame(DayOfWeek.FRIDAY));// 지난주 금요일(오늘 포함)

날짜 차이 계산 방식도 확실히 세련되어 졌다. 2개의 객체를 until 이라는 의미로 계산해서 차이를 Period라는 객체로 제공한다. 각 값에 차이를 출력하면 명확히 알 수 있다.

LocalDate currentDate = LocalDate.now();
LocalDate newDateTime = LocalDate.of(2020,8,15);

Period period = currentDate.until(newDateTime);
log.info("Gap - 년:{}, 월:{}, 일:{}", period.getYears(), period.getMonths(), period.getDays());

날짜 Formatting

이번 포스팅의 핵심이 될 수 있는 Formatting이다. 기존에는 “SimpleDateFormat” Class를 사용해서 Date, Calendar를 문자열로 변경해서 처리 하는 방식이었다면, 이제는 “DateTimeFormatter” 를 제공하고 있다. 지정한 Pattern으로 시간을 문자열로 출력이 가능하며, 다양한 표현을 직접 만들어 낼 수 있다. 앞으로 더 설명할 ISO Date Time 등의 표현 방식에서 이런 내용을 다시 언급하겠다.

DateTimeFormatter dateTimeFormatter = DateTimeFormatter.ofPattern("yyyy년 MM월 dd일 hh시 mm분 ss초");
String nowString = LocalDateTime.now().format(dateTimeFormatter); // 2020년 03월 01일 11시 20분 54초
DateTimeFormatter dateTimeFormatter = DateTimeFormatter.ofPattern("yyyy.MM.dd");
LocalDate newDate = LocalDate.parse("2020.03.01", dateTimeFormatter); // 날짜 문자열을 LocalDate로 변환

JSON REST API에서 처리는 그래도 불편해…

time package로 많은 부분에서 고려하여 정리된 것은 맞다. Java 안에서 날짜는 이제 강력해 졌다고 할 수 있지만, 어디까지나 다른 시스템과 통신하는 과정에서 Serialize, Deserialize를 생각하면 다시 문자열로 변환하고 Object로 재 변환하는 과정을 통해야만 한다.

REST API로 시스템간 서비스 호출이 손쉽게 이뤄지고 있는 변화방식에, time package 들의 전달에는 아래와 같이 Array Object로 전달이 이뤄진다. (Spring Boot 2.x.x 이상을 사용한다면, JSR-310[^2] 의 Dependency가 포함되어 있어, 자동으로 Serialize, Deserialize가 된다) [^2] : https://jcp.org/en/jsr/detail?id=310

SpringBoot 2.x.x 에서는 JSR310 Library가 기본 적용되어 있다.
// LocalDateTime 이 Serialize 되지 않고 응답된 형태
{
    "today": {
        "dayOfWeek": "SUNDAY",
        "dayOfYear": 61,
        "year": 2020,
        "month": "MARCH",
        "nano": 729000000,
        "monthValue": 3,
        "dayOfMonth": 1,
        "hour": 16,
        "minute": 3,
        "second": 15,
        "chronology": {
            "id": "ISO",
            "calendarType": "iso8601"
        }
    }
}

// LocalDateTime 의 Serialize 된 형태
{
    "today": "2020-03-01T15:10:55.407"
}

ISO 8601

날짜와 시간을 처리하는 기능이 time package로 인해 확실히 개선되었지만, 문자열로 주고 받는 과정에서는 표현하는 방식에 기준이 필요 할 수 밖에 없다. ISO 8601은 날짜와 시간과 관련된 교환을 다루는 국제 표준으로 이러한 역할을 한다. 날짜와 시간의 숫자 표현에 대한 명확한 사용법을 알아보자.

기본 표기 방식은 그레고리력

월은 1~12, 일은 1~31, 연중 일은 1~365, 주는 1~53, 요일은 월요일을 1로 일요일을 7로 표기.

ISTO 8601 날짜 기록 방법 3가지

  • 연월일 기록 방법
    • 확장형식 : YYYY-MM-DD 예) 2020-03-08 “2020년 3월 8일”
    • 기본형식 : YYYYMMDD 예) 20200308
  • 연과 연중 일수 기록 방법
    • 확장형식 : YYYY-DDD 예) 2020-068 “2020년의 68번째 날”
    • 기본형식 : YYYYDDD 예) 2020068
  • 연과 주와 주중 일수로 기록하는 방법
    • 확장형식 : YYYY-Www-D 예) 2020-W10-7 “2020년 10번째 주의 일요일”
    • 기본형식 : YYYYWwwD 예) 2020W107

시간과 시간대 표기

  • 시간 표기 방법 (hh : 00 ~ 24, mm : 00 ~ 59, ss : 00 ~ 59)
    • 확장형식 : hh:mm:ss 예) 09:08:44
    • 기본형식 : hhmmss 예) 090844
  • 날짜와 시간 병행 표기
    • 날짜와 시간 사이에 “T”를 넣어 표기.
    • 2020-03-08T09:08:44
  • 시간대 표기 방법
    • UTC 시간대를 표기할 때는 “z”를 넣어 표기.
      • 2020-03-08T09:08:44Z
    • UTC 외 시간대를 표기할 때는 “+- hh:mm”를 넣어 표기.
      • 2020-03-08T09:08:44+09:00 (2020-03-08T00:08:44Z 와 같은 의미)

날짜와 시간을 표기함에 있어서 이렇게 구체적으로 정리할 만큼 오해를 줄이고자 노력하고 있음이 보인다. 결론적으로 우리에게 중요한 점은 “2020-03-08T09:08:44Z”의 표기법 하나만 기억해도 된다.

Serializer, Deserializer

Serialize, Deserializer 우리말로 직렬화, 역직렬화로 같이 표현하면서 표기 하겠지만, 하나의 방법만 있지는 않다. JSON 을 관리는 Jackson Library 를 사용하여 처리 가능하지만, Spring Boot Library를 사용하여 처리 할 수 있다. (다시 언급하지만, Spring Boot 2.x.x는 JSR-310(time package)가 기본으로 포함되어 있다).

SpringBoot 기본 Serializer, Deserializer

“@JsonFormat”, “@DateTimeFormat” 두 개의 기본 제공 annotation을 사용하면, 직렬화, 역직렬화는 바로 적용된다. 아래, 예제들에서 샘플을 확인해 보자

Gradle 기준으로 JSR-310 Library를 포함한다.

    // https://mvnrepository.com/artifact/com.fasterxml.jackson.datatype/jackson-datatype-jsr310
    compile group: 'com.fasterxml.jackson.datatype', name: 'jackson-datatype-jsr310', version: '2.10.2'
@Data
public class TodayResponse {

    @JsonFormat(shape = JsonFormat.Shape.STRING, pattern = "yyyy-MM-dd'T'HH:mm:ss", timezone = "Asia/Seoul")
    @DateTimeFormat(pattern = "yyyy-MM-dd'T'HH:mm:ss")
    private LocalDateTime today;

    public TodayResponse() {
        today = LocalDateTime.now();
    }
}

“@JsonFormat”을 사용하여 지정한 포멧팅으로 Jackson Library를 사용하여 직렬화를 할 수 있다. 그럼에도 “@DateTimeFormat” 이라는 SpringBoot 제공 Library를 표시하는 이유는 SpringBoot GET방식 외에 처리하는 내용을 처리 함에 SpringBoot를 사용하기 때문에 이에 대한 라이브러리 같이 사용한다.

Custom Serializer, Deserializer

JSR-310 Library 추가와 간단한 annotation 사용만으로 문제가 충분히 해결될 수 있지만, 개발을 하다보면, 다양한 일은 언제든지 일어 날 수 있다. 가령 예를 들어, 시간 표준이 다양하게 제공해야 한다던지, 심지어 JSR-310 Library를 사용할 수 없을 수 있는(?) 믿기 어려운 제약 상황도 있다고 한다면 어떻게 할 것 인가?

여러 상황을 고려하였을 때, Custom 하게 Serializer, Deserializer 를 생각해 볼 필요가 있다. 물론 사용방법도 손쉽게 annotation 만으로 이뤄진다면 최적이라고 보여진다.

LocalDateTime을 처리하기위한 Serializer와 Deserializer를 만들자. Jackson Library에서 제공되는 StdSerializer를 확장해서 만들자. StdSerializer는 Jackson-databind Library에서 제공되는 basic 추상 클래스다.

public class LocalDateTimeSerializer extends StdSerializer<LocalDateTime> {

    public LocalDateTimeSerializer() {
        super(LocalDateTime.class);
    }

    @Override
    public void serialize(LocalDateTime value, JsonGenerator generator, SerializerProvider provider) throws IOException {
        generator.writeString(value.format(DateTimeFormatter.ISO_LOCAL_DATE_TIME));
    }
}

코드를 보면 심플하다.

public abstract void serialize(T value, JsonGenerator gen, SerializerProvider provider)

지정 Template Class (우리는 LocalDateTime)을 전달하고, JsonGenerator를 사용해서 ISTIO Date Time 포멧 문자열로 만들어서 생성하게 한다.

Deserializer 도 동일하다.

public class LocalDateTimeDeserializer extends StdDeserializer<LocalDateTime> {

    protected LocalDateTimeDeserializer() {
        super(LocalDateTime.class);
    }

    @Override
    public LocalDateTime deserialize(JsonParser parser, DeserializationContext context) throws IOException {
        return LocalDateTime.parse(parser.readValueAs(String.class));
    }
}

StdDeserializer를 확장해서 지정 Template Class(우리의 LocalDateTime)을 기준으로 parser API를 호출하여 JsonParse에서 읽어진 문자열을 그대로 전달하여 객체로 만들 수 있게 한다.

Serializer 와 Deserializer를 생성했으면, Json 통신이 필요한 TodayResponse 객체에 annotation으로 사용하자

@Data
public class TodayResponse {

    @JsonDeserialize(using = LocalDateTimeDeserializer.class)
    @JsonSerialize(using = LocalDateTimeSerializer.class)
    private LocalDateTime today;

    public TodayResponse() {
        today = LocalDateTime.now();
    }
}

Jackson-databind library에서 제공하는 JsonDeserialize, JsonSerialize annotation으로 이제 만들 클래스가 일을 할 수 있게 지정하고, 테스트 결과를 확인 해 보자.

{
    "today": "2020-03-01T16:53:57.677"
}
샘플코드는 아래 주소에 모두 작성되어 있다.
https://github.com/neocode24/localDateTime.git 

결론

사실 아직도 많은 개발코드에서 “new Date()”는 쉼없이 볼 수 있다. 그렇다고 잘못되었는가? 라고 말할 순 없다. Date Class의 방식을 잘 이해하고 써야 할 곳에 적절히 썼다면, 올바른 코드다. (그렇지만 글을 쓴사람의 노력과 읽은 사람의 시간을 생각해서라도 이제 new Instant() 정도로 바꿔서 해보자)

결론적으로 포스팅에서 전달하고 싶은 의미는 오래전에 만들어진 기본 Class로써는 세월의 흐름에 따라 발생될 수 있는 여러 결함과 사용의 어려움(난해함)을 알리고, 방식을 개선한 새로운 코드로 사용하면 좋겠다 하는 마음으로 정리하게 되었다.

Serialize, Deserialize 방식에서도 일반적으로 Framework에 의지하고 쓰기 보다는 생각보다 손쉬운 체계의 동작 방식에 Custom 된 코드를 조금 붙여본다는 의미의 기술 접근방식에 의미가 있다고 본다.

Reference

윤초이야기 : http://engineering.vcnc.co.kr/2016/12/struggling-with-the-leap-second/

ISO 8601 : https://ko.wikipedia.org/wiki/ISO_8601

Java의 날짜와 시간 API : https://d2.naver.com/helloworld/645609

댓글 남기기

이 사이트는 스팸을 줄이는 아키스밋을 사용합니다. 댓글이 어떻게 처리되는지 알아보십시오.

%d 블로거가 이것을 좋아합니다: