new Date()가 1900년을 더해야 하는 이유 — java.time이 해결한 날짜 API의 재앙
// Java 1.1 (1997년)
Date d = new Date(2024 - 1900, 1 - 1, 1);
// 연도에서 1900을 빼고, 월은 0부터 시작 — 상식과 정반대
이 API는 Java 1.0(1996년)에서 만들어졌고, 30년이 지나도 여전히 레거시 코드에 남아 있다. java.util.Date의 월은 0부터 시작하고, 연도는 1900을 빼야 하며, SimpleDateFormat은 thread-safe하지 않다. Java 8(2014년)의 java.time 패키지(JEP-150, JSR-310)가 이 재앙을 끝냈다. (java.time 패키지)
이 글은 java.time의 핵심 타입들 — Instant, LocalDate, ZonedDateTime, Duration, Period — 의 차이와 선택 기준, 그리고 시간대(time zone)의 함정을 풀어간다.
java.time의 핵심 타입 — 불변이고 스레드 안전
flowchart TD
subgraph timeline["타임라인 (기계적)"]
INST["Instant<br/>UTC 기준 에포크 나노초"]
DUR["Duration<br/>시간량 (시/분/초/나노)"]
end
subgraph human["인간용 (지역적)"]
LD["LocalDate<br/>날짜 (2024-07-10)"]
LT["LocalTime<br/>시간 (14:30:00)"]
LDT["LocalDateTime<br/>날짜+시간"]
ZDT["ZonedDateTime<br/>날짜+시간+시간대"]
end
subgraph span["기간"]
PD["Period<br/>날짜량 (년/월/일)"]
end
| 타입 | 의미 | 예 | 불변 |
|---|---|---|---|
Instant |
UTC 기준 시점(epoch) | 2024-07-10T05:30:00Z |
O |
LocalDate |
시간대 없는 날짜 | 2024-07-10 |
O |
LocalTime |
시간대 없는 시간 | 14:30:00 |
O |
LocalDateTime |
시간대 없는 날짜+시간 | 2024-07-10T14:30:00 |
O |
ZonedDateTime |
시간대 포함 날짜+시간 | 2024-07-10T14:30:00+09:00[Asia/Seoul] |
O |
Duration |
시간량(시/분/초/나노) | PT2H30M (2시간 30분) |
O |
Period |
날짜량(년/월/일) | P1Y2M (1년 2개월) |
O |
java.time의 모든 타입은 불변이다. 메서드는 객체를 수정하지 않고 새 객체를 반환한다 — String과 같은 방식. thread-safe가 자동 보장된다.
Instant — 기계용 절대 시점
// Java 25
Instant now = Instant.now(); // 현재 UTC 시점
Instant epoch = Instant.ofEpochSecond(0); // 1970-01-01T00:00:00Z
Instant future = now.plus(Duration.ofHours(2)); // 2시간 후
System.out.println(now); // 2026-07-10T05:30:00.123456789Z (Z = UTC)
System.out.println(now.getEpochSecond()); // Unix timestamp (초)
System.out.println(now.toEpochMilli()); // Unix timestamp (밀리초)
Instant는 로그 타임스탬프, 성능 측정, 캐시 만료 시간 등 시간대와 무관한 절대적 시점에 쓴다. UTC가 기준이므로 서버 간 시간 비교가 안전하다.
LocalDate / LocalTime / LocalDateTime — 인간용
// Java 25
LocalDate today = LocalDate.now(); // 2026-07-10
LocalDate christmas = LocalDate.of(2026, Month.DECEMBER, 25);
LocalTime now2 = LocalTime.now(); // 14:30:00.123
LocalTime lunch = LocalTime.of(12, 0);
LocalDateTime meeting = LocalDateTime.of(2026, 7, 15, 10, 0); // 2026-07-15T10:00
Local* 타입은 시간대 정보가 없다 — "2026년 7월 10일"이 어느 시간대인지 알 수 없다. 달력, 스케줄, 생일 등 시간대가 무의미한 경우에 쓴다.
LocalDateTime은 "2026-07-10 14:30"을 의미하지만, 이것이 서울인지 뉴욕인지 알 수 없다. 시간대가 필요하면ZonedDateTime을 써야 한다. DB 저장 시TIMESTAMP WITHOUT TIME ZONE에 해당한다.
ZonedDateTime — 시간대를 포함한 시점
// Java 25
ZoneId seoul = ZoneId.of("Asia/Seoul");
ZoneId newYork = ZoneId.of("America/New_York");
ZonedDateTime nowInSeoul = ZonedDateTime.now(seoul);
// 2026-07-10T14:30:00+09:00[Asia/Seoul]
ZonedDateTime nowInNY = ZonedDateTime.now(newYork);
// 2026-07-10T01:30:00-04:00[America/New_York]
// 같은 Instant, 다른 시간대 표현
Instant instant = nowInSeoul.toInstant();
ZonedDateTime same = instant.atZone(newYork); // 시간대 변환
서머타임(Daylight Saving Time) 함정
// Java 25 — 서머타임 전환 시점
// 미국 동부: 2026-03-08 02:00 → 03:00 (spring forward, 2시가 사라짐)
// 미국 동부: 2026-11-01 02:00 → 01:00 (fall back, 1시가 두 번)
ZoneId ny = ZoneId.of("America/New_York");
LocalDate date = LocalDate.of(2026, 3, 8);
LocalDateTime ambiguous = LocalDateTime.of(date, LocalTime.of(2, 30));
// ZonedDateTime.of(ambiguous, ny); // ZoneRulesException — 존재하지 않는 시간!
// 해결: withEarlierOffsetAtOverlap() 또는 withLaterOffsetAtOverlap()
서머타임이 시작되는 날 2:00→3:00으로 넘어가면 2:30이라는 시간이 존재하지 않는다. 반대로 끝나는 날 2:00→1:00으로 돌아가면 1:30이 두 번 존재한다.
ZonedDateTime은 이런 모호성을 예외 또는 메서드로 처리한다. (ZonedDateTime API)
Duration vs Period — 시간량의 두 종류
// Java 25
// Duration: 시/분/초 단위 (정밀)
Duration twoHours = Duration.ofHours(2);
Duration ninetyMins = Duration.ofMinutes(90);
System.out.println(ninetyMins.toMinutes()); // 90
// Period: 년/월/일 단위 (달력 기반)
Period oneMonth = Period.ofMonths(1);
Period oneYearHalf = Period.of(1, 6, 0); // 1년 6개월
// 차이: 날짜 계산에서 서로 다른 결과
LocalDate date = LocalDate.of(2026, 1, 31);
// Period.plusMonths(1) — 달력 기반
LocalDate plusMonth = date.plus(Period.ofMonths(1));
System.out.println(plusMonth); // 2026-02-28 (달력 기반, 2월은 28일까지)
// Duration으로 날짜에 30일 더하기
LocalDate plusDays = date.plus(Duration.ofDays(30));
System.out.println(plusDays); // 2026-03-02 (절대 일수 기반)
Period는 달력 기반(1개월 = 다음 달 같은 날),Duration은 절대 시간 기반(30일 = 30 × 24시간)이다. "한 달 후"가 필요하면Period, "30일 후"가 필요하면Duration을 쓴다.
포맷팅과 파싱
// Java 25
import java.time.format.DateTimeFormatter;
// 포맷팅
DateTimeFormatter formatter = DateTimeFormatter.ofPattern("yyyy년 MM월 dd일 HH시 mm분");
String formatted = LocalDateTime.now().format(formatter);
System.out.println(formatted); // 2026년 07월 10일 14시 30분
// 파싱
LocalDate parsed = LocalDate.parse("2026-07-10");
LocalDate custom = LocalDate.parse("2026/07/10", DateTimeFormatter.ofPattern("yyyy/MM/dd"));
// 미리 정의된 포맷
String iso = Instant.now().toString(); // ISO-8601: 2026-07-10T05:30:00Z
DateTimeFormatter는 thread-safe다.SimpleDateFormat(레거시)은 thread-safe하지 않아, 공유 필드로 두면 동시성 버그가 발생한다.java.time으로 전환하면 이 문제가 사라진다.
TemporalAdjuster — 복잡한 날짜 계산
"이번 달의 마지막 금요일"을 구하려면? 직접 계산하면 복잡하지만, TemporalAdjuster를 쓰면 한 줄이다:
// Java 25
import java.time.temporal.TemporalAdjusters;
LocalDate today = LocalDate.now();
// 미리 정의된 조정자
LocalDate firstDayOfMonth = today.with(TemporalAdjusters.firstDayOfMonth());
LocalDate lastDayOfMonth = today.with(TemporalAdjusters.lastDayOfMonth());
LocalDate firstMonday = today.with(TemporalAdjusters.firstInMonth(DayOfWeek.MONDAY));
LocalDate lastFriday = today.with(TemporalAdjusters.lastInMonth(DayOfWeek.FRIDAY));
LocalDate nextWednesday = today.with(TemporalAdjusters.next(DayOfWeek.WEDNESDAY));
System.out.println("이번 달 첫 날: " + firstDayOfMonth);
System.out.println("이번 달 마지막 날: " + lastDayOfMonth);
System.out.println("이번 달 첫 월요일: " + firstMonday);
System.out.println("이번 달 마지막 금요일: " + lastFriday);
System.out.println("다음 수요일: " + nextWednesday);
커스텀 TemporalAdjuster
// Java 25 — 영업일(주말 제외) 계산
LocalDate addBusinessDays(LocalDate date, int days) {
LocalDate result = date;
int added = 0;
while (added < days) {
result = result.plusDays(1);
if (result.getDayOfWeek() != DayOfWeek.SATURDAY
&& result.getDayOfWeek() != DayOfWeek.SUNDAY) {
added++;
}
}
return result;
}
LocalDate today2 = LocalDate.now();
System.out.println("오늘: " + today2);
System.out.println("3 영업일 후: " + addBusinessDays(today2, 3));
TemporalAdjuster는 함수형 인터페이스다 —Temporal adjustInto(Temporal temporal)를 구현하면 커스텀 날짜 규칙을 만들 수 있다. (TemporalAdjusters API)
실습 — 날짜/시간 연산
// Java 25 — DateTimeDemo.java
import java.time.*;
import java.time.format.DateTimeFormatter;
import java.time.temporal.ChronoUnit;
public class DateTimeDemo {
public static void main(String[] args) {
// 생일까지 남은 일수
LocalDate today = LocalDate.now();
LocalDate birthday = LocalDate.of(2026, Month.DECEMBER, 25);
long daysUntilBirthday = ChronoUnit.DAYS.between(today, birthday);
System.out.printf("크리스마스까지 %d일%n", daysUntilBirthday);
// 회의 시간을 여러 시간대로 변환
ZonedDateTime meetingSeoul = ZonedDateTime.of(
2026, 7, 15, 10, 0, 0, 0, ZoneId.of("Asia/Seoul"));
ZonedDateTime meetingNY = meetingSeoul.withZoneSameInstant(ZoneId.of("America/New_York"));
ZonedDateTime meetingLondon = meetingSeoul.withZoneSameInstant(ZoneId.of("Europe/London"));
DateTimeFormatter fmt = DateTimeFormatter.ofPattern("HH:mm z");
System.out.println("서울: " + meetingSeoul.format(fmt));
System.out.println("뉴욕: " + meetingNY.format(fmt));
System.out.println("런던: " + meetingLondon.format(fmt));
// 경과 시간 측정
Instant start = Instant.now();
// ... 작업 ...
Instant end = Instant.now();
Duration elapsed = Duration.between(start, end);
System.out.printf("경과: %d ms%n", elapsed.toMillis());
}
}
java DateTimeDemo.java
크리스마스까지 168일
서울: 10:00 KST
뉴욕: 21:00 EDT (전일)
런던: 02:00 BST
확인할 것: 서울 오전 10시가 뉴욕 전일 오후 9시, 런던 새벽 2시로 변환된다. ZonedDateTime.withZoneSameInstant()로 같은 시점을 다른 시간대로 표현한다.
레거시 Date/Calendar와의 상호 변환
// Java 25 — 레거시 변환
Instant instant = Instant.now();
// Instant → java.util.Date
java.util.Date legacy = Date.from(instant);
// java.util.Date → Instant
Instant back = legacy.toInstant();
// Instant → LocalDate (시간대 지정 필요)
LocalDate date = LocalDate.ofInstant(instant, ZoneId.of("Asia/Seoul"));
새 코드에서는 항상
java.time을 쓴다. 레거시 API(JDBC, 타사 라이브러리)와의 경계에서만 변환한다. JDBC 4.2+(Java 8+)는LocalDate,Instant등을 직접 지원한다 —ResultSet.getObject(1, LocalDate.class).
요약 — 이 글의 결론
java.time이 현대 Java 날짜 API의 유일한 선택이다.Date,Calendar,SimpleDateFormat은 thread-safe하지 않고 설계가 잘못됐다. 새 코드에서 쓰지 않는다.- Instant는 기계용(UTC 절대 시점),
Local*은 인간용(시간대 없음),ZonedDateTime은 시간대 포함. 용도에 맞게 선택한다. - Duration은 시/분/초, Period는 년/월/일. "한 달 후"와 "30일 후"는 다를 수 있다 — 달력 기반(Period)과 절대 시간(Duration)의 차이.
- 서머타임은 시간대 계산의 함정이다.
ZonedDateTime은 모호한 시간(봄의 빈 시간, 가을의 중복 시간)을 예외 또는 메서드로 처리한다. DateTimeFormatter는 thread-safe하다.SimpleDateFormat은 아니다 — 공유하면 동시성 버그.
생각해 볼 문제
LocalDateTime.now()와Instant.now()의 차이를 설명하라. 같은 컴퓨터에서 실행하면 어떤 값이 다른가?Duration.ofDays(1)가 항상 24시간인가? 서머타임 전환일에ZonedDateTime.plus(Duration.ofDays(1))를 하면 어떻게 되는가?LocalDate.of(2026, 2, 29)는 어떻게 되는가? 윤년이 아닌 해에 2월 29일을 지정하면?Instant를 DB에 저장할 때TIMESTAMPvsTIMESTAMP WITH TIME ZONE중 어느 것을 써야 하는가?- 한국은 서머타임이 없다(2018년 폐지).
Asia/Seoul시간대에서ZonedDateTime이 고려해야 할 특수 규칙이 있는가?
참고
- java.time 패키지 (Java 25) - 접근 2026-07-10
- Instant API - 접근 2026-07-10
- ZonedDateTime API - 접근 2026-07-10
- DateTimeFormatter API - 접근 2026-07-10
- JEP-150: Date & Time API - 접근 2026-07-10
'Develop Artifacts > Java' 카테고리의 다른 글
| Java - 17. annotation-reflection (0) | 2026.07.12 |
|---|---|
| Java - 16. io nio (0) | 2026.07.12 |
| Java - 14. optional (0) | 2026.07.12 |
| Java - 13. stream (0) | 2026.07.12 |
| Java - 12. lambda functional (0) | 2026.07.12 |
