목차11개 항목
출처와 참고
Dave DeLong의 「Your Calendrical Fallacy Is…」를 바탕으로 재구성한 노트다. 윤초와 시간 표현은 ByteByteGo의 System Design 101 가이드 「Why Meta, Google, and Amazon Stop Using Leap Seconds」와 Google Public NTP의 리프 스미어 문서로, 시스템 시계와 시각 저장 원칙은 Emil Mikulic의 「Some notes about time」으로 보강했다.
왜 착각이 생기나
날짜와 시간 코드는 내가 사는 곳의 달력이 전 세계의 규칙이라고 가정할 때 깨진다. 하루, 한 주, 한 달, 한 해, 시간대는 모두 천문 현상이 아니라 사람이 정한 제도다. 제도는 나라마다 다르고, 정치적 결정으로 며칠 만에 바뀌기도 한다.
아래 표의 왼쪽은 흔히 코드에 박아 넣는 가정이고, 오른쪽은 그 가정을 깨는 실제 사례다.
날과 주
| 착각 | 실제 |
|---|---|
| 하루는 86,400초다 | 윤초가 들어가는 날은 1초 길다 |
| 하루는 24시간이다 | 서머타임 시작일은 23시간, 종료일은 25시간이다 |
| 같은 시각은 하루에 한 번뿐이다 | 서머타임 종료일에는 한 시간이 두 번 지나간다. 그 시각에 맞춘 알람은 몇 번 울려야 할까 |
| 모든 날에는 자정이 있다 | 자정에 서머타임이 시작되는 곳(과거 브라질 등)은 23:59 다음이 01:00이다 |
| 하루는 자정에 시작한다 | 히브리 달력의 하루는 해 질 녘에 시작한다 |
| 한 날짜는 길어야 25시간 존재한다 | UTC+14에서 시작해 UTC−12에서 끝나므로, 같은 날짜가 지구 어딘가에 약 50시간 동안 존재한다 |
| 주말은 토·일요일이다 | 중동 여러 나라는 금·토요일이나 목·금요일이 주말이다 |
| 한 주는 일요일에 시작한다 | 미국은 일요일, 유럽은 월요일, 토요일에 시작하는 곳도 있다 |
| 근무일은 연달아 있다 | 브루나이는 월~목과 토요일에 일하고 금요일에 쉰다 |
달력 체계
| 착각 | 실제 |
|---|---|
| 모두 그레고리력을 쓴다 | 이슬람력, 히브리력, 콥트력, 태국 불기, 일본 연호가 공식적으로 쓰인다 |
| 율리우스력은 이제 아무도 안 쓴다 | 동방정교회는 지금도 쓰고, 천문학은 율리우스일(Julian day)로 날짜를 센다 |
| 한 달은 최소 28일이다 | 콥트력의 13번째 달은 5일이나 6일뿐이다 |
| 달의 길이는 규칙적이다 | 전통 이슬람력은 초승달을 눈으로 관측해 달을 시작한다. 날이 흐리면 길이가 바뀌어 미리 알 수 없다 |
| 1년은 12개월이다 | 히브리력·콥트력은 13개월짜리 해가 있다. 히브리력은 윤달이 해 중간에 끼어든다 |
| 1년은 365일이나 366일이다 | 이슬람력의 1년은 354일이나 355일이다 |
| 명절은 해마다 같은 계절에 온다 | 이슬람력 명절은 매년 약 11일씩 당겨져, 여러 해가 지나면 계절이 바뀐다 |
| 한 주는 언제나 7일이다 | 지금은 그렇지만, 역사적으로 8일·10일 주를 쓴 달력도 있었다 |
| 9~12월이 Sept-·Oct-·Nov-·Dec-인 건 자연스럽다 | 원래 7~10번째 달이라는 뜻이다. 로마 달력에 1·2월이 앞에 끼어들며 번호가 밀렸다 |
윤년과 연도
윤년 규칙은 한 줄씩 덧붙일 때마다 다시 틀린다.
flowchart TB
r1["4년마다 윤년"] --> r2["… 단, 100으로 나뉘면 평년"] --> r3["… 단, 400으로 나뉘면 다시 윤년"]
r1 --> e1["1900년은 윤년이 아니었다"]
r2 --> e2["2000년은 윤년이었다"]
r3 --> e3["그레고리력에서만 맞다<br>히브리력은 19년에 일곱 번 윤달을 넣는다"]| 착각 | 실제 |
|---|---|
| 연도는 새해 첫날에만 바뀐다 | 일본 연호는 천황이 바뀔 때 바뀐다. 2019년 4월 30일이 헤이세이 31년의 마지막 날이고, 5월 1일이 레이와 원년이다 |
| 연도 번호는 늘기만 한다 | 연호가 바뀌면 1로 돌아간다 |
| 중요한 연도는 네 자리다 | 일본 연호는 한두 자리다 |
| 1년에 새해는 한 번이다 | 히브리 전통에는 목적별 새해가 네 번 있다 |
| 날짜 범위 중간에 빠진 날은 없다 | 그레고리력 전환 때 10~11일이 사라졌다(가톨릭 국가 1582년, 영국·미국 식민지 1752년). 사모아는 2011년 날짜변경선을 넘어가며 12월 30일을 통째로 건너뛰었다 |
시간대
| 착각 | 실제 |
|---|---|
| 시간대 오프셋은 정시 단위다 | 인도(+5:30), 이란, 뉴펀들랜드 등은 30분 단위다 |
| 그래도 30분 단위다 | 네팔(+5:45), 채텀 제도(+12:45)는 15분 단위다 |
| 그래도 15분 단위다 | 지금은 그렇지만, 과거 라이베리아(약 −0:44)나 아일랜드(−0:25:21)처럼 초 단위까지 어긋난 오프셋이 있었다 |
| UTC와 12시간 넘게 차이 나는 곳은 없다 | 키리바시 일부는 UTC+14다 |
| 한 지역의 시간대는 바뀌지 않는다 | 사모아(2011), 칸쿤(2015)처럼 통째로 옮긴 곳이 있다 |
| 시간대는 더 생기지 않는다 | 2016년에도 러시아에 Asia/Tomsk가 새로 생겼다 |
| 시간대 이름은 바뀌지 않는다 | 2022년 Europe/Kiev가 Europe/Kyiv로 바뀌었다 |
| 유닉스 에포크는 1970년 1월 1일이다 | UTC 기준일 뿐이다. 로스앤젤레스 현지 시각으로는 1969년 12월 31일이다 |
시간대가 왜 단순한 오프셋이 아니라 변경 이력 전체인지는 자바스크립트에서 타임존 다루기에 정리했다.
서머타임
| 착각 | 실제 |
|---|---|
| 서머타임 규칙은 바뀌지 않는다 | 세계 어딘가에서 해마다 여러 번 바뀐다 |
| 바뀌더라도 미리 알려 준다 | 2016년 이집트는 시행 사흘 전에 취소했고, 2023년 레바논은 연기 발표를 나흘 만에 뒤집었다 |
| 서머타임은 1시간이다 | 호주 로드하우섬은 30분만 옮긴다. 2차 대전 때 영국은 2시간을 당겼다 |
윤초와 시간 표현
윤초(leap second) 는 원자시계로 세는 UTC를 조금씩 느려지는 지구 자전에 맞추려고 끼워 넣는 1초다. 윤초가 들어가는 날에는 23:59:59 다음이 00:00:00이 아니라 23:59:60이다. 1972년 이후 모두 양의 윤초로 27번 들어갔고, 마지막은 2016년 12월 31일이다. 2022년 국제도량형총회(CGPM)는 2035년까지 윤초를 폐지하기로 결의했다.
23:59:60을 표현하지 못하는 시스템이 많아 윤초는 시간 처리 버그의 단골이다. 윤초를 신경 써야 하는지는 어떤 시간 표현을 쓰느냐에 달렸다.
| 표현 | 정의 | 윤초 |
|---|---|---|
| UTC | 원자시를 기반으로 하되 지구 자전과 0.9초 이내로 맞춘 세계 표준시 | 넣는다 |
| GMT | 그리니치 평균 태양시. 일상에서는 UTC+0 시간대 이름으로 쓴다 | (UTC와 같게 취급) |
| TAI | 국제원자시. 원자시계만으로 센다 | 없다. 2017년부터 TAI는 UTC보다 37초 앞선다 |
| Unix 시간(epoch time) | 1970-01-01 00:00:00 UTC부터 흐른 초. 하루를 늘 86,400초로 센다 | 세지 않는다. 윤초 순간에는 같은 초가 두 번 나오거나 시계가 멈춘 것처럼 보인다 |
| GPS 시간 | 1980-01-06부터 원자시계로 센다 | 없다. UTC보다 18초 앞선다 |
| TrueTime | Google Spanner의 시간 API. 시각을 한 점이 아니라 [가장 이른, 가장 늦은] 구간으로 돌려준다. GPS 수신기와 원자시계로 불확실성을 몇 ms 안에 묶는다 | 스미어된 시간을 쓴다 |
리프 스미어
Google·AWS·Meta 같은 대형 인프라는 윤초를 한 번에 넣지 않고 리프 스미어(leap smear) 로 처리한다. 윤초 전후 수십 시간에 걸쳐 시계를 아주 조금씩 늦춰 1초를 흩어 넣는다. Google은 윤초 전날 정오부터 다음 날 정오까지 24시간 동안 선형으로 늦춘다. 그러면 23:59:60도, 같은 초의 반복도 생기지 않는다.
- 스미어 중에는 시계가 실제 UTC와 최대 0.5초까지 어긋난다. 스미어하는 NTP 서버와 하지 않는 서버를 섞어 쓰지 않는다.
- 분산 시스템에서 이벤트 순서를 벽시계로 정하면 윤초·NTP 보정·스미어에 모두 흔들린다. 순서는 논리 시계(Lamport 시계, 벡터 시계)나 단조 증가 ID로 정하고, 경과 시간은 단조 시계로 잰다(운영체제의 시간과 타이머, 분산 시스템의 시간과 장애).
시스템 시계는 틀리고, 뛴다
- 시스템 시계는 부정확하다. 수정 진동자의 속도가 온도에 따라 달라져 「시계의 1초」가 실제 1초와 다르고 그 차이도 변한다. 네트워크의 다른 컴퓨터는 각자 다르게 틀린다.
- NTP는 두 방법으로 시계를 맞춘다. 스텝(step)은 시각을 한 번에 앞뒤로 점프시키고, 슬루(slew)는 시계를 조금 빠르거나 느리게 돌려 서서히 맞춘다. 슬루가 덜 해롭지만 작은 오차에만 쓸 수 있다. 큰 오차는 스텝으로 고치므로 벽시계는 뒤로 갈 수 있다. 프로그램은 이를 견디게 짠다.
- 윤초는 6개월 전쯤에야 예고되므로, 6개월 넘게 남은 일정을 초 단위로 정확히 계산할 수 없다. NTP 서버는
leapfile설정으로 다가오는 윤초를 알리지만, 대부분의 말단 서버는 상위 서버가 맞게 처리하기를 기대할 뿐이다. - POSIX의 Unix 시간은 윤초를 직전 초를 한 번 더 반복하는 방식으로 처리한다(
59.75 → 59.00 → … → 00.00). 그래서 매일이 정확히 86,400초가 되어 윤초 표 없이 날짜를 계산할 수 있지만, 윤초 순간을 표현할 수 없고 시각이 뒤로 간다. - 상대론 효과도 실제로 있다. GPS 위성의 시계는 속도와 중력에 의한 시간 차이를 보정한다.
코드에서 지킬 것
| 원칙 | 이유 |
|---|---|
| 날짜 계산을 직접 구현하지 않는다 | 위의 예외를 다 아는 사람은 없다. ICU, 언어 표준 라이브러리, 검증된 날짜 라이브러리를 쓴다 |
시간대는 오프셋이 아니라 IANA 이름(Asia/Seoul)으로 저장한다 | 오프셋은 날짜에 따라 바뀐다 |
| tz 데이터를 최신으로 유지한다 | 규칙이 며칠 전 통보로 바뀐다 |
| 저장은 UTC, 표시할 때만 현지 시각 | 같은 현지 시각이 두 번 있거나 아예 없는 경우를 피한다. 시간대는 표현 계층의 문제이므로 코드 대부분은 Unix 시간(UTC 타임스탬프)만 주고받는다 |
| 사람이 읽을 시각(로그 등)은 타임스탬프와 함께 저장한다 | 문자열만 남기면 다시 계산할 수 없다 |
| 표시하거나 직렬화할 때는 항상 오프셋을 붙인다 | 오프셋 없는 2026-10-21 09:00은 어느 순간인지 알 수 없다. 2026-10-21T09:00:00+09:00(RFC 3339)처럼 쓴다 |
| 경과 시간은 단조 시계로 잰다 | 벽시계는 NTP 보정으로 뒤로 갈 수 있다(clock_gettime(CLOCK_MONOTONIC), performance.now()) |
| DB의 시각 타입이 오프셋을 저장하는지 확인한다 | MySQL DATETIME은 YYYY-MM-DD HH:MM:SS만 저장하고 오프셋이 없어, 세션 time_zone이 바뀌면 같은 값이 다른 순간이 된다. TIMESTAMP는 UTC로 저장하지만 2038년 문제가 있다. PostgreSQL은 timestamptz를 쓴다 |
| "하루 = 86,400초"로 날짜를 더하지 않는다 | 서머타임 경계에서 하루가 밀린다. "1일 더하기"는 달력 연산으로 한다 |
| 주의 시작, 주말, 달력 체계는 로케일에서 가져온다 | 하드코딩하면 다른 나라 사용자에게 틀린다 |
정리
- 하루, 주, 달, 해, 시간대는 모두 사람이 정한 제도라 나라와 시대마다 다르다.
- 시간대는 오프셋이 아니라 이력이다. 30분·45분 오프셋과 UTC+14도 있다.
- 서머타임은 며칠 전 통보로 바뀌고, 1시간이 아닐 수도 있다.
- 날짜 범위에도 빠진 날이 있을 수 있다.
- 1분은 61초일 수 있다(윤초). Unix 시간은 윤초를 세지 않고, 대형 인프라는 리프 스미어로 흩어 넣는다.
- 직접 계산하지 말고 검증된 라이브러리와 최신 tz 데이터에 맡긴다.
관련 노트: 자바스크립트에서 타임존 다루기, 운영체제의 시간과 타이머, 개발자가 빠지는 함정 모음, 날짜 라이브러리: date-fns와 Day.js