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)”가 버그의 절반입니다

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이 아예 존재하지 않을 수 있습니다. LocalDateTime을 ZonedDateTime으로 바꾸는 순간 이런 이슈가 현실이 됩니다. 글로벌 서비스라면 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/Timestamp는 toInstant() / Date.from()로 안전하게 브리지하면 됩니다.
다음 글: #24 파일 I/O 현대적으로 하기