Java 날짜/시간 API

내 이 세상 도처에서 쉴 곳을 찾아보았으나, 마침내 찾아낸, 컴퓨터가 있는 구석방보다 나은 곳은 없더라.

Java 날짜/시간 API

Java 8부터 새로운 날짜/시간 API가 추가되었다. 날짜/시간 관련 기능을 사용할 일이 생기면 어떤 클래스를 어떻게 사용해야 할지 생각나지 않는 경우가 많아 여기 각 클래스의 기본 사용법을 간단히 정리하려 한다.

기능을 간단히 테스트할 때는 jshell을 사용하면 편리하다. jshell을 실행한 후, 날짜/시간 관련 클래스를 쉽게 사용할 수 있도록 java.timejava.time.temporal 패키지를 import 해둔다.

$ jshell
|  Welcome to JShell -- Version 14.0.1
|  For an introduction type: /help intro

jshell> import java.time.*

jshell> import java.time.temporal.*

Instant

Instant는 타임라인의 특정 시점을 나타낸다. 사건이 발생한 시점을 기록하는 데 사용할 수 있다. Instant 객체는 정적 팩터리 메서드로 생성할 수 있다.

jshell> var now = Instant.now()
now ==> 2020-07-01T16:15:13.342880Z

지금이 아닌 다른 시점, 예를 들어 1시간 전 또는 하루 전 Instant는 어떻게 만들 수 있을까? epoch milliseconds를 안다면 Instant.ofEpochSecond 메서드를 사용할 수도 있겠지만, 다음과 같이 하는 게 더 직관적이다.

jshell> var oneHourAgo = now.minus(1, ChronoUnit.HOURS)
oneHourAgo ==> 2020-07-01T15:15:13.342880Z

jshell> var oneDayAgo = now.minus(1, ChronoUnit.DAYS)
oneDayAgo ==> 2020-06-30T16:15:13.342880Z

즉, Instant 객체에 plus 또는 minus 메서드를 사용해 다른 시점을 구할 수 있다. 또한 isAfter, isBefore 메서드로 두 시점의 선후 관계를 알 수 있다.

jshell> now.isAfter(oneHourAgo)
$6 ==> true

jshell> now.isBefore(oneDayAgo)
$7 ==> false

Instant에서는 연도, 월, 일과 같은 날짜 기본 정보를 얻을 수 없다. get 메서드로 이런 정보를 얻으려 하면 예외가 발생한다.

jshell> now.get(ChronoField.YEAR)
|  Exception java.time.temporal.UnsupportedTemporalTypeException: Unsupported field: Year
|        at Instant.get (Instant.java:566)
|        at (#6:1)

LocalDate / LocalTime / LocalDateTime

LocalDate는 시간 정보 없이 날짜만 저장한다. LocalTime은 날짜 정보 없이 시간만 저장한다. LocalDateTime은 날짜와 시간을 함께 저장한다. 세 클래스 모두 시간대(Time-zone) 정보가 없다.

LocalDate

일반 날짜를 저장할 때 사용할 수 있다. 시간 및 시간대 정보가 없기 때문에 타임라인의 특정 시점을 나타낼 수 없다. 오늘 날짜를 구하려면 now() 메서드를 사용하면 되며, of(year, month, dayOfMonth) 메서드로 원하는 날짜 객체를 만들 수 있다.

jshell> var today = LocalDate.now()
today ==> 2020-07-01

jshell> var d = LocalDate.of(2020, 1, 1)
d ==> 2020-01-01

LocalDate에는 날짜 기본 정보를 얻을 수 있는 여러 메서드가 있다. 예를 들어 연도는 getYear() 메서드로 알 수 있고, 월은 getMonth()getMonthValue() 메서드로 알 수 있다. getMonth()Month enum을 리턴하고, getMonthValue()는 월을 숫자로 리턴한다. getDayOfWeek()DayOfWeek enum을 리턴한다. get(ChronoField.DAY_OF_WEEK)는 요일을 숫자로 리턴한다.

jshell> today.getYear()
$11 ==> 2020

jshell> today.getMonth()
$12 ==> JULY

jshell> today.getMonthValue()
$13 ==> 7

jshell> today.getDayOfWeek()
$14 ==> WEDNESDAY

jshell> today.get(ChronoField.DAY_OF_WEEK)
$15 ==> 3

오늘 날짜는 getDayOfMonth()로 알 수 있다.

jshell> today.getDayOfMonth()
$16 ==> 1

Instant와 마찬가지로 plus, minus 메서드로 이전 또는 이후 날짜를 구할 수 있다. LocalDate{plus|minus}Days, {plus|minus}Weeks, {plus|minus}Months, {plus|minus}Years를 이용해 며칠, 몇 주, 몇 달, 몇 년 전/후 날짜를 구할 수 있다.

jshell> d.plus(10, ChronoUnit.DAYS)
$17 ==> 2020-01-11

jshell> d.minusMonths(2)
$18 ==> 2019-11-01

한가지 재미있는 점은 {plus|minus}Months 동작 방식이다. 2020-01-31을 생성한 다음 한 달 후를 구하면 2020-02-29가, 두 달 후를 구하면 2020-03-31, 세 달 후를 구하면 2020-04-30이 된다.

jshell> var d31 = LocalDate.of(2020, 1, 31)
d31 ==> 2020-01-31

jshell> d31.plusMonths(1)
$20 ==> 2020-02-29

jshell> d31.plusMonths(2)
$21 ==> 2020-03-31

jshell> d31.plusMonths(3)
$22 ==> 2020-04-30

2020-01-30을 생성한 다음 한 달 후를 구하면 2020-02-29가, 두 달 후를 구하면 2020-03-30, 세 달 후를 구하면 2020-04-30이 된다.

jshell> var d30 = LocalDate.of(2020, 1, 30)
d30 ==> 2020-01-30

jshell> d30.plusMonths(1)
$24 ==> 2020-02-29

jshell> d30.plusMonths(2)
$25 ==> 2020-03-30

jshell> d30.plusMonths(3)
$26 ==> 2020-04-30

날짜 수는 매 달마다 다르기 때문에 이렇게 동작하는 것 같다. 달을 더하거나 빼는 데는 약간의 모호함이 있다.

atTime 메서드를 이용해 LocalDate에 시간 정도를 더해 LocalDateTime 객체를 만들 수 있다.

jshell> d.atTime(12, 10, 25)
$27 ==> 2020-01-01T12:10:25

LocalDate에는 datesUntil 메서드가 있어 날짜 스트림을 만들 수 있다. Period로 스텝을 지정할 수도 있다.

jshell> d
d ==> 2020-01-01

jshell> d.datesUntil(d.plusDays(7)).collect(Collectors.toList())
$29 ==> [2020-01-01, 2020-01-02, 2020-01-03, 2020-01-04,
         2020-01-05, 2020-01-06, 2020-01-07]

jshell> d.datesUntil(d.plusDays(31), Period.ofDays(7)).collect(Collectors.toList())
$30 ==> [2020-01-01, 2020-01-08, 2020-01-15, 2020-01-22, 2020-01-29]

LocalDate 또한 isAfter, isBefore 메서드로 두 날짜의 선후 관계를 알 수 있다.

LocalTime

시간을 저장할 때 사용할 수 있다. 날짜 및 시간대 정보가 없기 때문에 타임라인의 특정 시점을 나타낼 수 없다. 시간은 나노 초 정밀도로 저장한다.

jshell> var t0 = LocalTime.now()
t0 ==> 16:41:19.327766

jshell> var t1 = LocalTime.of(9, 10, 25)
t1 ==> 09:10:25

plus, minus 메서드로 이전 또는 이후 시간을 구할 수 있다. LocalDate와 마찬가지로 {plus|minus}Hours, {plus|minus}Minutes, {plus|minus}Seconds, {plus|minus}Nanos로 몇 시간, 몇 분, 몇 초, 몇 나노초 전/후 시간을 구할 수 있다.

LocalDate와 비슷하게 atDate 메서드로 날짜를 지정해 LocalDateTime 객체를 만들 수 있고, isBefore, isAfter 메서드로 시간의 선후 관계를 확인할 수 있다.

LocalDateTime

시간과 날짜를 함께 저장할 때 사용할 수 있다. 시간대 정보는 없다. 시간은 LocalTime과 마찬가지로 나노 초 정밀도로 저장한다. 인터페이스는 LocalDateLocalTime과 비슷하다. 여러 개의 of 정적 팩터리 메서드가 있고, 다양한 plus*, minus 메서드가 있다.

atOffset 메서드로 오프셋 정보를 추가해 OffsetDateTime 객체를 만들 수 있고, atZone 메서드로 시간대 정보를 추가해 ZonedDateTime 객체를 만들 수 있다.

jshell> LocalDateTime.now()
$33 ==> 2020-07-01T17:33:45.298881

jshell> LocalDateTime.now().atOffset(ZoneOffset.ofHours(1))
$34 ==> 2020-07-01T17:34:08.290028+01:00

jshell> LocalDateTime.now().atZone(ZoneId.systemDefault())
$35 ==> 2020-07-01T17:34:32.793507+01:00[Europe/London]

OffsetDateTime / ZonedDateTime

OffsetDateTime은 날짜 시간에 UTC 오프셋이 함께 저장되며, ZonedDateTime은 날짜 시간에 시간대 정보가 함께 저장된다. Instant, OffsetDateTime, ZonedDatetime 모두 타임라인의 특정 시점을 저장하는데, Instant가 가장 단순한 형태고, OffsetDatetime은 인스턴트에 UTC 오프셋이 함께 있으므로 지역 시간을 얻을 수 있게 된다. ZonedDatetime은 가장 복잡한 형태로 일광 절약 시간제(daylight saving time, DST) 같은 시간대 규칙이 모두 저장된다.

Period / Duration

Period는 1년 2개월 3일과 같은 기간을 나타내고, Duration은 30분 21초와 같은 시간 기간을 나타낸다. 두 클래스 모두 TemporalAmount 인터페이스를 구현한다. 정적 팩터리 메서드 of로 기간을 직접 지정해 만들 수도 있고, between 메서드로 두 시점 사이의 기간을 구할 수 있다. Peroidbetween 메서드는 두 날 사이의 기간을 구하며 두 인자 모두 LocalDate로 받는다

jshell> var d1 = LocalDate.of(2018, 1, 1)
d1 ==> 2018-01-01

jshell> var d2 = LocalDate.now()
d2 ==> 2020-06-14

jshell> var p = Period.between(d1, d2)
p ==> P2Y5M13D

d1d2 사이의 Peroid를 구하면 P2Y5M13D로 표시되는데 이는 2년(2Y) 5개월(5M) 13일(13D)을 뜻한다. 각 값은 다음과 같은 구할 수 있다.

jshell> p.getYears()
$39 ==> 2

jshell> p.getMonths()
$40 ==> 5

jshell> p.getDays()
$41 ==> 13

Durationbetween 메서드도 비슷하지만, 파라미터 타입이 Temporal이다. LocalDate, LocalTime, OffsetDateTime, OffsetTime, Zoneddatetime 모두 Temporal 인터페이스를 구현하므로 Duration.between 메서드를 호출하는 데 사용할 수 있지만, LocalDate와 같이 시간 정보가 없는 객체로 호출하면 예외가 발생한다.

jshell> Duration.between(d1, d2)
|  Exception java.time.temporal.UnsupportedTemporalTypeException: Unsupported unit: Seconds
|        at LocalDate.until (LocalDate.java:1657)
|        at Duration.between (Duration.java:491)
|        at (#25:1)

jshell> var dt1 = LocalDateTime.of(2020, 6, 10, 10, 20)
dt1 ==> 2020-06-10T10:20

jshell> var dt2 = LocalDateTime.of(2020, 6, 11, 12, 45)
dt2 ==> 2020-06-11T12:45

jshell> var d = Duration.between(dt1, dt2)
d ==> PT26H25M20S

PT26H25M20S은 26시간 25분 20초를 뜻한다. getSeconds 메서드로 이 기간이 몇 초인지 알 수 있다.

jshell> d.getSeconds()
$46 ==> 95120

Peroid에는 getYears, getMonths, getDays 메서드가 있고 각각 해당 기간의 연, 개월, 날자 수를 리턴한다. Duration에서도 이에 대응하는 getHours, getSeconds 등의 메서드가 있어야 할 것 같은데, getHours 메서드는 없고, getSeconds 메서드는 해당 기간의 초 부분만 리턴하는 게 아니라 전체 기간을 초로 환산한 값을 리턴한다. 인터페이스에 일관성이 없다.

Peroid에도 해당 기간이 전체 며칠이나 되는지 알려주는 메서드가 있으면 좋을텐데 아쉽게 그런 메서드를 찾을 수 없었다. 대신 두 날짜 사이의 날수를 계산하려면 다음과 같이 해야 한다.

jshell> ChronoUnit.DAYS.between(d1, d2)
$47 ==> 895