Theme:

날짜를 다루다 보면 한 가지 이상한 경험을 하게 된다. "1월인데 왜 0이 나오지?" Java의 레거시 Date, Calendar 클래스를 써본 사람이라면 한 번쯤 겪었을 혼란이다. 왜 이렇게 설계했는지부터 시작해서, Java 8이 내놓은 해결책인 java.time 패키지를 차근차근 정리해본다.

Date와 Calendar가 왜 끔찍한가

Java 초창기부터 있던 java.util.Datejava.util.Calendar는 악명 높은 API다. 공부하다 보니 이 둘의 문제점이 한두 가지가 아니었다.

JAVA
// 문제 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-based1월이 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이 직접 설계에 참여했다.

핵심 클래스는 세 가지다.

PLAINTEXT
LocalDate       — 날짜만 (2026-03-19)
LocalTime       — 시간만 (14:30:00)
LocalDateTime   — 날짜 + 시간 (2026-03-19T14:30:00)

공통 특징:

  • 불변(immutable): 한번 만들면 값이 바뀌지 않는다
  • 스레드 안전: 여러 스레드에서 공유해도 문제없다
  • month가 1부터 시작: 1월 = 1, 12월 = 12. 상식적이다
  • 타임존 없음: "벽시계에 보이는 시간"을 표현한다
JAVA
// 직관적인 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); // 조합

날짜 생성과 조작

생성 방법

JAVA
// 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의 조작 메서드는 항상 새 객체를 반환한다. 원본은 절대 변하지 않는다.

JAVA
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 — 변하지 않음

비교와 판단

JAVA
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일" 같은 표현.

JAVA
// 두 날짜 사이의 간격
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 — 시간 기반 간격

기계적인 시간 간격이다. 초와 나노초 단위.

JAVA
// 두 시간 사이의 간격
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

한눈에 비교

구분PeriodDuration
기반년/월/일초/나노초
대상LocalDateLocalTime, LocalDateTime, Instant
예시2년 3개월9시간 30분
DST 영향받지 않음받을 수 있음

DateTimeFormatter — 포맷팅과 파싱

날짜를 문자열로 바꾸거나(포맷팅), 문자열을 날짜로 바꾸는(파싱) 작업에 사용한다.

미리 정의된 포맷터

JAVA
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

커스텀 패턴

실무에서는 대부분 커스텀 패턴을 쓰게 된다.

JAVA
// 커스텀 포맷터 생성
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

주요 패턴 문자

패턴의미예시
yyyy4자리 연도2026
MM2자리 월03
dd2자리 일19
HH24시간 형식 시14
hh12시간 형식 시02
mm30
ss00
a오전/오후PM
E요일

주의: MM은 월, mm은 분이다. 대소문자가 다르면 완전히 다른 의미다. 면접에서 이 포인트를 자주 물어보더라고요.

스레드 안전

DateTimeFormatter불변 객체라서 static final로 선언해서 공유해도 안전하다. 레거시 SimpleDateFormat과 결정적으로 다른 점이다.

JAVA
// 이렇게 써도 안전하다 — 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)을 포함한 날짜-시간이다.

JAVA
// 타임존을 지정해서 생성
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) 같은 규칙은 모르고, 단순히 고정된 시차만 표현한다.

JAVA
// 오프셋 지정
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 등)이 필요한 경우
OffsetDateTimeDB 저장, API 통신 등 고정 오프셋이 필요한 경우

Instant — 기계 시간, epoch 기반

Instant는 **유닉스 에포크(1970-01-01T00:00:00Z)**로부터 경과한 시간을 나노초 단위로 표현한다. 사람이 읽기 위한 것이 아니라, 타임스탬프를 저장하고 비교하기 위한 클래스다.

JAVA
// 현재 타임스탬프
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 저장 시 타입 선택

JAVA
// 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)
LocalDateDATEDATE
LocalTimeTIMETIME
LocalDateTimeDATETIMETIMESTAMP
InstantTIMESTAMPTIMESTAMPTZ

JSON 직렬화 (Jackson)

JAVA
// 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 변환

레거시 코드와 함께 일하다 보면 변환이 필요할 때가 있다.

JAVA
// 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());

변환 흐름 정리:

PLAINTEXT
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부터 시작해서 동기화가 왜 필요한지 알아본다.

댓글 로딩 중...