본문으로 건너뛰기
튜토리얼

프로덕션 환경의 Unix 타임스탬프: 초 vs 밀리초, 2038 문제, 타임존 버그

· 12 분 소요

모든 프로덕션 시스템은 결국 Unix 타임스탬프를 로�하거나 저장하거나 전송합니다. 그리고 모든 프로덕션 시스템은 결국 그것을 다루는 버그를 출시합니다 — 1970년 1월로 표시되는 날짜, 49년 전으로 예약된 이벤트, 한쪽은 밀리초를 기대하고 다른 쪽은 초를 보내는 API 연동. 이런 버그는 지루하고 흔하며, 타임스탬프가 실제로 어떻게 동작하는지를 지배하는 한 줌의 규칙을 알면 완전히 예방할 수 있습니다.

이 가이드는 실무 핵심을 다룹니다: 자릿수로 단위를 인식하는 법, 2038 문제와 오늘의 실제 상태, 실제로 발생하는 버그 패턴, 그리고 사용 중일 가능성이 높은 언어에서 타임스탬프를 올바르게 얻는 방법.

Unix 타임스탬프란 무엇인가(그리고 무엇이 아닌가)

Unix 타임스�프는 1970-01-01 00:00:00 UTC — “Unix 에포크” — 이후의 초 수를 셉니다. 이후 모든 것에 중요한 두 가지 속성:

  1. 타임존이 없습니다. 타임스탬프 1785235837은 지구 어디에서든 동시에 동일한 한 순간을 가리킵니다. 타임존은 인간에게 타임스탬프를 표시하거나 로컬 벽시계 구성요소에서 구성할 때만 등장합니다. 이 글에서 한 문장을 internalize한다면 그것을 외우세요: 타임스탬프는 UTC 순간이며, 타임존은 렌더링 문제입니다.
  2. 윤초를 무시합니다. Unix 시간은 모든 날이 정확히 86,400초라고 가정합니다. 윤초가 삽입될 때 POSIX 시간은 사실상 이를 반복하거나 smudge합니다(시스템에 따라 동작이 다름; Google의 “leap smear”는 하루에 걸쳐 분산). 대부분의 애플리케이션 코드에서 이것은 무관합니다; 윤초를 가로지르는 간격 계산에서는 “정확히 N초” 가정이 1만큼 빗나갈 수 있음을 의미합니다.

에포크 선택은 음수 타임스탬프가 유효하다는 것도 의미합니다: -1은 1969-12-31 23:59:59 UTC입니다. 음수 타임스�프를 오류로 다루는 코드는 1970 이전 날짜에서 깨집니다 — 역사적 데이터(생년월일, 기록 보관)를 다루는 시스템에서 실제 버그 패턴입니다.

초, 밀리초, 마이크로초: 자릿수 휴리스틱

타임스탬프는 서로 다른 생태계에서 서로 다른 단위로 도착하며, 단위를 잘못 식별하는 것이 가장 흔한 타임스탬프 버그입니다. 빠른 현장 테스트는 자릿수 세기입니다:

단위예(2026년 중반)자릿수이 폭에 도달하는 시점
1785235837102001-09-09부터 10자릿수; 2286년부터 11자릿수
밀리초1785235837000132001-09-09부터 13자릿수
마이크로초17852358370000001616자릿수
나노초1785235837000000000192262년에 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을 곱하면 17852358371785235837000초가 됩니다 — 대략 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_t ABI를 실행. 이들이 패치하기 어렵고 잊기 쉬워서 주된 잔존 위험 등급입니다.
  • 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^910^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 / TypeScriptDate.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!

Pythontime.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())

Gotime 패키지는 각 단위를 명시적으로 제공:

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비트 바이너리, MySQL TIMESTAMP 열이 잔존 위험입니다.
  • 실제 버그 패턴: 단위 혼동, 로컬-vs-UTC 실수, DST 경계, JS 0-indexed 월, µs/ns의 float64 정밀도 손실, 오프셋 없는 문자열 파싱.
  • 순간은 정수 타임스탬프로 저장하고, 필드는 단위와 함께 명명하고, UTC로 예약하고, 로컬로는 표시 시에만 변환 — 그리고 어쨌든 새는 디버깅을 위해 타임스탬프 변환기를 북마크하세요.