프로덕션 환경의 Unix 타임스탬프: 초 vs 밀리초, 2038 문제, 타임존 버그
모든 프로덕션 시스템은 결국 Unix 타임스탬프를 로�하거나 저장하거나 전송합니다. 그리고 모든 프로덕션 시스템은 결국 그것을 다루는 버그를 출시합니다 — 1970년 1월로 표시되는 날짜, 49년 전으로 예약된 이벤트, 한쪽은 밀리초를 기대하고 다른 쪽은 초를 보내는 API 연동. 이런 버그는 지루하고 흔하며, 타임스탬프가 실제로 어떻게 동작하는지를 지배하는 한 줌의 규칙을 알면 완전히 예방할 수 있습니다.
이 가이드는 실무 핵심을 다룹니다: 자릿수로 단위를 인식하는 법, 2038 문제와 오늘의 실제 상태, 실제로 발생하는 버그 패턴, 그리고 사용 중일 가능성이 높은 언어에서 타임스탬프를 올바르게 얻는 방법.
Unix 타임스탬프란 무엇인가(그리고 무엇이 아닌가)
Unix 타임스�프는 1970-01-01 00:00:00 UTC — “Unix 에포크” — 이후의 초 수를 셉니다. 이후 모든 것에 중요한 두 가지 속성:
- 타임존이 없습니다. 타임스탬프
1785235837은 지구 어디에서든 동시에 동일한 한 순간을 가리킵니다. 타임존은 인간에게 타임스탬프를 표시하거나 로컬 벽시계 구성요소에서 구성할 때만 등장합니다. 이 글에서 한 문장을 internalize한다면 그것을 외우세요: 타임스탬프는 UTC 순간이며, 타임존은 렌더링 문제입니다. - 윤초를 무시합니다. Unix 시간은 모든 날이 정확히 86,400초라고 가정합니다. 윤초가 삽입될 때 POSIX 시간은 사실상 이를 반복하거나 smudge합니다(시스템에 따라 동작이 다름; Google의 “leap smear”는 하루에 걸쳐 분산). 대부분의 애플리케이션 코드에서 이것은 무관합니다; 윤초를 가로지르는 간격 계산에서는 “정확히 N초” 가정이 1만큼 빗나갈 수 있음을 의미합니다.
에포크 선택은 음수 타임스탬프가 유효하다는 것도 의미합니다: -1은 1969-12-31 23:59:59 UTC입니다. 음수 타임스�프를 오류로 다루는 코드는 1970 이전 날짜에서 깨집니다 — 역사적 데이터(생년월일, 기록 보관)를 다루는 시스템에서 실제 버그 패턴입니다.
초, 밀리초, 마이크로초: 자릿수 휴리스틱
타임스탬프는 서로 다른 생태계에서 서로 다른 단위로 도착하며, 단위를 잘못 식별하는 것이 가장 흔한 타임스탬프 버그입니다. 빠른 현장 테스트는 자릿수 세기입니다:
| 단위 | 예(2026년 중반) | 자릿수 | 이 폭에 도달하는 시점 |
|---|---|---|---|
| 초 | 1785235837 | 10 | 2001-09-09부터 10자릿수; 2286년부터 11자릿수 |
| 밀리초 | 1785235837000 | 13 | 2001-09-09부터 13자릿수 |
| 마이크로초 | 1785235837000000 | 16 | 16자릿수 |
| 나노초 | 1785235837000000000 | 19 | 2262년에 int64 범위 초과 |
현재 시대(2001–2286)의 경험칙:
- 10자릿수 → 초. 항상 안전하게 가정할 수 있습니다.
- 13자릿수 → 밀리초. JavaScript의
Date.now(), Java의System.currentTimeMillis(), 그리고 대부분의 로깅/메트릭 시스템이 이것을 출력합니다. - 16자릿수 → 마이크로초. Python의
time.time()에 100만을 곱한 값, 일부 데이터베이스, 일부 추적 시스템. - 19자릿수 → 나노초. Go의
time.Now().UnixNano(), 그리고 이것은 함정입니다: 19자릿수는 2262년 이후 부호 있는 64비트 최대값(9,223,372,036,854,775,807)을 초과하지만, 오늘날 19자릿수 숫자는 int64에 겨우 들어맞습니다. 나노초 타임스탬프를 저장한다면 여유 공간을 가정한 산술은 하지 마세요.
디버깅할 때 빠른 정합성 검사: 숫자를 타임스탬프 변환기에 붙여넣고 날짜가 합리적으로 보이는지 확인하세요. “초” 해석이 1970을 산출하고 “밀리초” 해석이 그럴듯한 최근 날짜를 산출한다면, 두 초 안에 단위를 식별한 것입니다.
이에 대응하는 버그 패턴: 밀리초를 초로 취급 (혹은 그 반대). 초 타임스탬프에 불필요하게 1000을 곱하면 1785235837은 1785235837000초가 됩니다 — 대략 56,000년 후의 날짜로, 대부분의 렌더러는 자르거나 쓰레기로 감쌉니다. 밀리초 타임스탬프를 원래 초였는데 1000으로 나누면 1970-01-21이 됩니다 — 그래서 많은 깨진 시스템이 1월 1970의 날짜를 표시하는 것입니다. 프로덕션에서 1970이 보이면 먼저 단위 불일치를 의심하세요.
2038 문제
고전적인 Unix time_t는 초를 세는 부호 있는 32비트 정수였습니다. 최대값 2,147,483,647은 2038-01-19 03:14:07 UTC에 해당합니다. 1초 후 부호 있는 32비트 카운터는 −2,147,483,648로 wrap되어 1901-12-13 20:45:52 UTC로 렌더됩니다. 이것이 “Y2038” 또는 “Epochalypse” 문제 — 구조적으로는 Y2K와 동일하지만, 두 자리 연도 필드가 아니라 특정 C 타입에 뿌리를 둔 것입니다.
2026년 현재 실제로 여전히 문제가 되는 곳:
- 32비트 임베디드 시스템과 펌웨어. 긴 수명을 가진 장치 — 산업용 제어기, 자동차 시스템, 의료기기, 인프라 — 가 32비트 커널 또는 32비트
time_tABI를 실행. 이들이 패치하기 어렵고 잊기 쉬워서 주된 잔존 위험 등급입니다. - 32비트 초 카운터를 명시한 오래된 파일 형식과 프로토콜. 일부는 필드를 부호 없음으로 재해석해 한계가 2106으로 밀립니다; 다른 것들은 단순히 레거시입니다.
- 에포크 한계 타입을 가진 데이터베이스. 잘 알려진 예: MySQL의
TIMESTAMP타입은 역사적으로 초를 부호 없는 32비트 정수로 저장했기 때문에2038-01-19 03:14:07 UTC까지만 가능했습니다. (DATETIME은 9999년까지의 범위를 가지며 영향받지 않습니다.) 스키마가TIMESTAMP를 사용한다면 이 한계를 알아두세요.
이미 고친 것들:
- 64비트 Linux와 현대 64비트 시스템은 64비트
time_t를 사용, 약 2,920억 년까지 충분합니다. - Linux 커널의
time64작업(커널 5.6, 2020 완료)과 glibc의 64비트 시간 지원(glibc 2.34의_TIME_BITS=64)은 32비트 Linux 유저랜드에 마이그레이션 경로를 제공하지만, 바이너리는 새 ABI로 재빌드되어야 합니다 — 32비트 시스템의 기존 컴파일된 바이너리는 여전히 취약합니다. - JavaScript, Python, Go, Java는 모두 64비트(또는 float64) 시간 표현을 사용하며 인간이 관련지을 수 있는 어떤 기간에도 오버플로하지 않습니다. JavaScript의 float64 밀리초는 양쪽으로 ~285,000년입니다.
실무적 조언: 64비트 플랫폼에서 메인스트림 언어로 새 코드를 작성한다면 2038은 당신의 문제가 아닙니다. 임베디드 펌웨어, 32비트 타겟의 레거시 C, INT 에포크 열과 MySQL TIMESTAMP 필드로 가득한 스키마를 유지한다면 지금 감사하세요 — 2020년대에 설치된 시스템은 2038에도 여전히 돌아가고 있을 것입니다.
실제로 발생하는 버그 패턴
1. 단위 혼동(초 vs 밀리초)
위에서 다룸: 가장 흔하고 가장 알아보기 쉬움(1970 혹은 58000년의 날짜). 규약으로 수정: 변수와 필드를 단위와 함께 명명 — created_at_ms, expires_s — API 경계에서 검증. 예를 들어 10^9–10^10 초를 벗어나는 타임스탬프는 이번 세기에서 “초” 필드에 대해 의심스럽습니다.
2. UTC로 오해되는 로컬 시간
“타임스탬프에는 타임존이 없다”는 규칙의 역. 고전적 현현: 상하이 서버와 버지니아 서버가 “같은” 마감일을 다르게 계산합니다. 한쪽이 로컬 벽시계 구성요소에서 타임스탬프를 구성했기 때문입니다. 로컬 날짜/시간 필드 → 타임스탬프로 가는 모든 코드 경로는 그 필드의 타임존을 명시해야 합니다. JavaScript의 new Date(2026, 6, 28)은 브라우저의 로컬 타임존에서의 자정을 의미하며, 사용자마다 다른 순간입니다.
3. 서머타임 경계
DST는 두 종류의 깨진 벽시계 시간을 만듭니다:
- 존재하지 않는 시간. US 타임존에서 2026-03-08 02:30 로컬은 존재하지 않습니다 — 시계가 02:00 → 03:00으로 점프. “매일 02:30에 실행”으로 설정된 cron 스타일 스케줄러는 그 날 건너뛰거나, 두 번 실행되거나, 플랫폼 의존적으로 동작합니다.
- 모호한 시간. 2026-11-01에 01:30 로컬은 US 타임존에서 두 번 발생합니다 — 시계가 돌아가면서. 오프셋이나 타임존 없이 “01:30”을 저장하면 어느 쪽을 의미했는지 구분할 수 없습니다.
강력한 수정: 반복 작업은 UTC로 예약하고, 순간은 타임스탬프로 저장하고, 로컬 시간으로는 표시 시에만 변환. 미래의 로컬 벽시계 시간을 저장해야 한다면 (예: “사용자의 오전 9시 회의”) 로컬 시간과 IANA 타임존 이름(America/New_York)을 함께 저장하고 렌더 시점에 타임스탬프로 해석 — 정부는 DST 규칙을 바꾸고, 오늘 계산된 2028년 미팅용 타임스�프는 법 변경 후 틀릴 수 있습니다.
4. JavaScript의 0-indexed 월
new Date(2026, 6, 28)은 7월 28이며 6월 28이 아닙니다 — 월은 0–11, 일은 1–31입니다. 이 비일관성은 한 세대의 off-by-one 월 버그를 만들어 왔습니다. 완화: UTC를 의미한다면 Date.UTC(...), 명확성을 위해 ISO 문자열(new Date("2026-07-28T00:00:00Z")), 또는 라이브러리(널리 도입되면 Temporal이 1-indexed 월로 제대로 수정) 사용.
5. 초의 부동소수 정밀도
Python의 time.time()은 float를 반환합니다. float64는 53비트의 가수를 가지며; 현재 에포크 크기(~1.79 × 10^9 초)에서 해상도는 충분합니다(~240 나노초), 그래서 Python에서는 거의 물지 않습니다 — 하지만 같은 추론이 마이크로초/나노초 타임스탬프를 float64로 다루는 시스템에서는 물립니다: 16자리 마이크로초 값은 정확히 표현하려면 54비트가 필요하므로 float64 마이크로초는 이미 오늘 부정확합니다. 밀리초보다 더 정밀한 것은 정수를 쓰세요.
6. 오프셋 없는 문자열 파싱
JavaScript의 new Date("2026-07-28")은 UTC 자정으로 파싱되지만, new Date("2026-07-28 00:00:00")(T도 없고 Z도 없음)은 로컬 자정으로 파싱됩니다 — 문자열 형식 세부사항에 따른 무언 타임존 뒤집기. 직렬화된 datetime에는 항상 명시적인 Z 또는 숫자 오프셋을 포함하세요. 오프셋이 있는 ISO 8601을 선호: 2026-07-28T10:00:00+08:00.
언어별로 올바르게 타임스탬프 얻기
JavaScript / TypeScript — Date.now()는 밀리초를 반환; 초로 나누세요:
const ms = Date.now(); // 1785235837123
const seconds = Math.floor(Date.now() / 1000); // 1785235837
// 특정 순간으로부터는 항상 UTC를 명시:
const ts = Date.UTC(2026, 6, 28, 0, 0, 0) / 1000; // 월 0-indexed!
Python — time.time()은 float 초를 반환; 저장할 것은 명시적 UTC와 datetime 사용:
import time
from datetime import datetime, timezone
seconds = int(time.time()) # 1785235837
ns = time.time_ns() # 나노초, 정확한 정수
ts = int(datetime.now(timezone.utc).timestamp())
Go — time 패키지는 각 단위를 명시적으로 제공:
now := time.Now()
seconds := now.Unix() // int64 초
millis := now.UnixMilli() // int64 밀리초
nanos := now.UnixNano() // int64 나노초 — int64 범위 주의
Java — 레거시 Date/Calendar 피하고 java.time 사용:
long millis = System.currentTimeMillis();
long seconds = Instant.now().getEpochSecond();
long nanos = Instant.now().getNano(); // 초-나노, 에포크 나노 아님!
Bash — GNU와 macOS(BSD)의 date는 파싱이 다름:
date +%s # 현재 에포크 초 (둘 다)
date -d @1785235837 # GNU: 타임스탬프 → 사람이 읽는 날짜
date -r 1785235837 # macOS/BSD: 타임스탬프 → 사람이 읽는 날짜
date -u -d "2026-07-28 00:00:00 UTC" +%s # GNU: 날짜 → 타임스탬프
date -u -j -f "%Y-%m-%d %H:%M:%S" "2026-07-28 00:00:00" +%s # macOS
반복되는 주제를 주목하세요: 모든 올바른 API는 에포크 값을 직접 주거나 타임존을 명명하도록 강제합니다. 모든 위험한 API는 그것을 생략하게 둡니다.
타임스탬프와 상호작용 작업
로그나 API 응답에서 타임스탬프를 디버깅할 때 머릿속으로 계산하지 마세요. 우리의 타임스탬프 변환기에 붙여넣으세요: UTC와 로컬 타임존에서 순간을 보여주고 초/밀리초를 자동 감지합니다 — 위에서 설명한 모호성 검사가 정확히 그것입니다. 또한 테스트 픽스처를 만들기 위해 반대 방향(사람이 읽는 날짜 + 타임존 → 타임스탬프)으로도 변환합니다.
요약
- Unix 타임스탬프는 1970-01-01 UTC 이후의 초(또는 ms/µs/ns)입니다. 타임존도 윤초도 없습니다.
- 자릿수가 현대 시대의 단위를 식별합니다: 10 = 초, 13 = 밀리초, 16 = 마이크로초, 19 = 나노초.
- 2038은 부호 있는 32비트
time_t를 2038-01-19 03:14:07 UTC에 오버플로합니다. 64비트 시스템과 메인스트림 언어는 안전; 임베디드 펌웨어, 레거시 32비트 바이너리, MySQLTIMESTAMP열이 잔존 위험입니다. - 실제 버그 패턴: 단위 혼동, 로컬-vs-UTC 실수, DST 경계, JS 0-indexed 월, µs/ns의 float64 정밀도 손실, 오프셋 없는 문자열 파싱.
- 순간은 정수 타임스탬프로 저장하고, 필드는 단위와 함께 명명하고, UTC로 예약하고, 로컬로는 표시 시에만 변환 — 그리고 어쨌든 새는 디버깅을 위해 타임스탬프 변환기를 북마크하세요.