본문 바로가기

LocalDateTime·ZonedDateTime·Instant의 차이

시간대가 없는 LocalDate·LocalDateTime, 시간대 규칙을 담은 ZonedDateTime, 고정 오프셋의 OffsetDateTime, 한 순간을 나타내는 Instant를 직접 만들어 보고, 일광 절약 시간으로 시계가 바뀌는 날의 시각이 어떻게 처리되는지 확인합니다.

앞 글에서 java.time의 날짜·시간 타입을 표로 정리했습니다. 이 타입들은 of로 만들고, plus로 계산하고, toString()으로 출력하는 방식이 거의 같습니다. 그래서 처음에는 아무 타입이나 골라도 될 것처럼 보이지만, 담고 있는 정보가 서로 다릅니다. LocalDate는 달력의 날짜만, LocalTime은 하루 안의 시각만 담고, LocalDateTime은 둘을 합쳤지만 어느 지역의 시각인지는 모릅니다. ZonedDateTime은 지역의 시간대 규칙을, OffsetDateTime은 UTC와의 고정된 차이를 함께 담고, Instant는 지역과 상관없는 한 순간을 나타냅니다.

이 글에서는 게시글의 예약 공개 시각, 국제 회의, 서버의 기록 시각을 예로 들어 이 타입들을 차례로 만들어 봅니다. 그러면서 각 타입이 어떤 질문에 답할 수 있고 어떤 질문에는 답하지 못하는지 확인합니다. 일광 절약 시간 때문에 시계가 건너뛰거나 되돌아가는 날의 시각을 Java가 어떻게 처리하는지도 실제로 실행해 봅니다.

존재하지 않는 날짜는 만들 수 없다

날짜를 연, 월, 일 세 개의 int 필드로 저장하면 2월 30일 같은 값도 아무 문제 없이 저장됩니다. 그런 값은 한참 뒤에 요일을 구하거나 다음 날을 계산할 때에야 이상한 결과로 드러납니다. LocalDate는 이와 달리 객체를 만드는 순간 달력 규칙으로 값을 검사합니다.

lab/InvalidLocalDateFailure.java
import java.time.LocalDate;

public final class InvalidLocalDateFailure {
    public static void main(String[] args) {
        LocalDate impossible = LocalDate.of(2025, 2, 30);
        System.out.println(impossible);
    }
}
실행 결과의 첫 줄
Exception in thread "main" java.time.DateTimeException: Invalid date 'FEBRUARY 30'

LocalDate.of는 2월 30일이 어느 해에도 없는 날짜라고 판단해 DateTimeException을 던졌고, 프로그램은 println까지 가지 못했습니다. 메시지에 연도가 없는 것도 그 때문입니다. 연도에 따라 달라지는 날짜는 메시지가 다릅니다. 2025년 2월 29일을 넣으면 Invalid date 'February 29' as '2025' is not a leap year처럼 평년이라는 이유가 붙고, 13월을 넣으면 Invalid value for MonthOfYear (valid values 1 - 12): 13처럼 월의 허용 범위를 알려 줍니다.

잘못된 값을 만드는 자리에서 바로 거부하므로, 일단 만들어진 LocalDate는 언제나 실제로 존재하는 날짜입니다. 그래서 이 객체를 받는 코드는 날짜가 올바른지 다시 검사할 필요가 없습니다. 사용자가 입력한 날짜라면 이 예외를 잡아 '존재하지 않는 날짜입니다' 같은 안내로 바꿉니다. 반대로 코드에 직접 적은 날짜가 틀렸다면 예외를 그대로 두어 프로그램이 바로 멈추게 합니다. 개발하는 동안 곧바로 발견되는 편이 낫기 때문입니다.

시간대 없는 날짜와 시각

먼저 시간대 정보가 없는 세 타입으로 게시글의 공개 일정을 만들어 봅니다. 3월 10일 9시 30분에 게시글을 공개하고, 공개 후 50분 동안 검토한다고 합시다.

src/LocalBoardSchedule.java
import java.time.LocalDate;
import java.time.LocalDateTime;
import java.time.LocalTime;

public final class LocalBoardSchedule {
    public static void main(String[] args) {
        LocalDate date = LocalDate.of(2025, 3, 10);
        LocalTime start = LocalTime.of(9, 30);
        LocalDateTime publishAt = LocalDateTime.of(date, start);
        LocalDateTime reviewEndsAt = publishAt.plusMinutes(50);

        System.out.println("date=" + date);
        System.out.println("start=" + start);
        System.out.println("publishAt=" + publishAt);
        System.out.println("reviewEndsAt=" + reviewEndsAt);
        System.out.println("before=" + publishAt.isBefore(reviewEndsAt));
    }
}
date=2025-03-10
start=09:30
publishAt=2025-03-10T09:30
reviewEndsAt=2025-03-10T10:20
before=true

LocalDate.of와 LocalTime.of로 날짜와 시각을 따로 만든 뒤, LocalDateTime.of(date, start)로 합쳤습니다. 출력 형식은 국제 표준인 ISO 8601을 따릅니다. 날짜와 시각 사이에는 T가 들어가고, 초가 0이면 09:30처럼 초를 생략합니다. plusMinutes(50)은 9시 30분에 50분을 더해 10시 20분을 만들었고, 마지막 줄은 공개 시각이 검토 종료 시각보다 앞서는지 비교한 결과입니다.

plusMinutes는 publishAt을 바꾸지 않고 새 LocalDateTime을 돌려줍니다. java.time의 타입은 모두 불변이기 때문입니다. 그래서 결과를 변수에 받지 않으면 계산이 그냥 사라집니다. 다음은 자주 하는 실수입니다.

결과를 받지 않은 계산
LocalDateTime publishAt = LocalDateTime.of(2025, 3, 10, 9, 30);
publishAt.plusMinutes(50);        // 새 객체를 만들었지만 아무도 받지 않는다
System.out.println(publishAt);    // 2025-03-10T09:30

컴파일 오류도 경고도 없지만 publishAt은 그대로 9시 30분입니다. 불변 값과 with 메서드에서 본 것처럼, 불변 객체의 계산 메서드는 결과를 받아서 써야 의미가 있습니다.

예제에서 현재 시각을 돌려주는 LocalDateTime.now() 대신 of로 고정된 값을 만든 것은 결과를 재현하기 위해서입니다. now()는 실행할 때마다 다른 값을 돌려주므로 출력을 예상할 수도, 다른 사람의 결과와 비교할 수도 없습니다. 실제 서비스에서 현재 시각이 필요하면 시계 역할을 하는 Clock 객체를 매개변수로 받아 LocalDateTime.now(clock)처럼 씁니다. 그러면 운영 중에는 시스템 시계를, 테스트에서는 고정된 시계를 넘길 수 있습니다. 이 방법은 35장의 순수 함수를 다루는 글에서 자세히 다룹니다.

LocalDateTime으로 '3월 10일 9시 30분'이라는 일정을 나타낼 수는 있지만, 이 값은 아직 하나의 순간이 아닙니다. 서울의 9시 30분과 런던의 9시 30분은 9시간 떨어진 서로 다른 순간이기 때문입니다. 게시글을 정확한 순간에 공개하려면 어느 지역의 시각인지, 즉 시간대를 붙여야 합니다.

ZoneId와 ZonedDateTime으로 시간대 붙이기

Java에서 시간대는 ZoneId로 나타냅니다. ZoneId.of("Asia/Seoul")처럼 대륙(또는 대양)과 대표 도시의 이름으로 된 지역 ID를 쓰면, 그 지역이 과거부터 써 온 UTC와의 차이와 일광 절약 시간 규칙이 모두 따라옵니다. 반면 +09:00 같은 오프셋은 'UTC보다 9시간 빠르다'는 고정된 차이 하나일 뿐이라서, 계절이나 연도에 따라 바뀌는 규칙을 담지 못합니다. 지역 ID의 철자가 틀리면 ZoneId.of("Asia/Seol")을 호출하는 순간 ZoneRulesException: Unknown time-zone ID: Asia/Seol이 납니다. 잘못된 ID가 조용히 다른 시간대로 바뀌는 일은 없습니다.

ZonedDateTime은 날짜, 시각, 지역 ID를 함께 담는 타입입니다. 서울에서 1월 15일 오전 9시에 여는 회의가 런던과 뉴욕에서는 몇 시인지 구해 봅니다.

src/ZonedBoardMeeting.java
import java.time.ZoneId;
import java.time.ZonedDateTime;

public final class ZonedBoardMeeting {
    public static void main(String[] args) {
        ZonedDateTime seoul = ZonedDateTime.of(
                2025, 1, 15, 9, 0, 0, 0, ZoneId.of("Asia/Seoul"));
        ZonedDateTime london = seoul.withZoneSameInstant(ZoneId.of("Europe/London"));
        ZonedDateTime newYork = seoul.withZoneSameInstant(ZoneId.of("America/New_York"));

        System.out.println("seoul=" + seoul.toLocalDateTime());
        System.out.println("london=" + london.toLocalDateTime());
        System.out.println("new-york=" + newYork.toLocalDateTime());
        System.out.println("same=" + seoul.toInstant().equals(newYork.toInstant()));
    }
}
seoul=2025-01-15T09:00
london=2025-01-15T00:00
new-york=2025-01-14T19:00
same=true

withZoneSameInstant는 순간은 그대로 두고, 그 순간을 다른 지역의 시각으로 표시한 새 객체를 돌려줍니다. 1월의 런던은 UTC와 차이가 없고 뉴욕은 UTC보다 5시간 늦습니다. 그래서 서울 오전 9시, 즉 UTC 0시는 런던에서 자정이고 뉴욕에서는 전날 오후 7시입니다. 출력에 toLocalDateTime()을 쓴 것은 오프셋과 지역 ID를 빼고 현지 날짜와 시각만 보기 위해서입니다. 마지막 줄은 서울과 뉴욕의 값을 Instant로 바꾸어 비교한 결과이고, 같은 순간이므로 true입니다.

지역 표시가 달라도 같은 순간일 수 있다

ZonedBoardMeeting의 서울·런던·뉴욕 표시와 같은 순간이라는 관계를 비교한다.

같은 순간이라도 지역의 날짜·시각은 달라집니다

ZonedBoardMeeting은 서울의 2025-01-15 09:00을 만든 뒤 withZoneSameInstant로 변환합니다.

같은 순간이라도 지역의 날짜·시각은 달라집니다 2025-01-15T00:00Z인 같은 순간을 서울01-15 09/런던01-15 00/뉴욕01-14 19로 각각 표시한다. 지역 표시들의 실행 사슬은 아니다. 같은 Instant 2025-01-15 00:00:00Z Asia/Seoul 2025-01-15 09:00 Europe/London 2025-01-15 00:00 America/New_York 2025-01-14 19:00 화살표는 같은 순간의 표시 · 원1월 입력의 결과
같은 순간이라도 지역의 날짜·시각은 달라집니다 2025-01-15T00:00Z인 같은 순간을 서울01-15 09/런던01-15 00/뉴욕01-14 19로 각각 표시한다. 지역 표시들의 실행 사슬은 아니다. 같은 Instant 2025-01-15 00:00:00Z Asia/Seoul 2025-01-15 09:00 Europe/London 2025-01-15 00:00 America/New_York 2025-01-14 19:00 각 화살표는 같은 순간을 해당 지역에 맞춰 표시 지역 간 실행 사슬이 아님
지역 표시가 달라도 같은 순간일 수 있다
지역 ID원본이 출력한 현지 날짜·시각같은 순간의 관계
Asia/Seoul2025-01-15T09:00변환의 기준
Europe/London2025-01-15T00:00기준 순간을 유지한 런던 표시
America/New_York2025-01-14T19:00기준 순간을 유지해 날짜는 전날
Asia/Seoul
원본이 출력한 현지 날짜·시각: 2025-01-15T09:00
같은 순간의 관계: 변환의 기준
Europe/London
원본이 출력한 현지 날짜·시각: 2025-01-15T00:00
같은 순간의 관계: 기준 순간을 유지한 런던 표시
America/New_York
원본이 출력한 현지 날짜·시각: 2025-01-14T19:00
같은 순간의 관계: 기준 순간을 유지해 날짜는 전날

마지막 비교 seoul.toInstant().equals(newYork.toInstant())는 true입니다. 이 날짜의 표시이며 다른 날짜에도 런던이 항상 자정이라는 뜻은 아닙니다.

그림의 시각은 1월의 결과라는 점에 주의하세요. 런던과 뉴욕은 여름에 일광 절약 시간을 쓰므로, 7월에 같은 계산을 하면 서울 오전 9시가 런던에서는 오전 1시, 뉴욕에서는 전날 오후 8시가 됩니다.

이름이 비슷한 withZoneSameLocal은 반대로 현지 날짜와 시각을 그대로 두고 지역만 바꿉니다. 두 메서드를 같은 입력으로 비교해 봅니다.

src/SameLocalVersusSameInstant.java
import java.time.ZoneId;
import java.time.ZonedDateTime;

public final class SameLocalVersusSameInstant {
    public static void main(String[] args) {
        ZonedDateTime seoul = ZonedDateTime.of(
                2025, 1, 15, 9, 0, 0, 0, ZoneId.of("Asia/Seoul"));
        ZonedDateTime sameInstant = seoul.withZoneSameInstant(ZoneId.of("Europe/London"));
        ZonedDateTime sameLocal = seoul.withZoneSameLocal(ZoneId.of("Europe/London"));

        System.out.println("seoul=" + seoul.toInstant());
        System.out.println("same-instant=" + sameInstant + " -> " + sameInstant.toInstant());
        System.out.println("same-local=" + sameLocal + " -> " + sameLocal.toInstant());
    }
}
seoul=2025-01-15T00:00:00Z
same-instant=2025-01-15T00:00Z[Europe/London] -> 2025-01-15T00:00:00Z
same-local=2025-01-15T09:00Z[Europe/London] -> 2025-01-15T09:00:00Z

withZoneSameInstant의 결과는 런던 자정이지만, 순간은 서울 값과 같은 UTC 0시입니다. withZoneSameLocal의 결과는 런던 오전 9시로, 시계의 숫자는 같지만 순간은 9시간 뒤입니다. 그러니 '서울 회의가 런던에서는 몇 시인가'를 물을 때는 withZoneSameInstant를 써야 합니다. withZoneSameLocal은 '지점마다 현지 시각 9시에 문을 연다'처럼 시계의 숫자를 유지하는 것이 목적일 때 씁니다. 다만 이 메서드도 새 지역에 존재하지 않는 현지 시각이면 그 시각을 조정합니다. 어떤 시각이 존재하지 않는지는 다음 절에서 봅니다.

시계가 건너뛰거나 되돌아가는 날

일광 절약 시간을 쓰는 지역에서는 한 해에 두 번 시계가 바뀝니다. 파리는 2025년 3월 30일 새벽 2시에 시계를 3시로 앞당기고, 10월 26일 새벽 3시에 다시 2시로 되돌립니다. 그래서 3월 30일의 2시 30분은 시계에 한 번도 나타나지 않고, 10월 26일의 2시 30분은 두 번 나타납니다. 시계가 건너뛰는 구간을 공백(gap), 같은 시각이 반복되는 구간을 중첩(overlap)이라고 합니다. 이런 날의 LocalDateTime에 지역을 붙이면 어떻게 되는지 확인해 봅니다.

src/DstLocalTimeBoundary.java
import java.time.LocalDateTime;
import java.time.ZoneId;
import java.time.ZonedDateTime;

public final class DstLocalTimeBoundary {
    public static void main(String[] args) {
        ZoneId paris = ZoneId.of("Europe/Paris");
        LocalDateTime gap = LocalDateTime.of(2025, 3, 30, 2, 30);
        LocalDateTime overlap = LocalDateTime.of(2025, 10, 26, 2, 30);
        ZonedDateTime earlier = overlap.atZone(paris);
        ZonedDateTime later = earlier.withLaterOffsetAtOverlap();

        System.out.println("gap-offsets=" + paris.getRules().getValidOffsets(gap).size());
        System.out.println("gap-resolved=" + gap.atZone(paris));
        System.out.println("overlap-offsets=" + paris.getRules().getValidOffsets(overlap).size());
        System.out.println("earlier=" + earlier.toInstant());
        System.out.println("later=" + later.toInstant());
    }
}
gap-offsets=0
gap-resolved=2025-03-30T03:30+02:00[Europe/Paris]
overlap-offsets=2
earlier=2025-10-26T00:30:00Z
later=2025-10-26T01:30:00Z

getRules()는 그 지역의 시간대 규칙을 돌려주고, getValidOffsets는 주어진 현지 시각에 쓸 수 있는 오프셋의 목록을 돌려줍니다. atZone은 LocalDateTime에 지역을 붙여 ZonedDateTime을 만드는 메서드입니다. 결과를 정리하면 다음과 같습니다.

현지 시각유효한 오프셋만들어진 값같은 순간의 UTC 시각
3월 30일 02:30 (공백)없음atZone: 03:30, +02:0001:30
10월 26일 02:30 (중첩)+02:00, +01:00atZone: 02:30, +02:0000:30
10월 26일 02:30 (중첩)+02:00, +01:00withLaterOffsetAtOverlap(): 02:30, +01:0001:30

공백에 있는 2시 30분은 쓸 수 있는 오프셋이 없어서 개수가 0입니다. 그래도 atZone은 예외를 던지지 않습니다. ZonedDateTime의 API 문서에 적힌 대로, 공백에 빠진 현지 시각은 공백의 길이만큼 뒤로 밀립니다. 그래서 한 시간 뒤인 3시 30분이 되고, 오프셋도 바뀐 뒤의 +02:00이 됩니다.

중첩에 있는 2시 30분은 오프셋이 두 개입니다. 시계를 되돌리기 전의 여름 시간 +02:00으로 보면 UTC 0시 30분이고, 되돌린 뒤의 +01:00으로 보면 UTC 1시 30분이므로, 실제로 한 시간 떨어진 두 순간입니다. atZone은 이 가운데 이른 쪽 오프셋을 고르므로 earlier는 0시 30분이 됩니다. 나중 순간이 필요하면 withLaterOffsetAtOverlap()으로 늦은 쪽 오프셋을 고를 수 있고, 그래서 later는 1시 30분입니다.

이 예에서는 공백과 중첩이 한 시간이었지만 모든 지역이 그런 것은 아닙니다. 예를 들어 오스트레일리아의 로드하우섬(Australia/Lord_Howe)은 시계를 30분씩 바꿉니다. 그러니 '한 시간을 더하면 된다'고 직접 계산하지 말고 시간대 규칙에 맡겨야 합니다.

자동 조정이 언제나 바람직한 것은 아닙니다. 사용자가 새벽 2시 30분에 예약 게시를 걸었는데 3시 30분에 공개된다면 당황할 수 있습니다. 예약 시각이 반드시 사용자가 입력한 현지 시각과 같아야 한다면, 지역을 붙이기 전에 getValidOffsets로 오프셋의 개수를 먼저 확인합니다. 그리고 0개일 때 다른 시각을 고르라고 안내할지 뒤로 미룰지, 2개일 때 이른 순간과 늦은 순간 중 무엇을 쓸지를 서비스의 규칙으로 정해 둡니다.

고정 오프셋만 담는 OffsetDateTime

다른 시스템과 시각을 주고받을 때는 2025-01-15T09:00:00+09:00처럼 오프셋이 붙은 문자열을 자주 봅니다. 이 문자열에는 '서울'이라는 지역 정보가 없고, 'UTC보다 9시간 빠른 9시'라는 사실만 있습니다. 이런 값은 날짜, 시각, 오프셋을 담는 OffsetDateTime으로 정확히 표현할 수 있고, 문자열은 OffsetDateTime.parse로 바로 읽을 수 있습니다. 다음 프로그램은 서울 오전 9시에 해당하는 값을 만들고, 같은 순간이 파리에서는 몇 시인지 구합니다.

src/OffsetAndZoneBoard.java
import java.time.OffsetDateTime;
import java.time.ZoneId;
import java.time.ZoneOffset;
import java.time.ZonedDateTime;

public final class OffsetAndZoneBoard {
    public static void main(String[] args) {
        OffsetDateTime exchanged = OffsetDateTime.of(
                2025, 6, 1, 9, 0, 0, 0, ZoneOffset.ofHours(9));
        ZonedDateTime regional = exchanged.atZoneSameInstant(ZoneId.of("Europe/Paris"));

        System.out.println("offset=" + exchanged);
        System.out.println("paris-local=" + regional.toLocalDateTime());
        System.out.println("paris-zone=" + regional.getZone());
    }
}
offset=2025-06-01T09:00+09:00
paris-local=2025-06-01T02:00
paris-zone=Europe/Paris

첫 줄은 만든 값을 그대로 출력한 것으로, 지역 ID 없이 오프셋 +09:00만 붙어 있습니다. atZoneSameInstant는 같은 순간을 파리 시간대의 ZonedDateTime으로 바꿉니다. 이 순간은 UTC 0시이고, 6월의 파리는 일광 절약 시간이라 UTC보다 2시간 빠르므로 파리에서는 오전 2시입니다. 마지막 줄처럼 변환한 값에는 Europe/Paris라는 지역 ID가 들어 있습니다.

그런데 오프셋만으로는 부족한 경우가 있습니다. 파리 사용자가 1월 15일 오전 9시에 잡은 회의를 6개월 뒤 같은 시각에 다시 연다고 해 봅시다. 같은 값을 OffsetDateTime과 ZonedDateTime으로 각각 만들어 6개월을 더해 봅니다.

src/OffsetVersusZoneRepeat.java
import java.time.OffsetDateTime;
import java.time.ZoneId;
import java.time.ZoneOffset;
import java.time.ZonedDateTime;

public final class OffsetVersusZoneRepeat {
    public static void main(String[] args) {
        ZoneId paris = ZoneId.of("Europe/Paris");
        OffsetDateTime offsetOnly = OffsetDateTime.of(
                2025, 1, 15, 9, 0, 0, 0, ZoneOffset.ofHours(1));
        ZonedDateTime withZone = ZonedDateTime.of(2025, 1, 15, 9, 0, 0, 0, paris);

        OffsetDateTime offsetLater = offsetOnly.plusMonths(6);
        ZonedDateTime zoneLater = withZone.plusMonths(6);

        System.out.println("offset=" + offsetLater + " -> paris "
                + offsetLater.atZoneSameInstant(paris).toLocalTime());
        System.out.println("zone=" + zoneLater + " -> paris "
                + zoneLater.toLocalTime());
    }
}
offset=2025-07-15T09:00+01:00 -> paris 10:00
zone=2025-07-15T09:00+02:00[Europe/Paris] -> paris 09:00

1월의 파리 오프셋은 +01:00입니다. OffsetDateTime은 이 오프셋을 그대로 가진 채 6개월을 더하므로 7월 15일 9시 +01:00이 됩니다. 그런데 7월의 파리는 +02:00을 쓰므로, 이 순간은 파리 사람의 시계로 오전 10시입니다. 회의가 한 시간 밀린 것입니다. ZonedDateTime은 Europe/Paris의 규칙을 알고 있어서 7월에 맞는 +02:00을 적용하고, 현지 시각 9시를 그대로 유지합니다.

그래서 OffsetDateTime은 로그나 통신 프로토콜처럼 '그 시점에 어떤 오프셋이었는지'를 그대로 보존해야 하는 곳에 알맞습니다. 사용자의 지역 일정에는 지역 ID를 담은 ZonedDateTime을 써야 일광 절약 시간이 바뀌어도 현지 시각이 유지됩니다.

더 깊이: ZoneId.of("+09:00")은 지역이 아니다

ZoneId.of에 "+09:00" 같은 오프셋 문자열을 넘겨도 오류는 나지 않습니다. 하지만 이때 돌아오는 객체는 ZoneId의 하위 클래스인 ZoneOffset이고, 일광 절약 시간 규칙이 없는 고정된 차이입니다. "Asia/Seoul"을 넘기면 지역 규칙을 가진 다른 하위 클래스의 객체가 돌아옵니다. 서울과 도쿄는 지금 둘 다 +09:00이지만 서로 다른 지역 ID이고, 앞 글에서 본 것처럼 서울은 1987년과 1988년 여름에 +10:00을 썼습니다. 과거의 시각을 다루거나 앞으로 규칙이 바뀔 가능성까지 생각하면, 사용자의 시간대는 오프셋이 아니라 지역 ID로 저장해야 합니다.

한 순간을 나타내는 Instant

Instant는 1970년 1월 1일 0시 0분 0초(UTC)를 기준점으로 삼아, 거기서 몇 초와 몇 나노초가 지났는지로 한 순간을 나타냅니다. 이 기준점을 에포크(epoch)라고 부릅니다. Instant에는 연, 월, 일 같은 달력 필드도 시간대도 없으므로, 전 세계 어디서 보든 같은 값입니다.

src/InstantAuditRecord.java
import java.time.Instant;
import java.time.ZoneId;

public final class InstantAuditRecord {
    public static void main(String[] args) {
        Instant epoch = Instant.EPOCH;
        Instant recorded = epoch.plusSeconds(3_600);

        System.out.println("epoch=" + epoch);
        System.out.println("recorded=" + recorded);
        System.out.println("seconds=" + recorded.getEpochSecond());
        System.out.println("seoul=" + recorded.atZone(ZoneId.of("Asia/Seoul")));
    }
}
epoch=1970-01-01T00:00:00Z
recorded=1970-01-01T01:00:00Z
seconds=3600
seoul=1970-01-01T10:00+09:00[Asia/Seoul]

Instant.EPOCH는 기준점 자체이고, 여기에 3,600초를 더한 recorded는 UTC로 1시입니다. Instant의 toString()은 언제나 UTC 기준으로 출력하므로 끝에 Z가 붙습니다. getEpochSecond()는 기준점에서 지난 초를 돌려주므로 3600입니다. 마지막 줄은 이 순간을 서울 시간대로 표현한 것으로, UTC보다 9시간 빠른 오전 10시입니다.

Instant에 달력 필드가 없다는 것은 '몇 월인가' 같은 질문에 직접 답할 수 없다는 뜻입니다. 예를 들어 Instant.EPOCH.get(ChronoField.MONTH_OF_YEAR)를 호출하면 UnsupportedTemporalTypeException: Unsupported field: MonthOfYear가 납니다. 같은 순간이라도 지역에 따라 날짜가 다르므로, 날짜를 알고 싶다면 atZone으로 시간대부터 정해야 합니다. 대신 지역과 상관없는 값이라서 여러 서버의 기록 시각을 비교하거나 정렬하고, 두 사건 사이에 흐른 시간을 계산하는 데 알맞습니다.

반대로 사람이 정한 앞으로의 일정을 Instant로만 저장하면 정보를 잃을 수 있습니다. 6개월 뒤 오전 9시에 열리는 회의를 지금의 시간대 규칙으로 계산한 Instant로 저장했다고 해 봅시다. 그사이에 그 나라가 일광 절약 시간을 없애면, 저장한 순간을 현지 시각으로 바꾸었을 때 더는 9시가 아닙니다. 사용자가 원한 것은 특정 순간이 아니라 '그 지역의 9시'였기 때문입니다. 그래서 이미 일어난 사건의 시각은 Instant로, 사람이 정한 앞으로의 일정은 LocalDateTime과 ZoneId를 함께 저장하는 식으로 나누어 다룹니다.

데이터에 맞는 타입 고르기

지금까지 만든 타입을 '그 데이터가 답해야 할 질문'으로 정리하면 다음과 같습니다.

데이터가 답해야 할 질문타입
달력의 날짜만 필요한가LocalDate
하루 안의 시각만 필요한가LocalTime
지역을 정하지 않은 날짜와 시각인가LocalDateTime
특정 지역의 규칙으로 실제 순간을 정해야 하는가ZonedDateTime
주고받은 값의 오프셋을 그대로 보존해야 하는가OffsetDateTime
여러 서버가 같은 사건의 순간을 비교해야 하는가Instant

필요한 정보보다 많이 담는 타입을 쓰면 의미가 흐려지고, 적게 담는 타입을 쓰면 나중에 되살릴 수 없는 정보가 사라집니다. 예를 들어 생일 2000년 5월 1일을 서울 자정의 ZonedDateTime으로 저장한 뒤 뉴욕 시간대로 바꾸어 표시하면 4월 30일이 됩니다. 반대로 국제 회의를 LocalDateTime으로만 저장하면 어느 나라의 시각인지 알 수 없습니다. 아래 그림은 이 글에서 만든 실제 값들이 각각 어떤 정보를 보존하고, 순간이나 지역을 정하려면 무엇이 더 필요한지 정리합니다.

실제 시간 값에 보존된 정보와 빠진 정보를 읽는다

LocalBoardSchedule, OffsetAndZoneBoard, InstantAuditRecord의 실제 값에서 지역 없는 일정, 고정 오프셋 교환, 지역 표시, 공통 순간을 구분한다.

지역 없는 일정과 고정 오프셋은 다른 정보를 보관합니다

앞 절의 타입 목록을 실제 예제 값에 적용합니다. 같은 ‘날짜·시각’처럼 보여도 지역을 복구할 근거가 있는지가 다릅니다.

지역 없는 일정과 고정 오프셋은 다른 정보를 보관합니다점선은 서로 다른 원 프로그램의 LocalDateTime03-10 09:30,OffsetDateTime06-01 09+09,파리06-01 02+02,Instantepoch+3600을 그 값의 보관 정보와 연결한다. 타입별 독립 값이다.
LocalDateTime독립된 타입의 값
연결 관계LocalDateTime · 독립된 타입의 값 → 보관하는 정보 · 2025-03-10 / 09:30 · 지역·오프셋 없음
보관하는 정보2025-03-10 / 09:30지역·오프셋 없음
OffsetDateTime독립된 타입의 값
연결 관계OffsetDateTime · 독립된 타입의 값 → 보관하는 정보 · 2025-06-01 / 09:00 · 고정 +09:00
보관하는 정보2025-06-01 / 09:00고정 +09:00
ZonedDateTime독립된 타입의 값
연결 관계ZonedDateTime · 독립된 타입의 값 → 보관하는 정보 · 2025-06-01 / 02:00 · Europe/Paris / +02:00
보관하는 정보2025-06-01 / 02:00Europe/Paris / +02:00
Instant독립된 타입의 값
연결 관계Instant · 독립된 타입의 값 → 보관하는 정보 · 1970-01-01 · 01:00:00Z인 순간
보관하는 정보1970-01-0101:00:00Z인 순간

점선: 각 값이 보관하는 정보 · 타입 간 변환이나 실행 순서가 아님

지역 없는 일정과 고정 오프셋은 다른 정보를 보관합니다점선은 서로 다른 원 프로그램의 LocalDateTime03-10 09:30,OffsetDateTime06-01 09+09,파리06-01 02+02,Instantepoch+3600을 그 값의 보관 정보와 연결한다. 타입별 독립 값이다.
LocalDateTime독립된 타입의 값
정보정보LocalDateTime · 독립된 타입의 값 → 보관하는 정보 · 2025-03-10 / 09:30 · 지역·오프셋 없음
보관하는 정보2025-03-10 / 09:30지역·오프셋 없음
OffsetDateTime독립된 타입의 값
정보정보OffsetDateTime · 독립된 타입의 값 → 보관하는 정보 · 2025-06-01 / 09:00 · 고정 +09:00
보관하는 정보2025-06-01 / 09:00고정 +09:00
ZonedDateTime독립된 타입의 값
정보정보ZonedDateTime · 독립된 타입의 값 → 보관하는 정보 · 2025-06-01 / 02:00 · Europe/Paris / +02:00
보관하는 정보2025-06-01 / 02:00Europe/Paris / +02:00
Instant독립된 타입의 값
정보정보Instant · 독립된 타입의 값 → 보관하는 정보 · 1970-01-01 · 01:00:00Z인 순간
보관하는 정보1970-01-0101:00:00Z인 순간

점선: 값이 보관하는 정보값 사이 변환·실행 순서가 아님

실제 시간 값에 보존된 정보와 빠진 정보를 읽는다
원본 변수·출력보존된 정보여전히 별도로 필요한 정보
LocalBoardSchedule의 publishAt
2025-03-10T09:30
지역 없는 날짜·시각이 일정을 순간으로 정할 지역 규칙·처리 정책
OffsetAndZoneBoard의 exchanged
2025-06-01T09:00+09:00
날짜·시각과 고정 UTC 오프셋원래 사용자의 지역 ID·미래 변화 규칙
같은 프로그램의 regional
2025-06-01T02:00, Europe/Paris
교환 값의 순간을 유지한 파리의 현지 표시·지역 ID반복 간격·공휴일 같은 업무 정책
InstantAuditRecord.recorded
1970-01-01T01:00:00Z
기준 순간에서 3,600초 뒤인 UTC 순간현지 달력 표시를 정할 지역
LocalBoardSchedule의 publishAt
2025-03-10T09:30
보존된 정보: 지역 없는 날짜·시각
여전히 별도로 필요한 정보: 이 일정을 순간으로 정할 지역 규칙·처리 정책
OffsetAndZoneBoard의 exchanged
2025-06-01T09:00+09:00
보존된 정보: 날짜·시각과 고정 UTC 오프셋
여전히 별도로 필요한 정보: 원래 사용자의 지역 ID·미래 변화 규칙
같은 프로그램의 regional
2025-06-01T02:00, Europe/Paris
보존된 정보: 교환 값의 순간을 유지한 파리의 현지 표시·지역 ID
여전히 별도로 필요한 정보: 반복 간격·공휴일 같은 업무 정책
InstantAuditRecord.recorded
1970-01-01T01:00:00Z
보존된 정보: 기준 순간에서 3,600초 뒤인 UTC 순간
여전히 별도로 필요한 정보: 현지 달력 표시를 정할 지역

지역 없는 날짜·시각에 ZoneId를 결합할 때는 DST로 존재하지 않거나 두 번 나타나는 현지 시각의 처리 정책도 확인합니다.

정리

  • LocalDate.of는 존재하지 않는 날짜를 만드는 순간 DateTimeException으로 거부합니다. 그래서 만들어진 LocalDate는 언제나 실제로 있는 날짜입니다.
  • LocalDate, LocalTime, LocalDateTime에는 시간대가 없어서 하나의 순간을 가리키지 못합니다. 계산 메서드는 새 객체를 돌려주므로 결과를 받아서 써야 합니다.
  • ZonedDateTime은 지역 ID의 규칙을 따릅니다. 같은 순간을 다른 지역의 시각으로 표시할 때는 withZoneSameInstant를 씁니다.
  • 일광 절약 시간 때문에 존재하지 않는 현지 시각은 공백의 길이만큼 뒤로 밀리고, 두 번 나타나는 시각은 이른 쪽 오프셋으로 정해집니다. 정확히 그 시각이어야 한다면 getValidOffsets로 먼저 확인합니다.
  • OffsetDateTime은 고정된 오프셋을 보존하는 교환용 값입니다. 사용자의 지역 일정은 지역 ID를 가진 ZonedDateTime이어야 계절이 바뀌어도 현지 시각이 유지됩니다.
  • Instant는 에포크로부터 지난 초와 나노초로 나타낸 한 순간입니다. 이미 일어난 사건의 시각에 쓰고, 날짜가 필요하면 시간대를 붙여 변환합니다.

연습 문제

문제 1: 서울 수업의 UTC 시각

서울에서 2025년 5월 1일 오후 6시 30분에 시작하는 수업을 ZonedDateTime으로 만들고, 같은 순간의 UTC 표현과 Instant를 출력하세요.

해설 보기
src/SeoulClassInstantExercise.java
import java.time.ZoneId;
import java.time.ZonedDateTime;

public final class SeoulClassInstantExercise {
    public static void main(String[] args) {
        ZonedDateTime seoul = ZonedDateTime.of(
                2025, 5, 1, 18, 30, 0, 0, ZoneId.of("Asia/Seoul"));
        ZonedDateTime utc = seoul.withZoneSameInstant(ZoneId.of("UTC"));

        System.out.println("seoul=" + seoul);
        System.out.println("utc=" + utc);
        System.out.println("instant=" + seoul.toInstant());
    }
}
seoul=2025-05-01T18:30+09:00[Asia/Seoul]
utc=2025-05-01T09:30Z[UTC]
instant=2025-05-01T09:30:00Z

서울은 일광 절약 시간을 쓰지 않으므로 5월에도 UTC보다 9시간 빠릅니다. 그래서 오후 6시 30분에서 9시간을 뺀 오전 9시 30분이 UTC 시각입니다. 둘째 줄 끝의 [UTC]는 ZoneId.of("UTC")가 지역 ID처럼 다뤄지기 때문에 붙은 것이고, 고정 오프셋인 ZoneOffset.UTC로 바꾸면 2025-05-01T09:30Z처럼 대괄호 없이 출력됩니다.

세 줄은 표시 방법만 다를 뿐 모두 같은 순간을 가리킵니다. 사용자 화면에는 그 사람의 지역으로 표현한 값을 보여 주고, 저장이나 정렬에는 Instant를 쓸 수 있습니다.

문제 2: 평소 날의 오프셋

본문의 DstLocalTimeBoundary에서 현지 시각을 파리의 2025년 1월 15일 오전 9시로 바꾸면, 유효한 오프셋은 몇 개일까요? 그 값에 atZone을 적용한 결과와, 거기에 다시 withLaterOffsetAtOverlap()을 적용한 결과는 각각 어떤 Instant일까요?

해설 보기

1월 15일 오전 9시는 공백도 중첩도 아니므로 유효한 오프셋은 +01:00 하나입니다. 따라서 atZone의 결과는 9시 +01:00이고, Instant로는 UTC 8시인 2025-01-15T08:00:00Z입니다.

withLaterOffsetAtOverlap()은 중첩이 아닌 시각에 호출하면 값을 바꾸지 않으므로 결과도 같은 2025-01-15T08:00:00Z입니다. 오프셋이 두 개가 되는 것은 파리 시간대 전체의 성질이 아니라, 시계를 되돌리는 날의 특정 현지 시각에서만 생기는 일입니다.

문제 3: 뉴욕에서 사라지는 시각

뉴욕(America/New_York)은 2025년 3월 9일 새벽 2시에 시계를 3시로 앞당기고, UTC와의 차이가 -05:00에서 -04:00으로 바뀝니다. LocalDateTime.of(2025, 3, 9, 2, 30).atZone(ZoneId.of("America/New_York"))의 결과를 예상하세요.

해설 보기

결과는 2025-03-09T03:30-04:00[America/New_York]입니다. 2시 30분은 시계가 건너뛰는 공백 안에 있으므로 유효한 오프셋이 없고, atZone은 현지 시각을 공백의 길이인 한 시간만큼 뒤로 밀어 3시 30분으로 만듭니다. 오프셋은 바뀐 뒤의 -04:00이므로, 같은 순간을 UTC로 나타내면 7시 30분입니다. 본문의 파리 예와 날짜와 오프셋은 다르지만 같은 규칙이 적용됩니다.

다음 글

다음 글에서는 두 시점 사이의 간격을 다룹니다. 연·월·일로 세는 Period와 초 단위로 재는 Duration이 어떻게 다른지 비교하고, 이 글에서 본 일광 절약 시간 때문에 '하루 뒤'와 '24시간 뒤'가 달라지는 경우를 확인합니다.