~ / posts / developer

초·밀리초 타임스탬프 혼동 버그 — 1970년·5만 년이 찍히는 이유

화면에 1970년 1월이 찍히거나 연도가 5만 년대로 나오면 거의 확실히 초와 밀리초를 섞은 거예요. 언어별 기본 단위(JS는 밀리초, Python·JWT는 초)와 자릿수로 구분하는 법, 코드로 안전하게 변환하는 습관을 정리했습니다.

"가입일이 1970년 1월로 나와요." 이런 제보를 받으면 저는 코드를 보기도 전에 범인을 짐작해요. 결론부터 말하면 날짜가 1970년 1월로 찍히면 밀리초 타임스탬프를 초로 착각한 것(÷1000을 빼먹음)이고, 연도가 5만 년대로 나오면 초 타임스탬프를 밀리초로 착각한 거예요(×1000이 중복). 거의 예외가 없어요.

원인은 언어마다 타임스탬프의 기본 단위가 다르기 때문이에요. JavaScript는 밀리초, Python·PHP·Ruby·JWT는 초를 기본으로 써요. 이 둘이 한 코드에서 만나면 1,000배 차이로 엉뚱한 연도가 나와요. 자릿수로 바로 구분하는 법, 언어별 기본 단위, 그리고 다시는 안 틀리게 하는 코드 습관을 Node.js 22와 Python으로 확인하며 정리했어요.

핵심 요약

2025년 기준 초 타임스탬프는 10자리, 밀리초는 13자리예요. 자릿수만 봐도 단위를 알 수 있어요.

밀리초를 초로 읽으면(÷1000 누락) 1970년 1월로, 초를 밀리초로 읽으면(×1000 중복) 5만 년대로 나와요.

JavaScript의 Date.now()와 new Date(n)은 밀리초, Python time.time()과 JWT의 exp는 초예요.

변수 이름에 단위를 붙이고(epochMs, expSec), 경계에서 한 번만 변환하면 대부분 예방돼요.

자릿수로 초·밀리초 바로 구분하기

가장 빠른 판별법은 자릿수예요. 2001년 9월부터 2286년까지 초 타임스탬프는 10자리, 밀리초는 13자리예요.

초 10자리와 밀리초 13자리 정상 표시, 밀리초를 초로 읽어 1970년이 되는 경우와 초를 밀리초로 읽어 5만 년대가 되는 경우를 비교한 표
자릿수만 봐도 단위가 보인다
단위2025-10-05 00:00 UTC자릿수
초 (s)175962240010
밀리초 (ms)175962240000013
마이크로초 (µs)175962240000000016
나노초 (ns)175962240000000000019

마이크로초는 파이썬 일부 API나 데이터베이스, 나노초는 Go의 time.UnixNano() 나 모니터링 시스템에서 봐요. 단위마다 자릿수가 3씩 벌어져서, 사실 한눈에 구분이 돼요.

같은 시각(2025-10-05 00:00 UTC)을 단위별로 적었을 때 자릿수
05101520초 · 자릿수 10자리10초밀리초 · 자릿수 13자리13밀리초마이크로초 · 자릿수 16자리16마이크로초나노초 · 자릿수 19자리19나노초

단위가 1000배 커질 때마다 자릿수가 3씩 늘어난다

단위: 자리 · 자료: 1759622400 에 1000씩 곱해 Python len(str(...))로 직접 센 값 (2001~2286년 구간 기준)
표로 보기
구분자릿수
초10
밀리초13
마이크로초16
나노초19

숫자를 붙여 놓고 자리를 세기 번거로우면 타임스탬프 변환기에 넣으면 자릿수로 단위를 추정해 날짜를 보여 줘요.

왜 1,000배 차이가 이렇게 극적인 날짜를 만드는지 직접 확인해 봤어요.

new Date(1759622400).toISOString();      // '1970-01-21T08:47:02.400Z'  ← 밀리초인데 ÷1000 안 함
new Date(1759622400000).toISOString();   // '2025-10-05T00:00:00.000Z'  ← 정상(밀리초)
new Date(1759622400000000).toISOString(); // '+057730-03-20T...'  ← 초를 ms로 만들려 ×1000 중복

new Date() 는 밀리초를 받아요. 그래서 초 타임스탬프 1759622400 을 그대로 넣으면 "1970년부터 175만 초(약 20일)"로 해석돼 1970년 1월 21일이 나와요. 반대로 밀리초 값에 또 ×1000을 하면 5만 년대 미래가 나오고요.

언어·시스템마다 기본 단위가 다르다

혼동의 근본 원인은 이거예요. 같은 "현재 시각 타임스탬프"인데 언어마다 단위가 달라요. 직접 실행해서 확인했어요.

언어·대상함수단위예시 출력
JavaScriptDate.now()밀리초1791158439846
JavaScriptnew Date(n)밀리초 입력—
Pythontime.time()초 (소수점 있음)1791158439.88
Pythonint(time.time())초1791158439
PHPtime()초1791158439
PHPmicrotime(true)초 (소수점)1791158440.69
RubyTime.now.to_i초1791158440
JavaSystem.currentTimeMillis()밀리초—
JavaInstant.getEpochSecond()초—
Unix date +%s—초1791158439
JWT exp, iat—초 (RFC 7519)1759623300

규칙처럼 외울 수 있어요. JavaScript와 Java의 ...Millis 계열은 밀리초, 나머지와 표준·프로토콜(JWT, HTTP Date, 유닉스 명령)은 대체로 초. JS와 다른 언어·시스템이 만나는 경계가 사고 다발 지점이에요.

실제로 자주 보는 장면들

1. JWT 만료를 Date.now()로 설정

가장 흔한 사고예요. JWT의 exp 는 초인데 JS 밀리초를 넣는 거죠.

// 틀림: exp가 밀리초 값이라 사실상 영원히 안 만료됨
const exp = Date.now() + 15 * 60 * 1000;
new Date(exp * 1000).toISOString();   // '+058729-07-31T...' ← 5만 년 뒤

// 맞음: 초 단위로
const exp = Math.floor(Date.now() / 1000) + 15 * 60;

이러면 만료 검사가 무의미해져서 토큰이 영원히 유효해요. 보안 사고로 이어질 수 있어요. 토큰 만료 설계는 액세스·리프레시 토큰 만료 설계에 정리했어요.

2. 파이썬 백엔드 → JS 프런트

파이썬 time.time() 이나 DB에서 꺼낸 초 타임스탬프를 JSON으로 내려보내고, 프런트가 new Date(값) 에 그대로 넣으면 모든 날짜가 1970년으로 나와요.

const createdAt = new Date(data.created_at * 1000);  // 서버가 초로 주면 ×1000

API 문서에 단위를 명시하거나, 애초에 서버가 ISO 8601 문자열(2025-10-05T00:00:00Z)로 내려주는 게 가장 안전해요. new Date('2025-10-05T00:00:00Z') 는 단위 혼동이 없어요.

3. 정렬이 뒤죽박죽

일부 레코드는 초, 일부는 밀리초로 섞여 저장되면 시간순 정렬이 엉켜요. 1970년 레코드들이 항상 맨 앞이나 맨 뒤로 몰리는 증상이면 이걸 의심하세요.

4. setTimeout에 초를 넣음

setTimeout(fn, 5) 는 5초가 아니라 5밀리초예요. 반대로 5초를 기대하고 5000 을 넣어야 하는데 5 를 넣으면 즉시 실행되는 것처럼 보여요. JS의 시간 관련 API는 거의 다 밀리초라고 기억하세요.

자릿수로 자동 판별하는 코드

섞여 들어오는 값을 방어적으로 다뤄야 한다면, 자릿수로 단위를 추정해 밀리초로 통일하는 함수를 쓸 수 있어요.

function toDate(ts) {
  const n = Number(ts);
  // 10자리(~2286년)면 초, 13자리면 밀리초로 간주
  if (n < 1e12) return new Date(n * 1000);  // 초
  if (n < 1e15) return new Date(n);         // 밀리초
  return new Date(n / 1000);                // 마이크로초
}
toDate(1759622400).toISOString();     // '2025-10-05T00:00:00.000Z'
toDate(1759622400000).toISOString();  // '2025-10-05T00:00:00.000Z'
import datetime as dt
def to_datetime(ts):
    ts = float(ts)
    if ts >= 1e15: ts /= 1e6      # 마이크로초
    elif ts >= 1e12: ts /= 1e3    # 밀리초
    return dt.datetime.fromtimestamp(ts, dt.timezone.utc)

이 방식은 편하지만 어디까지나 추정이에요. 1970년대 초반 날짜나 먼 과거·미래 데이터에서는 자릿수 가정이 깨져요. 그래서 이건 외부에서 들어오는 신뢰할 수 없는 값을 다룰 때의 안전장치로만 쓰고, 내부 코드에서는 단위를 명확히 정하는 게 원칙이에요.

예방하는 습관

  1. 변수 이름에 단위를 붙인다: expSec, createdAtMs, epochMicros 처럼요. timestamp 라는 이름은 단위가 안 보여서 사고의 씨앗이에요.
  2. 경계에서 한 번만 변환한다: 외부에서 받은 값을 들어오는 즉시 한 단위(예: 내부는 전부 밀리초)로 바꾸고, 그 뒤로는 변환하지 않아요.
  3. 가능하면 ISO 8601 문자열로 주고받는다: API 경계에서 숫자 대신 2025-10-05T00:00:00Z 를 쓰면 단위 혼동이 원천 차단돼요.
  4. 상수를 명시한다: Date.now() / 1000 보다 Date.now() / MS_PER_SEC 가 의도를 드러내요.
  5. 테스트에 경계값을 넣는다: 10자리와 13자리 값을 모두 넣어 1970년이나 5만 년대가 안 나오는지 확인해요.

자주 묻는 질문

날짜가 1970년 1월로 나오는 이유는 무엇인가요?

밀리초 타임스탬프를 초로 해석했거나, 값이 0에 가깝기 때문이에요. Unix 타임스탬프는 1970년 1월 1일이 기준점(0)이라, 작은 숫자나 ÷1000을 빼먹은 값은 1970년 1월 근처로 찍혀요. JavaScript new Date() 에 초 타임스탬프를 그대로 넣는 게 대표적 원인이에요.

초인지 밀리초인지 어떻게 구분하나요?

2025년 기준으로 초는 10자리, 밀리초는 13자리예요. 자릿수를 세거나 타임스탬프 변환기에 넣으면 바로 알 수 있어요. 코드에서는 값 < 1e12 면 초로 보는 자릿수 판별을 쓸 수 있지만, 내부 로직은 단위를 명확히 정하는 게 더 안전해요.

JWT의 exp는 초인가요 밀리초인가요?

초예요. RFC 7519가 유닉스 초 단위로 정의해요. JavaScript에서 Date.now() 는 밀리초라서 그대로 넣으면 안 되고, Math.floor(Date.now() / 1000) 로 초로 바꿔야 해요. 밀리초를 넣으면 토큰이 사실상 만료되지 않아요.

setTimeout은 초 단위인가요?

밀리초예요. setTimeout(fn, 1000) 이 1초예요. JavaScript의 시간 관련 API는 거의 모두 밀리초를 쓰니, 초를 넣고 싶으면 1000을 곱하세요.

서버와 프런트의 날짜 단위를 어떻게 맞추나요?

가장 안전한 방법은 API에서 숫자 대신 ISO 8601 문자열(2025-10-05T00:00:00Z)을 주고받는 거예요. 숫자를 써야 한다면 API 문서에 단위를 명시하고, 받는 쪽에서 경계에서 한 번만 변환하세요.

정리

초·밀리초 혼동은 1,000배 차이라 증상이 아주 극적이에요. 1970년이면 ÷1000을 빠뜨린 것, 5만 년대면 ×1000이 중복된 것. 자릿수(10 vs 13)로 바로 판별되고, JS는 밀리초·나머지는 초라는 규칙만 기억하면 돼요. 예방은 변수 이름에 단위 붙이기와 경계에서 한 번만 변환하기, 그리고 가능하면 ISO 문자열로 주고받기예요.

숫자를 날짜로 확인할 땐 타임스탬프 변환기를 쓰고, 2038년 정수 한계 이야기는 Unix 타임스탬프와 2038년 문제에서, 시간대 저장 원칙은 타임존 KST·UTC DB 저장에서 이어서 볼 수 있어요.

#타임스탬프 초 밀리초#1970년 버그#Date.now 초#JWT exp 밀리초#날짜 1970#타임스탬프 자릿수#시간 변환 버그
← 이전 글JSON YAML XML 차이 — 용량·파싱 속도 실측과 고르는 기준다음 글 →정규식 탐욕적·게으른 매칭 차이와 ReDoS, 서버를 멈추는 패턴