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

만화로 보는 요약 — 먼저 읽어보세요
고정 오프셋 하나 → 변경되는 도시 규칙 → 같은 벽시계와 정확한 경과 시간 중 무엇을 고정할지 고르는 설계.
면접 실전 질문: ① OffsetDateTime과 ZonedDateTime이 들고 있는 정보는 어떻게 다른가? ② DST 전환일에 plusDays(1)과 plusHours(24)가 다른 결과를 내는 이유는? ③ withZoneSameInstant()와 withZoneSameLocal()은 각각 무엇을 보존하는가?
OffsetDateTime은 오프셋 하나에 매여 순간을 확정하고, ZonedDateTime은 규칙 전체를 데리고 다녀 미래의 변화까지 안다 — 이 차이가 plusDays(1)과 plusHours(24)를 서로 다른 연산으로 갈라놓는다.
면접 실전 질문: ①
OffsetDateTime과ZonedDateTime은 각각 무엇을 저장하며, 그 차이가 왜 미래 일정에서 중요한가? ②ZonedDateTime.plusDays(1)과plusHours(24)는 왜 다른 결과를 낼 수 있는가? ③withZoneSameInstant()와withZoneSameLocal()의 차이는 무엇이고, 언제 어느 쪽을 써야 하는가?
배경 — 결정 표의 빈칸
9장은 표 하나로 끝났습니다. 이미 일어난 일은 Instant로, 아직 오지 않은 벽시계 약속은 LocalDateTime + ZoneId로. 그런데 그 표 아래에 빈칸 하나가 남아 있었습니다. 오프셋은 알아야 하지만, LocalDateTime과 ZoneId를 따로 들고 다니긴 번거로운 경우. 이번 장은 그 빈칸을 채웁니다.
java.time은 이 자리에 타입을 두 개 준비해뒀습니다. OffsetDateTime과 ZonedDateTime. 이름부터 LocalDateTime을 공유하고, toString()도 얼핏 비슷하게 생겼습니다. 그래서 흔히 “오프셋 붙은 LocalDateTime”과 “타임존 붙은 LocalDateTime” 정도로 뭉뚱그려 기억됩니다. 틀린 말은 아니지만, 이 뭉뚱그림이 감추는 게 하나 있습니다. 둘 중 하나만 미래를 압니다. 나머지 하나는 지금 이 순간의 오프셋만 알 뿐, 그 오프셋이 왜 그 값인지, 다음 달에도 같은 값일지는 전혀 모릅니다.
스토리 — 오프셋 하나 vs 규칙 전체
두 타입이 실제로 무엇을 들고 있는지부터 봅니다.
import java.time.LocalDateTime;
import java.time.OffsetDateTime;
import java.time.ZoneId;
import java.time.ZoneOffset;
import java.time.ZonedDateTime;
public class Composition {
public static void main(String[] args) {
LocalDateTime local = LocalDateTime.of(2024, 11, 2, 22, 0);
OffsetDateTime odt = local.atOffset(ZoneOffset.of("-04:00"));
ZonedDateTime zdt = local.atZone(ZoneId.of("America/New_York"));
System.out.println("OffsetDateTime = " + odt);
System.out.println(" offset = " + odt.getOffset());
System.out.println("ZonedDateTime = " + zdt);
System.out.println(" zone = " + zdt.getZone());
System.out.println(" offset = " + zdt.getOffset());
}
}OffsetDateTime = 2024-11-02T22:00-04:00
offset = -04:00
ZonedDateTime = 2024-11-02T22:00-04:00[America/New_York]
offset = -04:00같은 벽시계 시각, 같은 오프셋인데 저장하는 정보의 양이 다릅니다. OffsetDateTime은 LocalDateTime + ZoneOffset — 딱 “UTC보다 4시간 느리다”는 숫자 하나입니다. 그 숫자가 왜 -04:00인지, 다음 주에도 -04:00일지는 이 값 안 어디에도 없습니다. ZonedDateTime은 LocalDateTime + ZoneId — America/New_York이라는 지역 그 자체를 들고 있고, 오프셋(-04:00)은 5장에서 본 IANA tz 규칙을 그 순간에 적용해 계산해낸 파생값일 뿐입니다. ZonedDateTime은 규칙집을 통째로 들고 다니고, OffsetDateTime은 그 규칙집이 오늘 내놓은 답 한 줄만 오려 들고 다니는 셈입니다.
이 차이가 실제로 갈라지는 지점은 6장에서 이미 확인한 뉴욕의 두 DST 전환일입니다. 전환 전날 저녁에서 출발해 하루를 더해 봅니다. plusDays(1)로 더할지, plusHours(24)로 더할지에 따라 결과가 달라진다는 것부터가 이 장의 핵심입니다.
import java.time.Duration;
import java.time.OffsetDateTime;
import java.time.ZoneId;
import java.time.ZoneOffset;
import java.time.ZonedDateTime;
public class ArithmeticAsymmetry {
public static void main(String[] args) {
ZoneId ny = ZoneId.of("America/New_York");
System.out.println("=== ZonedDateTime — 2024-11-03 가을 전환(오버랩) ===");
ZonedDateTime fallStart = ZonedDateTime.of(2024, 11, 2, 22, 0, 0, 0, ny);
ZonedDateTime fallPlusDays = fallStart.plusDays(1);
ZonedDateTime fallPlusHours = fallStart.plusHours(24);
System.out.println("시작 = " + fallStart);
System.out.println("plusDays(1) = " + fallPlusDays
+ " 실제 경과 = " + Duration.between(fallStart, fallPlusDays));
System.out.println("plusHours(24) = " + fallPlusHours
+ " 실제 경과 = " + Duration.between(fallStart, fallPlusHours));
System.out.println();
System.out.println("=== ZonedDateTime — 2024-03-10 봄 전환(갭) ===");
ZonedDateTime springStart = ZonedDateTime.of(2024, 3, 9, 22, 0, 0, 0, ny);
ZonedDateTime springPlusDays = springStart.plusDays(1);
ZonedDateTime springPlusHours = springStart.plusHours(24);
System.out.println("시작 = " + springStart);
System.out.println("plusDays(1) = " + springPlusDays
+ " 실제 경과 = " + Duration.between(springStart, springPlusDays));
System.out.println("plusHours(24) = " + springPlusHours
+ " 실제 경과 = " + Duration.between(springStart, springPlusHours));
System.out.println();
System.out.println("=== OffsetDateTime — 같은 두 시각, 오프셋 고정(-04:00) ===");
OffsetDateTime offStart = OffsetDateTime.of(2024, 11, 2, 22, 0, 0, 0, ZoneOffset.of("-04:00"));
OffsetDateTime offPlusDays = offStart.plusDays(1);
OffsetDateTime offPlusHours = offStart.plusHours(24);
System.out.println("시작 = " + offStart);
System.out.println("plusDays(1) = " + offPlusDays
+ " 실제 경과 = " + Duration.between(offStart, offPlusDays));
System.out.println("plusHours(24) = " + offPlusHours
+ " 실제 경과 = " + Duration.between(offStart, offPlusHours));
System.out.println("plusDays(1)와 plusHours(24) 결과 동일? = " + offPlusDays.equals(offPlusHours));
}
}=== ZonedDateTime — 2024-11-03 가을 전환(오버랩) ===
시작 = 2024-11-02T22:00-04:00[America/New_York]
plusDays(1) = 2024-11-03T22:00-05:00[America/New_York] 실제 경과 = PT25H
plusHours(24) = 2024-11-03T21:00-05:00[America/New_York] 실제 경과 = PT24H
=== ZonedDateTime — 2024-03-10 봄 전환(갭) ===
시작 = 2024-03-09T22:00-05:00[America/New_York]
plusDays(1) = 2024-03-10T22:00-04:00[America/New_York] 실제 경과 = PT23H
plusHours(24) = 2024-03-10T23:00-04:00[America/New_York] 실제 경과 = PT24H
=== OffsetDateTime — 같은 두 시각, 오프셋 고정(-04:00) ===
시작 = 2024-11-02T22:00-04:00
plusDays(1) = 2024-11-03T22:00-04:00 실제 경과 = PT24H
plusHours(24) = 2024-11-03T22:00-04:00 실제 경과 = PT24H
plusDays(1)와 plusHours(24) 결과 동일? = true숫자만 눈으로 따라가도 답이 나옵니다. 가을 전환일, plusDays(1)은 벽시계를 정확히 22:00으로 되돌려놓지만 실제로 흐른 시간은 25시간입니다. plusHours(24)는 정확히 24시간을 더하지만 그 결과 벽시계는 22:00이 아니라 21:00입니다. 봄 전환일은 반대 방향으로 어긋납니다 — plusDays(1)은 벽시계를 지키느라 23시간만 흐르고, plusHours(24)는 정확히 24시간을 채우느라 벽시계가 22:00이 아니라 23:00이 됩니다. 반면 OffsetDateTime은 같은 두 연산이 완전히 같은 결과를 냅니다. 벽시계도, 경과 시간도, 결과 객체 자체(equals 참)도 동일합니다.
핵심 — 두 연산이 답하는 질문이 다르다
이 결과가 나오는 이유는 두 메서드가 애초에 다른 질문에 답하기 때문입니다. ZonedDateTime.plusDays(1)은 **“같은 시각에, 다음 날 다시”**를 묻습니다. 벽시계 숫자(22:00)를 고정해두고, 그 숫자가 다음 날 그 지역에서 몇 시 몇 분 UTC 순간인지를 ZoneId의 규칙으로 새로 계산합니다. 그 사이 오프셋이 바뀌었다면(6장의 오버랩·갭), 실제 경과 시간은 24시간이 아니게 됩니다. plusHours(24)는 정반대로 **“지금부터 정확히 24시간 뒤”**를 묻습니다. 순간 축 위에서 24시간을 더하는 것뿐이라 경과 시간은 항상 정확히 24시간이고, 대신 그 결과를 벽시계로 읽었을 때 몇 시가 나오는지는 그날 오프셋이 바뀌었는지에 달려 있습니다. 두 메서드 다 옳습니다. 그저 무엇을 고정하고 무엇을 계산해낼지가 정반대일 뿐입니다.
OffsetDateTime에서 이 구분 자체가 사라지는 이유도 같은 논리로 설명됩니다. OffsetDateTime은 애초에 참조할 규칙이 없습니다. plusDays(1)이든 plusHours(24)이든, 오프셋을 바꿀 근거가 되는 ZoneId가 없으니 오프셋은 그대로 -04:00에 머무릅니다. 결과적으로 “벽시계를 고정할지, 경과 시간을 고정할지”라는 질문 자체가 성립하지 않습니다. 둘 다 그냥 24시간 뒤 순간을 가리킬 뿐입니다. OffsetDateTime의 산술이 단순한 건 장점이 아니라, 규칙을 아예 모르기 때문에 생기는 단순함입니다. 실제로 뉴욕은 2024-11-03에 서머타임이 끝나 오프셋이 -04:00에서 -05:00으로 바뀌었는데, 위 코드의 OffsetDateTime 결과는 그 사실을 전혀 반영하지 않고 하루 종일 -04:00을 고집합니다. 그 값이 실제로 뉴욕의 벽시계와 일치하는지는 OffsetDateTime 스스로는 확인할 방법이 없습니다.
실무로 옮기면 이렇습니다. 매일 새벽 2시에 도는 배치, 매주 월요일 오전 9시 회의처럼 “같은 벽시계 시각에 반복” 되어야 하는 일정은 plusDays류 연산이 맞는 도구입니다. 반대로 “결제 후 정확히 24시간 안에 취소 가능”, “토큰 발급 후 정확히 1시간 뒤 만료”처럼 경과 시간 자체가 계약인 로직은 plusHours류, 혹은 아예 Duration을 더하는 쪽이 맞습니다. 스케줄러 버그의 상당수는 둘을 바꿔 쓴 결과입니다. 벽시계를 지켜야 할 배치가 Duration.ofDays(1)을 더해 DST 전환일마다 한 시간씩 밀리거나, 정확히 24시간을 지켜야 할 만료 로직이 plusDays(1)을 써서 어떤 날은 23시간 만에, 어떤 날은 25시간 만에 만료됩니다. 16장에서 이 문제를 배치·스케줄러 관점으로 더 깊이 다룹니다.
언제 어느 타입을 쓰는가
이 차이는 저장 전략으로도 그대로 이어집니다. 9장의 규칙 — “사실은 Instant, 약속은 LocalDateTime + ZoneId” — 을 오프셋·타임존 있는 타입으로 옮기면 이렇게 됩니다.
- 이미 확정된 사실을 오프셋과 함께 표시해야 할 때는
OffsetDateTime으로 충분합니다. 로그를 사람이 읽기 좋게 남기거나, API로 이미 벌어진 트랜잭션 시각을 내보낼 때가 그렇습니다. 순간은 이미 확정됐고, 앞으로 그 값을 다시 계산할 일이 없으니 규칙을 들고 다닐 이유가 없습니다. 오히려ZoneId없이 오프셋 하나만 있는 쪽이 더 이식성이 좋습니다. RFC 3339 문자열 하나로 그대로 직렬화되고(7장), 어떤 언어의 파서도 문제없이 읽습니다. - 미래에 발생할, 규칙이 바뀔 수 있는 값에는
ZoneId가 있어야 합니다. 매주 반복되는 회의, 다음 분기 예약 발송, 알람처럼 “그 지역의 그 시각에” 다시 계산돼야 하는 일정이 대상입니다. 이때는ZonedDateTime을 쓰거나, 9장이 이미 제안한 대로LocalDateTime과ZoneId를 따로 저장해도 됩니다. 어느 쪽이든 핵심은 같습니다 — 오프셋이 아니라 규칙 자체를 들고 있어야, 규칙이 바뀌었을 때 값도 같이 갱신됩니다.
ZonedDateTime.toString()이 찍는 [Asia/Seoul] 같은 대괄호 표기는 7장에서 이미 확인했습니다 — Java 생태계 밖으로 나가는 순간 표준 파서가 거부하는 자체 확장입니다. ZonedDateTime을 API 응답이나 DB 컬럼에 그대로 실어 보내면 이 문제를 그대로 물려받습니다. 그래서 실무에서는 미래 약속조차 전송·저장 경계에서는 LocalDateTime과 ZoneId를 분리해 담는 경우가 많습니다. 이 경계 설계는 14장(JPA 매핑)과 15장(UTC 경계)에서 마무리합니다. 지금은 “왜 ZoneId가 필요한가”만 확정해둡니다.
withZoneSameInstant() vs withZoneSameLocal()
두 타입을 오가다 보면 반드시 마주치는 메서드 쌍이 있습니다. 같은 값을 다른 zone으로 옮기는 두 방법인데, 이름이 비슷해서 자주 혼동됩니다. 하나는 순간을 지키고, 하나는 벽시계 숫자를 지킵니다.
import java.time.ZoneId;
import java.time.ZonedDateTime;
public class SameInstantVsSameLocal {
public static void main(String[] args) {
ZonedDateTime seoul = ZonedDateTime.of(2024, 11, 3, 10, 0, 0, 0, ZoneId.of("Asia/Seoul"));
ZoneId ny = ZoneId.of("America/New_York");
ZonedDateTime sameInstant = seoul.withZoneSameInstant(ny);
ZonedDateTime sameLocal = seoul.withZoneSameLocal(ny);
System.out.println("원본(Asia/Seoul) = " + seoul);
System.out.println("원본 Instant = " + seoul.toInstant());
System.out.println();
System.out.println("withZoneSameInstant(NY) = " + sameInstant);
System.out.println(" 결과 Instant = " + sameInstant.toInstant());
System.out.println();
System.out.println("withZoneSameLocal(NY) = " + sameLocal);
System.out.println(" 결과 Instant = " + sameLocal.toInstant());
System.out.println();
System.out.println("두 결과의 Instant가 같은가? = " + sameInstant.toInstant().equals(sameLocal.toInstant()));
System.out.println("두 결과의 벽시계가 같은가? = " + sameInstant.toLocalDateTime().equals(sameLocal.toLocalDateTime()));
}
}원본(Asia/Seoul) = 2024-11-03T10:00+09:00[Asia/Seoul]
원본 Instant = 2024-11-03T01:00:00Z
withZoneSameInstant(NY) = 2024-11-02T21:00-04:00[America/New_York]
결과 Instant = 2024-11-03T01:00:00Z
withZoneSameLocal(NY) = 2024-11-03T10:00-05:00[America/New_York]
결과 Instant = 2024-11-03T15:00:00Z
두 결과의 Instant가 같은가? = false
두 결과의 벽시계가 같은가? = false서울 오전 10시 하나를 두 메서드에 각각 넣었을 뿐인데, 결과가 이렇게 갈립니다. withZoneSameInstant()는 “정확히 같은 순간을 뉴욕 벽시계로 다시 읽으면?”을 묻습니다. Instant는 2024-11-03T01:00:00Z로 원본과 완전히 같고, 그걸 뉴욕 시각으로 다시 읽으면 하루 전날 밤 9시(2024-11-02T21:00)가 나옵니다 — 회의 시각을 다른 지역 동료에게 안내할 때 필요한 연산이 이것입니다. withZoneSameLocal()은 정반대로 “벽시계에 적힌 숫자(10:00)는 그대로 두고, 뉴욕이면 몇 시일 순간인가?”를 묻습니다. 결과는 2024-11-03T10:00-05:00, 날짜와 시·분은 원본과 똑같은데 그 Instant는 원본과 14시간이나 떨어져 있습니다. 서울의 오전 10시와 뉴욕의 오전 10시는 전혀 다른 순간이니까요.
출력에서 오프셋이 한쪽은 -04:00, 다른 쪽은 -05:00으로 갈린 것도 우연이 아닙니다. 하필 이 예시가 6장에서 본 2024년 11월 3일 폴백의 양쪽에 하나씩 떨어지기 때문입니다. withZoneSameInstant()의 결과는 11월 2일 밤 9시 — 아직 전환 전이라 서머타임 오프셋 -04:00입니다. withZoneSameLocal()의 결과는 11월 3일 오전 10시 — 그날 새벽 2시에 시계를 되돌린 뒤라 표준시 오프셋 -05:00이고요. 같은 America/New_York인데 오프셋이 다른 게 아니라, 같은 zone의 서로 다른 날짜를 보고 있는 것입니다. 10장 첫머리에서 말한 “규칙 전체를 데리고 다닌다”가 바로 이 자리에서 일합니다.
두 메서드를 헷갈리면 정확히 이 자리에서 버그가 납니다. “글로벌 서비스 사용자가 설정한 알림 시각을 그 사람의 새 거주지 타임존으로 옮긴다”는 요구사항이 있다고 하면, 알림이 가리키는 건 벽시계 숫자(오전 9시에 울려야 한다)이지 원래 순간이 아닙니다. 이때 withZoneSameInstant()를 잘못 쓰면 알림이 엉뚱한 새벽 시각에 울립니다. 반대로 “다른 지역 동료에게 이 회의가 그쪽 몇 시인지 보여준다”는 요구사항에 withZoneSameLocal()을 쓰면, 회의는 실제로는 그 순간이 아닌 완전히 다른 시각으로 안내됩니다. 이름이 비슷한 두 메서드지만, 하나는 순간을 보존하고 하나는 벽시계를 보존한다는 것만 기억하면 헷갈릴 이유가 없습니다.
정리
OffsetDateTime=LocalDateTime+ 고정 오프셋.ZonedDateTime=LocalDateTime+ZoneId(규칙). 오프셋은 규칙이 그 순간에 내놓은 계산 결과일 뿐이며,ZonedDateTime만 그 규칙 자체를 들고 다닌다(실측: 두 타입의 구성 비교).plusDays(1)은 벽시계를 고정하고,plusHours(24)는 경과 시간을 고정한다. 뉴욕의 2024년 두 DST 전환일로 실측한 결과, 가을 전환일엔plusDays(1)이 25시간을, 봄 전환일엔 23시간을 만들었다.plusHours(24)는 두 날 모두 정확히 24시간을 채우는 대신 벽시계가 한 시간씩 어긋났다.OffsetDateTime은 이 구분 자체가 없다. 참조할 규칙이 없으니plusDays(1)과plusHours(24)가 완전히 같은 결과를 낸다(실측:equals참). 산술이 단순한 건 규칙을 몰라서 생기는 단순함이다.- 이미 확정된 사실은
OffsetDateTime, 규칙이 바뀔 수 있는 미래 일정은ZoneId가 있는 타입(ZonedDateTime또는LocalDateTime+ZoneId)으로.OffsetDateTime이 이식성은 더 좋고,ZonedDateTime.toString()의[Zone]대괄호는 7장에서 확인했듯 Java 밖에선 파싱되지 않는다. withZoneSameInstant()는 순간을 지키며 벽시계를 다시 읽고,withZoneSameLocal()은 벽시계를 지키며 순간을 옮긴다. 서울 10시를 두 메서드에 넣은 실측에서Instant가 14시간까지 벌어졌다 — 둘을 바꿔 쓰면 알림·회의 시각이 엉뚱한 순간에 걸린다.- DB 컬럼·JPA 엔티티에 이 타입들을 실제로 어떻게 매핑할지는 14장의 몫이다. 지금은 “무엇을 저장해야 하는가”까지만 확정한다.
생각해볼 질문: plusDays(1)과 plusHours(24)는 어느 쪽도 시스템 시계 자체를 의심하지 않았습니다. 서버의 벽시계가 사용자 조작이나 NTP 보정으로 갑자기 앞뒤로 튀면 어떻게 될까요? “24시간 뒤”를 벽시계 차이로 재는 코드는 그 순간 무엇을 놓치게 될까요?