본문으로 건너뛰기
안동민 개발노트 아이콘

안동민 개발노트

본문 시작
10장 : 래퍼·열거형·시간 API

지역 시간과 세계 시간

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

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/LocalStudySchedule.java
import java.time.LocalDate;
import java.time.LocalDateTime;
import java.time.LocalTime;

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

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

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

ZoneId와 시간대 규칙

Asia/Seoul 같은 지역 식별자는 과거와 미래의 오프셋 변경, 일광 절약 시간 규칙을 포함합니다. +09:00은 고정 차이만 표현합니다. 국제 회의처럼 지역 규칙이 필요하면 ZonedDateTime을 사용합니다.

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

public final class ZonedStudyMeeting {
    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는 공통 순간을 유지하면서 현지 표시를 바꿉니다. withZoneSameLocal은 벽시계 시간을 유지해 다른 순간이 되므로 일정 복사 목적이 아니라면 주의합니다.

오프셋·지역 시간 타입

외부 API가 2025-01-15T09:00:00+09:00처럼 오프셋만 전달하면 OffsetDateTime으로 정확히 표현할 수 있습니다. 그러나 다음 달 같은 지역 시간으로 일정을 반복하려면 +09:00만으로 미래 지역 규칙을 알 수 없습니다.

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

public final class OffsetAndZoneStudy {
    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라는 식으로 함께 모델링합니다.

날짜와 시간 타입 선택

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

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

연습 문제

서울 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를 사용할 수 있습니다.