날짜와 시간 조작하기

Java 8에서 도입된 java.time 패키지는 기존 Date와 Calendar의 문제점들을 해결한 현대적인 날짜/시간 API이다. 이번 포스트에는 ChronoUnit와 ChronoField에 대해 알아본다

날짜와 시간의 핵심 개념

날짜와 시간을 다룰 때 가장 먼저 이해해야 할 개념은 특정 시점의 시간과 시간의 간격을 명확히 구분하는 것이다.

특정 시점의 시간 (Temporal)

특정 시점을 나타내는 예시

  • 이 프로젝트는 2023년 10월 17일까지 마무리 해야합니다.
  • 다음 회의는 오후 3시 입니다.
  • 회사 창립일은 5월17일 입니다.

이러한 ‘언제’를 나타내는 시간 객체들은 TemporalAccessor/Temporal 계열 타입으로 표현합니다.

  • 구현체: LocalDateTime, LocalDate, LocalTime, ZonedDateTime, Instant 등

시간의 간격 (TemporalAmount)

시간의 양/간격을 나타내는 예시

  • 졸업까지는 앞으로 4년을 더 공부해야 합니다.
  • 이 프로젝트는 반년 남았습니다.
  • 3분 짜장은 3분동안 전자렌지에 돌려야 합니다.

이러한 “얼마 동안”을 나타내는 시간의 양은 ‘TemporalAmount’ 인터페이스를 구현합니다

  • 구현체: Period, Duration

인터페이스 계층 구조

   특정 시점의 시간                     시간의 간격
┌─────────────────┐              ┌──────────────┐
│ TemporalAccessor│              │TemporalAmount│
└────────┬────────┘              └──────┬───────┘
         │                              │
    ┌────┴────┐                    ┌────┴────┐
    │ Temporal│                    │ Period  │
    └────┬────┘                    │ Duration│
         │                         └─────────┘
    ┌────┴────────────┐
    │LocalDateTime    │
    │LocalDate        │
    │LocalTime        │
    │ZonedDateTime    │
    └─────────────────┘

핵심 포인트

  • TemporalAccessor: 읽기 전용(조회만 가능)
  • Temporal: 읽기 + 쓰기 (조회 + 조작 가능)
  • Temporal은 TemporalAccessor를 상속받아 모든 기능을 포함합니다

시간의 단위 – ChronoUnit

ChronoUnit이란?

ChronoUnit은 시간의 단위(Unit)를 나타내는 열거형이다. TemporalUnit 인터페이스의 구현체로, 시간 계산의 기본 단위를 제공한다

// 시간 단위
ChronoUnit.NANOS      // 나노초
ChronoUnit.MICROS     // 마이크로초
ChronoUnit.MILLIS     // 밀리초
ChronoUnit.SECONDS    // 초
ChronoUnit.MINUTES    // 분
ChronoUnit.HOURS      // 시간

// 날짜 단위
ChronoUnit.DAYS       // 일
ChronoUnit.WEEKS      // 주
ChronoUnit.MONTHS     // 월
ChronoUnit.YEARS      // 년
ChronoUnit.DECADES    // 10년
ChronoUnit.CENTURIES  // 세기
ChronoUnit.MILLENNIA  // 천년

ChronoUnit 활용 예제

public class ChronoUnitMain {
    public static void main(String[] args) {
        // 모든 ChronoUnit 열거형 값 조회
        ChronoUnit[] values = ChronoUnit.values();
        for (ChronoUnit value : values) {
            System.out.println("value = " + value);
        }
        
        // 특정 단위의 Duration 얻기
        System.out.println("HOURS = " + ChronoUnit.HOURS);
        System.out.println("HOURS.duration = " + 
            ChronoUnit.HOURS.getDuration().getSeconds()); // 3600초
        
        System.out.println("DAYS = " + ChronoUnit.DAYS);
        System.out.println("DAYS.duration = " + 
            ChronoUnit.DAYS.getDuration().getSeconds()); // 86400초
        
        // 두 시간 사이의 차이 구하기
        LocalTime lt1 = LocalTime.of(1, 10, 0);
        LocalTime lt2 = LocalTime.of(1, 20, 0);
        
        long secondsBetween = ChronoUnit.SECONDS.between(lt1, lt2);
        System.out.println("secondsBetween = " + secondsBetween); // 600초
        
        long minutesBetween = ChronoUnit.MINUTES.between(lt1, lt2);
        System.out.println("minutesBetween = " + minutesBetween); // 10분
    }
}

실무 활용 팁

  • between() 메서드는 두 시간 객체 간의 차이를 해당 단위로 간단하게 계산
  • 복잡한 날짜 계산 없이 직관적으로 시간 차이 산출 가능

시간의 필드 – ChronoField

ChronoField란?

ChronoField는 날짜와 시간의 특정 필드(Field)를 나타내는 열거형이다. TemporalField 인터페이스의 구현체로, 날짜/시간에서 특정 항목을 추출할 때 사용한다

ChronoUnit vs ChronoField 차이점

  • ChronoUnit: 시간의 단위 그 자체 (예: 일, 월, 년)
  • ChronoField: 날짜/시간 내의 특정 필드 (예: “월 중의 일”, “년 중의 월”)

주요 ChronoField 필드

연도 관련

ChronoField.YEAR              // 연도 (예: 2024)
ChronoField.YEAR_OF_ERA       // 연대 내의 연도
ChronoField.ERA               // 연대 (AD/BC)

월 관련

ChronoField.MONTH_OF_YEAR     // 1~12월

일 관련

ChronoField.DAY_OF_MONTH      // 월의 일 (1~28/31)
ChronoField.DAY_OF_YEAR       // 연의 일 (1~365/366)
ChronoField.DAY_OF_WEEK       // 요일 (1~7)

시간 관련

ChronoField.HOUR_OF_DAY       // 시간 (0~23)
ChronoField.MINUTE_OF_HOUR    // 분 (0~59)
ChronoField.SECOND_OF_MINUTE  // 초 (0~59)
ChronoField.NANO_OF_SECOND    // 나노초 (0~999,999,999)

// 특수한 필드들
ChronoField.MINUTE_OF_DAY     // 하루 중 분 (0~1439)
ChronoField.SECOND_OF_DAY     // 하루 중 초 (0~86399)

ChronoField 활용 예제

public class ChronoFieldMain {
    public static void main(String[] args) {
        // 모든 ChronoField와 범위 조회
        ChronoField[] values = ChronoField.values();
        for (ChronoField value : values) {
            System.out.println(value + ", range = " + value.range());
        }
        
        // 특정 필드의 범위 확인
        System.out.println("MONTH_OF_YEAR.range() = " + 
            ChronoField.MONTH_OF_YEAR.range()); // 1 - 12
        
        System.out.println("DAY_OF_MONTH.range() = " + 
            ChronoField.DAY_OF_MONTH.range()); // 1 - 28/31
    }
}

주목할 점

  • DAY_OF_MONTH의 범위가 1 – 28/31인 이유: 2월은 28일, 다른 달은 최대 31일까지
  • 각 필드는 range() 메서드로 유효한 값의 범위를 제공

날짜와 시간 조회하기

기본 조회 – get 메서드

public class GetTimeMain {
    public static void main(String[] args) {
        LocalDateTime dt = LocalDateTime.of(2030, 1, 1, 13, 30, 59);
        
        // ChronoField를 사용한 조회
        System.out.println("YEAR = " + dt.get(ChronoField.YEAR));
        System.out.println("MONTH_OF_YEAR = " + dt.get(ChronoField.MONTH_OF_YEAR));
        System.out.println("DAY_OF_MONTH = " + dt.get(ChronoField.DAY_OF_MONTH));
        System.out.println("HOUR_OF_DAY = " + dt.get(ChronoField.HOUR_OF_DAY));
        System.out.println("MINUTE_OF_HOUR = " + dt.get(ChronoField.MINUTE_OF_HOUR));
        System.out.println("SECOND_OF_MINUTE = " + dt.get(ChronoField.SECOND_OF_MINUTE));
    }
}

핵심 원리

  • TemporalAccessor.get(TemporalField field) 메서드 사용
  • LocalDateTime, LocalDate 등 모든 시간 클래스는 TemporalAccessor를 구현
  • ChronoField로 어떤 필드를 조회할지 지정

편의 메서드 활용

일관성 있는 get() 방식도 좋지만, 실무에서는 코드가 길어져 불편하다. 이를 위해 편의 메서드가 제공된다

// 기본 방식 vs 편의 메서드
dt.get(ChronoField.YEAR)           → dt.getYear()
dt.get(ChronoField.MONTH_OF_YEAR)  → dt.getMonthValue()
dt.get(ChronoField.DAY_OF_MONTH)   → dt.getDayOfMonth()
dt.get(ChronoField.HOUR_OF_DAY)    → dt.getHour()
dt.get(ChronoField.MINUTE_OF_HOUR) → dt.getMinute()
dt.get(ChronoField.SECOND_OF_MINUTE) → dt.getSecond()

편의 메서드가 없는 경우

자주 사용하지 않는 필드는 편의 메서드를 제공하지 않는다

// 이런 특수한 필드는 get() 사용
System.out.println("MINUTE_OF_DAY = " + 
    dt.get(ChronoField.MINUTE_OF_DAY)); // 810분 (13시 30분 = 810분)
System.out.println("SECOND_OF_DAY = " + 
    dt.get(ChronoField.SECOND_OF_DAY)); // 48659초

개발 가이드

  • 일반적인 경우: 편의 메서드 사용 (가독성 우수)
  • 편의 메서드가 없는 경우: get(ChronoField) 사용

날짜와 시간 조작하기

plus/minus로 더하고 빼기

public class ChangeTimePlusMain {
    public static void main(String[] args) {
        LocalDateTime dt = LocalDateTime.of(2018, 1, 1, 13, 30, 59);
        
        // 방법 1: plus() + ChronoUnit
        LocalDateTime plusDt1 = dt.plus(10, ChronoUnit.YEARS);
        System.out.println("plusDt1 = " + plusDt1); // 2028-01-01T13:30:59
        
        // 방법 2: 편의 메서드
        LocalDateTime plusDt2 = dt.plusYears(10);
        System.out.println("plusDt2 = " + plusDt2); // 2028-01-01T13:30:59
        
        // 방법 3: Period 사용
        Period period = Period.ofYears(10);
        LocalDateTime plusDt3 = dt.plus(period);
        System.out.println("plusDt3 = " + plusDt3); // 2028-01-01T13:30:59
    }
}

핵심 원리

  • Temporal.plus(long amount, TemporalUnit unit) 메서드 사용
  • 불변(Immutable)이므로 반환값을 받아야 한다
  • minus() 메서드도 동일한 방식으로 작동

편의 메서드

dt.plus(10, ChronoUnit.YEARS)  → dt.plusYears(10)
dt.plus(5, ChronoUnit.MONTHS)  → dt.plusMonths(5)
dt.plus(3, ChronoUnit.DAYS)    → dt.plusDays(3)

with()로 특정 필드 변경하기

with() 메서드는 특정 필드의 값만 변경하고 나머지는 유지한다

public class ChangeTimeWithMain {
    public static void main(String[] args) {
        LocalDateTime dt = LocalDateTime.of(2018, 1, 1, 13, 30, 59);
        
        // 방법 1: with() + ChronoField
        LocalDateTime changedDt1 = dt.with(ChronoField.YEAR, 2020);
        System.out.println("changedDt1 = " + changedDt1); // 2020-01-01T13:30:59
        
        // 방법 2: 편의 메서드
        LocalDateTime changedDt2 = dt.withYear(2020);
        System.out.println("changedDt2 = " + changedDt2); // 2020-01-01T13:30:59
    }
}

with의 의미

  • “Coffee with sugar” → 커피에 설탕을 더한 것
  • 기본 값에서 특정 부분만 교체한 새로운 객체 반환
  • 불변성을 유지하면서 부분 수정 가능

TemporalAdjusters로 복잡한 날짜 계산

“다음 주 금요일”, “이번 달 마지막 일요일”같은 복잡한 계산은 어떻게 해야 할까?

// TemporalAdjusters 사용
LocalDateTime dt = LocalDateTime.of(2018, 1, 1, 13, 30, 59);

// 다음 주 금요일
LocalDateTime nextFriday = dt.with(TemporalAdjusters.next(DayOfWeek.FRIDAY));
System.out.println("다음 금요일: " + nextFriday); // 2018-01-05T13:30:59

// 이번 달의 마지막 일요일
LocalDateTime lastSunday = dt.with(TemporalAdjusters.lastInMonth(DayOfWeek.SUNDAY));
System.out.println("이번 달의 마지막 일요일 = " + lastSunday); // 2018-01-28T13:30:59

TemporalAdjusters 주요 메서드

메서드설명
firstDayOfMonth()이번 달의 첫째 날
lastDayOfMonth()이번 달의 마지막 날
firstDayOfNextMonth()다음 달의 첫째 날
firstDayOfYear()이번 해의 첫 째 날
lastDayOfYear()이번 해의 마지막 날
next(DayOfWeek)다음 특정 요일
nextOrSame(DayOfWeek)다음 특정 요일 (오늘 포함)
previous(DayOfWeek)이전 특정 요일
firstInMonth(DayOfWeek)이번 달의 첫 번째 특정 요일
lastInMonth(DayOfWeek)이번 달의 마지막 특정 요일
  • 복잡한 날짜 계산을 직접 구현하지 말고 TemporalAdjusters의 사용사례를 필요할 때 찾아 쓰는 것으로도 충분하다

isSupported()로 지원 여부 확인하기

모든 시간 타입이 모든 필드를 지원하는 것은 아니다

public class IsSupportedMain {
    public static void main(String[] args) {
        LocalDate now = LocalDate.now();
        
        // LocalDate는 날짜만 다루므로 초 필드 미지원
        boolean supported = now.isSupported(ChronoField.SECOND_OF_MINUTE);
        System.out.println("supported = " + supported); // false
        
        if (supported) {
            int second = now.get(ChronoField.SECOND_OF_MINUTE);
            System.out.println("second = " + second);
        }
    }
}

예외 방지 패턴

// 나쁜 예: 예외 발생
LocalDate date = LocalDate.now();
int second = date.get(ChronoField.SECOND_OF_MINUTE); // UnsupportedTemporalTypeException!

// 좋은 예: 지원 여부 확인 후 사용
if (date.isSupported(ChronoField.SECOND_OF_MINUTE)) {
    int second = date.get(ChronoField.SECOND_OF_MINUTE);
}

핵심 요약

  • ChronoUnit: 시간의 단위 (YEARS, MONTHS, DAYS, HOURS…)
  • ChronoField: 날짜/시간의 특정 필드 (YEAR, MONTH_OF_YEAR, DAY_OF_MONTH…)
  • 조회: get(ChronoField) 또는 편의 메서드
  • 조작: plus/minus(ChronoUnit), with(ChronoField) 또는 편의 메서드
  • 복잡한 계산: TemporalAdjusters 활용
  • 안전성: isSupported()로 지원 여부 확인

Java Time API는 잘 설계된 API의 모범 사례라고 한다. 일관성 있는 인터페이스, 불변성, 타입 안전성을 모두 갖추고 있어, 한 번 익히면 평생 사용할 수 있는 지식이 된다

출처 – 김영한 님의 강의 중 김영한의 실전 자바 – 중급 1편