유닉스 타임스탬프란
유닉스 타임스탬프는 1970-01-01T00:00:00Z(에포크)부터 지난 초를 세는 값이며 윤초는 무시합니다. 1972년부터 2016년 12월 31일의 마지막 윤초까지 27번의 윤초가 삽입되었지만, 유닉스 시간은 하루를 항상 정확히 86,400초로 취급합니다. 이 값은 지구 어디서나 같은 하나의 순간을 가리키며 시간대 정보가 없습니다. 시간대는 이 숫자를 사람이 읽는 달력 날짜로 바꿀 때 비로소 등장합니다.
검산에 쓸 기준값 몇 개를 기억해 두세요. 0은 1970-01-01T00:00:00Z, 1700000000은 2023-11-14T22:13:20Z, 1790812800은 2026-10-01T00:00:00Z입니다. 음수도 유효하며 1970년 이전 날짜를 뜻합니다.
초, 밀리초, 마이크로초: 자릿수를 세라
시스템마다 세는 단위가 달라서 단위를 잘못 넘기면 1970년이나 58,000년대 날짜가 나옵니다. 현재 날짜 기준으로는 자릿수로 단위를 알 수 있습니다. 10자리는 초, 13자리는 밀리초, 16자리는 마이크로초, 19자리는 나노초입니다. 같은 순간 2026-10-01T00:00:00Z는 초로 1790812800, 밀리초로 1790812800000, 마이크로초로 1790812800000000입니다.
10자리 초 값은 9999999999, 즉 2286-11-20T17:46:39Z까지 이어지므로 사람이 입력할 만한 날짜에서는 자릿수 규칙이 믿을 만합니다. JavaScript의 Date.now()와 Java의 System.currentTimeMillis()는 밀리초를, Python의 time.time()과 PHP의 time()은 초를(Python은 float) 돌려줍니다. Go의 time.Now().Unix()는 초, UnixMilli()는 밀리초입니다. API 필드 이름이 단위 없이 timestamp라면 문서를 확인하고, 최근 값으로 한 번 더 대조하세요.
ISO 8601과 RFC 3339: 텍스트 형식
문자열로 주고받을 때는 ISO 8601의 확장 형식을 인터넷 프로토콜용으로 다듬은 RFC 3339 형태를 쓰세요. 2026-10-01T09:00:00+09:00 또는 2026-10-01T00:00:00Z입니다. 날짜와 시간은 T로 잇고, 오프셋은 UTC를 뜻하는 Z나 +hh:mm, -hh:mm입니다. 소수 초도 허용됩니다(...:00.250Z). 오프셋이 달라도 UTC로 환산한 시각이 같으면 같은 순간입니다. 2026-10-01T09:00:00+09:00과 2026-10-01T00:00:00Z는 같은 순간입니다.
RFC 3339는 더 엄격한 부분집합입니다. 명시적인 오프셋이 있는 완전한 날짜와 시간을 요구하는 반면, ISO 8601은 주 날짜, 연속 일수 날짜, 기간, 오프셋 없는 현지 시각도 허용합니다. RFC 3339에서는 -00:00으로 UTC이지만 현지 오프셋은 모른다는 뜻을 표현할 수 있습니다. 2026-10-01T09:00:00처럼 오프셋이 전혀 없는 문자열은 순간이 정해지지 않은 현지 시각이며 파서마다 처리가 다릅니다. JavaScript에서는 2026-10-01 같은 날짜만 있는 문자열은 UTC로, 오프셋 없는 날짜·시간 문자열은 현지 시각으로 해석하는데, 하루 어긋나는 버그가 여기서 자주 나옵니다.
UTC, 고정 오프셋, IANA 시간대는 서로 다르다
UTC는 서머타임이 없는 기준 시각입니다. +09:00 같은 오프셋은 한 시점에서 UTC와의 고정된 차이입니다. Asia/Seoul이나 America/New_York 같은 이름의 IANA 시간대는 규칙의 집합입니다. tz 데이터베이스가 정한 날짜에 오프셋이 바뀔 수 있고, 정부가 규칙을 바꾼 역사도 기록되어 있습니다. 멕시코는 2022년에 서머타임을 폐지했으므로 America/Mexico_City의 과거와 현재 날짜는 오프셋이 다릅니다.
오프셋이 항상 정수 시간인 것도 아닙니다. Asia/Kolkata는 UTC+05:30, Asia/Kathmandu는 UTC+05:45이며 둘 다 서머타임이 없습니다. Asia/Seoul은 고정 UTC+09:00입니다. EST, CST, IST 같은 약어는 모호하므로(CST는 미국 중부, 중국, 쿠바 모두 가능) 저장하지 말고 IANA 이름을 쓰세요.
실무 원칙은 이렇습니다. 지나간 순간은 UTC(에포크 숫자 또는 Z가 붙은 RFC 3339 문자열)로 저장합니다. 내년 베를린 오전 9시 회의처럼 현지 시각으로 잡는 미래 일정은 정부가 규칙을 바꿀 수 있으니 벽시계 시각과 IANA 시간대 이름을 함께 저장합니다.
실제 날짜로 보는 서머타임 공백과 중복
일 년에 두 번 현지 시계는 한 시간을 건너뛰거나 되풀이합니다. America/New_York에서 2026년 3월 8일(일)에는 07:00 UTC에 시계가 02:00 EST에서 03:00 EDT로 건너뜁니다. 현지 시각 02:00~02:59는 존재하지 않으므로 '2026-03-08 02:30 뉴욕'은 대응하는 순간이 없는 공백 시각입니다. 그날은 23시간입니다.
2026년 11월 1일(일)에는 06:00 UTC에 뉴욕 시계가 02:00 EDT에서 01:00 EST로 되돌아갑니다. 현지 01:00~01:59가 두 번 나타나므로 '2026-11-01 01:30 뉴욕'은 모호합니다. 05:30 UTC(EDT, -04:00)일 수도, 06:30 UTC(EST, -05:00)일 수도 있고, 에포크 값으로는 1793511000과 1793514600입니다. 그날은 25시간입니다.
EU 시간대는 3월과 10월의 마지막 일요일 01:00 UTC에 함께 바뀝니다. Europe/Berlin은 2026년 3월 29일 02:00 CET에서 03:00 CEST로, 2026년 10월 25일 03:00 CEST에서 02:00 CET로 바뀝니다. 반면 미국은 3월 둘째 일요일과 11월 첫째 일요일의 현지 시각 02:00에 바꾸므로, 봄과 가을에는 몇 주 동안 뉴욕과 베를린의 시차가 평소와 달라집니다.
- '24시간 더하기'와 '하루 더하기'는 변경일을 지나면 다릅니다. 뉴욕의 2026-03-07 12:00에 24시간을 더하면 2026-03-08 13:00 EDT이고, 달력으로 하루를 더하면 12:00 EDT입니다.
- 라이브러리마다 공백·중복 시각을 다르게 처리합니다. 앞으로 밀거나, 첫 번째 발생을 고르거나, 오류를 던집니다(예: pytz의
is_dst=None). 명시적으로 처리하세요. - 계산을 에포크 초나 UTC로 하면 이 문제를 모두 피할 수 있습니다. 현지 시각으로의 변환은 표시할 때만 하세요.
| 시간대 | 현지 변경 | UTC 순간 | 영향 |
|---|---|---|---|
| America/New_York | 2026-03-08 02:00 EST에서 03:00 EDT | 2026-03-08T07:00:00Z | 02:00~02:59 없음, 23시간짜리 하루 |
| America/New_York | 2026-11-01 02:00 EDT에서 01:00 EST | 2026-11-01T06:00:00Z | 01:00~01:59 두 번, 25시간짜리 하루 |
| Europe/Berlin | 2026-03-29 02:00 CET에서 03:00 CEST | 2026-03-29T01:00:00Z | 02:00~02:59 없음 |
| Europe/Berlin | 2026-10-25 03:00 CEST에서 02:00 CET | 2026-10-25T01:00:00Z | 02:00~02:59 두 번 |
| Asia/Seoul | 없음 | - | 항상 UTC+09:00 |
2038년 문제
부호 있는 32비트 정수는 2,147,483,647까지 담습니다. 타임스탬프로 보면 2038-01-19T03:14:07Z입니다. 1초 뒤에는 값이 넘쳐 -2,147,483,648로 돌아가고, 이는 1901-12-13T20:45:52Z입니다. 32비트 시스템의 옛 C time_t, 일부 임베디드 기기, 파일 형식, 데이터베이스 컬럼처럼 시간을 부호 있는 32비트 필드에 저장하는 소프트웨어는 그 순간 오동작합니다.
부호 없는 32비트 필드는 2106-02-07T06:28:15Z까지 갑니다. 초 단위 64비트 부호 있는 카운터는 약 2,920억 년을 버티므로, 해결책은 64비트를 쓰는 것입니다. 세 곳을 확인하세요. 운영체제와 언어 런타임, 4바이트 시간 필드가 있는 바이너리 파일·네트워크 프로토콜, 데이터베이스 컬럼 타입입니다. 예를 들어 MySQL의 TIMESTAMP 타입은 문서상 2038년까지이므로 DATETIME을 쓰거나 사용 중인 버전의 문서를 확인하세요. JavaScript는 Date가 밀리초 단위의 64비트 부동소수점을 쓰고 1970년 전후 약 27만 5천 년을 다루므로 영향이 없습니다.
JavaScript, Python, SQL 코드 조각
표는 같은 작업을 흔한 환경별로 보여 줍니다. 결과는 입력 1700000000초(2023-11-14T22:13:20Z)를 기준으로 합니다.
| 작업 | JavaScript | Python 3 | SQL |
|---|---|---|---|
| 현재 시각을 에포크 초로 | Math.floor(Date.now() / 1000) | int(time.time()) | PostgreSQL: EXTRACT(EPOCH FROM now()); MySQL: UNIX_TIMESTAMP(); SQLite: strftime('%s','now') |
| 에포크 초를 UTC 날짜로 | new Date(1700000000 * 1000).toISOString() // 2023-11-14T22:13:20.000Z | datetime.fromtimestamp(1700000000, tz=timezone.utc) | PostgreSQL: to_timestamp(1700000000); SQLite: datetime(1700000000,'unixepoch') |
| RFC 3339를 에포크로 | Date.parse('2026-10-01T09:00:00+09:00') / 1000 // 1790812800 | datetime.fromisoformat('2026-10-01T09:00:00+09:00').timestamp() | PostgreSQL: EXTRACT(EPOCH FROM TIMESTAMPTZ '2026-10-01 09:00+09') |
| IANA 시간대로 표시 | d.toLocaleString('ko-KR', { timeZone: 'Asia/Seoul' }) | dt.astimezone(ZoneInfo('Asia/Seoul')) | PostgreSQL: ts AT TIME ZONE 'Asia/Seoul' |
| 밀리초를 초로 | Math.floor(ms / 1000) | ms // 1000 | ms / 1000 (정수·소수 나눗셈 여부 확인) |
하나의 순간, 일곱 개의 벽시계
순간과 현지 시각의 차이를 몸에 익히는 가장 좋은 방법은 타임스탬프 하나를 여러 시간대로 바꿔 보는 것입니다. 순간 1790812800(2026-10-01T00:00:00Z)은 어디서나 같은 순간이지만 달력 날짜와 시계 읽기는 다르고, 뉴욕과 로스앤젤레스에서는 날짜가 아직 전날입니다.
| 시간대 | 현지 시각 | 그날의 오프셋 |
|---|---|---|
| UTC | 2026-10-01 00:00 | +00:00 |
| Asia/Seoul | 2026-10-01 09:00 | +09:00 (서머타임 없음) |
| Asia/Kolkata | 2026-10-01 05:30 | +05:30 (서머타임 없음) |
| Europe/Berlin | 2026-10-01 02:00 | +02:00 (CEST) |
| Europe/London | 2026-10-01 01:00 | +01:00 (BST) |
| America/New_York | 2026-09-30 20:00 | -04:00 (EDT) |
| America/Los_Angeles | 2026-09-30 17:00 | -07:00 (PDT) |
무엇을 저장할까: 결정표
시간의 종류마다 필요한 표현이 다르고, 스키마 실수의 대부분은 모든 것에 한 가지 컬럼 타입을 쓰는 데서 나옵니다.
| 기록하려는 것 | 저장 방식 | 이유 |
|---|---|---|
| 어떤 일이 일어난 때(로그, 결제, 가입) | UTC 순간: 에포크 숫자 또는 timestamptz | 정확히 하나의 순간이므로 표시할 때만 변환 |
| 현지 시각으로 잡은 미래의 회의 | 현지 날짜·시간과 IANA 시간대 (예: 2027-03-29 09:00 Europe/Berlin) | UTC 순간은 아직 바뀔 수 있는 서머타임 규칙에 좌우됨 |
| 시간 없는 날짜(생일, 공휴일) | 시간대 없는 date 타입 | 순간으로 변환하면 일부 시간대에서 하루가 밀림 |
| 기간(타임아웃, 구독 기간) | 초, 또는 달력 단위용 period 타입 | '1개월'과 '30일'은 다르고 '하루'는 23시간이나 25시간일 수 있음 |
| 사용자의 표시 설정 | 프로필에 IANA 시간대 이름 | 오프셋은 서머타임에 따라 바뀌므로 부족함 |
코드 리뷰에서 찾아볼 버그 체크리스트
이 패턴들은 실제 코드베이스에서 되풀이해 나타납니다. 하나의 시간대에서 돌린 테스트는 통과하고 운영에서 깨집니다.
- 오프셋 없는 현지 문자열로 타임스탬프를 비교하거나 정렬하는 경우. 서머타임 변경을 지나면 깨집니다.
- 오프셋 없는 날짜·시간 문자열을 파싱해 조용히 서버 현지 시간으로 취급하는 경우.
- JavaScript의
new Date(2026, 9, 1)에서 월이 0부터 시작함을 잊는 경우. 9는 10월입니다. - '하루의 시작'을 UTC로 계산하는 경우. 하루가 다른 순간에 시작하는 사용자에게는 일간 리포트가 밀리며, UTC에서 먼 사용자일수록 심합니다.
- 서머타임 변경을 지나며 '내일'을 86,400초를 더해 구하는 경우. 위에서 본 23시간·25시간짜리 하루가 그 예입니다.
- 밀리초를 다른 코드가 초로 읽는 컬럼이나 API 필드에 저장해 먼 미래의 날짜가 나오는 경우.
- 토큰 만료 같은 보안 결정에 클라이언트의 시계를 믿는 경우. 서버의 시계와 비교하세요.