"1월인데 왜 0이 나오지?" DateCalendar를 써본 사람이라면 한 번쯤 겪었을 혼란이다. month가 0부터 시작하는 것은 빙산의 일각일 뿐이다.

Date와 Calendar의 문제점

java.time 은 레거시 Date/Calendar의 설계 결함을 해결하기 위해 Java 8에서 도입된 날짜·시간 패키지예요. Joda-Time의 저자 Stephen Colebourne이 직접 설계에 참여했습니다.

레거시 API의 핵심 문제를 정리하면 이렇습니다.

문제설명
mutable날짜 객체의 값이 바뀔 수 있다. 사이드 이펙트의 원인
month 0-based1월이 0, 12월이 11. new Date(2026, 3, 19)는 4월 19일이다
** 스레드 안전하지 않음**SimpleDateFormat을 멀티스레드에서 공유하면 버그 발생
API 일관성 부족Date인데 시간도 포함, Calendar인데 날짜도 포함
** 타임존 처리 난해**Date는 내부 UTC인데 toString()은 시스템 타임존으로 출력

이 모든 문제가 java.time에서 해결됩니다. 불변 객체, 1-based month, 스레드 안전, 명확한 클래스 분리까지 갖추고 있어요.

java.time 핵심 클래스

핵심 클래스는 세 가지입니다.

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 는 년/월/일(사람 기준), Duration 은 초/나노초(기계 기준)예요.

Period — 날짜 기반 간격

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, 또는 그 반대로 변환하면 돼요.

주의할 점

LocalDateTime에 타임존 정보가 없습니다

LocalDateTime.now()는 시스템 타임존 기준이지만, 객체 자체에 타임존 정보가 없습니다. 이걸 DB에 저장하면 서버 타임존이 바뀔 때 의미가 달라져요. 글로벌 서비스에서는 반드시 Instant(UTC) 또는 ZonedDateTime을 사용해야 합니다.

SimpleDateFormat은 스레드 안전하지 않습니다

레거시 코드에서 SimpleDateFormatstatic final로 공유하면 멀티스레드 환경에서 파싱 결과가 뒤섞입니다. DateTimeFormatter는 불변이라 안전하게 공유할 수 있어요.

jackson-datatype-jsr310 누락

Jackson으로 java.time을 직렬화할 때 jackson-datatype-jsr310 모듈 없이 하면 [2026, 3, 19] 같은 배열 형태로 나옵니다. JavaTimeModule을 등록하고 WRITE_DATES_AS_TIMESTAMPS를 꺼야 ISO 형식이 돼요.

정리

항목핵심
LocalDate/Time/DateTime타임존 없는 "벽시계 시간". 불변, 스레드 안전
ZonedDateTime타임존(DST 포함) 있는 날짜·시간. 글로벌 서비스에서 사용
InstantUTC 기반 타임스탬프. DB 저장, 로그 기록용
Duration vs PeriodDuration은 초/나노초(기계), Period는 년/월/일(사람)
DateTimeFormatter불변이라 static final 공유 가능. SimpleDateFormat과 달리 스레드 안전
레거시 변환Instant를 허브로 사용. Date → Instant → java.time
DB 저장Instant(UTC) 또는 OffsetDateTime 권장

다음 글에서는 스레드 기초 를 다뤄봅니다. Thread와 Runnable부터 시작해서 동기화가 왜 필요한지 알아볼게요.

댓글 로딩 중...