Documents

날짜와 시간 API with Java8

글로벌 서비스를 한다면 타임존에 대한 이해는 필수이다. 사용자가 어떤 기준으로 시간을 보는지 항상 고려해야한다. 한번 고민 해보고 나면 크게 어려운 개념도 아니고 복잡한 계산도 아니지만, 이 업무를 처음 맡았을 때는 꽤나 고생했다. 시간에 대해서 고민해 본 적이 없던 것도 있었지만, 기존의 코드들이 특정 시간대를 기준으로만 개발되었거나(보통 개발자의 서버 시간을 기준으로 작성되었고, 요구사항이 없으므로 시간대를 고려하지 않은 상태이다), 문자열을 그대로 파싱하도록 작성되어 있었기 때문에 손 볼 곳이 많았다.

JDK8에서는 시간, 날짜 관련해서 유용한 API(java.time 패키지)를 제공한다. 훨씬 이해하기 쉽고 유용하다. 앞으로는 문자열을 파싱하고 직접 시간을 계산하지말고 이 API을 사용해보자. (직접 구현한 코드들에는 시간 계산에 관한 버그도 많고 잘 보이지도 않는다)

Java8 이전에도 Date, Calendar 그리고 Timestamp 클래스 등 날짜와 시간을 다루는 API는 존재했다. 하지만 여러 문제점을 가지고 있으므로(Java의 날짜와 시간 API 참고) Java8에 추가된 API를 사용하기를 권장하고, 어떠한 문제점이 있는지 알아두면 좋을 것 같다.

간략하게 정리한 JDK의 기본 날짜 클래스의 문제점
  • 불변객체가 아니여서 side-effect에 안전하지 않다.

  • int 상수 필드가 많아서 잘못 입력하더라도 컴파일 시점에 오류를 확인할 수 없다.

  • JDK 1.0 Date 클래스에서 1월을 0으로 표현하면서 누구나 크리티컬할 수 있는 실수를 반복한다.

  • Date와 Calendar의 역할 분담이 다소 명확하지 않다.

  • 잘못 시간대를 지정해도 오류를 발견하기 어렵다.

  • Date를 상속한 하위 클래스들에도 문제가 많다.

이 포스트에서 설명하는 대부분의 날짜, 시간 관련 API는 java.time 패키지 하위에 있다.

Temporal Hierarchy

타임존

Time-Zone(타임존)은 여러 가지 다른 것을 설명하는 데 사용할 수 있지만 대개 지역 또는 국가의 현지 시간을 나타내며, 주로 해당 국가에 의해 법적으로 지정된다. GMTUTC 는 같은 시간을 가르키면서 혼용되어 사용되지만, 엄밀히 구분하자면 다른 의미이다(완전히 동일하진 않고 초의 소숫점 단위에서 차이가 난다).

"GMT is a time zone and UTC is a time standard."

GMT(Greenwich Mean Time)

GMT는 경도 0도에 위치한 영국 런던 그리니치에 있는 왕립 천문대의 시간으로, 모든 시간대의 시작점을 나타내며, 일년내내 DST의 영향을 받지 않는다. GMT는 1925년 2월 5일부터 사용하기 시작했으며, 1972년 1월 1일까지 세계 표준시로 사용되었다.

UTC(협정 세계시, 協定世界時)

UTC는 국제 표준시를 뜻하며 타임존은 아니다. 즉, 공식적으로 UTC를 현지 시간으로 사용하는 국가나 지역은 없다. 협정 세계시를 영어권에서는 Coordinated Universal TimeCUT라고, 프랑스어권에서는 Temps Universel CoordonnéTUC라고 하는데, 혼돈을 방지하기 위해서 공식적으로 UTC라고 정해졌다.

위에 말한 듯이 타임존은 정부에 의해 변경되는 경우가 종종 있다. 하지만 Java는 타임존 변경이 일어났더라도 따로 JDK 버전업 필요 없이 독립적으로 타임존 데이터베이스(IANA 데이터베이스)를 업데이트한다. 즉, 하드코딩으로 관리하지 않아도 된다.

Java 8에서는 타임존을 고려한 날짜와 시간까지도 더 명확하고 편리하게 사용할 수 있다. 타임존은 ZoneId 클래스를 통해 날짜/시간별 DST 이 반영되었는지 확인 할 수도 있다. ZoneId.getAvailableZoneIds() 를 통해 지원하는 지역별 타임존을 확인할 수 있다(현재 시점 등록된 ZoneId는 600개이다).

DST(Daylight Saving time)

DST은 자연 일광을 보다 잘 활용하기 위해서 여름철에 표준 시간에서 1시간 앞으로, 그리고 다시 가을에 시간을 1시간 전으로 설정하는 것을 말한다. DST와 "summer time"은 같은 말을 뜻하며 특정 나라에서 주로 불린다. 영국에서 썸머타임이라고 많이 사용하며, DST가 적용되지 않는 표준시는 "winter time"이라고 사용되기도 한다. DST를 독일에서는 "sommerzeit", 스칸디나비아에서는 "sommertid"라고도 사용한다.

Table 1. Java8에서 개선된 날짜, 시간 관련 API
클래스 혹은 인터페이스 설명

java.time 패키지

     Clock

타임존을 사용한 현재 순간, 날짜 및 시간에 접근할 수 있는 클래스

     Duration

34.5초와 같이 시간 기반의 시간(amount of time)

     Instant

타임라인의 순간을 나타내는 클래스

     LocalDate

ISO-8601 캘린더 시스템에서 타임존이 없는 날짜(예: 2020-01-21)

     LocalDateTime

ISO-8601 캘린더 시스템에서 타임존이 없는 날짜와 시간(예: 2020-01-21 00:25:00)

     LocalTime

ISO-8601 캘린더 시스템에서 시간대가 없는 시간(예: 12:35:30)

     MonthDay

ISO-8601 캘린더 시스템의 월별 일(예: --01-21)

     OffsetDateTime

ISO-8601 캘린더 시스템에서 UTC로부터 offset이 포함된 날짜와 시간(예: 2020-01-21T00:47:00+09:00)

     OffsetTime

ISO-8601 캘린더 시스템에서 UTC로부터 offset이 포함된 시간(예: 00:47:00+09:00)

     Period

ISO-8601 캘린더 시스템의 날짜 기반의 시간(예: 2년 3개월 4일)

     Year

ISO-8601 캘린더 시스템에서 연도년(예: 2020)

     YearMonth

ISO-8601 캘린더 시스템에서 연(year)과 월(month)(예: 2020-01)

     ZonedDateTime

ISO-8601 캘린더 시스템에서 Asia//Seoul 같은 타임존이 포함된 날짜와 시간(예: 2020-01-21T00:53:30+09:00 Asia/Seoul)

     ZoneId

Asia/Seoul 과 같은 타임존 ID

     ZoneOffset

GMT/UTC로부터 타임존 오프셋(예: +09:00)

     DayOfWeek

요일을 나타내는 열거타입

     Month

월을 나타내는 열거타입

java.time.temporal 패키지

     TemporalAdjuster

Temporal 객체들을 조절하기 위한 함수형 인터페이스

     ChronoUnit

날짜/시간의 period를 나타내는 표준 셋을 가진 열거타입

java.time.format 패키지

     DateTimeFormatter

date-time 객체를 파싱하거나 출력하기 위한 포맷터

ISO 8601 Data elements and interchange formats - Information interchange - Representation of dates and times은 날짜와 시간과 관련된 데이터 교환을 다루는 국제 표준이다. 이 표준은 국제 표준화 기구(ISO)에 의해 공포되었으며 1988년에 처음으로 공개되었다. 이 표준의 목적은 날짜와 시간을 표현함에 있어 명백하고 잘 정의된 방법을 제공함으로써, 날짜와 시간의 숫자 표현에 대한 오해를 줄이고자함에 있는데, 숫자로 된 날짜와 시간 작성에 있어 다른 관례를 가진 나라들간의 데이터가 오갈때 특히 그렇다.

날짜, 시간 다루기

Instant

타임라인의 특정 순간을 나타내는 클래스로, TimeStamp 와 달리 Immutable 하고 thread-safe하다.

import static java.time.temporal.ChronoUnit.*;

Instant today = Instant.now();
Instant yesterday = today.minus(1, DAYS);
Instant tomorrow = today.plus(1, DAYS);
Instant midnight = Instant.now().truncatedTo(DAYS);

LocalDate

ISO-8601에서 타임존이 없는 날짜(예: 2020-01-21)를 나타낸다.

LocalDate today = LocalDate.now();
LocalDate yesterday = today.minusDays(1);
LocalDate tomorrow = today.plusDays(1);

today.isBefore(tomorrow); // true
today.isAfter(yesterday); // true

LocalDateTime

ISO-8601에서 타임존이 없는 날짜와 시간(예: 2020-01-21 00:25:00)을 나타낸다.

LocalDateTime now = LocalDateTime.ofInstant(Instant.now(), ZoneId.systemDefault());
LocalDateTime today = LocalDateTime.now();
LocalDateTime lastWeek = today.minusWeeks(1);
LocalDateTime nextWeek = today.plusWeeks(1);

ZonedDateTime

ISO-8601에서 Asia/Seoul 같은 타임존이 포함된 날짜와 시간(예: 2020-01-21T00:53:30+09:00 Asia/Seoul)을 나타낸다.

ZonedDateTime now = ZonedDateTime.ofInstant(Instant.now(), ZoneId.systemDefault());
ZonedDateTime midnight = ZonedDateTime.of(LocalDate.now(), LocalTime.MIDNIGHT, ZoneId.systemDefault());
ZonedDateTime zdt = ZonedDateTime.parse("2020-01-21T00:42:00+09:00", DateTimeFormatter.ISO_OFFSET_DATE_TIME);

TemporalAdjuster

시간과 관련된 객체(Temporal 을 구현하고 있는 클래스)를 조절하기 위한 전략을 나타내는 함수형 인터페이스다.

import static java.time.temporal.ChronoUnit.*;

TemporalAdjuster addOneWeek = temporal -> temporal.plus(7, DAYS);
ZonedDateTime nextWeek = ZonedDateTime.now().with(addOneWeek);

Temporal 인터페이스를 구현한 객체에는 with(TemporalAdjuster) 메서드를 모두 가지고 있으며, 이 메서드를 통해 시간을 조절한다. 시간 조절하는 방법에는 두 가지가 있는데, javadoc에서는 Temporal.with(TemporalAdjuster) 사용을 권장한다.

TemporalAdjusters

시간과 날짜 조절에 자주 사용될 것 같은 전략들(TemporalAdjuster 인터페이스 구현체)을 모아 놓은 유틸성 클래스이다.

LocalDate today = LocalDate.now();

today.with(TemporalAdjusters.firstDayOfYear());                      // 올해 1일
today.with(TemporalAdjusters.firstDayOfNextYear());                  // 내년 1일
today.with(TemporalAdjusters.firstDayOfMonth());                     // 이번달 1일
today.with(TemporalAdjusters.firstDayOfNextMonth());                 // 다음달 1일
today.with(TemporalAdjusters.firstInMonth(DayOfWeek.SUNDAY));        // 이번달 첫 번째 일요일
today.with(TemporalAdjusters.lastDayOfYear());                       // 올해 마지막날
today.with(TemporalAdjusters.lastDayOfMonth());                      // 이번달 마지막날
today.with(TemporalAdjusters.lastInMonth(DayOfWeek.SUNDAY));         // 이번달 마지막 일요일
today.with(TemporalAdjusters.next(DayOfWeek.MONDAY));                // 다음 월요일
today.with(TemporalAdjusters.nextOrSame(DayOfWeek.MONDAY));          // 다음 월요일(당일 포함)
today.with(TemporalAdjusters.previous(DayOfWeek.MONDAY));            // 지난 월요일
today.with(TemporalAdjusters.previousOrSame(DayOfWeek.MONDAY));      // 지난 월요일(당일 포함)
today.with(TemporalAdjusters.dayOfWeekInMonth(2, DayOfWeek.SUNDAY)); // 이번달 2번째 일요일
today.with(TemporalAdjusters.ofDateAdjuster(date -> date.minusMonths(2))); // 커스터마이징

DateTimeFormatter

날짜/시간 객체를 파싱하거나 출력하기 위한 포맷터이다.

LocalDate date = LocalDate.now();
String text = date.format(formatter);
LocalDate parsedDate = LocalDate.parse(text, formatter);

이 클래스는 DateTimeFormatter 를 구현한 주요 포맷터를 제공하며, 좀 더 복잡한 포맷터는 DateTimeFormatterBuilder를 통해 구현할 수 있다.

LocalDate.now().format(DateTimeFormatter.ofPattern("yyyy-MM-dd")); // 2020-01-28
LocalDate.now().format(DateTimeFormatter.ofPattern("yyyy MM dd HH:mm:ss")); (1)
LocalDateTime.now().format(DateTimeFormatter.ISO_DATE_TIME);        // 2020-01-28T17:38:36.856
ZonedDateTime.now().format(DateTimeFormatter.ISO_DATE_TIME);        // 2020-01-28T17:41:12.319+09:00[Asia/Seoul]
ZonedDateTime.now().format(DateTimeFormatter.ISO_ZONED_DATE_TIME);  // 2020-01-28T17:41:12.319+09:00[Asia/Seoul]
ZonedDateTime.now().format(DateTimeFormatter.ISO_OFFSET_DATE);      // 2020-01-28+09:00
ZonedDateTime.now().format(DateTimeFormatter.ISO_OFFSET_TIME);      // 17:41:12.32+09:00
ZonedDateTime.now().format(DateTimeFormatter.ISO_OFFSET_DATE_TIME); // 2020-01-28T17:41:12.32+09:00
1 표현할 수 없으므로 java.time.temporal.UnsupportedTemporalTypeException: Unsupported field: HourOfDay 예외 발생

레거시 전환하기

Date

DateInstant
Date date = new Date();

Instant now = date.toInstant();
DateLocalDate
Date date = new Date();

LocalDate ld1 = date.toInstant()
  .atZone(ZoneId.systemDefault())
  .toLocalDate();

LocalDate ld2 = Instant.ofEpochMilli(date.getTime())
  .atZone(ZoneId.systemDefault())
  .toLocalDate();

LocalDate ld3 = new java.sql.Date(date.getTime()).toLocalDate();

// LocalDate to Date
date = java.sql.Date.valueOf(LocalDate.now());
DateLocalDateTime
Date date = new Date();

LocalDate ldt1 = date.toInstant()
  .atZone(ZoneId.systemDefault())
  .toLocalDate();

LocalDate ldt2 = Instant.ofEpochMilli(date.getTime())
  .atZone(ZoneId.systemDefault())
  .toLocalDateTime();

LocalDate ldt3 = new java.sql.Date(date.getTime()).toLocalDateTime();
DateZonedDateTime
Date date = new Date();

ZonedDateTime zdt = date.toInstant()
   .atZone(ZoneId.systemDefault());
DateString
Date date = new Date();

String yyyyMMdd = now.toInstant()
    .atZone(ZoneId.systemDefault())
    .format(DateTimeFormatter.ofPattern("yyyy-MM-dd")));

Calendar

final Calendar cal = Calendar.getInstance();
final TimeZone timeZone = Optional.ofNullable(cal.getTimeZone()).orElse(TimeZone.getDefault());

// LocalDate
LocalDate localDate = LocalDateTime.ofInstant(cal.toInstant(), timeZone.toZoneId()).toLocalDate();

// LocalDateTime
LocalDateTime localDateTime = LocalDateTime.ofInstant(cal.toInstant(), timeZone.toZoneId());

// ZonedDateTime
ZonedDateTime zonedDateTime = ZonedDateTime.ofInstant(cal.toInstant(), timeZone.toZoneId());

Timestamp

import java.sql.Timestamp;

Timestamp ts = Timestamp.from(Instant.now());
Instant now = ts.toInstant();

SimpleDateFormat

DateTimeFormatterSimpleDateFormat 의 패턴이 완전히 동일하지 않으므로 리팩토링시 문서를 꼭 참고해야 한다.

SimpleDateFormat simpleDateFormat = new SimpleDateFormat("yyyy-MM-dd");
DateTimeFormatter formatter = DateTimeFormatter.ofPattern("yyyy-MM-dd");

System.out.println(simpleDateFormat.format(new Date()));
System.out.println(LocalDate.now().format(formatter));

실용 예제

아래 예제들은 실제 개발하면서 작성한 코드들이다. 앞으로도 시간 관련된 코드를 작성할 때 이곳에 추가하고 수정해나갈 예정이다.

어제 00:00:00 구하기

LocalDateTime dateTime1 = LocalDate.now()
    .atTime(LocalTime.MIN)
    .minus(1, ChronoUnit.DAYS);

LocalDateTime dateTime2 = LocalDate.now()
    .atStartOfDay()
    .minus(1, ChronoUnit.DAYS);

LocalDateTime dateTime3 = LocalDateTime.now()
    .truncatedTo(ChronoUnit.DAYS)
    .minus(1, ChronoUnit.DAYS);
ZonedDateTime zonedDateTime = LocalDate.now()
    .minus(1, ChronoUnit.DAYS)
    .atStartOfDay(ZoneId.of("Asia/Seoul"));

어제 23:59:59 구하기

final String actual = LocalDateTime.now()
    .minus(1, DAYS)
    .truncatedTo(DAYS)
    .format(DateTimeFormatter.ofPattern("yyyyMMddHHmmss"));
// 20191204235959

1주일 이후 시간 구하기

예를 들어, Santiago에서 2018년 5월 10일 10시 기준으로 7주일 이후에 회의를 잡으려고 한다. 이 경우에는 Period.ofDays(int) 을 사용한다.

// santiago 2018/05/13 00:00:00 이후로 DST 적용
final ZonedDateTime now = ZonedDateTime.of(2018, 5, 10, 10, 0, 0, 0, ZoneId.of("America/Santiago"));
final ZonedDateTime nextMeeting = now.plus(Period.ofDays(7));

System.out.println(now);
System.out.println(nextMeeting);
2018-05-10T10:00-03:00[America/Santiago]
2018-05-17T10:00-04:00[America/Santiago]

만약 Duration 을 사용했다면 Santiago의 DST가 적용되지 않아 잘못된 시간에 회의를 예약하게 된다.

final ZonedDateTime nextMeeting = now.plus(Duration.ofDays(7));
System.out.println(nextMeeting);
2018-05-17T09:00-04:00[America/Santiago]

타임존과 오프셋 커스텀하게 출력하기

GMT-04:00 Santiago
GMT+09:00 Seoul
GMT+10:00 Sydney

위와 같이 출력하고자 할 경우 아래와 같다.

// 현재 시간 기준(2018/03/21)
final List<ZoneId> timeZones = new ArrayList<>();
timeZones.add(ZoneId.of("America/Santiago"));
timeZones.add(ZoneId.of("Asia/Seoul"));
timeZones.add(ZoneId.of("Australia/Sydney"));

timeZones.forEach(zoneId -> {
    final ZoneOffset offset = zoneId.getRules().getStandardOffset(Instant.now());
    System.out.println(String.format("GMT%s %s", offset.getId(), zoneId.getId().split("/")[1]));
});

위 코드에는 한 가지 이슈가 있다. 현재 시점(2018년 2월 21일)에 Santiago는 DST가 시행중으로 offset은 1시간 당긴 -03:00 이다. 하지만, getStandardOffset() 은 표준 오프셋을 가져오므로 -04:00 를 출력한다(Sydney도 동일하다). 아래와 같이 offset 을 선언하면 DST가 적용된 offset을 가져올 수 있다.

final ZoneOffset offset = LocalDateTime.now().atZone(zoneId).getOffset();
생각해보기

에서 GMT는 DST로 변하지 않는다고 말한다. 그러면 위 코드처럼 DST가 적용된 시간을 GMT{offset} 으로 출력해도 되는가? 여러가지 생각해봤지만 어느것이 맞는지 더 찾아봐야겠다.

  • 각 나라의 표준시를 보여줄 것인가?

  • DST를 적용한 GMT를 보여줄 것인가?

  • DST를 적용한 UTC를 보여줄 것인가?

  • 따로 DST 적용기간 아이콘을 보여줄 것인가?

구글 캘린더에서는 (GMT-03:00) 산티아고 라고 DST를 적용한 GMT시간을 보여준다.

LocalDateTime에 ZoneId 설정하기

특정 지역 시간(localDateTime)에 Zone-ID를 추가하려면 아래와 같다.

localDateTime.atZone(ZoneId zoneId);
ZonedDateTime.of(LocalDateTime localDateTime, ZoneId zoneId);
Example
final LocalDateTime localDateTime = LocalDateTime.of(2017, Month.OCTOBER, 18, 9, 0);
final ZonedDateTime zonedDateTime1 = localDateTime.atZone(ZoneId.of("UTC"));
final ZonedDateTime zonedDateTime2 = ZonedDateTime.of(localDateTime, ZoneId.of("Asia/Seoul"));
System.out.println(zonedDateTime1);
System.out.println(zonedDateTime2);
Output
2017-10-18T09:00Z[UTC]
2017-10-18T09:00+09:00[Asia/Seoul]
헷갈릴 수 있는 코드

LocalDateTime 에서 atZone() 는 날짜/시간 값에 타임존 정보를 추가하는 것이지 타임존으로부터 시간 계산을 하는 것이 아니다. 그러므로 아래 LocalDateTime 인스턴스는 동일하다.

LocalDateTime dateTime1 = localDateTime.atZone(seoul).toLocalDateTime();
LocalDateTime dateTime2 = localDateTime.atZone(utc).toLocalDateTime();

assertEquals(dateTime1, dateTime2); // true

다른 시간대의 시간으로 변경하기

LA의 오전 9시를 서울 시간으로 변경하고자 할 땐 어떻게 해야할까? 절대적인 시간을 그대로 두려는 이 경우엔 zonedDateTime.withZoneSameInstant(ZoneId) 를 사용한다.

  • 도쿄 타임존으로 캘린더 일정 생성시 서울 시간으로 변경해서 표시해주고자 할 때

  • 시스템 타임존을 보고 있는 사용자가 LA에서 예약 결제를 한 경우 서울에 와서 언제 결제를 처리할 것인지 확인할 때

// given
final LocalDateTime localDateTime = LocalDateTime.of(2017, Month.OCTOBER, 18, 9, 0);

// when
final ZonedDateTime losAngeles = localDateTime.atZone(ZoneId.of("America/Los_Angeles")); (1)
final ZonedDateTime seoul = losAngeles.withZoneSameInstant(ZoneId.of("Asia/Seoul")); (2)

// then
assertEquals(losAngeles.toInstant(), seoul.toInstant()); // true
1 출력 결과: 2017-10-18T09:00-07:00[America/Los_Angeles]
2 출력 결과: 2017-10-19T01:00+09:00[Asia/Seoul]

시간은 그대로 두고 시간대만 변경하기

시간대를 잘못 설정해서 변경하고자 할 땐 withZoneSameLocal(ZoneId) 를 사용한다. 이 메서드를 통해 변경하는 경우에는 절대적인 시간이 서로 달라진다.

  • 시간대를 잘못 설정해서 변경해야할 때

// given
final LocalDateTime localDateTime = LocalDateTime.of(2017, Month.OCTOBER, 18, 9, 0);

// when
final ZonedDateTime losAngeles = localDateTime.atZone(ZoneId.of("America/Los_Angeles")); (1)
final ZonedDateTime seoul = losAngeles.withZoneSameLocal(ZoneId.of("Asia/Seoul")); (2)

// then
assertEquals(losAngeles.toLocalDateTime(), seoul.toLocalDateTime()); // true
assertEquals(losAngeles.toInstant(), seoul.toInstant()); // false
1 출력 결과: 2017-10-18T09:00-07:00[America/Los_Angeles]
2 출력 결과: 2017-10-18T09:00+09:00[Asia/Seoul]

더 알아볼 것

이 섹션은 작성중인 섹션이다.

참고 및 확인해볼 것
  • 왜 타임존 업데이트가 되지 않았는가?

  • java 타임존을 업데이트 하는 방법 - oracle jdk, openjdk

타임존 DB 업데이트하기

이 섹션은 작성중인 섹션이다.

참고 및 확인해볼 것

추가 정보

Europe/Istanbul 타임존

java8,timezone,zoneId,date,time,datetime