Chap 12 - 새로운 날짜와 시간 API

728x90

자바 API는 복잡한 애플리케이션을 만드는 데 필요한 여러 가지 유용한 컴포넌트를 제공한다. 하지만 자바 API도 완벽하지는 않다. 대부분의 자바 개발자가 지금까지의 날짜와 시간 관련 기능에 만족하지 못했다.

지금까지의 날짜와 시간 문제를 개선하는 새로운 날짜와 시간 API를 제공한다.

자바 1.0에서는 java.util.Date 클래스 하나로 날짜와 시간 관련 기능을 제공했다. 날짜를 의미하는 Date라는 클래스의 이름과는 달리 Date클래스는 특정 시점을 날짜가 아닌 밀리초(ms) 단위로 표현한다. 게다가 1900년을 기준으로 하는 오프셋과 달이 0에서 시작하는 모호한 설계로 유용성이 떨어졌다. 또한 Date 클래스의 toString으로는 반환되는 문자열을 추가로 활용하기가 어렵다.

자바 1.0의 Date 클래스에 문제가 있다는 점에서는 의문의 여지가 없었지만 과거 버전과 호환성을 깨뜨리지 않으면서 이를 해결할 수 있는 방법이 없었다. 결과적으로 자바 1.1에서는 Date 클래스의 여러 메서드를 deprecated 시키고 java.util.Calendar라는 클래스를 대안으로 제공했다. 안타깝게도 Calendar 클래스 역시 쉽게 에러를 일으키는 설계 문제를 갖고 있었다. 여전히 달의 인덱스가 0부터 시작했다는 것은 변함이 없었고, 심지어 Calendar의 등장으로 개발자들은 Date와 Calendar 사이에서 혼란이 가중된 것이다. 또한 DateFormat 같은 일부 기능은 Date 클래스에만 작동했다.

DateFormat은 Thread safe하지 않다는 문제점도 있었다. 즉, 두 스레드가 동시에 하나의 포매터로 날짜를 파싱할 때 예기치 못한 결과가 일어날 수 있다.

마지막으로 Date와 Calendar는 모두 가변 클래스다. 가변 클래스라는 설계는 유지보수가 어렵게하는 요소이다.

부실한 날짜와 시간 라이브러리 때문에 많은 개발자는 Joda-Time 같인 서드파티 날짜, 시간 라이브러리를 사용했다. 오라클은 좀 더 훌륭한 날짜와 시간 API를 제공하기로 정했다. 결국 자바8에서는 Joda-Time의 많은 기능을 java.time 패키지로 추가했다.

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

먼저 간단한 날짜와 시간 간격을 정의해보자. java.time 패키지는 LocalDate, LocalTime, LocalDateTime, Instant, Duration, Period 등 새로운 클래스를 제공한다.

LocalDate와 LocalTime 사용

새로운 날짜와 시간 API를 사용할 때 처음 접하게 되는 것이 LocalDate다. LocalDate 인스턴스는 시간을 제외한 날짜를 표현하는 불변 객체다. 특히 LocalDate 객체는 어떤 시간대 정보도 포함하지 않는다.

정적 팩토리 메서드 of로 LocalDate 인스턴스를 만들 수 있다. LocalDate 인스턴스를 만들 수 있다.

아래는 LocalDate 인스턴스의 사용 예시다.

LocalDate date = LocalDate.of(2017, 9, 21); // 2017-09-21
int year = date.getYear(); // 2017
Month month = date.getMonth(); // SEPTEMBER
int day = date.getDayOfMonth(); // 21
DayOfWeek dow = date.getDayOfWeek(); // THURSDAY 
int len = date.lengthOfMonth(); // 31 (3월의 일 수)
boolean leap = date.isLeapYear(); // false (윤년이 아님) 

팩토리 메서드 now는 시스템 시계의 정보를 이용해서 현재 날짜 정보를 얻는다.

LocalDate today = LocalDate.now();

get 메서드에 TemporalField를 전달해서 정보를 얻는 방법도 있다. TemporalField는 시간 관련 객체에서 어떤 필드의 값에 접근할지 정의하는 인터페이스다. Enum 타입인 ChronoField는 TemporalField 인터페이스를 정의하므로 다음 코드에서 보여주는 것처럼 ChornoField의 열거자 요소를 이용해서 원하는 정보를 쉽게 얻을 수 있다.

int year = date.get(ChronoField.YEAR);
int month = date.get(ChronoField.MONTH_OF_YEAR);
int day = date.get(ChronField.DAY_OF_MONTH);

다음처럼 내장 메서드 getYear(), getMonthValue(), getDayOfMonth() 등을 이용해 가독성을 높일 수 있다.

마찬가지로 13:45:20 같은 시간 정보는 LocalTime 클래스로 표현할 수 있다. 오버로드 버전의 두 가지 정적 메서드 of 로 LocalTime 인스턴스를 만들 수 있다. 즉, 시간과 분을 인수로 받는 of 메서드와 시간과 분, 초를 인수로 받는 of 메서드가 있다. LocalDate 클래스처럼 LocalTime 클래스는 다음과 같은 게터 메서드를 제공한다.

LocalTime = LocalTime.of(13, 45, 20); // 13:45:20
int hour = time.getHour(); // 13
int monute = time.getMinute();
int second = time.getSecond();

parse 메서드에 DateTimeFormatter를 전달할 수도 있다. DateTimeFormatter의 인스턴스는 날짜, 시간 객체의 형식을 지정한다. DateTimeFormatter는 이전에 설명했던 DateFormat 클래스를 대체하는 클래스다. 문자열을 LocalDate나 LocalTime으로 파싱할 수 없을 때 parse 메서드는 DateTimeParseException (RuntimeException을 상속받은 예외)을 일으킨다.

날짜와 시간 조합

LocalDateTime은 LocalDate + LocalTime 이다. LocalDateTime은 날짜와 시간을 모두 표현할 수 있으며 다음 코드에서 보여주는 것처럼 직접 LocalDateTime을 만드는 방법도 있고 날짜와 시간을 조합하는 방법도 있다.

// 2017-09-21T13:45:20
LocalDateTime dt1 = LocalDateTime.of(2017, Month.SEPTEMBER, 21, 13, 45, 20);
LocalDateTime dt2 = LocalDateTime.of(date, time);
LocalDateTime dt3 = date.atTime(13, 45, 20);
LocalDateTime dt4 = date.atTime(time);
LocalDateTime dt5 = time.atDate(date);

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

LocalDate date1 = dt1.toLocalDate();
LocalTime time1 = dt1.toLocalTime();

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

기계의 관점에서는 연속된 시간에서 특정 지점을 하나의 큰 수로 표현하는 것이 가장 자연스러운 시간 표현 방법이다. Instant 클래스는 유닉스 에포크 시간(1970sus 1월 1일 0시 0분 0초 UTC)을 기준으로 특정 지점까지의 시간을 초로 표현한다.

팩토리 메서드 ofEpochSecond에 초를 넘겨줘서 Instant 클래스 인스턴스를 만들 수 있다. Instant 클래스는 나노초(10억분의 1초)의 정밀도를 제공한다. 또한 오버로드된 ofEpochSecond 메서드 버전에서는 두 번째 인수를 이용해서 나노초 단위로 시간을 보정할 수 있다. 두 번째 인수에는 0 에서 999,999,999 사이의 값을 지정할 수 있다. 따라서 다음 네 가지 ofEpochSecond 호출 코드는 같은 Instant를 반환한다.

Instant.ofEpochSecond(3);
Instant.ofEpochSecond(3, 0);
Instant.ofEpochSecond(2, 1_000_000_000);
Instant.ofEpochSecond(4, -1_000_000_000);

Duration과 Period 정의

이전까지 설명한 모든 클래스는 Temporal 인터페이스를 구현하는데, Temporal 인터페이스는 특정 시간을 모델링하는 객체의 값을 어떻게 읽고 조작할지 정의한다.

이번에는 두 시간 객체 사이의지속시간 duration을 만들어볼 차례다. Duration 클래스의 정적 팩토리 메서드 between으로 두 시간 객체 사이의 지속시간을 만들 수 있다. 다음 코드에서 보여주는 것처럼 두 개의 LocalTime, 두 개의 LocalDateTime, 또는 두 개의 Instant로 Duration을 만들 수 있다.

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

LocalDateTime은 사람이 사용하도록, Instant는 기계가 사용하도록 만들어진 클래스로 두 인스턴스는 서로 혼합할 수 없다. 또한 Duration 클래스를 초와 나노초로 시간 단위를 표현하므로 between 메서드에 LocalDate를 전달할 수 없다. 년, 월, 일로 시간을 표현할 때는 Period 클래스를 사용한다. 즉, Period 클래스의 팩토리 메서드 between을 이용하면 LocalDate의 차이를 확인할 수 있다.

Duration threeMinutes = Duration.ofMinutes(3);
Duration threeMinutes = Duration.of(3, ChronoUnit.MINUTES);

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

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

날짜 조정, 파싱, 포매터

withAttribute 메서드로 기존의 LocalDate를 바꾼 버전을 직접 간단하게 만들 수 있다.

아래는 바뀐 속성을 포함하는 새로운 객체를 반환하는 메서드를 보여준다.

LocalDate date1 = LocalDate.of(2017, 9, 21);
LocalDate date2 = date1.withYear(2011);
LocalDate date3 = date2.withDayOfMonth(25);
LocalDate date4 = date3.with(ChronoField.MONTH_OF_YEAR, 2);

위의 예제의 마지막 행처럼 첫 번째 인수로 TemporalField를 갖는 메서드를 사용하면서 좀 더 범용적으로 메서드를 활용할 수 있다. 마지막 with 메서드는 get 메서드와 쌍을 이룬다. 이들 두 메서드는 날짜와 시간 API의 모든 클래스가 구현하는 Temporal 인터페이스에 정의되어 있다. Temporal 인터페이스는 LocalDate, LocalTime, LocalDateTime, Instant처럼 특정 시간을 정의한다. get과 with 메서드로 Temporal 객체의 필드값을 읽거나 고칠 수 있다(정확히는 기존의 Temporal 객체를 바꾸는 것이 아니라 필드를 갱신한 복사본을 만든다. = 함수형 갱신). 어떤 Temporal 객체가 지정된 필드를 지원하지 않으면 UnsupportedTemporalTypeException이 발생한다.

선언형으로 LocalDate를 사용하는 방법도 있다.

LocalDate date1 = LocalDate.of(2017, 9, 21); // 2017-09-21
LocalDate date2 = date1.plusWeek(1); 2017-09-28
LocalDate date3 = date2.minusYear(6); // 2011-09-28
LocalDate date4 = date3.plus(6, ChronoUnit.MONTHS); // 2012-03-28

특정 시점을 표현하는 날짜, 시간 클래스의 공통 메서드

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

TemporalAdjusters 사용하기

복잡한 날짜 조정기능이 필요한 경우 오버로드된 버전의 with 메서드에서 좀 더 다양한 동작을 수행할 수 있도록 해주는 TemporalAdjuster를 전달하는 방법으로 문제를 해결할 수 있다. 날짜와 시간 API는 다양한 상황에서 사용할 수 있도록 다양한 TemporalAdjuster를 제공한다.

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

LocalDate date1 = LocalDate.of(2014, 3, 18);
LocalDate date2 = date1.with(nextOrSame(DayOfWeek.SUNDAY));
LocalDate date3 = date2.with(lastDayOfMonth());

|메서드 | 설명|
|---|---|---|
|dayOfWeekInMonth|서수 요일에 해당하는 날짜를 반환하는 TemporalAdjuster를 반환함|
|firstDayOfMonth|현재 달의 첫 번째 날짜를 반환|
|firstDayOfNextMonth|다음 달의 첫 번째 날짜를 반환|
|firstDayOfNextYear|내년의 첫 번째 날짜를 반환|
|firstInMonth|올해의 첫 번째 날짜를 반환|
|lastDayOfMonth|현재 달의 마지막 날짜를 반환|
|lastDayOfNextMonth|다음 달의 마지막 날짜를 반환|
|lastDayOfNextYear|내년의 마지막 날짜를 반환|
|lastDayOfYear|올해의 마지막 날짜를 반환|
|lastInMonth|현재 달의 마지막 요일에 해당하는 날짜를 반환|
|next previous|현재 달에서 현재 날짜 이후로 지정한 요일이 청므으로 나타나는 날짜를 반환하는 TemporalAdjuster를 반환함|
|nextOrSame|현재 날짜 이후로 지정한 요일이 처음으로 나타나는 날짜를 반환하는 TemporalAdjuster를 반환함|
|previousOrSame|현재 날짜 이후로 지정한 요일이 이전으로 나타나는 날짜를 반환하는 TemporalAdjuster를 반환함|

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

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

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

날짜와 시간 관련 작업에서 포매팅과 파싱은 서로 떨어질 수 없는 관계다. 포매팅과 파싱 전용 패키지인 java.time.format이 새로 추가되었을 정도이다. 정적 팩토리 메서드와 상수를 이용해서 손쉽게 포매터를 만들 수 있다. DateTimeFormatter 클래스는 BASIC_ISO_DATE와 ISO_LOCAL_DATE등의 상수를 미리 정의하고 있다. DateTimeFormatter를 이용해서 날짜나 시간을 특정 형식의 문자열로 만들 수 있다.

LocalDate date = LocalDate.of(2014, 3, 18);
String s1 = date.format(DateTimeFormatter.BASIC_ISO_DATE);
String s2 = date.format(DateTimeFormatter.ISO_LOCAL_DATE);

기존의 java.util.DateFormat 클래스와 달리 모든 DateTimeFormatter는 스레드에서 안전하게 사용할 수 있는 클래스다. 또한 다음 예제에서 보여주는 것처럼 DateTimeFormatter 클래스는 특정 패턴으로 포매터를 만들 수 있는 정적 팩토리 메서드도 제공한다.

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

LocalDate의 format 메서드는 요청 형식의 패턴에 해당하는 문자열을 생성한다. 그리고 정적 메서드 parse는 같은 포매터를 적용해서 생성된 문자열을 파싱함으로써 다시 날짜를 생성한다. 다음 코드에서 보여주는 것처럼 ofPattern 메서드도 Locale로 포매터를 만들 수 있도록 오버로드된 메서드를 제공한다.

DateTimeFormatter italianFormatter = DateTimeFormatter.ofPattern("d. MMMM yyyy", Locale.ITALIAN);
LocalDate date1 = LocalDate.of(2014, 3, 18);
String formattedDate = date.format(italianFormatter); // 18. marzo 2014
LocalDate date2 = LocalDate.parse(formattedDate, italianFormatter);

DateTimeFormatterBuilder 클래스로 복합적인 포매터를 정의해서 좀 더 세부적으로 포매터를 제어할 수 있다. 즉, DateTimeFormatterBuilder 클래스로 대소문자를 구분하는 파싱, 관대한 규칙을 적용하는 파싱, 패딩, 포매터의 선택사항 등을 활용할 수 있다.

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

지금까지 살펴본 모든 클래스에는 시간대와 관련된 정보가 없었다. 새로운 날짜와 시간 API의 큰 편리함 중 하나는 시간대를 간단하게 처리할 수 있다는 것이다. 기존의 java.util.TimeZone을 대체할 수 있는 java.time.ZoneId 클래스가 새롭게 등장했다. 새로운 클래스를 이용하면 서머타임 같은 복잡한 사항이 자동으로 처리된다. 날짜와 시간 API에서 제공하는 다른 클래스와 마찬가지로 ZoneId는 불변 클래스다.

시간대 사용하기

표준 시간이 같은 지역을 묶어서 시간대(time zone) 규칙 집합을 정의한다. ZoneRules 클래스에는 약 40개 정도의 시간대가 들어있다. ZoneId의 getRules()를 이용해서 해당 시간대의 규정을 획득할 수 있다. 다음처럼 지역 ID로 특정 ZoneId를 구분한다.

ZoneId romeZone = ZoneId.of("Europe/Rome");

ZoneId 객체를 얻은 다음에는 LocalDate, LocalDateTime, Instant를 이용해서 ZonedDateTime 인스턴스로 변환할 수 있다. ZonedDateTime은 지정한 시간대에 상대적인 시점을 표현한다.

ZoneId를 이용해 LocalDateTime을 Instant로 바꾸는 방법도 있다.

Instant instant = Instant.now();
LocalDateTime timeFromInstant = LocalDateTime.ofInstant(instant, romeZone);

UTC/Greenwich 기준의 고정 오프셋

때로는 UTC(Universal Time Coordinated, 협정 시계시)/ GMT(Greenwich Mean Time, 그니니치 표준시)를 기준으로 시간대를 표현하기도 한다.

생략...

이슬람력

자바 8에 추가된 새로운 캘릭더 중 HijrahDate(이슬람력)가 가장 복잡한데 이슬람력에는 변형이 있기 때문이다.
자바 8에서는 HijrahDate의 표준 변형 방법으로 UmmAl-Qura를 제공한다.

728x90

'개발서적 > 모던 자바 인 액션' 카테고리의 다른 글

Chap09. 리팩터링, 테스팅, 디버깅  (0) 2021.03.14
Chap06. 스트림으로 데이터 수집  (0) 2021.03.05
Chap05. 스트림 활용  (0) 2021.03.05
Chap04. 스트림 소개  (0) 2021.02.26
Chap03. 람다 표현식  (0) 2021.02.25