12장. 새로운 날짜와 시간 API

Posted by yunki kim on March 30, 2022

  자바 8 이전에도 날짜, 시간 관련 API가 존재했다. 자바 1.0에서는 java.util.Date 클래스로, java 1.1에서는 java.util.Calendar 클래스로 이 기능을 제공했다. 하지만 이들은 다음과 같은 문제를 가지고 있다.

java.util.Date:

  1900년을 기준으로 오프셋, 0에서 시작하는 달 인덱스, JVM 기본 시간대인 CET(Central European Time - 중앙 유럽 시간) 사용, 가변 등.

  따라서 다음과 같이 괴상하게(?) 코드를 작성해야 한다.

1
Date date = new Date(117821); // 2017년 9월 21일
cs

java.util.Calendar:

  1900년 부터 시작하는 오프셋은 없지만 여전히 달의 인덱스가 0 부터 시작했다, 가변이다. DateFormate의 일부 기능은 Date에서만 동작했다.

DateFormat:

  thread-safe 하지 않다. 두 스레드가 동시에 하나의 포매터로 날짜를 파싱하면 예상치 못한 결과를 얻을 수 있다.

 

  이러한 문제 때문에 Joda-Time 같은 서드파티 날짜, 시간 라이브러리가 사용되었다. 결국 Joda-Time의 많은 기능들이 자바 8에서 java.time 패키지로 추가되었다.

LocalDate, LocalTime, Instant, Duration, Period 클래스

LocalDate와 LocalTime 사용

  LocalDate 인스턴스는 시간을 제외한 날짜를 표현하는 불변객체다. 정적 팩토리 메서드인 of로 LocalDate 인스턴스를 만들 수 있다. 

1
2
3
4
5
6
7
LocalDate date = LocalDate.of(20220330);
int year = date.getYear(); // 2022
Month month = date.getMonth(); // MARCH
int day = date.getDayOfMonth(); // 30
DayOfWeek dow = date.getDatyOfWeek(); // THURSDAY
int len = date.lengthOfMonth(); // 31
boolean leap = date.isLeapYear(); // 윤년 여부
cs

  LocalDate에 존재하는 now 팩토리 메서드를 이용하면 현재 날짜 정보를 얻을 수 있다.

  get 메서드를 활용해도 시간을 얻을 수 있다. get 메서드 인자로 TemporalField를 넘겨서 시간 관련 객체에서 어떤 필드에 접근할 수 있다. ChronoField는 TemporalField 인터페이스를 정의하고 있고 다음과 같이 get과 함께 사용할 수 있다.

1
2
3
int year = date.get(ChronoField.YEAR);
int month = date.get(ChronoField.MONTH_OF_YEAR);
int day = date.get(ChronoFiled.DAY_OF_MONTH):
cs

  LocalTime은 다음과 같은 메서드를 사용할 수 있다.

1
2
3
4
LocalTime time = LocalTime.of(134520);
int year = time.getHour(); // 13
int month = time.getMinute(); // 45
int day = time.getSecond(): // 20
cs

  날짜와 시간 문자열로 LocalDate와 LocalTime의 인스턴스를 만들 수 있다.

1
2
LocalDate date = LocalDate.parse("20217-09-21");
LocalTime time = LocalTime.parse("13:45:20");
cs

  parse 메서드에 DateTimeFormatter를 전달할 수도 있다. DateTimeFormatter는 java.util.DateFormat 클래스를 대체하는 클래스이며 날짜, 시간 객체 형식을 지정한다. parse 메서드는 문자열을 파싱할 수 없을 때 DateTimeParseException(RuntimeException을 상속받는 예외)을 일으킨다.

날짜와 시간 조합

  LocalDateTime은 LocalDate와 LocalTime을 가지고 있다. 실제로 LocalDate와 LocalTIme 필드를 가지고 있다. 이는 다음과 같이 사용할 수 있다.

1
2
3
4
5
6
LocalDateTIme dt1 = LocalDateTIme.of(2017, Month.SEPTEMBER, 21134520);
LocalDateTIme dt2 = LocalDateTIme.of(date, time); // LocalDate와 LocalTime 인스턴스를 넘긴다
LocalDateTIme dt3 = date.atTime(134520); // LocalDate 인스턴스에 시간 추가
LocalDateTIme dt4 = date.atTime(time); // LocalDate에 시간 추가
LocalDateTIme dt5 = time.atDate(date); // LocalTiem에 date 추가
 
cs

  LocalDateTime은 toLocalDate 메서드와 toLocalTime 메서드를 제공하고 있다. 이들을 통해 LocalDateTime 내부에 존재하는 LocalDate와 LocalTime 인스턴스를 가져올 수 있다,

1
2
LocalDate date = dt1.toLocalDate();
LocalTime time = dt1.toLocalTime();
cs

Instant 클래스: 기계의 날짜와 시간

  기계는 사람과 달리 연속된 시간에서 특정 지점을 하나의 큰 수로 표현하는 것이 가장 자연스럽다. java.time.Instant 클래스에서는 이런 기계적 관점의 시간을 표현한다. Instant 클래스는 유닉스 에포크 시간(Unix epoch time - 1970년 1월 1월 0시 0분 0초 UTC)을 기준으로 특정 지점까지의 시간을 초로 표현한다. Instant 클래스는 사람을 위한 클래스가 아닌 기계를 위한 클래스이다.

  팩토리 메서드인 ofEpochSecond에 초를 넘겨서 Instant 클래스를 만들 수 있으며 이는 나노초의 정밀도를 제공한다. 이 메서드는 오버로드 되어 있으며 두 번째 인자로 나노초 단위의 시간을 넘겨서 시간을 보정할 수 있다.

  Instant는 사람이 확인할 수 있게 시간을 표시하는 정적 팩토리 메서드 now를 제공한다.

  Instant는 Duration과 Period 클래스를 함께 활용할 수 있다.

Duration과 Period 정의

  위에서 말한 모든 클래스는 Temporal 인터페이스를 구현한다. Temporal은 특정 시간을 모델링하는 객체의 값을 읽고 조작할 방법을 정의한다. 이제는 두 시간 사이의 지속시간을 만들어 보자. Duration 클래스의 정적 팩토리 메서드 between을 이용해 두 시간 객체 사이의 지속 시간을 만들 수 있다. between 메서드는 두 개의 LocalTime 또는 두 개의 LocalDateTime을 받는다. 또는 두 개의 Instant를 받는다.

1
Duration d1 = Duration.between(time1, time2);
cs

  하지만 LocalDateTime과 Instant를 혼용할 수 없다. 이 둘을 사용해야 하는 대상이 다르기 때문이다. Duration은 초와 나노초로 시간 단위를 표현하기 때문에 LocalDate를 전달할 수 없다. 만약 년, 월, 일로 시간을 표현해야 한다면 Period 클래스를 사용하자. Period 클래스의 팩토리 메서드 between을 사용하면 두 LocalDate의 차이를 확인할 수 있다.

1
2
Period tenDays = Period.between(LocalDate.of(2017,911),
                                LocalDate.of(2017921));
cs

  Duration과 Period 메서드는 두 시간 객체를 사용하지 않아도 자신들의 인스턴스를 만들 수 있는 메서드를 제공한다.

1
2
3
4
5
6
Duration threeMinutes = Duration.of(3);
Duration threeMinutes = Duration.of(3, ChronoUnit.MINUTES);
 
Period tenDays = Period.ofDays(10);
Period threeWeeks = Period.ofWeek(3);
Perido twoYearsSixMonthsOneDay = Period.of(261);
cs

  Period와 Duration 클래스가 공통으로 제공하는 메서드는 다음과 같다.

메서드 정적 여부 설명
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 객체에서 간격을 뺌

  지금까지 설명한 클래스는 모두 불변이다. 불변 클래스는 함수형 프로그래밍과 스레드 안전성과 도메인 모델의 일관성 유지에 도움을 준다. 

날짜 조정, 파싱, 포매팅

  withAttribute 메서드로 기존의 LocalDate를 바꾼 버전을 만들 수 있다. 이 메서드들은 불변이다.

1
2
3
4
5
LocalDate date1 = LocalDate.of(2017921); // 2017-09-21
LocalDate date2 = date1.withYear(2011); // 2011-09-21
LocalDate date3 = date2.withDayOfMont(25); // 2011-09-25
LocalDate date4 = date3.with(ChronoField.MONTH_OF_YEAR, 2); // 2011-02-25
 
cs

  with는 첫 번째 인수로 TemporalField를 갖고 있어서 좀 더 범용적으로 메서드를 활용할 수 있다. 또 한 Temporal 인터페이스는 LocalDate, LocalTime, LocalDateTIme, Instant 처럼 특정 시간을 정의하고 있다. 따라서 with 메서드를 통해 Temporal 객체의 필드값을 읽거나 고칠 수 있다. 어떤 Temporal 객체가 지정된 필드를 지원하지 않으면  UnsupportedTemporalTypeException이 발생한다.

  with의 내부 구현은 다음과 같다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
public LocalDate with(TemporalField field, long newValue) {
    if (field instanceof ChronoField) {
        ChronoField f = (ChronoField) field;
        f.checkValidValue(newValue);
        switch (f) {
            case DAY_OF_WEEK: return plusDays(newValue - getDayOfWeek().getValue());
            case ALIGNED_DAY_OF_WEEK_IN_MONTH: return plusDays(newValue - getLong(ALIGNED_DAY_OF_WEEK_IN_MONTH));
            case ALIGNED_DAY_OF_WEEK_IN_YEAR: return plusDays(newValue - getLong(ALIGNED_DAY_OF_WEEK_IN_YEAR));
            case DAY_OF_MONTH: return withDayOfMonth((int) newValue);
            case DAY_OF_YEAR: return withDayOfYear((int) newValue);
            case EPOCH_DAY: return LocalDate.ofEpochDay(newValue);
            case ALIGNED_WEEK_OF_MONTH: return plusWeeks(newValue - getLong(ALIGNED_WEEK_OF_MONTH));
            case ALIGNED_WEEK_OF_YEAR: return plusWeeks(newValue - getLong(ALIGNED_WEEK_OF_YEAR));
            case MONTH_OF_YEAR: return withMonth((int) newValue);
            case PROLEPTIC_MONTH: return plusMonths(newValue - getProlepticMonth());
            case YEAR_OF_ERA: return withYear((int) (year >= 1 ? newValue : 1 - newValue));
            case YEAR: return withYear((int) newValue);
            case ERA: return (getLong(ERA) == newValue ? this : withYear(1 - year));
        }
        throw new UnsupportedTemporalTypeException("Unsupported field: " + field);
    }
    return field.adjustInto(this, newValue);
}
cs

  Temporal 인터페이스에 정의된 plus, minus 메서드를 사용하면 Temporal을 특정 시간만큼 이동시킬 수 있다. 메서드 인수로는 숫자와 TemporalUnit을 활용할 수 있으며 ChronoUnit 열거형은 Temporal 인터페이스를 활용할 수 있는 구현을 제공한다.

  LocalDate, LocalTIme, LocalDateTime, Instant 등 날짜와 시간을 표현하는 모든 클래스는 서로 비슷한 메서드를 제공한다.

TemporalAdjusters 사용하기

  만약 단순한 날짜 조작이 아닌 상대적인 날짜(다음 주, 돌아오는 요일 등) 조작이 필요하다면 오버로드된 버전의 with 메서드에서 사용되는 TemporalAdjusters를 전달하는 방식으로 문제를 해결할 수 있다.

1
2
3
4
LocalDate date1 = LocalDate.of(2014318); // 2014-03-18
LocalDate date2 = date1.with(nextOrSame(DayOfWeek.SUNDAY)); // 2014-03-23
LocalDate date3 = date2.with(lastDayOfMonth()); // 2014-03-31
 
cs

  TemporalAdjusters의 팩토리 메서드들은 다음과 같다

 

  만약 원하는 기능이 없다면 TemporalAdjuster 인터페이스를 사용해 원하는 기능을 재정의 할 수 있다. 이 인터페이스의 구현은 Temporal 객체를 어떻게 다른 Temporal 객체로 변환할 지를 정의한다. 따라서 UnaryOperator<Temporal>과 같은 형식으로 간주할 수 있다.

1
2
3
4
@FunctionalInterface
public interface TemporalAdjuster {
    Temporal adjustInto(Temporal temporal);
}
cs

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

  날짜와 시간 관련 작업에서 포매팅과 파싱이 자주 사용된다. 이에 따라 java.time.format 이라는 포매틴과 파싱 전용 패키지가 추가되었다. 여기에는 DateTimeFomatter가 정의되 있다. 이를 사용하면 날짜, 시간을 특정 형식의 문자열로 만들 수 있다.

1
2
3
LocalDate date = LocalDate.of(2014318);
String s1 = date.format(DateTimeFormatter.BASIC_ISO_DATE); // 20140318
String s2 = date.format(DateTimeFormatter.ISO_LOCAL_DATE); // 2014-03-18 
cs

  반대로 문자열을 날짜 객체로 다시 만들 수 있다.

1
2
Localdate date1 = LocalDate.parse("20140318", DateTimeFormatter.BASIC_ISO_DATE);
 
cs

  DataTimeFormatter는 기존의 java.util.DateFormat 클래스와 달리 thread-safe 하다.

  DateTimeFormatter가 제공하는 정적 팩토리 메서드를 사용하면, 특정 패턴으로 포매터를 만들 수 있다.

1
2
3
4
5
DateTimeFormatter formatter = DateTimeFormatter.ofPattern("dd/MM/yyyy");
LocalDate date1 = LocalDate.of(2014318);
String formattedDate = date1.format(formatter);
LocalDate date2 = LocalDate.parse(formattedDate, formatter);
 
cs

  ofPattern 메서드는 Local로 포매터를 만들 수 있게 오버로드된 메서드를 제공한다.

1
2
3
4
5
DateTimeFormatter italianFormatter =
    DateTimeFormatter.ofPattern("d. MMMM yyyy", Local.ITALIAN);
LocalDate date1 = LocalDate.of(2014318);
String formattedDate = date.format(italianFormatter); // 18. marzo 2014
LocalDate date2 = LocalDate.parse(formattedDate, italianFormatter);
cs

  DateTimeFormatterBuilder 클래스를 사용하면 세부적으로 포메터를 정의할 수 있다. 위헤서 사용한 italianFormatter를 DateTimeFOrmatterBuilder를 사용하면 다음과 같이 만들 수 있다.

1
2
3
4
5
6
7
8
DateTimeFormatter italianFormatter = new DateTimeFormatterBuilder()
    .appendText(ChronoField.DAT_OF_MONTH)
    .appendLiteral(". ")
    .appendText(ChronoField.MONTH_OF_YEAR)
    .appendLiteral(" ")
    .appendText(ChronoField.YEAR)
    .parseCaseInsensitive()
    .toFormatter(Local.ITALIAN);
cs

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

  기존의 java.util.TimeZone을 대체하는 java.time.ZoneId는 시간대를 간단히 처리하게 해준다. 이는 서머타임(Daylight Saving TIme - DST) 같은 복잡한 사항이 자동으로 처리된다. ZoneId는 불변이다.

시간대 사용하기

  ZoneRules 클래스에는 40개 정도의 시간대가 있다. ZoneID의 getRules()를 이용해 해당 시간대의 규정을 획득할 수 있다.

1
2
// 지역 ID는 "[지역]/[도시]" 형식
ZoneId romeZone = ZoneId.of("Europe/Rome");
cs

  toZoneID를 사용하면 기존의 TimeZone을 객체로 변환할 수 있다.

1
ZoneId zoneId = TimeZone.getDefault().toZoneId();
cs

  ZoneId 객체를 얻은 후, LocalDate, LocalDateTime, Instant를 이용하면 ZonedDateTime 인스턴스로 변환할 수 있다.

1
2
3
4
5
6
7
8
9
10
ZoneId romeZone = ZoneId.of("Europe/Rome");
 
LocalDate date = LocalDate.of(2014, Month.MARCH, 18);
ZonedDateTime zdt1 = date.atStartOfDay(romeZone);
 
LocalDateTime dataTime = LocalDateTime.of(2014, Month.MARCH, 181345);
ZonedDateTime zdt2 = dataTime.atZone(romeZone);
 
Instant Instant = Instant.now();
ZonedDateTime zdt3 = Instant.atZone(romeZone);
cs

  LocalDate, LocalTime, LocalDateTime, ZonedId의 차이는 다음과 같다.

  ZoneId를 이용해 LocalDateTime을 Instant로 바꾸기 위한 방법은 다음과 같다.

1
2
3
Instant instant = Instant.now();
LocalDateTIme timeFromInstant = LocalDateTIme.ofInstant(instant, romeZone);
 
cs

UTC/Greenwich 기준의 고정 오프셋

  UTC(Universla Tiem Coordinated - 협정 세계시)/GMT(Greenwich Mean Time - 그리니치 표준시)를 기준으로 시간대를 표현하고 싶다면 ZoneId의 서브 클래스인 ZoneOffset 클래스로 런던의 크리니치 0도 자오선과 시간값의 차이를 표현할 수 있다.

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

  하지만 위에서 사용한 ZoneOffset으로는 서머타임을 제대로 처리할 수 없어서 권장하지 않는다.

대안 캘린더 시스템 사용하기

  ISO-8601 캘린더 시스템은 실질적으로 전 세계에서 통용된다. 그럼에도, 자바 8에서는 추가로 4개의 캘린더 시스템을 제공한다. ThaiBuddhisDate, MinguoDate, JapaneseDate, HijrahDate. 이 4개의 클래스와 LocalDate 클래스는 ChronoLocalDate 인터페이스를 구현한다. 이 인터페이스는 임의의 연대기에서 특정 날짜를 표현할 수 있는 기능을 제공하는 인터페이스이다.

  날짜 시간 API에서는 입출력 지역화를 하는게 아니라면 ChronoLocalDate를 사용하지 않을 것을 권장한다.

 

이슬람력

  자바 8에서 Hijrah 캘린더 시스템이 추가되었다. 이슬람력은 변형이 많은데 withVariant 메서드를 통해 원하는 변형 방법을 선택할 수 있다. 자바 8에는 HijrahDate의 표준 변형으로 UmmAl-Qura를 제공한다.

 

출처 - 모던 자바 인 액션