본문 바로가기
Converter

Unix 타임스탬프 변환기

타임스탬프와 날짜를 양방향으로, KST·UTC 동시에

지금 이 순간의 Unix 타임스탬프

이 값은 1초마다 자동으로 갱신됩니다. 지금 값이 필요하면 초 복사 또는 밀리초 복사 버튼을 누르세요.

브라우저에서 계산합니다

타임스탬프 → 날짜

단위

KST (한국 표준시, UTC+9)

2024-07-15 17:00:00

2024년 7월 15일 (월) 오후 5:00

UTC (협정 세계시)

2024-07-15 08:00:00

ISO 8601

2024-07-15T08:00:00.000Z

오프셋 표기: 2024-07-15T17:00:00+09:00

요일

월요일

상대 시간

브라우저에서 계산합니다

날짜 → 타임스탬프

입력한 값을 KST(UTC+9)로 해석합니다

초 (Unix 타임스탬프)

1721001600

대부분의 API·DB·JWT exp 가 쓰는 단위

밀리초

1721001600000

JavaScript Date.now() 가 주는 단위

UTC

2024-07-15 00:00:00

ISO 8601

2024-07-15T00:00:00.000Z

Unix 시간은 “1970년부터 흐른 초”가 아니다

거의 모든 설명이 Unix 타임스탬프를 “1970년 1월 1일 UTC 자정부터 지금까지 흐른 초”라고 정의합니다. 편리한 요약이지만, 엄밀히 말하면 틀렸습니다.

POSIX 표준이 정한 실제 정의는 이렇습니다: (epoch 이후 날짜 수) × 86400 + (그날 자정 이후 초). 즉 Unix 시간은 흐른 시간을센 값이 아니라, 달력을 산술로 옮긴 값입니다. 하루는 무조건 86,400초라고 가정하고 계산합니다.

문제는 지구의 자전이 그 가정을 지키지 않는다는 데 있습니다. 자전은 아주 조금씩 불규칙해서, 원자시계가 재는 시간과 실제 하늘의 시간이 어긋나면 국제기구가 윤초를 끼워 넣습니다. 1972년 이후 27번 삽입됐습니다. 그런 날의 마지막 1분은 61초입니다 — 23:59:59 다음에 23:59:60이 있고 그 다음이 자정입니다.

Unix 시간에는 그 60초를 담을 자리가 없습니다. 하루가 86,400초여야 하니까요. 그래서 대부분의 시스템은 같은 타임스탬프를 두 번 내보냅니다.

2016-12-31 23:59:59 UTC  →  1483228799
2016-12-31 23:59:60 UTC  →  1483228799   ← 같은 값이 반복된다
2017-01-01 00:00:00 UTC  →  1483228800

결과적으로 두 타임스탬프를 빼서 얻은 값은 실제로 흐른 물리적 초가 아닙니다. 1972년 이후의 구간을 빼면 최대 27초까지 모자랍니다. 일상적인 로그 분석에서는 신경 쓸 필요가 없지만, 금융 거래 순서나 정밀 계측처럼 초 단위 이하가 중요한 곳에서는 이것 때문에 별도의 단조 증가 시계(performance.now(), CLOCK_MONOTONIC)를 씁니다.

뒤집어 말하면 이 “결함”은 의도된 설계입니다. 윤초를 정직하게 반영했다면 미래의 타임스탬프를 날짜로 바꾸는 일이 불가능해집니다 — 2030년까지 윤초가 몇 번 들어갈지 아무도 모르기 때문입니다. Unix 시간은 정확한 경과 시간을 포기하고 안정적인 달력 변환을 얻은 거래입니다.

초와 밀리초를 섞는 것, 현업 1위 버그

타임스탬프 관련 장애의 절반 이상이 이 하나에서 나옵니다. 원인은 단순합니다 — 생태계가 두 단위로 갈라져 있습니다.

  • 를 쓰는 쪽: POSIX time(), 대부분의 REST API, JWT의 exp·iat 클레임, PostgreSQL extract(epoch from ...), 파이썬 time.time()(소수점 있는 초), Redis EXPIREAT
  • 밀리초를 쓰는 쪽: JavaScript Date.now(), Java System.currentTimeMillis(), Kafka 메시지 타임스탬프, MongoDB Date

프론트엔드가 JS로 짜여 있고 백엔드가 그렇지 않으면, 이 경계는 모든 API 호출마다 지나갑니다. 그리고 타입은 둘 다 그냥 number라 컴파일러가 잡아 주지 않습니다.

증상은 극단적으로 갈라지고, 그래서 오히려 알아보기 쉽습니다:

// JWT exp(초)를 JS Date(밀리초)에 그대로 넣었다
new Date(1721030400)
// → 1970-01-20  ... 토큰이 55년 전에 만료됐다고 나온다

// Date.now()(밀리초)를 초로 취급해 1000을 곱했다
new Date(1721030400000 * 1000)
// → 서기 56514년  ... 토큰이 영원히 만료되지 않는다

두 번째가 훨씬 위험합니다. 첫 번째는 로그인이 즉시 깨져서 바로 발견되지만, 두 번째는 아무 일도 일어나지 않습니다. 만료되지 않는 세션, 절대 지워지지 않는 캐시, 영원히 유효한 초대 링크가 조용히 쌓입니다. 보안 감사에서 발견될 때까지 몇 달이 걸리기도 합니다.

방어법은 규칙이 아니라 이름입니다. 변수에 timestamp라고 쓰지 말고 expiresAtSec, createdAtMs처럼 단위를 이름에 박아 넣으세요. TypeScript라면 브랜드 타입(type Seconds = number & { __unit: 'sec' })으로 컴파일 타임에 섞이지 않게 막을 수 있습니다. 주석은 읽히지 않지만 타입 에러는 읽힙니다.

2038년 문제는 아직 끝나지 않았다

Y2K는 지나갔고 사람들은 그걸 소동으로 기억하지만, 2038년 문제는 성격이 다릅니다. Y2K는 “연도를 두 자리로 저장한” 관행의 문제였고, 2038년은 자료형 자체의 물리적 한계입니다.

부호 있는 32비트 정수의 최댓값은 2,147,483,647입니다. 이 숫자를 초로 읽으면 2038년 1월 19일 03:14:07 UTC(한국 시간으로 같은 날 낮 12시 14분 7초)에 도달하고, 1초 뒤에는 부호 비트가 넘어가서 −2,147,483,648, 즉 1901년 12월 13일이 됩니다. 미래로 가려던 시계가 137년 과거로 튕겨 나갑니다.

“요즘 서버는 다 64비트인데?”라는 반문이 자연스럽지만, 남아 있는 곳이 있습니다:

  • MySQL의 TIMESTAMP 컬럼. 지금 만드는 테이블에도 이 문제가 그대로 있습니다. 상한이 2038-01-19 03:14:07로 문서에 명시돼 있습니다.DATETIME은 9999년까지 가므로 이 한계가 없습니다. 스키마를 새로 짜면서 습관적으로 TIMESTAMP를 고르는 것이 지금 만드는 2038년 부채입니다.
  • 임베디드 시스템. 라우터, 셋톱박스, 산업용 컨트롤러, 자동차 ECU는 32비트 MCU를 쓰고 10년 넘게 교체되지 않은 채 돌아갑니다. 지금 출고되는 기기 중에도 2038년을 못 넘기는 것이 있습니다.
  • 파일 포맷과 프로토콜. 32비트 필드로 시각을 담기로 규격에 못 박은 것들은 런타임이 64비트여도 소용이 없습니다.

그리고 2038년은 이미 시작됐습니다. 만료일·보증 종료일·장기 계약처럼 미래를 저장하는 컬럼은 오늘 넣는 값이 벌써 경계를 넘습니다. 위 변환기가 경계를 넘는 값에 경고를 띄우는 이유입니다.

KST가 개발자를 안심시키는 방식

한국에서 날짜 코드를 짜면 이상할 정도로 잘 돌아갑니다. 이유는 실력이 아니라 지리적 행운입니다.

한국은 1948~1951년, 1955~1960년, 그리고 1987~1988년에 서머타임을 시행했다가 폐지했습니다. 마지막 시행은 서울올림픽 중계 시간을 맞추기 위한 것이었고, 국민 반발로 곧 사라졌습니다. 그래서 1988년 이후 KST는 예외 없이 UTC+9입니다. 30년 넘게 상수입니다.

이게 코드에 주는 선물은 큽니다. 타임존 라이브러리 없이 이렇게 써도 정확합니다:

// KST 는 DST 가 없으므로 이 계산이 언제나 맞는다
const KST_OFFSET = 9 * 60 * 60 * 1000;
const kst = new Date(ms + KST_OFFSET);
kst.getUTCHours();   // 한국 시각의 '시'

이 페이지의 변환기도 정확히 이 방식을 씁니다. 하지만 이건 한국만 다룬다는 전제 위에 세운 집입니다. 미국 사용자가 한 명 생기는 순간 전부 무너집니다. 미국은 3월 둘째 일요일과 11월 첫째 일요일에 오프셋이 바뀌고, 그 규칙은 2007년에 한 번 바뀌었으며, 애리조나와 하와이는 아예 따르지 않습니다. 브라질은 2019년에 서머타임을 폐지했고, 이집트는 2023년에 부활시켰습니다. 타임존은 정치이고, 정치는 코드보다 자주 바뀝니다.

그래서 한국 서비스가 글로벌로 나갈 때 타임존 버그가 한꺼번에 터집니다. 그동안 코드가 옳아서 문제가 없었던 게 아니라, 틀린 코드를 KST가 덮어 주고 있었을 뿐이기 때문입니다. IANA 타임존 데이터베이스(Asia/Seoul, America/New_York)를 쓰는 라이브러리가 필요한 건 그 지점부터입니다.

같은 문자열, 9시간 다른 순간

JavaScript의 날짜 파싱에는 실무에서 가장 자주 터지는 함정이 하나 숨어 있습니다. 아래 두 줄은 눈으로는 같아 보이지만 서로 다른 순간을 만듭니다.

new Date("2026-07-17")       // → 2026-07-17T00:00:00Z  (UTC 자정)
new Date("2026-07-17 00:00") // → 2026-07-17T00:00:00+09:00 (로컬 자정, 한국이라면)

// 한국에서 두 값의 차이는 정확히 9시간이다.

차이를 만드는 건 공백 하나입니다. 명세는 날짜만 있는 ISO 형식(YYYY-MM-DD)을 UTC로 해석하고, 날짜와 시각이 붙은 형식을 로컬로 해석하도록 정했습니다. 하나의 함수 안에서 규칙이 갈립니다.

이게 실제로 어떻게 터지느냐면, 날짜 선택기에서 사용자가 “7월 17일”을 고르면"2026-07-17"이 나옵니다. 이걸 new Date()에 넣으면 UTC 자정, 즉 한국 시간 오전 9시가 됩니다. 여기까진 날짜가 맞습니다. 그런데 이 값을 UTC-5인 뉴욕 사용자 화면에 로컬 날짜로 표시하면 7월 16일 저녁 7시가 됩니다. 사용자가 고른 날짜와 화면에 보이는 날짜가 하루 다릅니다.

더 나쁜 건 한국에서 테스트하면 절대 재현되지 않는다는 점입니다. UTC+9는 UTC보다 앞서 있어서 UTC 자정이 한국에서는 여전히 같은 날 아침입니다. 버그는 서쪽 타임존 사용자에게만 보입니다. 반대로 CI가 UTC로 돌면 로컬 파싱에 의존한 테스트가 개발자 노트북에서만 통과하고 CI에서 깨집니다.

해법은 문자열을 Date에게 해석하게 두지 않는 것입니다:

// 나쁨: 파싱 규칙이 문자열 모양과 실행 환경에 달려 있다
const d = new Date(userInput);

// 좋음: 숫자로 쪼개서 의도한 타임존으로 직접 조립한다
const [y, m, day] = userInput.split("-").map(Number);
const ms = Date.UTC(y, m - 1, day) - 9 * 60 * 60 * 1000;  // KST 자정

저장은 UTC로, 표시는 현지로

위의 모든 이야기가 하나의 규칙으로 수렴합니다. 시각은 순간(instant)으로 저장하고, 타임존은 화면에 그릴 때만 적용한다.

데이터베이스 컬럼 타입이 이 규칙을 강제하거나 배신합니다. PostgreSQL을 예로 들면:

  • timestamptz(= timestamp with time zone): 넣는 값을 UTC로 정규화해 저장합니다. 이름과 달리 타임존을 저장하지는 않고, 순간을 저장합니다. 대부분의 경우 이게 정답입니다.
  • timestamp(without time zone): 넣은 벽시계 숫자를 그대로 보관합니다. 이건 순간이 아니라 문자열에 가깝습니다. 어느 타임존인지 모르는 채로 두면 나중에 아무도 복원할 수 없습니다.

예외도 분명히 있습니다. “매일 오전 9시에 알림” 같은 벽시계 약속은 순간으로 저장하면 안 됩니다. 사용자가 이사를 가거나 서머타임이 시작되면 알림이 오전 8시에 울립니다. 이런 값은 벽시계 시각과 타임존 이름을 따로 저장하고 매번 계산해야 합니다.

정리하면 판단 기준은 하나입니다 — “이 시각은 물리적 순간인가, 사람과의 약속인가?” 로그·결제·이벤트는 순간이므로 타임스탬프로 저장합니다. 알람·영업 시간·마감 시각은 약속이므로 벽시계로 저장합니다. 이 둘을 같은 컬럼 타입에 넣는 것이 대부분의 날짜 버그의 뿌리입니다.

자주 묻는 질문

10자리와 13자리 중 어느 쪽이 초인가요?
지금 시점 기준으로 10자리는 초, 13자리는 밀리초입니다. 초 단위 타임스탬프는 2001년 9월에 10자리가 되었고 2286년까지 10자리를 유지하므로, 앞으로 260년 동안 이 구분은 안전합니다. 자릿수를 세기 귀찮다면 변환해 본 결과가 1970년 언저리로 나오면 밀리초를 초로 읽은 것이고, 서기 5만 년쯤으로 나오면 초를 밀리초로 읽은 것입니다. 위 변환기는 이 규칙으로 자동 판별하되, 틀릴 수 있으니 단위 토글로 직접 지정할 수 있게 해 두었습니다.
Unix 타임스탬프에 타임존이 들어 있나요?
없습니다. 그리고 이게 이 형식의 가장 큰 장점입니다. 타임스탬프는 UTC 기준 epoch로부터의 경과 초 하나뿐이라, 서울에서 만들든 뉴욕에서 만들든 같은 순간이면 같은 숫자입니다. 타임존은 그 숫자를 사람에게 보여줄 때 비로소 개입합니다. 그래서 '이 타임스탬프는 KST인가요?'라는 질문 자체가 성립하지 않습니다 — 타임스탬프는 순간이고, KST는 그 순간을 읽는 방법입니다.
한국 시간은 서머타임을 고려해야 하나요?
아닙니다. 한국은 1988년 서울올림픽 이후로 서머타임을 시행하지 않고 있어서, KST는 예외 없이 UTC+9 고정입니다. 그래서 한국 날짜만 다루는 코드는 타임존 라이브러리 없이 9시간을 더하는 것만으로 정확합니다. 다만 이 편의는 한국에 갇혀 있습니다. 미국이나 유럽 사용자가 생기는 순간 오프셋이 연 2회 바뀌기 때문에, 같은 방식으로 짠 코드는 전환일 근처에서 1시간씩 어긋나기 시작합니다.
MySQL의 TIMESTAMP와 DATETIME 중 뭘 써야 하나요?
2038년까지만 살아 있을 데이터라면 TIMESTAMP도 되지만, 그렇지 않다면 DATETIME이나 (PostgreSQL이라면) TIMESTAMPTZ를 쓰는 편이 낫습니다. MySQL의 TIMESTAMP는 내부적으로 32비트 초라서 2038-01-19에서 끝나고, 만료일·계약 종료일처럼 미래를 담는 컬럼은 이미 지금도 이 벽에 부딪힙니다. 반면 TIMESTAMP는 타임존을 인식해 UTC로 저장하고 DATETIME은 넣은 문자열을 그대로 보관한다는 차이도 있으니, 범위와 타임존 처리 중 무엇이 더 중요한지 보고 고르세요.
new Date('2026-07-17')이 하루 전으로 나오는 이유가 뭔가요?
날짜만 있는 ISO 문자열을 JavaScript는 UTC 자정으로 해석하기 때문입니다. 2026-07-17T00:00:00Z는 한국에서 7월 17일 오전 9시이므로 날짜는 맞게 보이지만, 이 값을 다시 현지 날짜로 포맷하는 과정이나 UTC-5 같은 서쪽 타임존에서는 7월 16일로 표시됩니다. 반대로 new Date('2026-07-17 00:00')처럼 공백을 넣으면 로컬 자정으로 해석되어 완전히 다른 순간이 됩니다. 날짜 문자열을 직접 파싱하지 말고 연·월·일을 분해해 Date.UTC로 조립하는 편이 안전합니다.
이 변환기는 입력한 값을 서버로 보내나요?
보내지 않습니다. 모든 변환은 브라우저 안에서 JavaScript로 계산되고, 타임스탬프 값이 네트워크로 나가는 일은 없습니다. 로그에서 긁어온 타임스탬프에 내부 시스템의 배포 시각이나 사용자 활동 시각이 담겨 있을 수 있는데, 그런 값을 붙여 넣어도 이 페이지 밖으로 나가지 않습니다.