Skip to Content

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

만화로 보는 요약 — 먼저 읽어보세요

만화로 보는 요약 — 먼저 읽어보세요

시간축 위 한 점 Instant → 지역 벽시계 눈금 LocalDateTime → 이미 일어난 일과 미래 약속에 서로 다른 타입을 고르는 기준.

면접 실전 질문: ① Instant와 LocalDateTime의 근본 차이는 무엇인가? ② LocalDateTime을 Instant로 바꿀 때 왜 갭과 오버랩이 문제인가? ③ 미래 약속을 LocalDateTime과 ZoneId로 저장하는 이유는?


Instant는 시간축 위에 고정된 점이고 LocalDateTime은 그 점을 가리키는 벽시계 눈금일 뿐이다 — 점에서 눈금으로 가는 길은 항상 있지만, 눈금에서 점으로 가는 길은 없을 수도, 두 갈래일 수도 있다.

면접 실전 질문: ① InstantLocalDateTime의 근본적인 차이는 무엇인가? ② LocalDateTime.now()를 호출하면 실제로 어떤 값을 읽어오는가? ③ 왜 LocalDateTimeInstant로 바꾸는 방향이 그 반대 방향보다 위험한가?


배경 — 개념을 나눴으니, 이제 이름을 나눈다

8장은 java.timeDateCalendar의 실패에 내놓은 답을 두 단어로 요약하며 끝났습니다. 불변, 그리고 개념마다 다른 타입. 이 장은 그 두 번째 원칙이 실제로 무엇을 갈라놓았는지, 그중에서도 가장 자주 헷갈리는 두 타입을 나란히 세워놓고 봅니다. InstantLocalDateTime.

두 이름은 이미 답을 절반쯤 담고 있습니다. 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는 어느 타임존을 쓰는지 지정할 방법 자체가 마땅치 않았지만, LocalDateTimenow(ZoneId.of("Asia/Seoul"))처럼 명시적으로 zone을 넣는 오버로드를 나란히 두고 있습니다. 인자 없는 now()를 그대로 부르는 습관 하나가, 코드가 배포된 서버의 타임존에 따라 완전히 다른 값을 만들어냅니다. 함정은 여전히 거기 있지만, 이제는 피해 갈 문이 코드 옆에 나 있는 겁니다.

핵심 — 왜 한쪽 방향만 항상 통하는가

InstantLocalDateTime을 오가는 변환 메서드는 네 가지로 정리됩니다. Instant.atZone(zone)은 순간을 특정 지역의 벽시계 시각(ZonedDateTime)으로 바꾸고, LocalDateTime.atZone(zone)은 벽시계 시각에 지역을 붙여 같은 ZonedDateTime을 만듭니다. ZonedDateTime.toInstant()는 그 시각이 가리키는 순간을 꺼내고, LocalDateTime.toInstant(offset)은 오프셋을 직접 지정해 곧바로 순간을 계산합니다. 이 네 메서드가 하는 일을 한 문장으로 줄이면 이렇습니다. 한쪽은 언제나 성공하고, 다른 쪽은 그렇지 않습니다.

6장에서 확인한 뉴욕의 두 DST 전환일 — 2024년 3월 10일(봄, 갭)과 2024년 11월 3일(가을, 오버랩) — 을 그대로 가져와 왕복시켜 봤습니다. InstantLocalDateTime → 다시 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은 이 상황에서 예외를 던지는 대신 합리적인 기본값(이른 오프셋, 갭만큼 밀기)을 골라 조용히 답을 돌려줍니다. 편리하지만, 그 기본값이 항상 호출한 쪽이 원한 답이라는 보장은 없습니다.

정리하면 — InstantLocalDateTime은 zone만 있으면 언제나 성공하는 함수입니다. LocalDateTimeInstant는 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 매핑까지 내려가서 마무리합니다. 지금은 원칙만 확정해둡니다 — 저장하는 값이 “사실”인지 “약속”인지에 따라 타입이 갈립니다.

LocalDateLocalTime — 정보를 줄이는 것도 설계다

InstantLocalDateTime 사이의 스펙트럼에는 정보를 아예 더 줄인 타입도 있습니다. 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에 이미 있습니다. ZonedDateTimeOffsetDateTime입니다. 이름조차 비슷한 이 둘이 정확히 무엇을 감싸고 무엇이 다른지는 다음 장에서 풀어갑니다.

정리

  • 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가 되는 오류가 생긴다(실측 확인).
  • LocalDateTimeZoneId를 하나로 묶은 타입 — ZonedDateTime, OffsetDateTime — 은 10장에서 다룬다.

생각해볼 질문: 미래의 벽시계 약속을 LocalDateTimeZoneId를 따로 저장하는 대신, 애초에 이 둘을 하나로 묶은 객체로 들고 다니면 더 편하지 않을까요? 자바는 이미 그런 타입을 두 개나 준비해뒀습니다 — ZonedDateTimeOffsetDateTime. 이름조차 비슷한 이 둘은 정확히 무엇이 다르고, 어느 쪽을 언제 골라야 할까요?

10장 · ZonedDateTime과 OffsetDateTime — 시간대를 품은 시각

Last updated on