안동민 개발노트

본문 시작

지역 시간과 세계 시간

잘못된 달력 날짜 실패를 재현하고 지역 날짜·시각, 타임존 일정, 고정 오프셋, 공통 순간을 게시글 모델에 배치합니다.

java.time 타입은 모두 비슷한 메서드를 제공하지만 담고 있는 정보가 다릅니다.

LocalDate는 달력 날짜만, LocalTime은 하루 안의 시각만, LocalDateTime은 둘을 결합하지만 지역 정보는 없습니다.

ZonedDateTime은 ZoneId 규칙을, OffsetDateTime은 고정 오프셋을, Instant는 UTC 타임라인의 한 순간을 표현합니다.


유효하지 않은 날짜 생성

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

실패가 객체 생성 위치에서 즉시 발생하므로 이후 코드는 LocalDate가 유효한 달력 날짜라고 믿을 수 있습니다.

사용자 입력 파싱에서는 이 예외를 필드 오류 메시지로 변환하고, 내부 코드에서 상수 날짜가 틀렸다면 빠르게 실패시킵니다.


지역 없는 날짜와 시각

now는 실행 환경 시계에 의존해 결과가 바뀌므로 교재와 테스트에서는 of로 고정 값을 만듭니다.

실제 서비스의 현재 시각은 Clock을 주입해 결정성을 확보할 수 있습니다.

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

plusMinutes는 원본을 바꾸지 않고 새 LocalDateTime을 반환합니다.

“매주 월요일 9시” 같은 지역 일정 후보는 LocalDateTime으로 조립할 수 있지만 실제 순간으로 예약하려면 ZoneId를 결합해야 합니다.


ZoneId와 시간대 규칙

Asia/Seoul 같은 지역 식별자는 과거와 미래의 오프셋 변경, 일광 절약 시간 규칙을 포함합니다.

+09:00은 고정 차이만 표현합니다.

국제 회의처럼 지역 규칙이 필요하면 ZonedDateTime을 사용합니다.

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는 공통 순간을 유지하면서 현지 표시를 바꿉니다.

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

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

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

지역 표시가 달라도 같은 순간일 수 있다
지역 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입니다. 이 날짜의 표시이며 다른 날짜에도 런던이 항상 자정이라는 뜻은 아닙니다.

withZoneSameLocal은 현지 날짜·시각을 유지하려고 하므로 적용되는 오프셋에 따라 순간이 달라질 수 있습니다. DST 공백에서는 현지 시각도 조정될 수 있으므로, 같은 순간의 지역 표시에는 withZoneSameInstant를 사용합니다.


오프셋·지역 시간 타입

외부 API가 2025-01-15T09:00:00+09:00처럼 오프셋만 전달하면 OffsetDateTime으로 정확히 표현할 수 있습니다.

그러나 다음 달 같은 지역 시간으로 일정을 반복하려면 +09:00만으로 미래 지역 규칙을 알 수 없습니다.

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

OffsetDateTime은 로그와 프로토콜에서 원래 오프셋을 보존하는 데 좋습니다.

사용자 지역 일정은 ZoneId도 보관해야 DST 전환을 따릅니다.


공통 순간과 Instant

Instant는 1970-01-01T00:00:00Z를 기준으로 한 초와 나노초를 표현합니다.

지역 달력 필드가 없어 “몇 월 며칠”을 직접 답하지 못하지만 서버 간 기록 시각 비교와 경과 시간 계산에 적합합니다.

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만으로 저장하면 원래 사용자가 의도한 지역 9시를 잃을 수 있습니다.

사건 발생 시각은 Instant, 반복 일정 의도는 LocalDateTime과 ZoneId라는 식으로 함께 모델링합니다.


날짜와 시간 타입 선택

  • 달력의 날짜만 필요한가?
  • 하루 안의 시각만 필요한가?
  • 지역을 모르는 벽시계 날짜·시각인가?
  • 특정 지역 규칙으로 실제 순간을 정해야 하는가?
  • 외부 교환에서 고정 오프셋을 보존해야 하는가?
  • 여러 서버가 같은 사건 순간을 비교해야 하는가?

필요 정보보다 넓은 타입을 쓰면 의미가 흐려지고, 좁은 타입을 쓰면 나중에 복구할 수 없는 지역 정보가 사라집니다.

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

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

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

실제 시간 값에 보존된 정보와 빠진 정보를 읽는다
원본 변수·출력보존된 정보여전히 별도로 필요한 정보
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로 존재하지 않거나 두 번 나타나는 현지 시각의 처리 정책도 확인합니다.


연습 문제

서울 2025-05-01 18: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

세 표현은 같은 순간을 가리킵니다.

사용자 화면은 지역 표현을, 저장·정렬은 Instant를 사용할 수 있습니다.