자바 8 인 액션

12. 새로운 날짜와 시간 API

Featured image

기존 java 1.0에서 제공하던 날짜/시간 API였던 java.util.Date와 java.util.Calendar로 날짜를 다뤘으나 여러 문제가 많아 java 8에서는 새로운 시간 API를 사용한다.

LocalDate, LocalTime, Instant, Duration, Period

쟈바 8에서 java.time 패키지는 LocalDate, LocalTime, LocalDateTime, Instant, Duration, Period 등 새로운 클래스를 제공한다.

LocalDate와 LocalTime 사용

LocalDate는 시간을 제외한 날짜를 표현하는 불변 객체다. 특히, LocatDate 객체는 어떤 시간대 정보도 포함하지 않는다.
정적 팩토리 메서드 of로 LocalDate 인스턴스를 만들 수 있다.

LocalDate date = LocalDate.of(2014, 3, 18); //  2014-03-18
int year = date.getYear();                  //  2014
Month month = date.getMonth();              //  MARCH
int day = date.getDayOfMonth();             //  18
DayOfWeek dow = date.getDayOfWeek();        //  TUESDAY
int len = date.lengthOfMonth();             //  31(3월의 일수)
boolean leap = date.isLeapYear();           //  false(윤년이 아님)
LocalDate today = LocalDate.now();
LocalDate date = LocalDate.of(2014, 3, 18);
int year = date.get(ChronoField.YEAR);
int month = date.get(ChronoField.MONTH_OF_YEAR);
int day = date.get(ChronoField.DAY_OF_MONTH);
System.out.println("결과 : " + year + " " + month + " " + day); //  결과 : 2014 3 18

TemporalField는 시간 관련 객체에서 어떤 필드의 값에 접근할지 정의하는 인터페이스다.
열거자 ChronoField는 TemporalField 인터페이스를 정의하므로 위 코드처럼 ChronoField의 열거자 요소를 이용해서 원하는 정보를 쉽게 얻을 수 있다.

LocalTime time = LocalTime.of(13, 45, 20);  //  13:45:20
int hour = time.getHour();                  //  13
int minute j= time.getMinute();             //  45
int second = time.getSecond();              //  20
LocalDate date = LocalDate.parse("2014-03-18");
LocalTime time = LocalTime.parse("13:45:20");
System.out.println("date = " + date + " time = " + time);   //  결과 : date = 2014-03-20 time = 13:45:20

parse 메서드에 DateTimeFormatte를 전달한다. 문자열을 LocalDate나 LocalTime으로 파싱할 수 없을 때 parse 메서드는 DateTimeParseException(RuntimeException을 상속받은 예외)을 일으킨다.

날짜와 시간 조합

LocalDateTime은 LocalDate와 LocalTime을 쌍으로 갖는 복합 클래스다. 날짜와 시간을 모두 표현할 수 있으며 직접 LocalDateTime을 만드는 방법도 있고 날짜외 시간을 조합할 수도 있다.

// 2020-09-05T19:40:30
LocalDate date = LocalDate.parse("2020-09-05");
LocalTime time = LocalTime.parse("19:40:30");

LocalDateTime dt1 = LocalDateTime.of(2020, Month.SEPTEMBER, 5, 19, 40, 30);
LocalDateTime dt2 = LocalDateTime.of(date, time);
LocalDateTime dt3 = date.atTime(13, 45, 20);
LocalDateTime dt4 = date.atTime(time);
LocalDateTime dt5 = time.atDate(data);

LocalDate의 atTime 메서드에 시간을 제공하거나 LocalTime의 atDate 메서드에 날짜를 제공해서 LocalDateTime을 만들 수도 있다.
LocalDateTime의 toLocalDatetoLocalTime 메서드로 LocalDate나 LocalTime 인스턴스를 추출할 수 있다.

LocalDate date1 = dt1.toLocalDate();    //  2020-09-05
LocalTime time2 = dt1.toLocalTime();    //  19:40:30

Instant: 기계의 날짜와 시간

java.time.Instant 클래스에서는 기계적인 관점에서 시간을 표현한다. 즉, Instant 클래스는 유닉스 에포크 시간(Unix epoch time)(1970년 1월 1일 0시 0분 0초 UTC)을 기준으로 특정 지점까지의 시간을 초로 표현한다.

Instant.ofEpochSecond(3);
Instant.ofEpochSecond(3, 0);
Instant.ofEpochSecond(2, 1_000_000_000);    //  2초 이후의 1억 나노초(1초)
Instant.ofEpochSecond(4, -1_000_000_000);   //  4초 이전의 1억 나노초(1초)

팩토리 메서드 ofEpochSecond에 초를 넘겨줘서 Instant 클래스 인스턴스를 만들 수 있다. Instant 클래스는 나노초(10억 분의 1초)의 정밀도를 제공한다.

Instant 클래스도 사람이 확인할 수 있도록 시간을 표시해주는 정적 팩토리 메서드인 now메서드를 제공한다. 하지만 Instant 는 기계 전용의 유틸리티기 때문에 초와 나노초 정보도 포함된다.

int day = Instant.now().get(ChronoField.DAY_OF_MONTH);
System.out.println(day);

Instant 클래스는 사람이 읽을 수 있는 시간 정보는 제공하지 않기 때문에 위 결과는 java.time.temporal.UnsupportedTemporalTypeException: Unsupported field: DayOfMonth 예외를 발생시킨다.
따라서 Instant 클래스는 Duration과 Period 클래스와 함께 활용할 수 있다.

Duration과 Period 정의

지금 까지 살펴본 클래스는 Temporal 인터페이스를 구현한다. Temporal 인터페이스는 특정 시간을 모델링하는 객체의 값을 어떻게 읽고 조작할지 정의한다.
반면, Duration 클래스는 정적 팩토리 메서드 between를 통해 두 시간 객체 사이의 지속시간을 만들 수 있다.

Duration d1 = Duration.between(time1, time2); 
Duration d1 = Duration.between(dateTime1, dateTime2);
Duration d2 = Duration.between(instant12, instant2);

두 개의 LocalTime, 두 개의 LocalDateTime, 또는 두 개의 Instant로 Duration을 만들 수 있다.
LocalDateTime과 Instant 클래스가 혼합되어 사용될 수는 없다.
또한 Duration 클래스는 초와 나노초로 시간 단위를 표현하므로 between 메서드에 LocalDate를 전달할 수 없다. 년, 월, 일로 시간을 표현할 때는 Period 클래스를 사용해야 한다.

Period tenDays = Period.between(LocalDate.of(2010, 8, 5), LocalDate.of(2020, 9, 9));
System.out.println(tenDays);    //  P10Y1M4D
Duration threeMinutes = Duration.ofMinutes(3);
Duration threeMinutes1 = Duration.of(3, ChronoUnit.MINUTES);
System.out.println(threeMinutes + " " +threeMinutes1);   //  PT3M PT3M

Period tenDays = Period.ofDays(10);
Period threeWeeks = Period.ofWeeks(3);
Period twoYearsSixMonthsOneDay = Period.of(2, 6, 1);
System.out.println(tenDays + " " + threeWeeks + " " + twoYearsSixMonthsOneDay); //  P10D P21D P2Y6M1D
메서드 정적 설명
between O 두 시간 사이의 간격을 생성함
from O 시간 단위로 간격을 생성함
of O 주어진 구성 요소에서 간격 인스턴스를 생성함
parse O 문자열을 파싱해서 간격 인스턴스를 생성함
addTo X 현재값의 복사본을 생성한 다음에 지정된 Temporal 객체에 추가함
get X 현재 간격 정보값을 읽음
isNegative X 간격이 음수 인지 확인함
isZero X 간격이 0인지 확인함
minus X 현재값에서 주어진 시간을 뺀 복사본을 생성함
multipliedBy X 현재값에 주어진 값을 곱한 복사본을 생성함
negated X 주어진 값의 부호를 반전한 복사본을 생성함
plus X 현재값에 주어진 시간을 더한 복사본을 생성함
subtractFrom X 지정된 Temporal 객체에서 간격을 뺌

지긤까지 살펴본 모든 클래스는 불변이다. 불변 클래스는 함수형 프로그래밍 그리고 스레드 안전성과 도메인 모델의 일관성을 유지하는데 좋은 특징이다.
하지만, 새로운 날짜와 시간 API에서는 변경된 객체 버전을 만들 수 있는 메서드를 제공한다.

날짜 조정, 파싱, 포매팅

LocalDate date1 = LocalDate.of(2020, 9, 9); //  2020-09-09
LocalDate date2 = date1.withYear(2011);     //  2011-09-09
LocalDate date3 = date2.withDayOfMonth(25); //  2011-09-25
LocalDate date4 = date3.with(ChronoField.MONTH_OF_YEAR, 8); //  2011-08-25

위에서 사용된 메서드들은 모두 기존 객체를 바꾸진 않는다.

LocalDate date1 = LocalDate.of(2020-09-09); //  2020-09-09
LocalDate date2 = date1.plusWeek(1);        //  2020-09-16
LocalDate date3 = date2.minusYear(3);       //  2017-09-16
LocalDate date4 = date3.plus(3, ChronoUnit.MONTHS); //  2017-12-16

절대적인 방식에서 본 with 메서드와 상대적인 방식에서 본 plus, minus 메서드 모두 Temporal 인터페이스에 정의되어 있다.
Temporal 인터페이스 모두 LocalDate, LocalTime, LocalDateTime, Instant 처럼 특정 시간을 정의한다.

메서드 정적 설명
from O 주어진 Temporal 객체를 이용해서 클래스의 인스턴스를 생성함
now O 시스템 시계로 Temporal 객체를 생성함
of O 주어진 구성 요소에서 Temporal 객체의 인스턴스를 생성함
parse O 문자열을 파싱해서 Temporal 객체를 생성함
atOffSet X 시간대 오프셋과 Temporal 객체를 합침
atZone X 시간대와 Temporal 객체를 합침
format X 지정된 포매터를 이용해서 Temporal 객체를 문자열로 변환함(Instant는 지원 X)
get X Temporal 객체의 상태를 읽음
minus X 특정 시간을 뺀 Temporal 객체의 복사본을 생성함
plus X 특정 시간을 더한 Temporal 객체의 복사본을 생성함
with X 일부 상태를 바꾼 Temporal 객체의 복사본을 생성함

TemporalAdjusters 사용하기

위에서 본 것 보다 좀 더 복잡한 날짜 조정이 필요한 경우에는 TemporalAdjuster를 전달하는 방법으로 문제를 해결할 수 있다.

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

LocalDate date1 = LocalDate.of(2020, 9, 9);
LocalDate date2 = date1.with(nextOrSame(DayOfWeek.SUNDAY)); //  2020-09-13(다음주 일요일)
LocalDate date3 = date2.with(lastDayOfMonth());             //  2020-09-30(이 달의 마지막 날)
메서드 설명
dayOfWeekInMonth '3월의 둘째 화요일'처럼 서수 요일에 해당하는 날짜를 반환하는 TemporalAdjuster를 반환함
firstDayOfMonth 현재 달의 첫 번째 날짜를 반환하는 TemporalAdjusters를 반환함
firstDayOfNextMonth 다음 달의 첫 번째 날짜를 반환하는 TemporalAdjusters를 반환함
firstDayOfNextYear 내년의 첫 번째 날짜를 반환하는 TemporalAdjusters를 반환함
firstDayOfYear 올해의 첫 번째 날짜를 반환하는 TemporalAdjusters를 반환함
firstInMonth '3월의 첫 번째 화요일'처럼 현재 달의 첫 번째 요일에 해당하는 날짜를 반환하는 TemporalAdjusters를 반환함
lastDayOfMonth 현재 달의 마지막 날짜를 반환하는 TemporalAdjusters를 반환함
lastDayOfNextMonth 다음 달의 마지막 날짜를 반환하는 TemporalAdjusters를 반환함
lastDayOfYear 올해의 마지막 날짜를 반환하는 TemporalAdjusters를 반환함
lastInMonth '3월의 마지막 화요일'처럼 현재 달의 마지막 요일에 해당하는 날짜를 반환하는 TemporalAdjusters를 반환함
next 현재 날짜 이후로 지정한 요일이 처음으로 나타나는 날짜를 반환하는 TemporalAdjusters를 반환함(현재 날짜는 포함 X)
previous 현재 날짜 이후로 역으로 날짜를 거슬러 올라가며 지정한 요일이 처음으로 나타나는 날짜를 반환하는 TemporalAdjusters를 반환함(현재 날짜는 포함 X)
nextOrSame 현재 날짜 이후로 지정한 요일이 처음으로 나타나는 날짜를 반환하는 TemporalAdjusters를 반환함(현재 날짜도 포함 O)
previousOrSame 현재 날짜 이후로 역으로 날짜를 거슬러 올라가며 지정한 요일이 처음으로 나타나는 날짜를 반환하는 TemporalAdjusters를 반환함(현재 날짜도 포함 O)

TemporalAdjusters 인터페이스는 하나의 메서드만 정의하는 함수형 인터페이스로, 필요한 기능은 커스텀 TemporalAdjusters를 구현하여 만들 수도 있다.

@FunctionalInterface
public interface TemporalAdjuster {
    Temporal adjustInto(Temporal temporal);
}

TemporalAdjuster 인터페이스 구현은 Temporal 객체를 어떻게 다른 Temporal 객체로 변환할지 정의한다. 결국 TemporalAdjuster 인터페이스를 UnaryOperator과 같은 형식으로 간주할 수 있다.

커스텀 TemporalAdjuster 구현하기

영업일만 체크하는 NextWorkingDay 클래스를 구현해본다.

date = date.with(new NextWorkingDay());

위와 같이 사용하려면 아래와 같이 NextWorkingDay 클래스를 구현한다.

package com.company;

import java.time.DayOfWeek;
import java.time.temporal.ChronoField;
import java.time.temporal.ChronoUnit;
import java.time.temporal.Temporal;
import java.time.temporal.TemporalAdjuster;

public class NextWorkingDay implements TemporalAdjuster {

    @Override
    public Temporal adjustInto(Temporal temporal) {
        DayOfWeek dow = DayOfWeek.of(temporal.get(ChronoField.DAY_OF_WEEK));
        int dayToAdd = 1;
        if (dow == DayOfWeek.FRIDAY) {
            dayToAdd = 3;
        } else if (dow == DayOfWeek.SATURDAY) {
            dayToAdd = 2;
        }

        return temporal.plus(dayToAdd, ChronoUnit.DAYS);
    }
}
    public static void main(String[] args) {
        LocalDateTime date = LocalDateTime.now();
        date = date.with(temporal -> {
            DayOfWeek dow = DayOfWeek.of(temporal.get(ChronoField.DAY_OF_WEEK));
            int dayToAdd = 1;
            if (dow == DayOfWeek.FRIDAY) {
                dayToAdd = 3;
            } else if (dow == DayOfWeek.SATURDAY) {
                dayToAdd = 2;
            }

            return temporal.plus(dayToAdd, ChronoUnit.DAYS);
        });

        System.out.println(date);   //  2020-09-07T00:16:22.861463 (09-05에 적용)
    }
    TemporalAdjuster nextWorkingDay = TemporalAdjusters.ofDateAdjuster(
        temporal -> {
            DayOfWeek dow = DayOfWeek.of(temporal.get(ChronoField.DAY_OF_WEEK));
            int dayToAdd = 1;
            if (dow == DayOfWeek.FRIDAY) {
                dayToAdd = 3;
            } else if (dow == DayOfWeek.SATURDAY) {
                dayToAdd = 2;
            }
        return temporal.plus(dayToAdd, ChronoUnit.DAYS);
    });

    LocalDate date = LocalDate.now();
    System.out.println(date.with(nextWorkingDay));  //  2020-09-07

캡슐화하게 되면 팀 전체가 재사용할 수 있는 나ㅣㄹ짜를 조정하는 작은 라이브러리를 만들수 있다.

날짜와 시간 객체 출력과 파싱

포매팅과 파싱 전용 패키지인 java.time.format을 사용할 수 있다. 이 패키지에서 DateTimeFormatter를 사용하여 날짜나 시간을 특정 형식의 문자열로 만들 수 있다.

LocalDate date = LocalDate.of(2020, 9, 9);
String s1 = date.format(DateTimeFormatter.BASIC_ISO_DATE);  //  20200909
String s2 = date.format(DateTimeFormatter.ISO_LOCAL_DATE);  //  2020-09-09

LocalDate date1 = LocalDate.parse("20200909", DateTimeFormatter.BASIC_ISO_DATE);
LocalDate date2 = LocalDate.parse("2020-09-09", DateTimeFormatter.ISO_LOCAL_DATE);
System.out.println(date1 + " " +date2); //  2020-09-09 2020-09-09

기존 java.util.DateFormat 클래스와 달리 모든 DateTimeFormatter는 스레드에서 안전하게 사용할 수 있는 클래스다.

DateTimeFormatter formatter = DateTimeFormatter.ofPattern("dd/MM/yyyy");
LocalDate date1 = LocalDate.of(2020, 9, 9);
String formattedDate = date1.format(formatter);
LocalDate date2 = LocalDate.parse(formattedDate, formatter);

System.out.println(date1 + " " + date2);    //  2020-09-09 2020-09-09 
DateTimeFormatter italianFormatter = DateTimeFormatter.ofPattern("d. MMMM yyyy", Locale.ITALIAN);
LocalDaate date1 = LocalDate.of(2020, 3, 9);
String formattedDate = date.format(italianFormatter);   //  9. marzo 2020
LocalDate date2 = LocalDate.parse(formattedDate, italianFormatter); 
DateTimeFormatter italianFormatter = new DateTimeFormatterBuilder()
            .appendText(ChronoField.DAY_OF_MONTH)
            .appendLiteral(". ")
            .appendText(ChronoField.MONTH_OF_YEAR)
            .appendLiteral(" ")
            .appendText(ChronoField.YEAR)
            .parseCaseInsensitive()
            .toFormatter(Locale.ITALIAN);

System.out.println(italianFormatter);   //  Text(DayOfMonth)'. 'Text(MonthOfYear)' 'Text(Year)ParseCaseSensitive(false)

DateTimeFormatterBuilder 클래스로 복합적인 포매터를 정의해서 좀 더 세부적으로 포매터를 제어할 수 있다.
즉, DateTimeFormatterBuilder 클래스로 대소문자를 구분하는 파싱, 관대한 규칙을 적용하는 파싱(정해진 형식과 정확하게 일치하지 않는 입력을 해석할 수 있도록 체험적 방식의 파서 사용), 패딩, 포매터의 선택사항 등을 활용할 수 있다.

다양한 시간대와 캘린더 활용 방법

기존에 사용하던 java.util.TimeZone을 대체하는 java.time.ZoneId 클래스가 새롭게 등장했다. 이를 활용함으로써 서머타임(DST)와 같은 복잡한 사항이 자동으로 처리된다.

import java.time.Instant;
import java.time.LocalDate;
import java.time.LocalDateTime;
import java.time.Month;
import java.time.ZoneId;
import java.time.ZonedDateTime;
import java.util.TimeZone;

ZoneId romeZone = ZoneId.of("Europe/Rome");
ZoneId zoneId = TimeZone.getDefault().toZoneId();

LocalDate date = LocalDate.of(2020, Month.SEPTEMBER, 6);
ZonedDateTime zdt1 = date.atStartOfDay(romeZone);

LocalDateTime dateTime = LocalDateTime.of(2020, Month.SEPTEMBER, 6, 22, 42);
ZonedDateTime zdt2 = dateTime.atZone(romeZone);

Instant instant = Instant.now();
ZonedDateTime zdt3 = instant.atZone(romeZone);

System.out.println(zdt1 + " " + zdt2 + " " + zdt3); //  2020-09-06T00:00+02:00[Europe/Rome] 2020-09-06T22:42+02:00[Europe/Rome] 2020-09-06T15:46:26.366728+02:00[Europe/Rome] 

ZonedDateTime은 지정한 시간대에 상대적인 시점을 표현한다.

UTC/GMT 기준의 고정 오프셋

예를 들어 ‘뉴욕은 런던보다 5시간 느리다’라는 것을 표현해야 할 때, ZoneId의 서브클래스인 ZoneOffset 클래스로 런던의 그리니치 0도 자오선과 시간값의 차이를 표현할 수 있다.

ZoneOffset newYorkOffset = ZoneOffset.of("-05:00");

요약