타임스탬프, 시간대, 그리고 두 번 찾아오는 한 시간
타임스탬프는 두 가지 질문 중 하나에 답하며, 날짜 관련 버그의 대부분은 그 둘을 뒤섞는 데서 나옵니다. 타임스탬프는 모든 관찰자가 동의하는 시간선 위의 한 점인 순간을 가리키거나, 누구의 벽에 걸린 시계인지 알기 전까지는 아무 의미가 없는 벽시계 값을 가리킵니다. 결제는 어떤 순간에 일어났고, 치과 예약은 벽시계 값입니다. 한쪽을 다른 쪽으로 저장하면 나중에, 정확히 한 시간만큼, 어느 일요일 아침에 깨집니다.
Unix 시간은 시각을 알려주는 것이 아니라 세는 것입니다
Unix 시간은 1970-01-01 00:00:00 UTC 이후 흐른 초의 수입니다. 여기서 UTC는 세기 시작한 지점을 말해줄 뿐이고, 값 자체에는 시간대가 담겨 있지 않습니다. 같은 순간은 암스테르담에서도 오클랜드에서도 리마에서도 같은 숫자입니다.
문제는 단위이므로 자릿수를 세십시오.
| 자릿수 | 예시 | 단위 |
|---|---|---|
| 10 | 1756080000 | 초 |
| 13 | 1756080000000 | 밀리초 |
오늘날의 모든 날짜는 초 단위로 10자리, 밀리초 단위로 13자리이며 2286년까지 그렇습니다. 두 가지 오독은 모두 티가 크게 납니다. 밀리초를 초로 읽으면 57626년이 나오고, 초를 밀리초로 읽으면 1970년 1월이 나옵니다. 해석된 날짜를 보여주는 타임스탬프 변환기를 쓰면 곧바로 드러납니다.
또 다른 한계는 담는 그릇입니다. 부호 있는 32비트 초 카운트는 2147483647에서 끝나는데, 이는 2038년 1월 19일 03:14:07 UTC이며 그다음에는 음수로 감겨 1901년 12월로 읽힙니다. 30년짜리 만료일을 저장하는 시스템은 이미 그 지점을 넘어 계산하고 있습니다. 부호 없는 32비트는 2106년까지 가지만 1970년 이전의 모든 날짜를 잃습니다. 64비트에는 현실적인 상한이 없습니다.
ISO 8601과 RFC 3339
외워둘 만한 형태는 2026-08-25T14:30:00Z입니다. 날짜, T, 시각, 그리고 시간대 지정자 순입니다. RFC 3339는 인터넷 프로토콜을 위한 ISO 8601의 더 엄격한 프로필이며 그 지정자를 반드시 요구합니다. ISO 8601 자체는 주 단위 날짜, 20260825T143000Z 같은 구분자 없는 형태, 오프셋이 없는 타임스탬프도 허용하므로, "ISO 8601"을 지원한다고 말하는 API는 대개 RFC 3339 부분집합을 뜻합니다.
Z는 오프셋이 0이라는 뜻입니다. +02:00은 현지 시계가 UTC보다 두 시간 앞선다는 뜻이므로 UTC는 빼서 구합니다. 이 방향을 거꾸로 잡는 것이 두 시간씩 어긋나는 흔한 실수입니다.
| 문자열 | 무엇을 가리키는가 |
|---|---|
2026-08-25T14:30:00Z | 하나의 순간, 모호함 없이 |
2026-08-25T16:30:00+02:00 | 같은 순간을 두 시간 앞선 시계로 표시한 것 |
2026-08-25T14:30:00 | 벽시계 값이며 순간은 아님 |
2026-08-25 | 날짜이며 길이는 23시간, 24시간, 25시간 중 하나 |
Z도 오프셋도 없는 문자열은 모호하며 파서마다 해석이 다릅니다. 어떤 파서는 UTC로 가정하고 어떤 파서는 시스템 시간대로 가정합니다. 생일처럼 날짜만 있는 값이 그리니치 서쪽에서 하루 앞당겨 표시되는 일이 잦은 이유가 이것입니다.
오프셋은 시간대가 아닙니다
+02:00은 한 장소의 한 순간에 관한 사실입니다. Europe/Amsterdam은 규칙의 묶음입니다. 겨울에는 +01:00, 여름에는 +02:00이며 이전 수십 년 동안에는 또 다른 모습이었습니다. 오프셋을 저장하면 측정된 그 순간에 대한 답은 남지만 다른 어떤 값도 계산할 능력은 버리게 됩니다.
오프셋은 고유하지도 않습니다. 여름 오후의 +02:00은 암스테르담과 라고스와 요하네스버그를 모두 포함하므로, 오프셋만 가지고는 규칙으로 돌아갈 길이 없고 그 시계가 다가올 12월에 무엇을 가리킬지 알 방법도 없습니다. 약어는 더 나쁩니다. CST 하나가 서로 다른 세 개의 시간대를 가리키기 때문입니다. Area/Location 형식의 IANA 식별자를 저장하십시오.
존재하지 않는 한 시간과 두 번 찾아오는 한 시간
EU 전역에서 시계는 3월 마지막 일요일에 앞으로 가고 10월 마지막 일요일에 뒤로 갑니다. 암스테르담에서는 봄에 02:00이 03:00이 되므로, 그날에는 02:00:00부터 02:59:59까지의 모든 현지 시각이 존재하지 않습니다. 가을에는 03:00이 02:00이 되므로 02:30이 두 번 찾아옵니다. 한 번은 +02:00에서, 한 시간 뒤에 다시 +01:00에서입니다.
- 반복 알람. 현지 시각 02:30으로 잡아둔 작업은 봄의 그 일요일에 유효한 시각을 갖지 못합니다. 스케줄러마다 처리가 달라서 건너뛰기도 하고, 03:00으로 옮기기도 하고, 01:30으로 옮기기도 합니다. 가을의 그 일요일에는 같은 작업이 한 번 실행될 수도 두 번 실행될 수도 있으니, cron 파서로 다음 몇 번의 실행 시각을 확인하십시오.
- 로그 파일. 오프셋이 없는 현지 시각으로 기록된 한 시간 분량은 정렬할 수 없고, 한 시간 떨어진 두 사건이 똑같은 텍스트를 갖습니다. UTC로 기록하거나 모든 줄에 오프셋을 넣으십시오.
- 기간. 암스테르담 현지 시각으로 2026년 10월 24일 20:00부터 다음 날 20:00까지는 25시간입니다. "하루를 더한다"와 "24시간을 더한다"는 서로 다른 연산이며, 달력상의 날짜 차이는 시간 단위로 계산하는 기간 변환기와 다른 질문에 답합니다.
규칙은 바뀌므로 저장 방식이 그것을 견뎌야 합니다
IANA 시간대 데이터베이스인 tzdata가 이 규칙들을 담고 있으며 1년에 여러 번 배포됩니다. 규칙이 곧 법령이기 때문입니다. 정부는 서머타임을 폐지하기도 하고, 도입하기도 하고, 전환 날짜를 옮기기도 하며, 때로는 몇 주 전에야 알립니다. 고정된 컨테이너 이미지와 브라우저와 언어 런타임이 각자의 사본을 들고 있어서 한 기기 안에서도 서로 다를 수 있습니다. 미래의 현지 시각을 순간으로 변환하는 일은 예측이며, 그 시점이 실제로 와야 확정됩니다.
| 값의 종류 | 저장 방식 | 이유 |
|---|---|---|
| 이미 일어난 일 | UTC로 표시한 순간 | 과거는 다시 입법될 수 없습니다 |
| 벽시계 기준의 미래 약속 | 현지 날짜와 시각에 IANA 시간대 식별자를 함께 | 의도는 시계가 가리키는 값입니다 |
| 시간대와 무관하게 고정된 미래의 순간 | UTC | 의도는 그 순간 자체입니다 |
| 시각이 없는 날짜 | 시간대도 자정도 없는 순수한 날짜 | 시간대를 붙이면 값이 흘러갑니다 |
내년 회의에 대해 "그냥 UTC로 저장하라"가 틀린 이유가 이것입니다. 2027년 3월 30일 Europe/Amsterdam 09:00을 오늘 UTC로 변환하면 07:00Z가 나옵니다. 그때까지 전환 날짜가 옮겨지면 회의는 여전히 현지 09:00이지만 07:00Z는 이제 08:00으로 표시됩니다. 저장된 값이 예측을 얼려버리고 의도를 버린 것입니다. 현지 시각과 시간대를 진실의 원천으로 삼고, 그 옆의 UTC 열은 캐시로 두십시오.
사람에게 날짜 보여주기
03/04는 문자열만으로는 모호함을 없앨 수 없습니다. 세계 대부분에서는 4월 3일이고 미국에서는 3월 4일이며, 03/04/05는 미지수를 하나 더 얹습니다. 사람이 읽을 출력에는 월을 단어로 적고(4 Apr 2026), 기계끼리 주고받을 때는 ISO를 쓰십시오. Discord 타임스탬프 태그는 읽는 사람의 시간대와 로캘로 렌더링해서 채팅에서 이 문제를 비켜 갑니다. 값은 모호하지 않게 저장하고 서식은 가장 바깥에서 입히는 방식입니다.
주 번호에는 별도의 규칙이 있습니다. ISO 주는 월요일에 시작하고, 1주는 1월의 첫 목요일이 들어 있는 주, 다시 말해 1월 4일이 들어 있는 주입니다. 그래서 1월 1일이 전년도 ISO 기준 52주나 53주에 들어갈 수 있고, 12월 29일이 다음 해의 1주에 들어갈 수 있습니다. 북미에서 흔한 관례는 대신 1월 1일이 들어 있는, 일요일에 시작하는 주부터 번호를 매기므로 같은 날짜가 두 개의 번호를 갖게 됩니다. 요일 계산기가 내놓는 ISO 주 번호는 스프레드시트와 대조해 볼 만합니다.
기간, 달, 윤년
"한 달 뒤"는 고정된 일수가 아닙니다. 28일일 수도, 29일일 수도, 30일일 수도, 31일일 수도 있습니다. 1월 31일에 한 달을 더하는 데는 올바른 답이 없고, 라이브러리는 대체로 2월 말로 잘라 맞추는데 그 때문에 이 연산은 되돌릴 수 없게 됩니다. 한 달을 더했다가 다시 빼면 1월 31일이 1월 28일이 되어 있습니다. 반올림도 같은 함정의 축소판입니다. 시계 시각을 가장 가까운 15분 단위로 잘라내는 것과 반올림하는 것은 최대 한 구간만큼 차이가 납니다.
윤년 규칙을 온전히 적으면 이렇습니다. 4로 나누어떨어지면 윤년이되, 100으로 나누어떨어지면 윤년이 아니고, 다만 400으로도 나누어떨어지면 다시 윤년입니다. 그래서 1900년은 윤년이 아니었고, 2000년은 윤년이었으며, 2100년은 윤년이 아닐 것입니다. 이렇게 하면 한 해의 평균 길이가 365.2425일이 됩니다. 4로 나누어떨어지는지만 검사하는 코드는 살아 있는 사람의 기억 속 모든 해에 대해 옳으며, 그래서 2100년까지는 살아남습니다. 2월 29일 기념일은 이와 관련된 골칫거리입니다. 다음 해에는 2월 28일과 3월 1일이 있을 뿐 어느 쪽을 고를지 정해주는 규칙이 없으므로, 의도적으로 하나를 고르십시오.