Skip to Content

6장. 없는 시각, 두 번 오는 시각 — DST의 두 얼굴

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

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

봄의 존재하지 않는 지역 시각 → 가을의 두 번 오는 지역 시각 → 정확한 순간을 명시해 중복 실행을 막는 설계.

면접 실전 질문: ① ZonedDateTime.of()가 갭의 지역 시각을 받으면 어떻게 동작하는가? ② 오버랩 구간의 기본 오프셋은 어느 쪽인가? ③ LocalDateTime만 저장하면 어떤 정보가 손실되는가?


서머타임이 지나가는 순간, 지역 시각은 아예 존재하지 않거나 정확히 한 시간 간격으로 두 번 존재한다 — 그리고 여는 글의 배치가 두 번 실행된 이유는 후자다.

면접 실전 질문: ① ZonedDateTime.of()에 존재하지 않는 지역 시각을 넣으면 예외가 나는가, 아니면 다른 값이 되는가? ② 겹치는 지역 시각을 java.time은 기본적으로 어느 오프셋으로 해석하는가? ③ 저장된 LocalDateTime 하나만으로 그게 정확히 어느 순간이었는지 되돌릴 수 없는 이유는 무엇인가?


배경 — 시계를 강제로 밀고 당기는 결정

5장은 Asia/Seoul 한 줄 뒤에 1987~88년 서울올림픽을 앞두고 두 해 동안 시행됐던 서머타임이 숨어 있다는 걸 확인했습니다. 그 규칙이 GAPOVERLAP이라는 두 표시로 남아 있다는 것도 봤고요. 이 장은 그 두 표시를 실제로 열어봅니다. 오프셋이 바뀌는 그 순간, 지역 시각에게 무슨 일이 일어나는지를요.

먼저 짚어둘 건, 서머타임(DST)이 물리 법칙이 아니라 정책 결정이라는 점입니다. 애초 도입 명분은 “해가 떠 있는 시간에 맞춰 활동 시간을 옮기면 에너지를 아낄 수 있다”는 것이었고, 전시(戰時) 자원 절약을 계기로 여러 나라가 채택했습니다. 그런데 이 명분은 계속 다시 검증대에 오릅니다. 유럽의회는 2019년에 이미 서머타임 폐지에 찬성표를 던졌지만, 27개 회원국이 “그럼 겨울시로 통일할지 여름시로 통일할지”를 합의하지 못해 시행이 계속 미뤄지고 있습니다. 5장에서 본 한국의 1987~88년 서머타임도 두 해 만에 접혔죠. 서머타임 규칙은 지어진 지 얼마 안 됐어도 언제든 다시 바뀔 수 있는 입법 기록이라는 5장의 결론이, 이 장에서는 “그 규칙이 실행되는 그 찰나”의 문제로 좁혀집니다.

그 찰나에 지역 시각은 두 가지 방식으로 망가집니다. 봄에 시계를 앞으로 미는 순간에는 어떤 지역 시각이 아예 존재하지 않게 되고, 가을에 시계를 뒤로 당기는 순간에는 어떤 지역 시각이 정확히 한 시간 간격으로 두 번 존재하게 됩니다. 이 둘은 겉보기엔 둘 다 “DST 버그”로 뭉뚱그려지지만, 코드에 미치는 영향은 정반대입니다.

스토리 — 새벽 2시, 두 번 실행된 배치

여는 글의 그 밤을 다시 떠올려 볼까요. 정산 배치는 매일 새벽 2시에 실행되도록 스케줄러에 등록돼 있었습니다. 그런데 스케줄러가 실제로 검사하는 조건은 “지금이 지역 시각으로 02:00을 지났는가” 뿐입니다. 그 지역 시각이 UTC 기준 정확히 어느 순간을 가리키는지는 스케줄러의 관심사가 아니에요. 크론 표현식 0 2 * * *도, 이걸 실행하는 대부분의 스케줄러도 마찬가지입니다. 벽시계 눈금만 보고 판단합니다.

문제는 서머타임이 끝나는 밤, 그 벽시계 눈금 자체가 두 번 반복된다는 겁니다. 중부유럽 표준시(CET)를 쓰는 지역에서는 서머타임이 끝날 때 시계가 03:00에서 02:00으로 정확히 한 시간 되돌아갑니다. 그러니까 그 밤에는 새벽 02:00부터 02:59까지가 문자 그대로 두 번 흘러갑니다. 첫 번째 02:0002:59는 서머타임 오프셋(+02:00)으로, 한 시간 뒤 다시 오는 두 번째 02:0002:59는 표준 오프셋(+01:00)으로요. 여는 글의 제목이 “새벽 2시가 두 번 온 날”인 건 과장이 아니라, 이 지역의 서머타임 규칙을 문자 그대로 옮긴 겁니다.

스케줄러 입장에서는 그냥 “지역 시각이 02:00을 지났다”는 조건이 그날 밤에만 두 번 참이 되는 거예요. 첫 번째 02:00에 배치가 한 번 돌고, 실제로는 한 시간밖에 안 지난 시점에서(UTC로는 딱 한 시간 뒤) 벽시계가 다시 02:00을 가리키니 배치가 또 한 번 돕니다. 정산 로직이 지역 시각 문자열만 보고 “오늘 새벽 배치는 아직 안 돌았다”고 판단했다면, 이 이중 실행을 걸러낼 방법이 없습니다. 고객 계좌에서 같은 금액이 두 번 빠져나간 이유가 이겁니다 — 코드가 틀린 게 아니라, 코드가 믿은 시계 자체가 그날 밤엔 거짓말을 하고 있었던 겁니다.

이걸 java.time으로 직접 재현해 봤습니다. 유럽 대신 미국 동부(America/New_York)로 확인한 이유는 2024년 전환 날짜가 명확하기 때문입니다 — 2024년 3월 10일 새벽 2시에 봄이 시작됐고(시계가 03:00으로 건너뜀), 2024년 11월 3일 새벽 2시에 가을이 시작됐습니다(시계가 01:00으로 되돌아감). 뉴욕은 그 되돌아가는 시각이 01:00~01:59이지만, 존재하지 않는 시각과 겹치는 시각이라는 현상 자체는 CET든 동부시든 완전히 같습니다.

먼저 존재하지 않는 시각부터 봅니다. 봄이 시작되는 새벽, 02:30이라는 지역 시각을 그대로 만들어 보면 어떻게 될까요.

import java.time.LocalDateTime; import java.time.ZoneId; import java.time.ZonedDateTime; public class GapDemo { public static void main(String[] args) { ZoneId ny = ZoneId.of("America/New_York"); LocalDateTime nonexistent = LocalDateTime.of(2024, 3, 10, 2, 30, 0); ZonedDateTime result = ZonedDateTime.of(nonexistent, ny); System.out.println("입력한 LocalDateTime = " + nonexistent); System.out.println("ZonedDateTime.of(nonexistent, America/New_York) = " + result); System.out.println("실제 LocalDateTime = " + result.toLocalDateTime()); System.out.println("실제 오프셋 = " + result.getOffset()); } }
입력한 LocalDateTime = 2024-03-10T02:30 ZonedDateTime.of(nonexistent, America/New_York) = 2024-03-10T03:30-04:00[America/New_York] 실제 LocalDateTime = 2024-03-10T03:30 실제 오프셋 = -04:00

java.time은 예외를 던지지 않습니다. 02:30은 뉴욕에서 그날 새벽엔 존재한 적 없는 시각인데도, 조용히 갭의 길이만큼(한 시간) 밀어서 03:30을 돌려줍니다. 이게 딱 잘라 경고해야 할 대목입니다. 이건 반올림 오차가 아니라 잘못된 입력입니다. 그런데 코드는 그걸 알아서 “고쳐서” 돌려줍니다. 호출한 쪽은 자기가 존재하지 않는 시각을 넣었다는 사실을 영영 모를 수 있습니다.

핵심 — 존재하지 않음과 모호함은 다른 종류의 버그다

이제 반대쪽, 두 번 오는 시각을 봅니다. 뉴욕의 가을 전환일, 01:30이라는 지역 시각으로 확인했습니다.

import java.time.Duration; import java.time.LocalDateTime; import java.time.ZoneId; import java.time.ZonedDateTime; public class OverlapDemo { public static void main(String[] args) { ZoneId ny = ZoneId.of("America/New_York"); LocalDateTime ambiguous = LocalDateTime.of(2024, 11, 3, 1, 30, 0); ZonedDateTime defaultChoice = ZonedDateTime.of(ambiguous, ny); ZonedDateTime earlier = defaultChoice.withEarlierOffsetAtOverlap(); ZonedDateTime later = defaultChoice.withLaterOffsetAtOverlap(); System.out.println("입력한 LocalDateTime = " + ambiguous); System.out.println("ZonedDateTime.of(기본값) = " + defaultChoice); System.out.println("withEarlierOffsetAtOverlap() = " + earlier); System.out.println("withLaterOffsetAtOverlap() = " + later); System.out.println("earlier.toInstant() = " + earlier.toInstant()); System.out.println("later.toInstant() = " + later.toInstant()); System.out.println("두 Instant 차이 = " + Duration.between(earlier.toInstant(), later.toInstant())); } }
입력한 LocalDateTime = 2024-11-03T01:30 ZonedDateTime.of(기본값) = 2024-11-03T01:30-04:00[America/New_York] withEarlierOffsetAtOverlap() = 2024-11-03T01:30-04:00[America/New_York] withLaterOffsetAtOverlap() = 2024-11-03T01:30-05:00[America/New_York] earlier.toInstant() = 2024-11-03T05:30:00Z later.toInstant() = 2024-11-03T06:30:00Z 두 Instant 차이 = PT1H

2024-11-03T01:30이라는 똑같은 문자열이 서로 다른 두 순간을 가리킵니다. 정확히 한 시간 차이 나는 두 Instant요. ZonedDateTime.of()는 기본적으로 둘 중 더 이른 오프셋을 고릅니다(공식 문서 표현으로 “여름 시각에 해당하는 쪽”). withEarlierOffsetAtOverlap()을 불러도 값이 그대로인 게 그 증거입니다. 다른 오프셋을 원하면 withLaterOffsetAtOverlap()을 명시적으로 불러야 합니다.

여기서 갭과 오버랩의 차이가 선명해집니다. 갭은 없는 값을 만들었는데 코드가 조용히 고쳐주는 문제였습니다. 오버랩은 유효한 값인데 어느 쪽인지 코드가 (합리적으로) 짐작해서 정하는 문제입니다. 그리고 일단 그 시각이 오프셋 없이 문자열이나 LocalDateTime으로 저장되고 나면, 나중에 아무리 따져봐도 그게 이른 쪽이었는지 늦은 쪽이었는지 복원할 방법이 없습니다. 그 정보는 저장하는 순간 이미 사라진 겁니다.

ZoneRules.getValidOffsets(LocalDateTime)으로 이 세 가지 상태를 한 번에 확인할 수 있습니다.

import java.time.LocalDateTime; import java.time.ZoneId; import java.time.zone.ZoneRules; import java.util.List; public class ValidOffsetsDemo { public static void main(String[] args) { ZoneRules rules = ZoneId.of("America/New_York").getRules(); LocalDateTime normal = LocalDateTime.of(2024, 6, 15, 12, 0); LocalDateTime gap = LocalDateTime.of(2024, 3, 10, 2, 30); LocalDateTime overlap = LocalDateTime.of(2024, 11, 3, 1, 30); System.out.println("평상시(" + normal + ") 크기 = " + rules.getValidOffsets(normal).size() + " 내용 = " + rules.getValidOffsets(normal)); System.out.println("갭 (" + gap + ") 크기 = " + rules.getValidOffsets(gap).size() + " 내용 = " + rules.getValidOffsets(gap)); System.out.println("중첩(" + overlap + ") 크기 = " + rules.getValidOffsets(overlap).size() + " 내용 = " + rules.getValidOffsets(overlap)); } }
평상시(2024-06-15T12:00) 크기 = 1 내용 = [-04:00] 갭 (2024-03-10T02:30) 크기 = 0 내용 = [] 중첩(2024-11-03T01:30) 크기 = 2 내용 = [-04:00, -05:00]

이보다 더 깔끔한 증거는 없습니다. 유효한 오프셋이 0개면 존재하지 않는 시각, 1개면 평범한 시각, 2개면 두 번 오는 시각입니다. 지역 시각 하나를 getValidOffsets에 넣어보는 것만으로 그게 지금 어떤 상태인지 즉시 알 수 있습니다.

마지막으로, 여는 글의 또 다른 장면 — “하루가 25시간이던 날” — 도 같은 밤의 다른 얼굴입니다. 가을 전환일에 지역 시각으로 자정부터 다음 날 자정까지를 재보면 무슨 일이 벌어질까요.

import java.time.Duration; import java.time.ZoneId; import java.time.ZonedDateTime; public class WallClockDuration { public static void main(String[] args) { ZoneId ny = ZoneId.of("America/New_York"); ZonedDateTime start = ZonedDateTime.of(2024, 11, 3, 0, 0, 0, 0, ny); ZonedDateTime end = start.plusDays(1); Duration elapsed = Duration.between(start, end); System.out.println("시작 = " + start); System.out.println("끝(다음날 같은 벽시계 00:00) = " + end); System.out.println("실제 경과 시간 = " + elapsed + " (시간 단위 = " + elapsed.toHours() + "시간)"); } }
시작 = 2024-11-03T00:00-04:00[America/New_York] 끝(다음날 같은 벽시계 00:00) = 2024-11-04T00:00-05:00[America/New_York] 실제 경과 시간 = PT25H (시간 단위 = 25시간)

벽시계로는 하루(00:00 → 00:00)지만, 실제로 흐른 시간은 25시간입니다. “하루 배치 주기”를 가정하고 짠 로직이 있다면, 이 밤 하루만 한 시간어치 계산이 어긋납니다. 봄철 전환일을 같은 방식으로 재보면 정확히 반대가 나옵니다.

2024-03-10 자정 → 03-11 자정 = PT23H 2024-11-03 자정 → 11-04 자정 = PT25H

같은 “하루”가 한 해에 두 번, 23시간과 25시간으로 어긋납니다.

이 모든 결과를 하나로 묶으면 이렇습니다. LocalDateTime은 한 순간을 가리키는 값이 아닙니다. 오프셋도 타임존도 없는 벽시계 눈금일 뿐이에요. 평상시엔 그 눈금과 실제 순간이 1대 1로 대응하니 문제가 안 보이지만, 갭과 오버랩의 밤에는 그 대응이 깨집니다. 스케줄링, 정산, 감사 로그처럼 나중에 정확히 그 순간을 복원해야 하는 값을 지역 시각만으로 저장하면, 그 정보는 저장하는 순간 이미 손실됩니다. 되돌릴 방법이 없어요. 이 원칙은 9장의 Instant vs LocalDateTime, 15장의 “서버는 UTC, 표시만 로컬” 경계 설계에서 그대로 다시 나옵니다.

덧붙여, 크론이나 여러 스케줄러는 애초에 지역 시각만 보고 트리거를 판단하도록 만들어진 경우가 많습니다. “매일 새벽 2시”라는 스케줄 자체가 이미 벽시계에 묶인 규칙이라는 뜻이고, 그 규칙이 1년에 딱 이틀 — 봄과 가을의 전환일 — 배신할 수 있다는 걸 아는 것과 모르는 것의 차이가, 여는 글에서 본 그 밤의 차이입니다.

정리

  • DST는 물리 법칙이 아니라 정책이다. 도입 명분(에너지 절약)은 계속 재검증되고, 유럽의회는 2019년 폐지에 찬성했지만 회원국 합의 실패로 아직 시행되지 않았다. 규칙은 언제든 다시 바뀐다.
  • 봄(스프링 포워드) — 시계가 앞으로 건너뛰는 순간, 어떤 지역 시각은 존재하지 않는다. ZonedDateTime.of()는 예외를 던지지 않고 갭의 길이만큼 조용히 미뤄 돌려준다(실측: 02:3003:30-04:00). 잘못된 입력을 코드가 알아서 “고쳐준다”는 뜻이다.
  • 가을(폴백) — 시계가 뒤로 돌아가는 순간, 어떤 지역 시각은 정확히 한 시간 간격으로 두 번 존재한다. ZonedDateTime.of()는 기본으로 더 이른 오프셋을 고른다(실측: withEarlierOffsetAtOverlap()과 기본값이 동일). withLaterOffsetAtOverlap()으로 명시하지 않으면 어느 쪽인지 코드 밖에서는 알 수 없다.
  • ZoneRules.getValidOffsets(LocalDateTime)이 반환하는 리스트 크기가 그대로 상태다 — 0이면 존재하지 않음, 1이면 평상시, 2이면 두 번(실측 확인).
  • 폴백 밤의 “하루”는 벽시계로는 24시간이지만 실제로는 25시간, 봄철 갭의 밤은 23시간이다 (실측: Duration.between 결과 각각 PT25H·PT23H).
  • 결론: LocalDateTime은 순간이 아니라 눈금이다. 스케줄러가 지역 시각만 보고 트리거를 판단하면, 오버랩 밤에는 같은 조건이 두 번 참이 된다 — 여는 글의 배치가 두 번 실행된 이유가 이것이다. 오프셋 없이 저장된 지역 시각은 나중에 절대 복원할 수 없다는 원칙은 9장·15장에서 다시 다룬다.

생각해볼 질문: 이 장은 지역 시각을 문자열로 다시 마주쳤을 때 생기는 문제를 보여줬습니다. 그런데 애초에 그 문자열 자체가 표준이 하나가 아니라면 어떨까요? 2024-11-03 01:30이라고만 적힌 로그 한 줄을 팀 밖의 누군가가 받았을 때, 그 사람은 이게 어느 나라, 어느 오프셋, 어느 포맷 규칙으로 적힌 시각인지 어떻게 알 수 있을까요?

7장 · 문자열로 시간을 적는 법 — ISO 8601과 RFC 3339

Last updated on