JAVA

Java 17 java.time 완전 가이드: LocalDate/LocalDateTime/ZonedDateTime, 포맷팅, 레거시(Date) 변환

IT Lab 2026. 2. 23. 20:00

Java 17 기준으로 LocalDate/LocalDateTime/ZonedDateTime 선택 기준, DateTimeFormatter 포맷팅/파싱, 레거시 Date·Calendar·Timestamp 변환까지 실무 관점으로 정리합니다.

1) 도입 (문제 상황)

서버 로그는 UTC인데 화면에는 KST로 보여야 하고, DB에는 Timestamp가 들어가 있는데 API 요청은 "2026-02-22T10:30:00"처럼 문자열로 들어오는 상황을 자주 마주하실 거예요. 그때마다 Date, Calendar, SimpleDateFormat을 섞어 쓰다 보면 “왜 9시간이 밀렸지?” 같은 버그가 생기기 쉽습니다. Java 8부터의 java.time을 제대로 잡아두면 이런 문제를 안정적으로 줄일 수 있습니다.

2) 핵심 개념: Local vs Zoned, 그리고 “시간대(Zone)”가 버그의 절반입니다

LocalDateTime과 ZonedDateTime, Instant의 관계(저장/표시 흐름) 다이어그램

java.time의 핵심은 **“시간대가 있느냐/없느냐”**를 타입으로 강제한다는 점입니다. 예전 Date는 내부적으로 UTC 기반의 “순간(Instant)”인데, 출력/해석은 시스템 기본 시간대에 기대는 경우가 많아 혼란이 생겼습니다. java.time은 “달력상의 날짜/시간”과 “실제 순간”을 분리해 생각하게 해요.

LocalDate / LocalDateTime / ZonedDateTime, 언제 뭘 쓰나요?

  • LocalDate: 생일, 마감일처럼 날짜만 중요한 값(시간/시간대 없음)
  • LocalDateTime: “2026-02-22 10:30”처럼 날짜+시간은 있지만, 어느 시간대인지는 문맥으로 정해지는
    (예: “한국 서비스의 예약 시간은 기본 KST로 해석한다” 같은 규칙이 있을 때)
  • ZonedDateTime: “어느 지역 시간대 기준인지”까지 포함한 값(서머타임 포함)
    (예: 글로벌 서비스, 사용자 시간대 표시, 스케줄링/알림)
  • Instant: 타임라인 상의 “순간” 그 자체(UTC). 시스템 간 교환/저장에 가장 안전한 형태

아래 표처럼 생각하시면 선택이 빨라집니다.

타입 시간대 포함 의미 대표 사용처 주의점
LocalDate X 날짜 생일, 휴일, 정산 기준일 시간 개념을 억지로 붙이지 않기
LocalDateTime X 날짜+시간(벽시계) “매일 10:00 오픈” 같은 로컬 규칙 저장/전송 시 시간대 해석 규칙 필수
ZonedDateTime O 날짜+시간+지역 시간대 사용자 표시, 예약(해외), DST 포함 일정 지역(ZoneId) 선택이 중요
Instant O(UTC) 순간 DB 저장, 이벤트 타임스탬프, 로그 사람이 읽기엔 불편(포맷 필요)

포맷팅/파싱: DateTimeFormatter는 “스레드 안전”이 기본입니다

레거시 SimpleDateFormat은 스레드에 안전하지 않아(공유 시) 장애로 이어질 수 있었죠. DateTimeFormatter는 불변(immutable)이라 싱글턴으로 재사용해도 안전합니다.

  • ISO 표준(예: 2026-02-22T10:30:00Z)은 가능하면 그대로 쓰기
  • 사람이 읽는 포맷이 필요하면 DateTimeFormatter를 명시적으로 고정
  • “시간대 없는 문자열”을 파싱할 때는 그 문자열을 어느 시간대로 해석할지 정책을 코드에 넣기

ZonedDateTime의 함정: 서머타임(DST)로 “존재하지 않는 시간”이 생깁니다

예를 들어 어떤 지역은 DST 전환 시각에 02:30이 아예 존재하지 않을 수 있습니다. LocalDateTimeZonedDateTime으로 바꾸는 순간 이런 이슈가 현실이 됩니다. 글로벌 서비스라면 ZonedDateTime/Instant 기반으로 저장하고, 화면 표시에서만 사용자 Zone으로 변환하는 패턴이 가장 안전합니다.

flowchart LR
  A["Client 입력 문자열"] --> B["파싱: LocalDateTime 또는 Instant"]
  B --> C["저장: Instant(UTC) 권장"]
  C --> D["표시: 사용자 ZoneId로 ZonedDateTime 변환"]
  D --> E["포맷팅: DateTimeFormatter"]

위 흐름처럼 “저장은 Instant(UTC), 표시는 Zone 변환”으로 분리하면 시간대 버그가 크게 줄어듭니다.

 

3) 코드 예제: LocalDate/LocalDateTime/ZonedDateTime, 포맷팅, Date 변환까지 한 번에

아래 코드는 Java 17에서 그대로 실행됩니다(단일 파일).

  • 타입별 생성/변환
  • DateTimeFormatter 포맷/파싱
  • 레거시 Date/Timestamp 변환
  • “시간대 없는 문자열”을 KST로 해석해 Instant로 저장하는 예시
import java.sql.Timestamp;
import java.time.*;
import java.time.format.DateTimeFormatter;
import java.time.format.DateTimeParseException;
import java.util.Date;
import java.util.Locale;

public class JavaTimeGuide {
    // 스레드 안전: static final로 재사용 권장
    private static final DateTimeFormatter ISO_LOCAL = DateTimeFormatter.ISO_LOCAL_DATE_TIME;

    // 사람이 읽는 포맷(예: 화면 표시용)
    private static final DateTimeFormatter DISPLAY_KO =
            DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss", Locale.KOREA);

    public static void main(String[] args) {
        // 1) LocalDate: 날짜만
        LocalDate date = LocalDate.of(2026, 2, 22);
        System.out.println("LocalDate = " + date);

        // 2) LocalDateTime: 날짜+시간(시간대 없음)
        LocalDateTime ldt = LocalDateTime.of(2026, 2, 22, 10, 30, 0);
        System.out.println("LocalDateTime = " + ldt);

        // 3) ZonedDateTime: 시간대 포함
        ZoneId seoul = ZoneId.of("Asia/Seoul");
        ZonedDateTime zdtSeoul = ldt.atZone(seoul);
        System.out.println("ZonedDateTime(Seoul) = " + zdtSeoul);

        // 4) Instant: UTC 기준 '순간' (저장/교환에 유리)
        Instant instant = zdtSeoul.toInstant();
        System.out.println("Instant(UTC) = " + instant);

        // 5) 포맷팅: 표시용 문자열
        String display = zdtSeoul.format(DISPLAY_KO);
        System.out.println("Display(KO) = " + display);

        // 6) 파싱: ISO_LOCAL_DATE_TIME (시간대 없는 문자열)
        String input = "2026-02-22T10:30:00";
        LocalDateTime parsedLdt = LocalDateTime.parse(input, ISO_LOCAL);
        System.out.println("Parsed LocalDateTime = " + parsedLdt);

        // 7) "시간대 없는 입력"을 서비스 정책(예: KST)으로 해석해 Instant로 변환(저장)
        Instant storedInstant = parsedLdt.atZone(seoul).toInstant();
        System.out.println("Stored Instant(interpret as KST) = " + storedInstant);

        // 8) 저장된 Instant를 사용자 시간대로 표시(예: LA)
        ZoneId la = ZoneId.of("America/Los_Angeles");
        ZonedDateTime zdtLa = storedInstant.atZone(la);
        System.out.println("Display in LA = " + zdtLa.format(DISPLAY_KO));

        // 9) 레거시 Date 변환
        Date legacyDate = new Date(); // 내부적으로 epoch milli 기반
        Instant fromDate = legacyDate.toInstant();
        Date backToDate = Date.from(fromDate);
        System.out.println("Legacy Date -> Instant = " + fromDate);
        System.out.println("Instant -> Legacy Date = " + backToDate);

        // 10) java.sql.Timestamp 변환 (DB 연동에서 자주 등장)
        Timestamp ts = Timestamp.from(Instant.now());
        Instant fromTs = ts.toInstant();
        Timestamp backToTs = Timestamp.from(fromTs);
        System.out.println("Timestamp -> Instant = " + fromTs);
        System.out.println("Instant -> Timestamp = " + backToTs);

        // 11) 예외 처리: 파싱 실패는 DateTimeParseException
        try {
            LocalDateTime.parse("2026/02/22 10:30:00", ISO_LOCAL);
        } catch (DateTimeParseException e) {
            System.out.println("Parse failed: " + e.getMessage());
        }
    }
}

4) 실무 팁

💡 실무에서는: “DB/메시지에는 Instant(UTC) 또는 OffsetDateTime으로 저장”을 우선순위로 두세요

  • 여러 시스템이 얽히면 “로컬 시간 문자열”은 해석 규칙이 깨지는 순간 사고가 납니다.
  • 가능하면 **저장: Instant(epoch) / 표시: ZonedDateTime**로 역할을 분리해 보세요.
  • JPA를 쓰신다면 DB 컬럼 타입과 매핑 전략(UTC 고정 여부)을 팀 규칙으로 문서화하는 게 효과가 큽니다.

💡 실무에서는: LocalDateTime을 쓸 때 “이 값의 기준 시간대”를 코드로 드러내세요

  • LocalDateTime 자체엔 시간대가 없어서, 변환 시점에 실수가 나기 쉽습니다.
  • 예: parsedLdt.atZone(ZoneId.of("Asia/Seoul"))처럼 ZoneId를 하드코딩하지 말고 설정값으로 주입하거나, 메서드 시그니처에 ZoneId를 받게 설계해 보세요.
  • 운영 환경의 기본 시간대(ZoneId.systemDefault())에 기대는 코드는 컨테이너/서버 설정 변경에 취약합니다.

핵심 요약: Local*는 “벽시계”, ZonedDateTime/Instant는 “시간대/순간”을 다룹니다.
저장은 Instant(UTC)로 단순화하고, 표시는 ZoneId로 변환해 포맷팅하세요.
레거시 Date/TimestamptoInstant() / Date.from()로 안전하게 브리지하면 됩니다.

다음 글: #24 파일 I/O 현대적으로 하기