[모던 자바 인 액션] chap12. 새로운 날짜와 시간 API

2023. 3. 27. 16:58Java

내용
자바 8에서 새로운 날짜와 시간 라이브러리를 제공하는 이유
사람이나 기계까 이해할 수 있는 날짜와 시간 표현 방법
시간의 양 정의하기
날짜 조작, 포매팅, 파싱
시간대와 캘린더 다루기

자바 8에서는 지금까지의 날짜와 시간 문제를 개선하는 새로운 날짜와 시간 API를 제공한다. 자바 1.0에서는 java.util.Date 클래스 하나로 날짜와 시간 관련 기능을 제공했다. 날짜를 의미하는 Date라는 클래스의 이름과 달리 Date 클래스는 특정 시점을 날짜가 아닌 밀리초 단위로 표현한다. 게다가 1900년을 기준으로 하는 오프셋, 0에서 시작하는 달 인덱스 등 모호한 설계로 유용성이 떨어졌다.

Date date = new Date(117, 8, 21);

// 출력 결과 : Thu SEp 21 00:00:00 CET 2017
// 결과가 직관적이지 않다...

Date의 문제점

  • 결과가 직관적이지 않다. (toString을 추가로 활용하기도 어렵다)
  • Date는 JVM의 기본시간대인 CET(중앙유럽의 시간대)를 사용한다.
  • 자체적으로 시간대 정보를 알고 있는 것이 아니다.
  • 가변 클래스다. (유지보수가 어려워진다)

이 외에도 Calendar, DateFormat 등등 많은것이 있었지만 좋지않은 결과가 있었다.

 

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

1. LocalDate와 LocalTime 사용

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

LocalDate date = LocalDate.of(2017, 9, 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는 시간 관련 객체에서 어떤 필드의 값에 접근할지 정의하는 인터페이스다. 열거자 ChronoField는 TemporalField 인터페이스를 정의하므로 다음 코드에서 보여주는 것처럼 ChronoField의 열거자 요소를 이용해서 원하는 정보를 쉽게 얻을 수 있다.

int y = date.get(ChronoField.YEAR);
int m = date.get(ChronoField.MONTH_OF_YEAR);
int d = date.get(ChronoField.DAY_OF_MONTH);

int y = date.getYear();
int m = date.getMonthValue();
int d = date.getDayOfMonth();
// 내장 메서드도 있다.

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

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

LocalDate date = LocalDate.parse("2017-09-21");
LocalTime time = LocalTime.parse("13:45:20");
// 날짜와 시간 문자열로 LocalDate와 LocalTime의 인스턴스를 만드는 방법도 있다.

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

 

2. 날짜와 시간 조합

LocalDateTime은 LocalDate와 LocalTime을 쌍으로 갖는 복합 클래스다. 즉, LocalDateTime은 날짜와 시간을 모두 표현할 수 있으며 다음 코드에서 보여주는 것처럼 직접 LocalDateTime을 만드는 방법도 있고 날짜와 시간을 조합하는 방법도 있다.

LocalDateTime dt1 = LocalDateTime.of(2014, Month.MARCH, 18, 13, 45, 20); // 2014-03-18T13: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 date1 = dt1.toLocalDate();	// 2014-03-18
LocalTime time1 = dt1.toLocalTime();	// 13:45:20

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

 

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

기계의 관점에서는 연속된 시간에서 특정 지점을 하나의 큰 수로 표현하는 것이 가장 자연스러운 시간 표현 방법이다. 새로운 java.time.Instant 클래스에서는 이와 같은 기계적인 관점에서 시간을 표현한다. 즉, Instant 클래스는 유닉스 에포크 시간을 기준으로 특정 지점까지의 시간을 초로 표현한다.

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

Instant instant = Instant.ofEpochSecond(3);
Instant instant = Instant.ofEpochSecond(3, 0);
Instant instant = Instant.ofEpochSecond(2, 1_000_000_000);
Instant instant = Instant.ofEpochSecond(4, -1_000_000_000);
// 같은 Instant를 반환한다.

LocalDate 드을 포함하여 사람이 읽을 수 있는 날짜 시간 클래스에서 그랬던 것처럼 Instant 클래스도 사람이 확인할 수 있또록 시간을 표시해주는 정적 팩토리 메서드 now를 제공한다. 즉, Instant는 초와 나노초 정보를 포함한다. 따라서 Instant는 사람이 읽을 수 있는 시간 정보를 제공하지 않는다.

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

// java.time.temporal.UnsupportedTemporalTypeException: Unsupported field: DayOfMonth를 일으킨다.

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

 

4. Duration과 Period 정의

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

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

Duration d1 = Duration.between(LocalTime.of(13, 45, 10), time);
Duration d2 = Duration.between(instant, now);

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

Period tenDays = Period.between(LocalDate.of(2017, 9, 11),
			(LocalDate.of(2017, 9, 21));

마지막으로 Duration과 Period 클래스는 자신의 인스턴스를 만들 수 있도록 다양한 팩토리 메서드를 제공한다.

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

Period tenDays = Period.ofDays(10);
Period threeWeeks = Period.ofWeeks(3);
Period twoYearsSixMonthsOneDay = Period.of(2, 6, 1);

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

 

2. 날짜 조정, 파싱, 포매팅

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

LocalDate date1 = LocalDate.of(2014, 3, 18);					// 2014-03-18
LocalDate date2 = date1.withYear(2011);							// 2011-03-18
LocalDate date2 = date2.withDayOfMonth(25);						// 2011-03-25
LocalDate date2 = date3.with(ChronoField.MONTH_OF_YEAR, 2);		// 2011-02-25

예제의 마지막 행에서 보여주는 것처럼 첫 번째 인수로 TemporalField를 갖는 메서드를 사용하면 좀 더 범용적으로 메서드를 활용할 수 있다. 마지막 with 메서드는 get 메서드와 쌍을 이룬다. 이들 두 메서드는 날짜와 시간 API의 모든 클래스가 구현하는 Temporal 인터페이스에 정의되어 있다. Temporal 인터페이스는 LocalDate, LocalTime, LocalDateTime, Instant 처럼 특정 시간을 정의한다. 정확히 표현하자면 get과 with 메서드로 Temporal 객체의 필드값을 읽거나 고칠 수 있다. 어떤 Temporal 객체가 지정된 필드를 지원하지 않으면 UnsupportedTemporalTypeException이 발생한다. 예를 들어 Instant에 ChronoField.MONTH_OF_YEAR를 사용하거나 LocalDate에 ChronoField.NANO_OF_SECOND를 사용하면 예외가 발생한다. 선언형으로 LocalDate를 사용하는 방법도 있다.

LocalDate date1 = LocalDate.of(2014, 3, 18);					// 2014-03-18
LocalDate date2 = date1.plusWeeks(1);							// 2014-03-25
LocalDate date2 = date2.minusYears(6);						// 2008-03-25
LocalDate date2 = date3.plus(10, ChronoUnit.MONTHS);		// 2009-01-25

예제에서는 with, get 메서드와 비슷한 plus, minus 메서드를 사용했다. plus, minus 메서드도 Temporal 인터페이스에 정의되어 있다. 이들 메서드를 이용해서 Temporal을 특정 시간만큼 앞뒤로 이동시킬 수 있다. 메서드의 인수에 숫자와  TemporalUnit을 활용할 수 있다. ChronoUnit 열거형은 TemporalUnit 인터페이스를 쉽게 활용할 수 있는 구현을 제공한다. LocalDate, LocalTime, LocalDateTime, Instat 등 날짜와 시간을 표현하는 모든 클래스는 서로 비슷한 메서드를 제공한다.

 

1. TemporalAdjusters 사용하기

지금까지 살펴본 날짜 조정 기능은 비교적 간단한 편에 속한다. 때로는 다음 주 일요일, 돌아오는 평일, 어떤 달의 마지막 날 등 좀 더 복잡한 날짜 조정 기능이 필요할 것이다. 이때는 오버로드된 버전의 with 메서드에 좀 더 다양한 동작을 수행할 수 있또록 하는 기능을 제공하는 TemporalAdjuster를 전달하는 방법으로 문제를 해결할 수 있다. 날짜와 시간 API는 다양한 상황에서 사용할 수 있도록 다양한 TemporalAdjuster를 제공한다. TemporalAdjusters에서 정의하는 정적 팩토리 메서드로 이들 기능을 이용할 수 있다.

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

LocalDate date = LocalDate.of(2014, 3, 18);			// 2014-03-18
date = date.with(nextOrSame(DayOfWeek.SUNDAY));		// 2014-03-23
date = date.with(lastDayOfMonth());					// 2014-03-31
팁 : TemporalAdjuster는 인터페이스, TemporalAdjusters는 TemporalAdjuster를 반환하는 정적 팩토리 메서드를 포함하는 클래스이다. 이름이 비슷하지만 TemporalAdjusters는 구현체, TemporalAdjuster는 인터페이스다.

TemporalAdjuster를 이용하면 좀 더 복잡한 날짜 조정 기능을 직관적으로 해결할 수 있다. 그 뿐만 아니라 필요한 기능이 정의되어 있지 않을 때는 비교적 쉽게 커스텀 TemporalAdjuster 구현을 만들 수 있다. 실제로 TemporalAdjuster 인터페이스는 다음처럼 하나의 메서드만 정의한다. (함수형 인터페이스다)

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

TemporalAdjuster 인터페이스 구현은 Temporal 객체를 어떻게 다른 Temporal 객체로 변환할지 정의한다. 결국 TemporalAdjuster 인터페이스를 UnaryOperator<Temporal>과 같은 형식으로 간주할 수 있다. 자주 사용하는 또 다른 동작으로 각자의 상황에 맞는 다양한 형식으로 날짜와 시간 객체를 출력해야 할 때가 있다. 반면 문자열로 표현된 날짜를 날짜 객체로 다시 변환해야 할 때도 있다.

 

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

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

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

반대로 날짜나 시간을 표현하는 문자열을 파싱해서 날짜 객체를 다시 만들 수 있다. 날짜와 시간 api에서 특정 시점이나 간격을 표현하는 모든 클래스의 팩토리 메서드 parse를 이용해서 문자열을 날짜 객체로 만들 수 있다.

LocalDate date = LocalDate.parse("20140318",
	DateTimeFormatter.BASIC_ISO_DATE);
LocalDate date2 = LocalDate.parse("2014-03-18",
	DateTimeFormatter.ISO_LOCAL_DATE);

기존의 java.util.DateFormat 클래스와 달리 모든 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는 같은 포매터를 적용해서 생성된 문자열을 파싱함으로써 다시 날짜를 생성한다.

DateTimeFormatter italianFormatter = 
	DateTimeFormatter.ofPattern("d. MMMM yyyy", Locale.ITALIAN);
LocalDate date = LocalDate.of(2014, 3, 18);
String formattedDate = date1.format(italianFormatter);			// 18. marzo 2014
// 이탈리아 방식인듯
LocalDate date2= LocalDate.parse(formattedDate, italianFormatter);

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

DateTimeFormatter complexFormatter = new DateTimeFormatterBuilder()
        .appendText(ChronoField.DAY_OF_MONTH)
        .appendLiteral(". ")
        .appendText(ChronoField.MONTH_OF_YEAR)
        .appendLiteral(" ")
        .appendText(ChronoField.YEAR)
        .parseCaseInsensitive()
        .toFormatter(Locale.ITALIAN);

지금까지 시간과 간격으로 날짜를 만들고, 조작하고, 포맷하는 방법을 살펴봤다.

 

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

1. 시간대 사용하기

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

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

지역 ID는 '{지역}/{도시}' 형식으로 이루어지며 IANA Time Zone Database에서 제공하는 지역 집합 정보를 사용한다. (https://www.iana.org/time-zones)

ZoneId zoneId = TiemeZone.getDefault().toZoneId();

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

LocalDate date = LocalDate.of(2014, Month.MARCH, 18);
ZonedDateTime zdt1 = date.atStartOfDay(romeZone);
LocalDateTime dateTime = LocalDateTime.of(2014, Month.MARCH, 18, 13, 45);
ZonedDateTime zdt2 = dateTime.atZone(romeZone);
Instant instant = Instant.now();
ZonedDateTime zdt3 = instant.atZone(romeZone);

LocalDateTime timeFromInstant = LocalDateTime.ofInstant(instant, romeZone);
// ZoneId를 이용해서 LocalDateTime을 Instant로 바꾸는 방법도 있다.

기존의 Date 클래스를 처리하는 코드를 사용해야 하는 상황이 있을 수 있으므로 Instant로 작업하는 것이 유리하다. 폐기된 API와 새 날짜와 시간 API 간의 동작에 도움이 되는 toInstant(), 정적 메서드 fromInstant() 두 개의 메서드가 있다.

 

마치며

  • 자바 8 이전 버전에서 제공하는 기존의 java.util.Date 클래스와 관련 클래스에서는 여러 불일치점들과 가변성, 어설픈 오프셋, 기본값, 잘못된 이름 결정 등의 설계 결함이 존재했다.
  • 새로운 날짜와 시간 API에서 날짜와 시간 객체는 모두 불변이다.
  • 새로운 API는 각각 사람과 기계가 편리하게 날짜와 시간 정보를 관리할 수 있도록 두 가지 표현 방식을 제공한다.
  • 날짜와 시간 객체를 절대적인 방법과 상대적인 방법으로 처리할 수 있으며 기존 인스턴스를 변환하지 않도록 처리 결과로 새로운 인스턴스가 생성된다.
  • TemporalAdjuster를 이용하면 단순히 값을 바꾸는 것 이상의 복잡한 동작을 수행할 수 있으며 자신만의 커스텀 날짜 변환 기능을 정의할 수 있다.
  • 날짜와 시간 객체를 특정 포맷으로 출력하고 파싱하는 포매터를 정의할 수 있다. 패턴을 이용하거나 프로그램으로 포매터를 만들 수 있으며 포매터는 스레드 안전성을 보장한다.
  • 특정 지역/장소에서 상대적인 시간대 또는 UTC/GMT 기준의 오프셋을 이용해서 시간대를 정의할 수 있으며 이 시간대를 날짜와 시간 객체에 적용해서 지역화할 수 있다.
  • ISO-8601 표준 시스템을 준수하지 않는 캘린더 시스템도 사용할 수 있다.