java.time: LocalDate, ZonedDateTime 및 Duration

1. 개요

날짜와 시간 처리는 흔한 버그의 원인입니다: 청구서가 하루 밀린다든지, 회의 일정의 시간대가 틀렸거나, 일광절약시간으로 인해 작업이 두 번 실행되는 등의 문제가 있습니다. java.time 패키지(Java 8+)는 이러한 대부분의 문제를 해결해주지만, 올바른 데이터 타입을 선택해야 합니다.

이 글에서는 가장 자주 쓰이는 세 가지 타입인 LocalDate, ZonedDateTime, Duration을 다룹니다. 이 글을 읽고 나면, 각 타입을 언제 사용할지, 시간대 변환과 일광절약시간(DST)을 어떻게 처리할지 알게 될 것입니다. 실행 시점에 의존하지 않는 테스트 작성 방법도 배울 수 있습니다.

2. java.time을 사용해야 하는 이유

java.util.Date와 Calendar는 많은 문제가 있습니다. 변경 가능해서 thread-safe하지 않습니다. 월이 0부터 시작하고, Date라는 이름이 오해를 불러일으킵니다. 실제로는 시간의 한 지점을 나타내기 때문입니다. java.time은 이 문제들을 해결합니다:

  • 모든 객체가 **불변(immutable)**이고 thread-safe입니다. plusDays() 같은 메서드는 항상 새 객체를 반환합니다.
  • 각 개념이 고유한 타입을 가집니다:
타입 의미 예시
LocalDate 날짜, 시간과 시간대 없음 생년월일, 만료일
LocalDateTime 날짜 + 시간, 시간대 없음 달력상의 시간, 아직 위치가 정해지지 않음
ZonedDateTime 날짜 + 시간 + ZoneId 하노이에서 오전 9시의 회의
Instant 시간축상의 한 지점(UTC) 데이터베이스 저장 타임스탬프, 로그
Duration 초/나노초 단위의 시간 간격 30초 타임아웃
Period 년/월/일 단위의 시간 간격 3개월 요금제

JDK에 모두 포함되어 있으므로 추가 의존성이 필요 없습니다. 테스트 예제를 실행하려면 JUnit 5와 AssertJ가 필요합니다(spring-boot-starter-test 사용 시 기본 포함):

<dependency>
    <groupId>org.junit.jupiter</groupId>
    <artifactId>junit-jupiter</artifactId>
    <version>5.10.2</version>
    <scope>test</scope>
</dependency>
<dependency>
    <groupId>org.assertj</groupId>
    <artifactId>assertj-core</artifactId>
    <version>3.25.3</version>
    <scope>test</scope>
</dependency>

3. LocalDate: 날짜로 작업하기

3.1. 생성 및 파싱

LocalDate dueDate = LocalDate.of(2024, 5, 15);
LocalDate parsed = LocalDate.parse("2024-02-29");

DateTimeFormatter vnFormat = DateTimeFormatter.ofPattern("dd/MM/yyyy");
LocalDate fromForm = LocalDate.parse("15/05/2024", vnFormat);

System.out.println(dueDate);                     
System.out.println(fromForm.equals(dueDate));    
System.out.println(dueDate.format(vnFormat));    
2024-05-15
true
15/05/2024

LocalDate.of()는 1부터 12까지의 월을 받습니다. 더 명확하게 Month.MAY를 전달할 수도 있습니다. formatter가 없는 parse()는 ISO-8601 표준(yyyy-MM-dd)을 사용합니다. 다른 형식이 필요하면 DateTimeFormatter를 만들어야 하며, formatter는 thread-safe이므로 static final 상수로 선언하는 것이 좋습니다.

주의: yyyy 대신 YYYY를 사용하지 마세요. YYYY는 주 기반 연도입니다. 2024년 12월 30일을 YYYY로 포맷하면 2025가 됩니다. 이 오류는 연말쯤에 드러날 가능성이 높습니다.

3.2. 덧셈, 뺄셈 및 월말 동작

LocalDate orderDate = LocalDate.of(2024, 1, 31);

System.out.println(orderDate.plusDays(10));     
System.out.println(orderDate.plusMonths(1));    
System.out.println(orderDate.plusMonths(1).plusMonths(1)); 
System.out.println(orderDate.plusMonths(2));    
2024-02-10
2024-02-29
2024-03-29
2024-03-31

2024년 2월에는 31일이 없으므로, plusMonths(1)은 유효한 마지막 날인 29일로 밀립니다. 결과적으로 한 번에 한 달씩 두 번 더하는 것이 한 번에 두 달을 더하는 것과 다릅니다. 월간 결제 주기를 계산할 때는 항상 기준 날짜에서 계산하세요. 예를 들어 start.plusMonths(n). 이전 기간의 결과에 누적으로 더하지 마세요.

3.3. TemporalAdjusters

“월의 마지막 날” 또는 “다음 월요일” 같은 질문에 대한 adjuster가 있습니다:

LocalDate today = LocalDate.of(2024, 5, 15); // 수요일

LocalDate endOfMonth = today.with(TemporalAdjusters.lastDayOfMonth());
LocalDate nextMonday = today.with(TemporalAdjusters.next(DayOfWeek.MONDAY));
LocalDate firstOfNextMonth = today.with(TemporalAdjusters.firstDayOfNextMonth());

System.out.println(endOfMonth + " " + nextMonday + " " + firstOfNextMonth);
2024-05-31 2024-05-20 2024-06-01

3.4. 두 날짜 간의 거리: Period와 ChronoUnit

LocalDate start = LocalDate.of(2024, 1, 15);
LocalDate end = LocalDate.of(2024, 3, 10);

Period period = Period.between(start, end);
long days = ChronoUnit.DAYS.between(start, end);

System.out.println(period);              
System.out.println(period.getDays());    
System.out.println(days);                
P1M24D
24
55

이것은 매우 흔한 오류입니다. period.getDays()는 “1개월 24일”의 날짜 부분(24)만 반환하며, 총 일수는 아닙니다. 총 일수를 원하면 ChronoUnit.DAYS.between()을 사용하세요.

4. ZonedDateTime: 시간대가 포함된 날짜와 시간

4.1. 생성 및 시간대 변환

ZoneId hcm = ZoneId.of("Asia/Ho_Chi_Minh");
ZoneId newYork = ZoneId.of("America/New_York");

ZonedDateTime meetingHcm = ZonedDateTime.of(
        LocalDateTime.of(2024, 6, 1, 9, 0), hcm);

ZonedDateTime sameMoment = meetingHcm.withZoneSameInstant(newYork);
ZonedDateTime sameClock = meetingHcm.withZoneSameLocal(newYork);

System.out.println(meetingHcm);
System.out.println(sameMoment);
System.out.println(sameClock);
2024-06-01T09:00+07:00[Asia/Ho_Chi_Minh]
2024-05-31T22:00-04:00[America/New_York]
2024-06-01T09:00-04:00[America/New_York]

첫 번째 줄만 출력된 것입니다. 전체 출력은:

2024-06-01T09:00+07:00[Asia/Ho_Chi_Minh]
2024-05-31T22:00-04:00[America/New_York]
2024-06-01T09:00-04:00[America/New_York]

이 두 메서드는 다음과 같이 다릅니다:

  • withZoneSameInstant()는 시간의 지점을 유지합니다. 호찌민시의 오전 9시는 뉴욕의 전날 밤 10시입니다(일광절약시간 사용 중, UTC-4). 이것이 95% 경우에 필요한 메서드입니다. 예를 들어 다른 시간대의 사람에게 회의를 표시할 때입니다.
  • withZoneSameLocal()은 시계 표시 시간을 유지하고 다른 시간의 지점을 만듭니다. 이 메서드는 데이터가 잘못된 시간대로 할당되어 다시 연결하려는 경우에만 사용합니다.

항상 Europe/Berlin 같은 Region/City 형식의 ID를 사용하세요. EST나 ICT 같은 약자는 피하세요. 이들은 모호합니다. +07:00 같은 고정 오프셋도 일광절약시간 규칙을 알 수 없습니다.

4.2. 일광절약시간: 존재하지 않는 시간대

2024년 3월 31일 독일에서 시계는 02:00에서 바로 03:00으로 넘어갑니다. 그래서 그 날 02:30은 존재하지 않습니다:

ZoneId berlin = ZoneId.of("Europe/Berlin");
ZonedDateTime inGap = ZonedDateTime.of(
        LocalDateTime.of(2024, 3, 31, 2, 30), berlin);

System.out.println(inGap);
2024-03-31T03:30+02:00[Europe/Berlin]

java.time은 예외를 던지지 않고 간격의 길이만큼 뒤로 밀어냅니다(1시간). 반대 방향에서는 일광절약시간이 끝날 때 시계상의 한 시간이 두 번 나타납니다. 기본적으로 java.time은 더 빠른 오프셋을 선택합니다. withLaterOffsetAtOverlap()으로 변경할 수 있습니다.

4.3. Instant: 데이터베이스에 저장할 대상

“언제 이벤트가 발생했는가”를 저장할 때는 Instant로 변환하세요(또는 데이터베이스에서 TIMESTAMP WITH TIME ZONE 타입):

Instant createdAt = meetingHcm.toInstant();
System.out.println(createdAt);                      
System.out.println(createdAt.atZone(newYork));      
2024-06-01T02:00:00Z
2024-05-31T22:00-04:00[America/New_York]

일반적인 규칙은 Instant를 저장하고 사용자의 시간대로 표시할 때만 ZonedDateTime으로 변환하는 것입니다.

5. Duration: 정확한 시간 간격 측정

5.1. 생성 및 읽기

Duration timeout = Duration.ofSeconds(30);
Duration delivery = Duration.ofMinutes(135);

System.out.println(delivery);                   
System.out.println(delivery.toMinutes());       
System.out.printf("%d시간 %d분%n",
        delivery.toHoursPart(), delivery.toMinutesPart());

Instant start = Instant.parse("2024-05-15T08:00:00Z");
Instant end = Instant.parse("2024-05-15T08:00:42.5Z");
System.out.println(Duration.between(start, end).toMillis()); 
PT2H15M
135
2시간 15분
42500

toMinutes()는 총 분 수를 반환합니다. 반면 toHoursPart()와 toMinutesPart()(Java 9+)는 각 부분으로 분해하여 표시합니다. 이것은 섹션 3.4의 Period.getDays() 함정과 같은 종류입니다.

5.2. Duration과 Period: “1일”이 항상 24시간은 아닙니다

이것은 이 글에서 가장 중요한 차이입니다:

ZoneId berlin = ZoneId.of("Europe/Berlin");
ZonedDateTime beforeDst = ZonedDateTime.of(
        LocalDateTime.of(2024, 3, 30, 12, 0), berlin);

ZonedDateTime calendarDay = beforeDst.plusDays(1);              
ZonedDateTime exact24h = beforeDst.plus(Duration.ofDays(1));    

System.out.println(calendarDay);
System.out.println(exact24h);
System.out.println(Duration.between(beforeDst, calendarDay));
2024-03-31T12:00+02:00[Europe/Berlin]
2024-03-31T13:00+02:00[Europe/Berlin]
PT23H

위의 결과는 세 가지를 보여줍니다:

  • plusDays(1)은 달력을 따라 더하므로 다음 날 정오를 유지합니다. 실제로 두 시간대 사이의 간격은 23시간입니다.
  • plus(Duration.ofDays(1))은 정확히 24시간을 더하므로 13:00에 도달합니다.
  • Duration.between()은 실제 간격이 23시간임을 확인합니다.

타입 선택 규칙은 매우 간단합니다. 사용자가 달력으로 생각한다면(“매일 오전 9시에 알려줘”, “1개월 요금제”), Period 또는 plusDays/plusMonths를 사용하세요. 시스템이 경과 시간을 측정한다면(타임아웃, SLA 24시간, rate limit), Duration을 사용하세요.

주의: Duration을 LocalDate에 더할 수 없습니다. LocalDate.plus(Duration.ofHours(5))는 UnsupportedTemporalTypeException을 던집니다. LocalDate에는 시간이 없으므로 일보다 작은 단위는 의미가 없습니다.

6. Clock을 사용하여 테스트 용이한 코드 작성

비즈니스 로직에서 직접 LocalDate.now()를 호출하면 테스트가 실행 날짜에 의존하게 됩니다. 올바른 방법은 Clock을 주입하는 것입니다:

public class SubscriptionService {

    private static final int TRIAL_DAYS = 14;
    private final Clock clock;

    public SubscriptionService(Clock clock) {
        this.clock = clock;
    }

    public LocalDate trialEndDate() {
        return LocalDate.now(clock).plusDays(TRIAL_DAYS);
    }

    public boolean isExpired(LocalDate expiryDate) {
        return LocalDate.now(clock).isAfter(expiryDate);
    }

    public Duration timeUntil(Instant deadline) {
        return Duration.between(Instant.now(clock), deadline);
    }
}

java.time의 모든 now() 메서드는 Clock을 받는 오버로드가 있습니다. Spring Boot 3.x에서는 빈을 선언할 수 있습니다:

@Configuration
public class TimeConfig {

    @Bean
    public Clock clock() {
        return Clock.system(ZoneId.of("Asia/Ho_Chi_Minh"));
    }
}

systemDefaultZone() 대신 명시적 zone을 가진 Clock.system(...)을 사용하세요. 그러면 서버가 UTC에서 실행되고 개발 머신이 베트남 시간대에서 실행되더라도 결과가 같습니다.

테스트에서는 Clock.fixed()로 시간을 고정합니다:

class SubscriptionServiceTest {

    private static final ZoneId HCM = ZoneId.of("Asia/Ho_Chi_Minh");
    // 03:00 UTC = 2024년 5월 15일 호찌민시 오전 10:00
    private final Clock fixedClock =
            Clock.fixed(Instant.parse("2024-05-15T03:00:00Z"), HCM);
    private final SubscriptionService service = new SubscriptionService(fixedClock);

    @Test
    void trialEndsAfterFourteenDays() {
        assertThat(service.trialEndDate()).isEqualTo(LocalDate.of(2024, 5, 29));
    }

    @Test
    void expiredOnlyAfterExpiryDate() {
        assertThat(service.isExpired(LocalDate.of(2024, 5, 15))).isFalse();
        assertThat(service.isExpired(LocalDate.of(2024, 5, 14))).isTrue();
    }

    @Test
    void calculatesRemainingTime() {
        Instant deadline = Instant.parse("2024-05-15T05:30:00Z");
        assertThat(service.timeUntil(deadline)).isEqualTo(Duration.ofMinutes(150));
    }

    @Test
    void calendarDayAcrossDstIsTwentyThreeHours() {
        ZonedDateTime start = ZonedDateTime.of(
                LocalDateTime.of(2024, 3, 30, 12, 0), ZoneId.of("Europe/Berlin"));
        assertThat(Duration.between(start, start.plusDays(1))).isEqualTo(Duration.ofHours(23));
    }
}

두 번째 테스트는 경계를 확인합니다: 5월 15일에 만료되는 구독도 5월 15일 당일에는 여전히 유효합니다. 마지막 테스트는 섹션 5.2의 DST 동작을 “잠금”합니다. 따라서 누군가 plusDays를 plus(Duration.ofDays(1))로 변경하면 테스트가 즉시 알려줍니다.

놓치기 쉬운 세부사항 하나. Instant.parse("2024-05-14T18:00:00Z")를 고정 시간으로 사용하면, 호찌민시에서 LocalDate.now(clock)은 15일(오전 1시)이 됩니다. UTC에서는 여전히 14일입니다. 따라서 Clock의 zone이 “오늘”이 어느 날인지를 결정합니다.

7. 흔한 오류 및 사용하면 안 되는 경우

  • 이벤트 타임스탬프로 LocalDateTime 사용. LocalDateTime은 시간대를 모르므로, 나중에 읽을 때 “10:00”이 어디의 10:00인지 알 수 없습니다. 이벤트에는 Instant 또는 OffsetDateTime/ZonedDateTime을 사용하세요.
  • 순수 날짜에 ZonedDateTime 사용. 생년월일이나 휴일은 LocalDate입니다. 시간대를 추가하면, zone 변환 시 날짜가 전날로 “밀려”버릴 수 있습니다.
  • ZoneId를 오프셋으로 저장. +07:00은 계절에 따라 변하지 않지만, Europe/Berlin은 변합니다. 미래의 일정을 위해서는 LocalDateTime과 ZoneId(문자열)를 함께 저장하세요. DST 규칙은 예약 날짜 전에 변경될 수 있으며, 이러한 저장 방식은 올바르게 다시 계산할 수 있도록 합니다.
  • getDays() / toMinutesPart()을 총합으로 착각. ChronoUnit.X.between() 또는 toMinutes()를 사용하세요.
  • 다른 zone의 두 ZonedDateTime을 equals()로 비교. equals()는 zone까지 비교하므로 같은 시간의 지점이어도 호찌민시 오전 9시와 뉴욕 밤 10시는 false를 반환합니다. 시간의 지점을 비교하려면 isEqual(), isBefore(), isAfter()를 사용하세요.
  • 비즈니스 코드에서 Clock 없이 now() 호출. 테스트가 실행 날짜 또는 시간에 따라 불안정해집니다.

주의: java.time은 업무용 달력 라이브러리를 대체하지 않습니다. 근무일, 베트남 휴일, 음력 달력은 별도 로직이나 특화된 라이브러리가 필요합니다. 사용자 정의 TemporalAdjuster는 이러한 로직을 캡슐화하기에 적합한 장소입니다.

8. 결론

올바른 데이터 타입을 선택하는 것이 가장 중요한 단계입니다. 달력상의 날짜에는 LocalDate, 특정 위치에 연결된 시간에는 ZonedDateTime, 시간의 지점을 저장할 때는 Instant를 사용하세요. 시간 간격의 경우, Period는 달력을 따르고 Duration은 시계를 따르며, 섹션 5.2에서 보았듯이 일광절약시간에 만날 때 다른 결과를 줍니다.

또한 Clock을 서비스에 주입하고 시간대를 명시적으로 선언해야 합니다. 월말, 일광절약시간 변환, 자정 UTC 같은 경계 케이스에 대한 테스트를 작성하세요. 이렇게 하면 대부분의 날짜/시간 버그가 프로덕션에 가기 전에 차단될 것입니다.

javajava-timedatetimetimezonetesting