9장. Instant vs LocalDateTime — 무엇을 언제 쓰는가

만화로 보는 요약 — 먼저 읽어보세요
시간축 위 한 점 Instant → 지역 벽시계 눈금 LocalDateTime → 이미 일어난 일과 미래 약속에 서로 다른 타입을 고르는 기준.
면접 실전 질문: ① Instant와 LocalDateTime의 근본 차이는 무엇인가? ② LocalDateTime을 Instant로 바꿀 때 왜 갭과 오버랩이 문제인가? ③ 미래 약속을 LocalDateTime과 ZoneId로 저장하는 이유는?
Instant는 시간축 위에 고정된 점이고 LocalDateTime은 그 점을 가리키는 벽시계 눈금일 뿐이다 — 점에서 눈금으로 가는 길은 항상 있지만, 눈금에서 점으로 가는 길은 없을 수도, 두 갈래일 수도 있다.
면접 실전 질문: ①
Instant와LocalDateTime의 근본적인 차이는 무엇인가? ②LocalDateTime.now()를 호출하면 실제로 어떤 값을 읽어오는가? ③ 왜LocalDateTime을Instant로 바꾸는 방향이 그 반대 방향보다 위험한가?
배경 — 개념을 나눴으니, 이제 이름을 나눈다
8장은 java.time이 Date와 Calendar의 실패에 내놓은 답을 두 단어로 요약하며 끝났습니다. 불변, 그리고 개념마다 다른 타입. 이 장은 그 두 번째 원칙이 실제로 무엇을 갈라놓았는지, 그중에서도 가장 자주 헷갈리는 두 타입을 나란히 세워놓고 봅니다. Instant와 LocalDateTime.
두 이름은 이미 답을 절반쯤 담고 있습니다. Instant는 “순간” — 시간축 위의 점 하나입니다. 서울에서 읽든 뉴욕에서 읽든 같은 값입니다. LocalDateTime은 “지역의(local) 날짜와 시각” — 벽시계에 적힌 숫자입니다. 그런데 이름 어디에도 어느 지역의 벽시계인지는 나와 있지 않습니다. 이 결핍은 설계 실수가 아니라 설계 그 자체입니다. LocalDateTime은 원래 지역 정보를 갖지 않는 타입으로 만들어졌습니다.
두 타입을 가르는 질문은 하나로 요약됩니다. “언제 일어났는가”를 묻는 값인가, “시계가 몇 시를 가리킬 것인가”를 묻는 값인가. Instant는 전자에 답하고, LocalDateTime은 후자에 답합니다. 이 둘은 서로 바꿔 쓸 수 있는 동의어가 아니라, 애초에 질문 자체가 다른 두 타입입니다. 그리고 6장은 이미 그 답이 왜 위험한 값인지를 실측으로 보여줬습니다 — 벽시계 눈금 하나가 존재하지 않거나 두 번 존재할 수 있다는 것을요. 이 장은 그 위험을 Instant와 정면으로 마주 세워, 언제 어느 타입을 골라야 하는지 실무 규칙으로 정리합니다.
스토리 — 같은 순간, 서로 다른 세 개의 벽시계
말로는 그렇다 쳐도, 코드로 확인하는 게 이 책의 방식입니다. JVM 기본 타임존을 두 번 바꿔가며 Instant.now()와 LocalDateTime.now()를 나란히 찍어봤습니다.
import java.time.Instant;
import java.time.LocalDateTime;
import java.util.TimeZone;
public class InstantVsLocalNow {
public static void main(String[] args) {
TimeZone.setDefault(TimeZone.getTimeZone("Asia/Seoul"));
Instant instantSeoul = Instant.now();
LocalDateTime localSeoul = LocalDateTime.now();
System.out.println("JVM 기본 zone = Asia/Seoul");
System.out.println(" Instant.now() = " + instantSeoul);
System.out.println(" LocalDateTime.now() = " + localSeoul);
TimeZone.setDefault(TimeZone.getTimeZone("America/New_York"));
Instant instantNy = Instant.now();
LocalDateTime localNy = LocalDateTime.now();
System.out.println("JVM 기본 zone = America/New_York");
System.out.println(" Instant.now() = " + instantNy);
System.out.println(" LocalDateTime.now() = " + localNy);
System.out.println("두 Instant는 (거의) 같은 순간 — 초 단위 차이 = "
+ Math.abs(instantNy.getEpochSecond() - instantSeoul.getEpochSecond()) + "s");
System.out.println("두 LocalDateTime은 서로 다른 값 = " + !localSeoul.equals(localNy));
}
}JVM 기본 zone = Asia/Seoul
Instant.now() = 2026-07-31T01:03:45.214615911Z
LocalDateTime.now() = 2026-07-31T10:03:45.221699798
JVM 기본 zone = America/New_York
Instant.now() = 2026-07-31T01:03:45.227377230Z
LocalDateTime.now() = 2026-07-30T21:03:45.229473533
두 Instant는 (거의) 같은 순간 — 초 단위 차이 = 0s
두 LocalDateTime은 서로 다른 값 = true이 출력 한 블록이 이 장 전체의 결론입니다. Instant.now()는 JVM 기본 타임존을 바꿔도 값이 바뀌지 않습니다. 초 단위 차이가 0입니다 — 같은 순간이니까요. 반면 LocalDateTime.now()는 두 번 다른 값을 내놓습니다. 서울 쪽은 07-31 10:03, 뉴욕 쪽은 07-30 21:03. 정확히 13시간 차이인데, 이건 서울의 오프셋(+09:00)과 뉴욕의 여름철 오프셋(-04:00)의 차이(9 − (−4) = 13)와 정확히 일치합니다. 참고로 13은 고정 상수가 아닙니다 — 같은 코드를 겨울철(EST, -05:00)에 돌렸다면 14시간이 나옵니다. 같은 순간을 서로 다른 벽시계 두 개가 서로 다른 숫자로 읽은 것뿐입니다.
여기서 짚어야 할 함정이 하나 있습니다. LocalDateTime.now()는 인자를 받지 않는데도 내부적으로 JVM 기본 타임존을 조용히 읽어옵니다. 8장에서 Date.toString()이 출력할 때 JVM 기본 타임존을 몰래 빌려 쓰는 걸 봤죠. LocalDateTime.now()는 그 함정을 입력 쪽에서 그대로 반복합니다. 새 API가 이 함정을 없앤 게 아닙니다. 함정을 눈에 보이게 만들었을 뿐입니다. Date는 어느 타임존을 쓰는지 지정할 방법 자체가 마땅치 않았지만, LocalDateTime은 now(ZoneId.of("Asia/Seoul"))처럼 명시적으로 zone을 넣는 오버로드를 나란히 두고 있습니다. 인자 없는 now()를 그대로 부르는 습관 하나가, 코드가 배포된 서버의 타임존에 따라 완전히 다른 값을 만들어냅니다. 함정은 여전히 거기 있지만, 이제는 피해 갈 문이 코드 옆에 나 있는 겁니다.
핵심 — 왜 한쪽 방향만 항상 통하는가
Instant와 LocalDateTime을 오가는 변환 메서드는 네 가지로 정리됩니다. Instant.atZone(zone)은 순간을 특정 지역의 벽시계 시각(ZonedDateTime)으로 바꾸고, LocalDateTime.atZone(zone)은 벽시계 시각에 지역을 붙여 같은 ZonedDateTime을 만듭니다. ZonedDateTime.toInstant()는 그 시각이 가리키는 순간을 꺼내고, LocalDateTime.toInstant(offset)은 오프셋을 직접 지정해 곧바로 순간을 계산합니다. 이 네 메서드가 하는 일을 한 문장으로 줄이면 이렇습니다. 한쪽은 언제나 성공하고, 다른 쪽은 그렇지 않습니다.
6장에서 확인한 뉴욕의 두 DST 전환일 — 2024년 3월 10일(봄, 갭)과 2024년 11월 3일(가을, 오버랩) — 을 그대로 가져와 왕복시켜 봤습니다. Instant → LocalDateTime → 다시 Instant로.
import java.time.Instant;
import java.time.LocalDateTime;
import java.time.ZoneId;
import java.time.ZonedDateTime;
public class RoundTripAsymmetry {
public static void main(String[] args) {
ZoneId ny = ZoneId.of("America/New_York");
// 1) 평상시 — 왕복이 그대로 돌아온다.
Instant normal = Instant.parse("2024-06-15T16:00:00Z");
LocalDateTime normalLocal = normal.atZone(ny).toLocalDateTime();
Instant normalBack = normalLocal.atZone(ny).toInstant();
System.out.println("[평상시]");
System.out.println(" 원본 Instant = " + normal);
System.out.println(" atZone().toLocalDateTime() = " + normalLocal);
System.out.println(" 다시 atZone().toInstant() = " + normalBack);
System.out.println(" 왕복 성공(원본과 동일)? = " + normal.equals(normalBack));
// 2) 가을 오버랩 — 6장의 "두 번째(늦은 오프셋)" 순간에서 출발.
Instant overlapLater = Instant.parse("2024-11-03T06:30:00Z"); // 01:30 EST(-05:00)
LocalDateTime overlapLocal = overlapLater.atZone(ny).toLocalDateTime();
Instant overlapBack = overlapLocal.atZone(ny).toInstant(); // 기본은 이른 오프셋
System.out.println("[가을 오버랩]");
System.out.println(" 원본 Instant(늦은 쪽) = " + overlapLater);
System.out.println(" atZone().toLocalDateTime() = " + overlapLocal);
System.out.println(" 다시 atZone().toInstant() = " + overlapBack);
System.out.println(" 왕복 성공(원본과 동일)? = " + overlapLater.equals(overlapBack));
// 3) 봄 갭 — 애초에 어떤 Instant도 이 LocalDateTime으로 오지 않는다.
LocalDateTime gapLocal = LocalDateTime.of(2024, 3, 10, 2, 30, 0);
ZonedDateTime gapZoned = gapLocal.atZone(ny);
System.out.println("[봄 갭]");
System.out.println(" 존재하지 않는 LocalDateTime = " + gapLocal);
System.out.println(" atZone() 결과 ZonedDateTime = " + gapZoned);
System.out.println(" 실제로 저장된 LocalDateTime = " + gapZoned.toLocalDateTime());
}
}[평상시]
원본 Instant = 2024-06-15T16:00:00Z
atZone().toLocalDateTime() = 2024-06-15T12:00
다시 atZone().toInstant() = 2024-06-15T16:00:00Z
왕복 성공(원본과 동일)? = true
[가을 오버랩]
원본 Instant(늦은 쪽) = 2024-11-03T06:30:00Z
atZone().toLocalDateTime() = 2024-11-03T01:30
다시 atZone().toInstant() = 2024-11-03T05:30:00Z
왕복 성공(원본과 동일)? = false
[봄 갭]
존재하지 않는 LocalDateTime = 2024-03-10T02:30
atZone() 결과 ZonedDateTime = 2024-03-10T03:30-04:00[America/New_York]
실제로 저장된 LocalDateTime = 2024-03-10T03:30평상시엔 왕복이 정확히 원본으로 돌아옵니다. 그런데 가을 오버랩 구간에서 출발한 값은 정확히 한 시간이 어긋난 채로 돌아옵니다. 01:30이라는 지역 시각은 그 밤에 두 번 존재했는데(6장), 원본 Instant는 그중 늦은 쪽(EST, -05:00)이었습니다. 하지만 LocalDateTime으로 바뀐 순간 “어느 쪽 01:30이었는지”는 사라지고, 다시 Instant로 되돌릴 때 atZone()은 기본값인 이른 쪽(EDT, -04:00)을 고릅니다. 봄 갭 구간은 더 노골적입니다. 존재한 적 없는 02:30을 넣었더니, 조용히 한 시간 밀려 03:30이 되어 돌아옵니다.
이 비대칭이 왜 일어나는지는 두 변환이 서로 다른 질문에 답하고 있다는 걸 보면 분명해집니다. Instant.atZone(zone)은 “이 순간, 이 zone의 규칙을 적용하면 벽시계는 몇 시를 가리키는가”를 묻습니다. 순간 하나에 규칙을 적용해 숫자를 읽는 것뿐이라, 규칙이 무엇이든 답은 항상 정확히 하나 나옵니다. 반대로 LocalDateTime.atZone(zone)은 “벽시계가 이 숫자를 가리키게 만드는 순간은 언제인가”를 묻습니다. 이건 역함수를 구하는 질문인데, 6장이 이미 보여줬듯 지역 시각과 순간의 대응은 일대일이 아닙니다. 오버랩 구간에서는 순간 두 개가 같은 지역 시각으로 접히고(전사이지만 단사가 아님), 갭 구간에서는 어떤 순간도 그 지역 시각으로 접히지 않습니다(전사가 아님). 함수가 일대일 대응이 아니면 역함수는 애초에 깔끔하게 존재할 수 없습니다. java.time은 이 상황에서 예외를 던지는 대신 합리적인 기본값(이른 오프셋, 갭만큼 밀기)을 골라 조용히 답을 돌려줍니다. 편리하지만, 그 기본값이 항상 호출한 쪽이 원한 답이라는 보장은 없습니다.
정리하면 — Instant → LocalDateTime은 zone만 있으면 언제나 성공하는 함수입니다. LocalDateTime → Instant는 zone이 있어도 실패(갭)하거나 모호(오버랩)할 수 있는, 원래부터 깔끔한 역함수가 아닌 연산입니다. 이게 이 장 제목 아래 있는 한 줄 — “점에서 눈금으로 가는 길은 항상 있지만, 눈금에서 점으로 가는 길은 없을 수도, 두 갈래일 수도 있다” — 의 정체입니다.
실무 규칙 — 이미 일어난 일과 아직 오지 않은 약속
이 비대칭은 저장 전략으로 곧장 이어집니다. 규칙은 단순합니다.
이미 일어난 일은 Instant로 저장합니다. 로그, 감사 기록, created_at, 결제가 확정된 시각 — 전부 이미 벌어진 사실입니다. 사실은 확정되고 나면 바뀌지 않습니다. 어느 zone의 규칙이 나중에 바뀌어도, “그 일이 일어난 순간” 자체는 흔들리지 않습니다. Instant로 저장해두면 이후 어떤 표시 방식으로도 안전하게 변환할 수 있습니다(atZone()은 항상 성공하니까요).
아직 오지 않은, 사람에게 한 벽시계 약속은 LocalDateTime + ZoneId로 저장합니다. “다음 달 15일 오후 2시에 서울에서 보자”는 약속이 좋은 예입니다. 이 약속을 지금 당장 Instant나 오프셋(+09:00)으로 확정해서 저장하면, 그 뒤에 한국이 서머타임을 새로 도입하거나 표준시 자체를 바꾸는 결정을 내릴 경우 — 5장이 이미 증명했듯, 타임존 규칙은 언제든 다시 쓰일 수 있는 입법 기록입니다 — 저장된 값은 더 이상 “오후 2시”를 가리키지 않게 됩니다. 사람이 실제로 약속한 건 특정 순간이 아니라 특정 벽시계 눈금이었습니다. LocalDateTime(2024-08-15T14:00)과 ZoneId(Asia/Seoul)를 따로 저장해두면, 실제 순간으로의 변환을 그 약속이 다가올 때 — 그때 유효한 규칙으로 — 미룰 수 있습니다. 7장이 남긴 질문(“오프셋만으로 충분한가”)의 답도 여기 있습니다. 오프셋은 그 순간의 사실만 기록할 뿐, 규칙이 바뀌면 갱신되지 않습니다. ZoneId는 규칙 그 자체를 가리키므로, 규칙이 바뀌면 변환 결과도 같이 바뀝니다. 그게 바로 사람이 원한 동작입니다.
여기서 “그냥 서버는 항상 UTC만 쓰면 되는 거 아닌가”라는 반론이 나올 법합니다. 이미 벌어진 일에는 맞는 말입니다. 그런데 미래의 벽시계 약속에는 틀린 말입니다. 지금 UTC Instant로 확정해버리는 순간, 그 약속은 “특정 벽시계 시각”이 아니라 “특정 순간”으로 바뀌어버립니다. 규칙이 바뀌면 둘은 더 이상 같은 것을 가리키지 않습니다. 이 둘을 언제, 어느 컬럼에, 어떤 타입으로 나눠 담을지는 13~15장에서 MySQL의 DATETIME·TIMESTAMP와 JPA 매핑까지 내려가서 마무리합니다. 지금은 원칙만 확정해둡니다 — 저장하는 값이 “사실”인지 “약속”인지에 따라 타입이 갈립니다.
LocalDate와 LocalTime — 정보를 줄이는 것도 설계다
Instant와 LocalDateTime 사이의 스펙트럼에는 정보를 아예 더 줄인 타입도 있습니다. LocalDate(날짜만)와 LocalTime(시각만)입니다. 생일이 좋은 예입니다. 생일에 시각도, 타임존도 필요하지 않습니다. 오히려 억지로 붙이면 문제가 생깁니다.
import java.time.Instant;
import java.time.LocalDate;
import java.time.ZoneId;
public class LocalDateBirthday {
public static void main(String[] args) {
LocalDate birthday = LocalDate.of(1990, 5, 20);
System.out.println("생일 = " + birthday + " (시각도, 타임존도 없다)");
Instant asSeoulMidnight = birthday.atStartOfDay(ZoneId.of("Asia/Seoul")).toInstant();
Instant asNyMidnight = birthday.atStartOfDay(ZoneId.of("America/New_York")).toInstant();
Instant asUtcMidnight = birthday.atStartOfDay(ZoneId.of("UTC")).toInstant();
System.out.println("atStartOfDay(Asia/Seoul).toInstant() = " + asSeoulMidnight);
System.out.println("atStartOfDay(America/New_York).toInstant() = " + asNyMidnight);
System.out.println("atStartOfDay(UTC).toInstant() = " + asUtcMidnight);
}
}생일 = 1990-05-20 (시각도, 타임존도 없다)
atStartOfDay(Asia/Seoul).toInstant() = 1990-05-19T15:00:00Z
atStartOfDay(America/New_York).toInstant() = 1990-05-20T04:00:00Z
atStartOfDay(UTC).toInstant() = 1990-05-20T00:00:00Z“생일의 Instant는 무엇인가”는 애초에 잘 정의된 질문이 아닙니다. 물어보려면 먼저 하루 중 어느 시각인지, 어느 zone인지를 발명해야 합니다. 세 zone으로 억지로 순간을 만들어보면 세 가지 서로 다른 Instant가 나옵니다 — 같은 생일인데도요. LocalDate에 zone을 붙이는 건 실수를 미리 막아주는 게 아니라, 애초에 없던 정보를 만들어내는 겁니다. 정보를 줄인 타입은 부족해서 쓰는 게 아니라, 그 개념에 원래 없는 정보를 억지로 요구하지 않기 위해 쓰는 겁니다. LocalTime도 같은 논리입니다. “매일 오전 9시”라는 반복 알람은 특정 날짜에 묶여 있지 않으니 LocalTime 하나로 충분합니다.
결정 표 — 상황에서 타입으로
| 상황 | 저장할 타입 | 이유 |
|---|---|---|
로그·감사 기록·created_at(이미 일어난 일) | Instant | 이미 확정된 사실, 규칙이 바뀌어도 변하지 않는다 |
| 미래 회의·예약처럼 사람에게 한 벽시계 약속 | LocalDateTime + ZoneId | 사람이 약속한 건 눈금이지 순간이 아니다, 규칙은 바뀔 수 있다 |
| 생일 | LocalDate | 시각도 zone도 그 개념에 없는 정보다 |
| 매일 반복되는 알람(예: 매일 09:00) | LocalTime | 날짜 없이 매일 되풀이되는 벽시계 눈금 |
| 이미 확정된 트랜잭션을 API로 주고받을 때 | Instant (또는 오프셋 포함 문자열) | 전송 경계 — 시스템 밖으로 나가는 값이라, 읽는 쪽 타임존과 무관하게 해석되어야 한다 |
표에 없는 자리도 남습니다. 예를 들어 오프셋은 알아야 하지만 LocalDateTime + ZoneId를 따로 들고 다니기는 번거로운 경우 — 이 둘을 하나의 객체로 묶은 타입이 java.time에 이미 있습니다. ZonedDateTime과 OffsetDateTime입니다. 이름조차 비슷한 이 둘이 정확히 무엇을 감싸고 무엇이 다른지는 다음 장에서 풀어갑니다.
정리
Instant는 시간축 위의 점,LocalDateTime은 그 점을 읽는 벽시계 눈금이다.Instant.now()는 JVM 기본 zone을 바꿔도 값이 그대로지만,LocalDateTime.now()는 zone마다 다른 값을 내놓는다(실측 확인).LocalDateTime.now()는 인자 없이도 JVM 기본 타임존을 조용히 읽는다.Date.toString()이 출력 시점에 저지른 함정과 같은 함정을, 입력 시점에 반복한다. 다만now(ZoneId)라는 명시적 대안이 나란히 있어, 함정이 사라진 게 아니라 드러나 있다.Instant → LocalDateTime은 zone만 있으면 항상 성공한다. 순간 하나에 규칙을 적용해 숫자를 읽는 것뿐이라 답이 언제나 정확히 하나다.LocalDateTime → Instant는 실패(갭)하거나 모호(오버랩)할 수 있다. 6장의 뉴욕 DST 전환일로 실측한 결과, 오버랩 구간의 왕복은 원본과 정확히 한 시간 어긋난Instant로 돌아왔고, 갭 구간의LocalDateTime은 조용히 한 시간 밀린 값으로 바뀌었다(실측 확인). 지역 시각과 순간의 대응이 일대일이 아니기 때문이다.- 이미 일어난 일은
Instant로, 아직 오지 않은 벽시계 약속은LocalDateTime+ZoneId로 저장한다. 오프셋이나Instant로 미래 약속을 미리 확정하면, 5장이 보여준 대로 타임존 규칙이 바뀌었을 때 약속된 벽시계 시각과 저장된 값이 어긋난다. - 정보를 줄이는 것도 올바른 설계다. 생일은
LocalDate로 충분하고, 여기에 시각과 zone을 억지로 붙이면 같은 생일이 zone마다 다른Instant가 되는 오류가 생긴다(실측 확인). LocalDateTime과ZoneId를 하나로 묶은 타입 —ZonedDateTime,OffsetDateTime— 은 10장에서 다룬다.
생각해볼 질문: 미래의 벽시계 약속을 LocalDateTime과 ZoneId를 따로 저장하는 대신, 애초에 이 둘을 하나로 묶은 객체로 들고 다니면 더 편하지 않을까요? 자바는 이미 그런 타입을 두 개나 준비해뒀습니다 — ZonedDateTime과 OffsetDateTime. 이름조차 비슷한 이 둘은 정확히 무엇이 다르고, 어느 쪽을 언제 골라야 할까요?