날짜와 시간 — java.time으로 날짜 다루기
날짜를 다루다 보면 한 가지 이상한 경험을 하게 된다. "1월인데 왜 0이 나오지?" Java의 레거시 Date, Calendar 클래스를 써본 사람이라면 한 번쯤 겪었을 혼란이다. 왜 이렇게 설계했는지부터 시작해서, Java 8이 내놓은 해결책인 java.time 패키지를 차근차근 정리해본다.
Date와 Calendar가 왜 끔찍한가
Java 초창기부터 있던 java.util.Date와 java.util.Calendar는 악명 높은 API다. 공부하다 보니 이 둘의 문제점이 한두 가지가 아니었다.
// 문제 1: month가 0부터 시작한다
Date date = new Date(2026, 3, 19); // 2026년 3월 19일... 이 아니라 3927년 4월 19일
// year는 1900을 더하고, month는 0-based라서 3 = 4월
// 문제 2: mutable — 값이 바뀐다
Date start = new Date();
Date end = start; // 같은 객체를 참조
end.setTime(0); // start도 같이 바뀐다!
// 문제 3: Calendar도 마찬가지로 혼란스럽다
Calendar cal = Calendar.getInstance();
cal.set(Calendar.MONTH, 1); // 2월이다. 1월이 아니다!
정리하면 이런 문제들이 있다.
| 문제 | 설명 |
|---|---|
| mutable | 한번 만든 날짜 객체의 값이 바뀔 수 있다. 사이드 이펙트의 원인 |
| month 0-based | 1월이 0, 12월이 11. 실수를 유발하는 설계 |
| 스레드 안전하지 않음 | SimpleDateFormat이 대표적. 멀티스레드에서 공유하면 버그 |
| API 일관성 부족 | Date인데 시간도 포함, Calendar인데 날짜도 포함 |
| 타임존 처리 난해 | Date는 내부적으로 UTC인데 toString()은 시스템 타임존으로 출력 |
면접에서 "왜 java.time을 사용해야 하나요?"라고 물으면, **"레거시 API가 mutable하고 month가 0-based라서 버그를 유발하기 때문"**이라고 답하면 핵심을 짚은 것이다.
java.time 패키지 개요
Java 8에서 도입된 java.time 패키지는 위의 모든 문제를 해결하기 위해 만들어졌다. Joda-Time 라이브러리의 저자인 Stephen Colebourne이 직접 설계에 참여했다.
핵심 클래스는 세 가지다.
LocalDate — 날짜만 (2026-03-19)
LocalTime — 시간만 (14:30:00)
LocalDateTime — 날짜 + 시간 (2026-03-19T14:30:00)
공통 특징:
- 불변(immutable): 한번 만들면 값이 바뀌지 않는다
- 스레드 안전: 여러 스레드에서 공유해도 문제없다
- month가 1부터 시작: 1월 = 1, 12월 = 12. 상식적이다
- 타임존 없음: "벽시계에 보이는 시간"을 표현한다
// 직관적인 API
LocalDate date = LocalDate.of(2026, 3, 19); // 2026년 3월 19일. 그냥 3이 3월이다!
LocalTime time = LocalTime.of(14, 30); // 오후 2시 30분
LocalDateTime dateTime = LocalDateTime.of(date, time); // 조합
날짜 생성과 조작
생성 방법
// now() — 현재 시간
LocalDate today = LocalDate.now(); // 오늘 날짜
LocalTime now = LocalTime.now(); // 현재 시각
LocalDateTime current = LocalDateTime.now(); // 현재 날짜+시간
// of() — 직접 지정
LocalDate birthday = LocalDate.of(1995, 8, 15);
LocalDate sameDay = LocalDate.of(1995, Month.AUGUST, 15); // enum 사용 가능
// parse() — 문자열에서 생성
LocalDate parsed = LocalDate.parse("2026-03-19");
LocalTime parsedTime = LocalTime.parse("14:30:00");
조작 메서드
java.time의 조작 메서드는 항상 새 객체를 반환한다. 원본은 절대 변하지 않는다.
LocalDate today = LocalDate.of(2026, 3, 19);
// 더하기
LocalDate nextWeek = today.plusDays(7); // 2026-03-26
LocalDate nextMonth = today.plusMonths(1); // 2026-04-19
LocalDate nextYear = today.plusYears(1); // 2027-03-19
// 빼기
LocalDate lastWeek = today.minusDays(7); // 2026-03-12
LocalDate lastMonth = today.minusMonths(1); // 2026-02-19
// 특정 값으로 변경
LocalDate withDay = today.withDayOfMonth(1); // 2026-03-01 (이번 달 1일)
LocalDate withMonth = today.withMonth(12); // 2026-12-19
// 원본은 그대로다!
System.out.println(today); // 2026-03-19 — 변하지 않음
비교와 판단
LocalDate date1 = LocalDate.of(2026, 3, 19);
LocalDate date2 = LocalDate.of(2026, 12, 25);
// 비교
boolean isBefore = date1.isBefore(date2); // true
boolean isAfter = date1.isAfter(date2); // false
boolean isEqual = date1.isEqual(date2); // false
// 유용한 판단 메서드
boolean isLeapYear = date1.isLeapYear(); // false (2026년은 윤년 아님)
int lengthOfMonth = date1.lengthOfMonth(); // 31 (3월은 31일)
DayOfWeek dayOfWeek = date1.getDayOfWeek(); // THURSDAY
Duration과 Period — 시간 간격 vs 날짜 간격
공부하다 보니 여기서 많이 헷갈렸다. 둘 다 "간격"을 나타내는데, 용도가 다르다.
Period — 날짜 기반 간격
사람이 생각하는 방식의 간격이다. "1년 3개월 5일" 같은 표현.
// 두 날짜 사이의 간격
LocalDate start = LocalDate.of(2024, 1, 1);
LocalDate end = LocalDate.of(2026, 3, 19);
Period period = Period.between(start, end);
System.out.println(period); // P2Y2M18D (2년 2개월 18일)
System.out.println(period.getYears()); // 2
System.out.println(period.getMonths()); // 2
System.out.println(period.getDays()); // 18
// 직접 생성
Period oneYearThreeMonths = Period.of(1, 3, 0); // 1년 3개월
Period twoWeeks = Period.ofWeeks(2); // 14일
// 날짜에 적용
LocalDate future = start.plus(oneYearThreeMonths); // 2025-04-01
Duration — 시간 기반 간격
기계적인 시간 간격이다. 초와 나노초 단위.
// 두 시간 사이의 간격
LocalTime morning = LocalTime.of(9, 0);
LocalTime evening = LocalTime.of(18, 30);
Duration workHours = Duration.between(morning, evening);
System.out.println(workHours); // PT9H30M (9시간 30분)
System.out.println(workHours.toHours()); // 9
System.out.println(workHours.toMinutes()); // 570
// 직접 생성
Duration twoHours = Duration.ofHours(2);
Duration thirtyMinutes = Duration.ofMinutes(30);
Duration fiveSeconds = Duration.ofSeconds(5);
// 시간에 적용
LocalTime lunchEnd = morning.plus(Duration.ofHours(4)); // 13:00
한눈에 비교
| 구분 | Period | Duration |
|---|---|---|
| 기반 | 년/월/일 | 초/나노초 |
| 대상 | LocalDate | LocalTime, LocalDateTime, Instant |
| 예시 | 2년 3개월 | 9시간 30분 |
| DST 영향 | 받지 않음 | 받을 수 있음 |
DateTimeFormatter — 포맷팅과 파싱
날짜를 문자열로 바꾸거나(포맷팅), 문자열을 날짜로 바꾸는(파싱) 작업에 사용한다.
미리 정의된 포맷터
LocalDateTime now = LocalDateTime.of(2026, 3, 19, 14, 30, 0);
// 기본 포맷터 사용
String isoDate = now.format(DateTimeFormatter.ISO_LOCAL_DATE); // 2026-03-19
String isoDateTime = now.format(DateTimeFormatter.ISO_LOCAL_DATE_TIME); // 2026-03-19T14:30:00
커스텀 패턴
실무에서는 대부분 커스텀 패턴을 쓰게 된다.
// 커스텀 포맷터 생성
DateTimeFormatter korean = DateTimeFormatter.ofPattern("yyyy년 MM월 dd일 HH시 mm분");
DateTimeFormatter slash = DateTimeFormatter.ofPattern("yyyy/MM/dd");
DateTimeFormatter simple = DateTimeFormatter.ofPattern("yy.MM.dd");
LocalDateTime now = LocalDateTime.of(2026, 3, 19, 14, 30, 0);
// 포맷팅 — 날짜 → 문자열
System.out.println(now.format(korean)); // 2026년 03월 19일 14시 30분
System.out.println(now.format(slash)); // 2026/03/19
System.out.println(now.format(simple)); // 26.03.19
// 파싱 — 문자열 → 날짜
LocalDate parsed = LocalDate.parse("2026/03/19", slash);
System.out.println(parsed); // 2026-03-19
주요 패턴 문자
| 패턴 | 의미 | 예시 |
|---|---|---|
yyyy | 4자리 연도 | 2026 |
MM | 2자리 월 | 03 |
dd | 2자리 일 | 19 |
HH | 24시간 형식 시 | 14 |
hh | 12시간 형식 시 | 02 |
mm | 분 | 30 |
ss | 초 | 00 |
a | 오전/오후 | PM |
E | 요일 | 목 |
주의:
MM은 월,mm은 분이다. 대소문자가 다르면 완전히 다른 의미다. 면접에서 이 포인트를 자주 물어보더라고요.
스레드 안전
DateTimeFormatter는 불변 객체라서 static final로 선언해서 공유해도 안전하다. 레거시 SimpleDateFormat과 결정적으로 다른 점이다.
// 이렇게 써도 안전하다 — DateTimeFormatter는 불변
public class DateUtils {
private static final DateTimeFormatter FORMATTER =
DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss");
public static String format(LocalDateTime dateTime) {
return dateTime.format(FORMATTER); // 멀티스레드 안전
}
}
ZonedDateTime과 OffsetDateTime — 타임존 처리
LocalDateTime은 타임존 정보가 없다. "3월 19일 오후 2시"라고만 하면, 서울의 오후 2시인지 뉴욕의 오후 2시인지 알 수 없다. 글로벌 서비스에서는 타임존이 필수다.
ZonedDateTime
타임존(ZoneId)을 포함한 날짜-시간이다.
// 타임존을 지정해서 생성
ZonedDateTime seoulTime = ZonedDateTime.now(ZoneId.of("Asia/Seoul"));
ZonedDateTime nyTime = ZonedDateTime.now(ZoneId.of("America/New_York"));
System.out.println(seoulTime); // 2026-03-19T14:30:00+09:00[Asia/Seoul]
System.out.println(nyTime); // 2026-03-19T01:30:00-04:00[America/New_York]
// 같은 시점이지만 표현이 다르다
System.out.println(seoulTime.isEqual(nyTime)); // true — 같은 순간
// 타임존 변환
ZonedDateTime tokyoTime = seoulTime.withZoneSameInstant(ZoneId.of("Asia/Tokyo"));
// 서울과 도쿄는 같은 시간대라 시간이 같다
// LocalDateTime에 타임존 부여
LocalDateTime local = LocalDateTime.of(2026, 3, 19, 14, 30);
ZonedDateTime zoned = local.atZone(ZoneId.of("Asia/Seoul"));
OffsetDateTime
UTC로부터의 오프셋(+09:00 같은)만 포함한다. 서머타임(DST) 같은 규칙은 모르고, 단순히 고정된 시차만 표현한다.
// 오프셋 지정
OffsetDateTime odt = OffsetDateTime.of(
LocalDateTime.of(2026, 3, 19, 14, 30),
ZoneOffset.of("+09:00")
);
System.out.println(odt); // 2026-03-19T14:30+09:00
// UTC로 변환
OffsetDateTime utc = odt.withOffsetSameInstant(ZoneOffset.UTC);
System.out.println(utc); // 2026-03-19T05:30Z
언제 뭘 쓸까?
| 클래스 | 용도 |
|---|---|
LocalDateTime | 타임존이 필요 없는 경우 (생년월일, 영업시간 등) |
ZonedDateTime | 타임존 규칙(DST 등)이 필요한 경우 |
OffsetDateTime | DB 저장, API 통신 등 고정 오프셋이 필요한 경우 |
Instant — 기계 시간, epoch 기반
Instant는 **유닉스 에포크(1970-01-01T00:00:00Z)**로부터 경과한 시간을 나노초 단위로 표현한다. 사람이 읽기 위한 것이 아니라, 타임스탬프를 저장하고 비교하기 위한 클래스다.
// 현재 타임스탬프
Instant now = Instant.now();
System.out.println(now); // 2026-03-19T05:30:00.123456789Z (항상 UTC)
// epoch 초로 생성
Instant fromEpoch = Instant.ofEpochSecond(1_774_000_000L);
Instant fromMilli = Instant.ofEpochMilli(System.currentTimeMillis());
// 비교
long secondsBetween = Duration.between(fromEpoch, now).getSeconds();
// Instant → ZonedDateTime (타임존 부여)
ZonedDateTime zdt = now.atZone(ZoneId.of("Asia/Seoul"));
Instant 사용 시나리오:
- 로그 타임스탬프
- 이벤트 발생 시각 기록
- 두 시점 사이의 경과 시간 계산
- DB에 UTC 타임스탬프 저장
실무 팁
DB 저장 시 타입 선택
// JPA 엔티티 예시
@Entity
public class Order {
// 주문 시각 — 타임존이 중요하다면 Instant
@Column(name = "ordered_at")
private Instant orderedAt;
// 배송 예정일 — 날짜만 필요
@Column(name = "delivery_date")
private LocalDate deliveryDate;
// 영업 시작 시간 — 시간만 필요
@Column(name = "open_time")
private LocalTime openTime;
}
| Java 타입 | DB 타입 (MySQL) | DB 타입 (PostgreSQL) |
|---|---|---|
LocalDate | DATE | DATE |
LocalTime | TIME | TIME |
LocalDateTime | DATETIME | TIMESTAMP |
Instant | TIMESTAMP | TIMESTAMPTZ |
JSON 직렬화 (Jackson)
// Jackson에서 java.time 지원을 위한 의존성
// build.gradle: implementation 'com.fasterxml.jackson.datatype:jackson-datatype-jsr310'
// ObjectMapper 설정
ObjectMapper mapper = new ObjectMapper();
mapper.registerModule(new JavaTimeModule());
mapper.disable(SerializationFeature.WRITE_DATES_AS_TIMESTAMPS); // ISO 형식으로 출력
// DTO에서 포맷 지정
public class EventDto {
@JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss")
private LocalDateTime startTime;
@JsonFormat(pattern = "yyyy-MM-dd")
private LocalDate eventDate;
}
TIP:
jackson-datatype-jsr310모듈 없이 java.time을 직렬화하면 배열 형태[2026, 3, 19]로 나온다. 실무에서 흔한 실수이니 주의하자. 전체 예제는 핸드북의examples/15를 참고하면 된다.
Date ↔ java.time 변환
레거시 코드와 함께 일하다 보면 변환이 필요할 때가 있다.
// Date → Instant → LocalDateTime
Date legacyDate = new Date();
Instant instant = legacyDate.toInstant();
LocalDateTime ldt = LocalDateTime.ofInstant(instant, ZoneId.systemDefault());
// LocalDateTime → Instant → Date
LocalDateTime localDateTime = LocalDateTime.of(2026, 3, 19, 14, 30);
Instant toInstant = localDateTime.atZone(ZoneId.systemDefault()).toInstant();
Date backToDate = Date.from(toInstant);
// Calendar → Instant
Calendar calendar = Calendar.getInstance();
Instant fromCal = calendar.toInstant();
ZonedDateTime fromCalZoned = fromCal.atZone(calendar.getTimeZone().toZoneId());
변환 흐름 정리:
Date ──toInstant()──→ Instant ──atZone()──→ ZonedDateTime ──toLocalDateTime()──→ LocalDateTime
↑
Calendar ──toInstant()───┘
LocalDateTime ──atZone()──→ ZonedDateTime ──toInstant()──→ Instant ──Date.from()──→ Date
핵심은 **Instant를 허브(hub)**로 사용하는 것이다. 레거시 → Instant → java.time, 또는 그 반대로 변환한다.
정리 테이블
| 클래스 | 용도 | 타임존 | 예시 |
|---|---|---|---|
LocalDate | 날짜만 | 없음 | 생년월일, 휴일 |
LocalTime | 시간만 | 없음 | 영업시간, 알람 |
LocalDateTime | 날짜 + 시간 | 없음 | 예약 시각 (단일 타임존) |
ZonedDateTime | 날짜 + 시간 + 타임존 | 있음 (ZoneId) | 글로벌 이벤트 시각 |
OffsetDateTime | 날짜 + 시간 + 오프셋 | 있음 (고정) | API 응답, DB 저장 |
Instant | 타임스탬프 | UTC 고정 | 로그, 이벤트 기록 |
Duration | 시간 간격 | - | 2시간 30분 |
Period | 날짜 간격 | - | 1년 3개월 |
DateTimeFormatter | 포맷팅/파싱 | - | "yyyy-MM-dd" |
기억할 포인트:
LocalXxx는 타임존이 없다. "벽시계 시간"이다.- 글로벌 서비스 →
ZonedDateTime또는Instant - DB 저장 →
Instant(UTC) 또는OffsetDateTime - 포맷터는
static final로 공유해도 안전하다 - 레거시 변환은
Instant를 허브로 사용한다
다음 글에서는 스레드 기초를 다룬다. Thread와 Runnable부터 시작해서 동기화가 왜 필요한지 알아본다.