글로벌 서비스를 한다면 타임존에 대한 이해는 필수이다. 사용자가 어떤 기준으로 시간을 보는지 항상 고려해야한다. 한번 고민 해보고 나면 크게 어려운 개념도 아니고 복잡한 계산도 아니지만, 이 업무를 처음 맡았을 때는 꽤나 고생했다. 시간에 대해서 고민해 본 적이 없던 것도 있었지만, 기존의 코드들이 특정 시간대를 기준으로만 개발되었거나(보통 개발자의 서버 시간을 기준으로 작성되었고, 요구사항이 없으므로 시간대를 고려하지 않은 상태이다), 문자열을 그대로 파싱하도록 작성되어 있었기 때문에 손 볼 곳이 많았다.
JDK8에서는 시간, 날짜 관련해서 유용한 API(java.time
패키지)를 제공한다. 훨씬 이해하기 쉽고 유용하다. 앞으로는 문자열을 파싱하고 직접 시간을 계산하지말고 이 API을 사용해보자.
(직접 구현한 코드들에는 시간 계산에 관한 버그도 많고 잘 보이지도 않는다)
Note
|
Java8 이전에도 간략하게 정리한 JDK의 기본 날짜 클래스의 문제점
|
이 포스트에서 설명하는 대부분의 날짜, 시간 관련 API는 java.time 패키지 하위에 있다.
타임존
Time-Zone(타임존)은 여러 가지 다른 것을 설명하는 데 사용할 수 있지만 대개 지역 또는 국가의 현지 시간을 나타내며, 주로 해당 국가에 의해 법적으로 지정된다. GMT 와 UTC 는 같은 시간을 가르키면서 혼용되어 사용되지만, 엄밀히 구분하자면 다른 의미이다(완전히 동일하진 않고 초의 소숫점 단위에서 차이가 난다).
Note
|
|
위에 말한 듯이 타임존은 정부에 의해 변경되는 경우가 종종 있다. 하지만 Java는 타임존 변경이 일어났더라도 따로 JDK 버전업 필요 없이 독립적으로 타임존 데이터베이스(IANA 데이터베이스)를 업데이트한다. 즉, 하드코딩으로 관리하지 않아도 된다.
Java 8에서는 타임존을 고려한 날짜와 시간까지도 더 명확하고 편리하게 사용할 수 있다. 타임존은 ZoneId
클래스를 통해 날짜/시간별 DST 이 반영되었는지 확인 할 수도 있다. ZoneId.getAvailableZoneIds()
를 통해 지원하는 지역별 타임존을 확인할 수 있다(현재 시점 등록된 ZoneId는 600개이다).
Note
|
DST(Daylight Saving time)
DST은 자연 일광을 보다 잘 활용하기 위해서 여름철에 표준 시간에서 1시간 앞으로, 그리고 다시 가을에 시간을 1시간 전으로 설정하는 것을 말한다. DST와 "summer time"은 같은 말을 뜻하며 특정 나라에서 주로 불린다. 영국에서 썸머타임이라고 많이 사용하며, DST가 적용되지 않는 표준시는 "winter time"이라고 사용되기도 한다. DST를 독일에서는 "sommerzeit", 스칸디나비아에서는 "sommertid"라고도 사용한다. |
클래스 혹은 인터페이스 | 설명 |
---|---|
|
타임존을 사용한 현재 순간, 날짜 및 시간에 접근할 수 있는 클래스 |
|
34.5초와 같이 시간 기반의 시간(amount of time) |
|
타임라인의 순간을 나타내는 클래스 |
|
ISO-8601 캘린더 시스템에서 타임존이 없는 날짜(예: |
|
ISO-8601 캘린더 시스템에서 타임존이 없는 날짜와 시간(예: |
|
ISO-8601 캘린더 시스템에서 시간대가 없는 시간(예: |
|
ISO-8601 캘린더 시스템의 월별 일(예: |
|
ISO-8601 캘린더 시스템에서 UTC로부터 offset이 포함된 날짜와 시간(예: |
|
ISO-8601 캘린더 시스템에서 UTC로부터 offset이 포함된 시간(예: |
|
ISO-8601 캘린더 시스템의 날짜 기반의 시간(예: |
|
ISO-8601 캘린더 시스템에서 연도년(예: |
|
ISO-8601 캘린더 시스템에서 연(year)과 월(month)(예: |
|
ISO-8601 캘린더 시스템에서 Asia//Seoul 같은 타임존이 포함된 날짜와 시간(예: |
|
|
|
GMT/UTC로부터 타임존 오프셋(예: |
|
요일을 나타내는 열거타입 |
|
월을 나타내는 열거타입 |
|
|
|
날짜/시간의 period를 나타내는 표준 셋을 가진 열거타입 |
|
date-time 객체를 파싱하거나 출력하기 위한 포맷터 (thread-safe) |
Note
|
ISO 8601
|
Note
|
TimeZone ID
|
날짜, 시간 다루기
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
날짜/시간 객체를 파싱하거나 출력하기 위한 포맷터이다. 이 객체는 immutable 하고 thread-safe 하다.
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
-
표현할 수 없으므로 java.time.temporal.UnsupportedTemporalTypeException: Unsupported field: HourOfDay 예외 발생
레거시 전환하기
Date
Date
→ Instant
Date date = new Date();
Instant now = date.toInstant();
// sql package
Instant.ofEpochMilli(rs.getTimestamp("date_column").getTime())
.atZone(timeZone)
.toLocalDateTime()
Date
→ LocalDate
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());
Date
→ LocalDateTime
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();
Date
→ ZonedDateTime
Date date = new Date();
ZonedDateTime zdt = date.toInstant()
.atZone(ZoneId.systemDefault());
Date
→ String
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
DateTimeFormatter 와 SimpleDateFormat 의 패턴이 완전히 동일하지 않으므로 리팩토링시 문서를 꼭 참고해야 한다.
기존에 제공하던 SimpleDateFormat
, DateFormat
은 thread-safe 하지 않으므로, 상수로 선언하여 사용할 땐 DateTimeFormatter
을 사용해야 한다.
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]
Warning
|
만약
|
타임존과 오프셋 커스텀하게 출력하기
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();
Note
|
생각해보기
이 글에서 GMT는 DST로 변하지 않는다고 말한다. 그러면 위 코드처럼 DST가 적용된 시간을
구글 캘린더에서는 |
LocalDateTime에 ZoneId 설정하기
특정 지역 시간(localDateTime)에 Zone-ID를 추가하려면 아래와 같다.
localDateTime.atZone(ZoneId zoneId);
ZonedDateTime.of(LocalDateTime localDateTime, ZoneId zoneId);
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);
2017-10-18T09:00Z[UTC]
2017-10-18T09:00+09:00[Asia/Seoul]
Warning
|
헷갈릴 수 있는 코드
|
다른 시간대의 시간으로 변경하기
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
-
출력 결과: 2017-10-18T09:00-07:00[America/Los_Angeles]
-
출력 결과: 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
-
출력 결과: 2017-10-18T09:00-07:00[America/Los_Angeles]
-
출력 결과: 2017-10-18T09:00+09:00[Asia/Seoul]
더 알아볼 것
Warning
|
이 섹션은 작성중인 섹션이다. 참고 및 확인해볼 것
|
타임존 DB 업데이트하기
Warning
|
이 섹션은 작성중인 섹션이다. 참고 및 확인해볼 것
|
추가 정보
ZonedDateTime 비교
The comparison is based first on the instant, then on the local date-time, then on the zone ID, then on the chronology. It is “consistent with equals”, as defined by Comparable.
val kr = ZonedDateTime.of(
2022, 10, 12, 18, 0, 0, 0, ZoneId.of("Asia/Seoul")
)
// 2022-10-12T18:00:00+09:00
// 2022-10-12T17:00:00+08:00
// 2022-10-12T09:00:00Z
val hk = ZonedDateTime.of(
2022, 10, 12, 17, 0, 0, 0, ZoneId.of("Asia/Hong_Kong")
)
// 2022-10-12T18:00:00+09:00
// 2022-10-12T17:00:00+08:00
// 2022-10-12T09:00:00Z
logger.info { hk < kr } // true
logger.info { hk == kr } // false
logger.info { hk > kr } // false
logger.info { hk.toInstant() < kr.toInstant() } // false
logger.info { hk.toInstant() == kr.toInstant() } // true
logger.info { hk.toInstant() > kr.toInstant() } // false
hk.withZoneSameInstant(ZoneId.of("Asia/Seoul")).also {
logger.info { it < kr } // false
logger.info { it == kr } // true
logger.info { it > kr } // false
}
Europe/Istanbul
타임존
-
터키는 타임존을 사용하지 않는다.
-
Europe/Istanbul
사용함 -
ZoneId.of("Turkey")
은 Deprecated. wiki -
Tzdata 버전은 tzdata2016g이 반영되야함. 오라클 문서
-
https://stackoverflow.com/questions/40400793/java-timezone-in-turkey-rejected-daylight-saving
Timestamp
Unix time
-
특정 시점(point in time, timestamp)를 설명하는 시스템이다.
-
윤초를 제외한 Unix epoch 이후 경과된 초(second)를 나타낸다.
-
Unix epoch: 1970년 1월 1일 00:00:00 UTC
-
-
여러 이름으로 사용된다.
-
Epoch time, Posix time, seconds since the Epoch, Unix timestamp, UNIX Epoch time
-
posix는 unix os를 기반으로 둔 os interface여서 같은 의미로 사용된다.
-
-
Unix time은 UTC의 진정한 표현이 아니다. 윤초와 윤초 앞의 초는 동일한 Unix time을 갖기 때문이다.
-
Unix time의 하루는 정확히 86400초를포함한다. 양수/음수의 운초의 결과를 하루에 포함하거나 제외하지 않는다.
-
Kotlin
-
ISO 8601을 기반으로 하는 kotlin muliplatform library로는 kotlinx-datetime이 있다.
ETC
-
jdk8, 11, 17의
Instant.now()
차이// jdk 1.8.0_66 2022-08-22T06:02:36.473Z // jdk 10.0.1 2022-08-22T06:04:23.142454Z // jdk 11.0.4 2022-08-22T06:04:34.116949Z // jdk 17.0.1 2022-08-22T06:04:48.256750828Z
-
이걸 통해 확인해야할 점: DB 엔티티와 값 비교시 생성한 객체와 조회한 객체의 equals 비교에 문제가 발생할 수 있음
-
MySQL DATETIME에서 fractional seconds part 는 0~6 지원
-
-