유닉스 타임스탬프·UTC·ISO 8601 입문: 실행하며 익히는 날짜 기초
서버 로그가 9시간 어긋나고 날짜가 1970년이나 58142년이 되는 이유를 직접 실행해 확인합니다. 순간과 벽시계 시각, 초와 밀리초, ISO 8601, 파이썬 naive/aware TypeError 와 utcnow 함정까지.
시리즈: 날짜·시간·타임존 강좌 1/6 (초급). new Date() 나 datetime.now() 를 별생각 없이 써 왔는데, 로그 시각이 9시간 어긋나거나 날짜가 1970년으로 찍혀서 당황한 분을 위한 글입니다. 다 읽고 나면 "순간" 과 "벽시계 시각" 을 구분하고, 에포크 초·밀리초·ISO 8601 문자열을 서로 바꾸고, 파이썬 naive datetime 의 함정을 피할 수 있습니다.
한국에서 개발하면 시간 문제는 쉬워 보입니다. 지금의 Asia/Seoul 에는 서머타임이 없고 일 년 내내 +09:00 이니까요. (1987~88년에는 있었습니다. tz 데이터베이스로 확인하는 건 2회에서 합니다.) 그런데 운영 서버는 대개 UTC 로 돌고, 고객 문의 시각과 로그가 "정확히 9시간" 차이 납니다. 프런트엔드 자바스크립트에서 넘어온 숫자가 파이썬 백엔드에서 서기 58142년이 되기도 하고, 해외 사용자가 생기는 순간 "내일" 이 어긋납니다. 이 강좌는 입사 첫 주에 부딪히는 이런 문제를 순간, 벽시계 시각, 둘을 잇는 타임존 세 가지로 나눠서 풉니다. 아래 출력은 모두 Apple Silicon Mac(M1 Pro)에서 실제로 실행한 결과입니다. Python 3.9.6(비교용 3.12), PHP 8.5.6, JavaScriptCore jsc, Java 26, .NET 10, Swift 6.4, Dart 3.11, sqlite 3.51 을 썼고, 모든 명령에 TZ=... 로 타임존을 명시해서 기계 설정에 좌우되지 않게 했습니다.
"순간" 과 "벽시계 시각" 은 다르다
강좌 전체에서 쓸 용어부터 정리합니다.
- 순간(instant): 전 세계 공통 시간축 위의 한 점. "결제가 확정된 그 순간" 은 지구 어디서나 같습니다.
- 벽시계 시각(wall-clock time, 로컬 시각): 벽시계가 보여 주는 날짜와 시각, 예를 들어
2026-03-04 21:00. 이것만으로는 순간이 정해지지 않습니다. 서울의 21:00 과 런던의 21:00 은 9시간 차이입니다. - UTC(협정 세계시): 모두가 합의한 기준 시계. 서머타임이 없습니다.
- 오프셋: 어느 순간에 UTC 와의 차이.
+09:00,-05:00처럼 씁니다. - 타임존:
Asia/Seoul,America/New_York같은 이름이 붙은 규칙 묶음. 언제 어떤 오프셋을 쓰는지(서머타임 전환 포함)를 정합니다. 2회의 주제입니다.
순간을 적는 방법은 여러 가지입니다. 기계용으로는 유닉스 에포크(1970-01-01T00:00:00Z)부터 센 초, 사람이 읽을 수 있으면서 모호하지 않은 형태는 오프셋이 붙은 ISO 8601 문자열입니다. 벽시계 시각은 표시할 타임존을 고를 때 비로소 정해집니다.
처음 5분: 한 순간을 여러 도시의 시계로 보기
~/demo/l1_instant.py 로 저장하고 TZ=UTC python3 l1_instant.py 로 실행합니다.
from datetime import datetime, timezone
from zoneinfo import ZoneInfo
t = 1772625600 # 유닉스 초
u = datetime.fromtimestamp(t, tz=timezone.utc)
print("UTC ", u.isoformat())
for z in ["America/New_York", "Europe/London", "Europe/Madrid", "America/Mexico_City", "Asia/Tokyo", "Asia/Seoul"]:
print(f"{z:20}", u.astimezone(ZoneInfo(z)).isoformat())
print("back to epoch", int(u.astimezone(ZoneInfo("Asia/Tokyo")).timestamp()))
UTC 2026-03-04T12:00:00+00:00
America/New_York 2026-03-04T07:00:00-05:00
Europe/London 2026-03-04T12:00:00+00:00
Europe/Madrid 2026-03-04T13:00:00+01:00
America/Mexico_City 2026-03-04T06:00:00-06:00
Asia/Tokyo 2026-03-04T21:00:00+09:00
Asia/Seoul 2026-03-04T21:00:00+09:00
back to epoch 1772625600
벽시계 시각은 여섯 가지지만 순간은 하나입니다. 도쿄 값을 에포크로 되돌려도 같은 1772625600 입니다. "UTC 12:00 = 서울 21:00", 이것이 서버 로그 "9시간 차이" 의 정체입니다. 로그가 틀린 게 아니라 UTC 벽시계로 적혀 있을 뿐입니다. 서울과 도쿄가 같은 +09:00 이라는 점도 기억해 두세요. 오프셋이 같다고 같은 타임존은 아닙니다.
macOS 의 BSD date 로도 확인할 수 있습니다.
date -u -r 0
date -u -r 1772625600
date -u -r 2147483647
Thu Jan 1 00:00:00 UTC 1970
Wed Mar 4 12:00:00 UTC 2026
Tue Jan 19 03:14:07 UTC 2038
2147483647 은 부호 있는 32비트 정수의 최댓값입니다. 에포크 초를 32비트로 저장하는 시스템은 이 1초에서 한계에 닿습니다(2038년 문제). 5회에서 C 로 직접 넘쳐 보게 합니다.
유닉스 시간: 초인가, 밀리초인가
에포크는 같아도 단위는 다릅니다. 자바스크립트 프런트는 밀리초를 보내고 파이썬이나 PHP 백엔드는 초로 읽습니다. 신입이 반드시 한 번은 밟는 버그입니다.
// ~/demo/l1_ms.js 실행: TZ=UTC jsc l1_ms.js
const ms = Date.UTC(2026, 2, 4, 12, 0, 0); // 월은 0부터: 2 = 3월
print("Date.UTC ->", ms);
print("as ms ->", new Date(ms).toISOString());
print("seconds passed as ms ->", new Date(ms / 1000).toISOString());
print("ms*1000 ->", new Date(ms * 1000).toISOString());
Date.UTC -> 1772625600000
as ms -> 2026-03-04T12:00:00.000Z
seconds passed as ms -> 1970-01-21T12:23:45.600Z
ms*1000 -> +058142-04-09T00:00:00.000Z
(jsc 는 macOS 에 들어 있는 JavaScriptCore 셸이고 위치는 /System/Library/Frameworks/JavaScriptCore.framework/Versions/Current/Helpers/jsc 입니다. alias 를 만들어 두면 편합니다. print 는 있지만 console 은 없습니다.)
다른 언어에서 단위를 잘못 넣으면 이렇게 됐습니다.
| 도구 | 원래 단위 | 잘못 넣은 값 | 실제 결과 |
|---|---|---|---|
JavaScript Date |
밀리초 | 초 1772625600 |
1970-01-21T12:23:45.600Z |
Java Instant.ofEpochMilli |
밀리초 | 초 | 1970-01-21T12:23:45.600Z |
Dart fromMillisecondsSinceEpoch |
밀리초 | 초 | 1970-01-21 12:23:45.600Z |
Python fromtimestamp |
초(float) | 밀리초 | ValueError: year 58142 is out of range |
PHP date('c', …) |
초(int) | 밀리초 | 58142-04-09T00:00:00+00:00 |
Java Instant.ofEpochSecond |
초 | 밀리초 | +58142-04-09T00:00:00Z |
.NET FromUnixTimeSeconds |
초 | 밀리초 | ArgumentOutOfRangeException |
SQLite datetime(x,'unixepoch') |
초 | 밀리초 | NULL(빈 값) |
1970년 1월 이 보이면 "초를 밀리초로 읽었다", 수만 년 뒤 날짜면 반대입니다. 큰소리로 에러를 내 주는 건 파이썬과 .NET 뿐이고, PHP 와 자바는 태연하게 58142년을 돌려줬습니다.
Swift 에는 함정이 하나 더 있습니다. Date(timeIntervalSince1970:) 은 유닉스 에포크 기준이지만 Date(timeIntervalSinceReferenceDate:) 는 2001-01-01 기준입니다. 유닉스 숫자를 넣었더니 2057-03-04 12:00:00 +0000, 31년 뒤가 나왔습니다.
해결: 필드와 변수 이름에 단위를 넣습니다(created_at_ms, expires_s). JSON API 라면 1000배 어긋날 일이 없는 ISO 8601 문자열이 가장 좋습니다.
UTC, 오프셋, 타임존 이름은 서로 다른 것
오프셋은 "그 순간 UTC 와 몇 시간 차이였나" 만 알려 줄 뿐 다음 달 규칙은 알려 주지 않습니다. 한국만 보면 일 년 내내 +09:00 이라 "오프셋 = 타임존" 이라고 착각하기 쉽습니다. 하지만 America/New_York 은 3월 전환 전에는 -05:00, 후에는 -04:00 입니다.
Z또는+00:00: UTC 시각.+09:00: 그 순간 UTC 보다 9시간 빨랐다. 순간은 정해지지만 "다음 주 같은 시각" 은 계산할 수 없다.Asia/Seoul: 타임존. 모든 계산이 가능하다. 단, 오프셋이 아니라 이름을 저장해야 한다.
ISO 8601 과 RFC 3339: 써야 할 문자열 형태
ISO 8601 은 날짜 표기 국제 표준이고 RFC 3339 는 그중 인터넷용으로 좁힌 규격입니다. 로그·JSON·DB 에서 쓸 형태는 이것입니다.
2026-03-04T12:00:00Z
2026-03-04T21:00:00+09:00
2026-03-04T12:00:00.123+00:00
연도가 맨 앞이라 오프셋이 같다면 문자열 그대로 정렬해도 순서가 맞습니다. 한국식 2026. 3. 4. 나 2026-03-04 는 연도가 앞이라 그나마 안전하지만, 미국식 03/04/2026 은 파이썬에서 두 가지 읽기가 모두 통과했습니다.
datetime.strptime("03/04/2026", "%m/%d/%Y").date() # 2026-03-04 (미국식)
datetime.strptime("03/04/2026", "%d/%m/%Y").date() # 2026-04-03 (유럽식)
출력은 쉽습니다. 파이썬은 isoformat(), 자바스크립트는 toISOString() 과 JSON.stringify({"at":"2026-03-04T12:00:00.000Z"}), PHP 는 format(DATE_ATOM). 문제는 읽는 쪽의 버전 차이입니다. Python 3.9 의 fromisoformat 은 생각보다 엄격합니다.
3.9.6
2026-03-04T12:00:00+00:00 -> datetime.datetime(2026, 3, 4, 12, 0, tzinfo=datetime.timezone.utc)
2026-03-04T12:00:00Z -> ValueError: Invalid isoformat string: '2026-03-04T12:00:00Z'
2026-03-04 12:00:00 -> datetime.datetime(2026, 3, 4, 12, 0)
20260304T120000Z -> ValueError: Invalid isoformat string: '20260304T120000Z'
같은 스크립트를 Python 3.12.14 로 돌리면 Z 형태 둘 다 통과했습니다. 3.9/3.10 을 지원해야 한다면 끝의 Z 를 +00:00 으로 바꾼 다음 읽으세요. 세 번째 줄도 보세요. 오프셋이 없는 문자열은 에러 없이 naive datetime 이 됩니다. 이게 다음 함정입니다.
파이썬의 naive 와 aware
파이썬 datetime 은 두 종류입니다. naive 는 tzinfo=None 인, 타임존 없는 벽시계 시각입니다. aware 는 tzinfo 를 갖고 있어서 순간을 나타냅니다.
# ~/demo/l1_naive.py
from datetime import datetime, timezone
naive = datetime(2026, 3, 4, 12, 0) # tzinfo 없음
aware = datetime(2026, 3, 4, 12, 0, tzinfo=timezone.utc) # UTC 붙임
print(naive.tzinfo, aware.tzinfo)
print(naive == aware)
for op in ("naive < aware", "aware - naive"):
try:
eval(op)
except TypeError as e:
print(f"{op}: TypeError: {e}")
print("naive.timestamp():", naive.timestamp())
print("aware.timestamp():", aware.timestamp())
--- TZ=Asia/Seoul
None UTC
False
naive < aware: TypeError: can't compare offset-naive and offset-aware datetimes
aware - naive: TypeError: can't subtract offset-naive and offset-aware datetimes
naive.timestamp(): 1772593200.0
aware.timestamp(): 1772625600.0
<와-는TypeError가 납니다. 이건 친절한 쪽입니다.==는 에러 없이False를 돌려줍니다. 둘 다 "12:00" 으로 보이는데 딕셔너리 조회나if a == b가 조용히 빗나갑니다.naive.timestamp()는 기계의 타임존을 가정합니다.TZ=Asia/Seoul에서는 32,400초(9시간) 이른 순간이 됐고,TZ=UTC에서는1772625600.0으로 일치했습니다. 코드의 의미가 서버 설정에 따라 바뀌는 셈입니다.
흔한 실수: datetime.utcnow()
이름은 맞아 보이지만 utcnow() 는 UTC 숫자를 담은 naive 값을 돌려줍니다. 여기에 .timestamp() 를 부르면 그 숫자를 로컬 시각으로 해석합니다.
# ~/demo/l1_utcnow.py
import warnings, time
warnings.simplefilter("default")
from datetime import datetime, timezone
n = datetime.utcnow()
print(type(n.tzinfo).__name__)
print(round(n.timestamp() - time.time())) # 어긋난 초
print(round(datetime.now(timezone.utc).timestamp() - time.time())) # 고친 버전
--- Python 3.9.6, TZ=UTC
NoneType
0
0
--- Python 3.9.6, TZ=Asia/Seoul
NoneType
-32400
0
--- Python 3.12.14, TZ=UTC
~/demo/l1_utcnow.py:4: DeprecationWarning: datetime.datetime.utcnow() is deprecated and scheduled for removal in a future version. Use timezone-aware objects to represent datetimes in UTC: datetime.datetime.now(datetime.UTC).
n = datetime.utcnow()
NoneType
0
0
UTC 서버에서는 아무 일도 없고, 한국 시간으로 설정된 개발 PC 에서는 9시간 어긋납니다. "운영에선 맞는데 내 PC 에선 틀리다" 의 전형입니다. 3.12 부터는 경고가 나옵니다. 해결: datetime.now(timezone.utc)(3.9 에서도 됨) 또는 datetime.now(datetime.UTC)(3.11 이상). 둘 다 모든 타임존에서 차이 0 이었습니다.
자바스크립트도 마찬가지입니다. new Date(2026, 2, 4, 12, 0) 은 "실행 환경 로컬 시각의 12:00" 이라 TZ=UTC 에서는 2026-03-04T12:00:00.000Z, TZ=Asia/Seoul 에서는 2026-03-04T03:00:00.000Z 가 됐습니다. 반면 new Date("2026-03-04") 는 어디서든 2026-03-04T00:00:00.000Z(UTC 취급)였습니다. 이 불일치는 3회에서 자세히 봅니다.
저장은 UTC, 표시는 로컬
날짜 버그 대부분을 막는 규칙은 순간을 UTC 로 저장하고, 보여 주기 직전에 보는 사람의 타임존으로 바꾼다 입니다. SQLite 로 만든 작은 예입니다.
# ~/demo/l1_store.py
import sqlite3
from datetime import datetime, timezone
from zoneinfo import ZoneInfo
db = sqlite3.connect(":memory:")
db.execute("CREATE TABLE orders (id INTEGER PRIMARY KEY, created_at TEXT NOT NULL)")
created = datetime(2026, 3, 4, 12, 0, tzinfo=timezone.utc)
db.execute("INSERT INTO orders (created_at) VALUES (?)", (created.isoformat(),))
raw = db.execute("SELECT created_at FROM orders").fetchone()[0]
print("stored :", raw)
for user_zone in ["America/New_York", "Europe/Madrid", "America/Bogota", "Asia/Tokyo", "Asia/Seoul"]:
local = datetime.fromisoformat(raw).astimezone(ZoneInfo(user_zone))
print(f"{user_zone:17} {local:%Y-%m-%d %H:%M} ({local:%Z}, {local.isoformat()[-6:]})")
stored : 2026-03-04T12:00:00+00:00
America/New_York 2026-03-04 07:00 (EST, -05:00)
Europe/Madrid 2026-03-04 13:00 (CET, +01:00)
America/Bogota 2026-03-04 07:00 (-05, -05:00)
Asia/Tokyo 2026-03-04 21:00 (JST, +09:00)
Asia/Seoul 2026-03-04 21:00 (KST, +09:00)
데이터 한 줄로 다섯 가지 올바른 표시가 나옵니다. "어차피 국내 서비스" 라며 KST 벽시계 시각으로 저장해 두면, 해외 사용자가 생기거나 클라우드 리전을 옮길 때 전부 다시 고쳐야 합니다. 예외는 미래의 로컬 일정("매주 월요일 9시, 서울 기준")으로, 이건 벽시계 시각과 타임존 이름을 함께 저장합니다. 6회에서 다룹니다.
첫 주에 자주 하는 실수
| 실수 | 실제로 일어난 일 | 해결 |
|---|---|---|
JS new Date(x) 에 초를 넣음 |
1970-01-21T12:23:45.600Z |
1000배. 이름에 _ms / _s |
| 파이썬에 밀리초를 넣음 | ValueError: year 58142 is out of range |
1000으로 나누기 |
datetime.utcnow().timestamp() |
TZ=Asia/Seoul 에서 -32,400초 어긋남 |
datetime.now(timezone.utc) |
| naive 와 aware 비교 | < 는 TypeError, == 는 조용히 False |
둘 다 aware 로 |
Python 3.9 에서 ...Z 파싱 |
ValueError: Invalid isoformat string |
Z 를 +00:00 으로, 또는 3.11 이상 |
| 로그가 "9시간 틀렸다" | UTC 벽시계로 찍혔을 뿐 | 표시할 때 Asia/Seoul 로 변환 |
| 지난 일을 KST 벽시계로 저장 | 타임존·서버가 바뀌면 깨짐 | UTC 로 저장, 표시는 로컬 |
작은 측정: 변환 비용은 얼마나 될까
전부 UTC 로 두고 표시할 때마다 바꾸면 느리지 않을까요? Python 3.9.6, Apple Silicon Mac(M1 Pro)에서 무작위 타임스탬프 200,000개(seed 42)를 5회씩 처리한 중앙값입니다.
fromtimestamp(int, utc) 60.8 ms 304 ns/op
fromisoformat(str) 18.6 ms 93 ns/op
strptime(str, %z) 1249.4 ms 6247 ns/op
isoformat() out 131.4 ms 657 ns/op
astimezone(Asia/Tokyo) 47.7 ms 239 ns/op
두 번째 실행도 5% 이내였습니다. 의외인 점이 둘 있습니다. ISO 문자열을 fromisoformat 으로 읽는 쪽이 정수에서 만드는 것보다 빨랐고, 같은 문자열에서 strptime 은 fromisoformat 보다 67배 느렸습니다. 실제 타임존으로 바꾸는 데는 1회 약 0.24마이크로초. 정확성은 싸고, 루프 안의 strptime 은 비쌉니다. 크기는 에포크 초 10자, 밀리초 13자, …Z 형식 20자, +00:00 형식 25자였습니다.
솔직한 한계와 버전 메모
zoneinfo는 Python 3.9 에 추가됐고 시스템 tz 데이터베이스(여기서는/usr/share/zoneinfo의 2026d)를 읽습니다. Windows 에서는tzdata패키지가 필요하지만 여기서는 시험하지 않았습니다.fromisoformat이Z를 받는 것은 릴리스 노트상 3.11 부터입니다. 3.9.6 거부, 3.12.14 통과를 확인했습니다.utcnow()는 3.12 부터 deprecated 입니다.- 여기
jsc에는timeZone을 받는Intl.DateTimeFormat이 있지만Temporal은undefined였습니다. 브라우저와 Node.js 는 실행하지 않았습니다.Date.toString()은 OS 언어로 타임존 이름을 붙이므로 생략했습니다. - 측정은 한 대, 한 빌드의 결과입니다. 자릿수 감으로만 보세요.
- 유닉스 시간은 윤초를 세지 않습니다(하루는 언제나 86,400초). 의미는 6회에서 설명합니다.
연습 문제
- JSON API 가
"created": 1772625600을 돌려주고 프런트에서new Date(created).toISOString()을 했습니다. 무엇이 찍히고 어떻게 고치나요? TZ=Asia/Seoul에서datetime(2026, 3, 4, 12, 0).timestamp()는 무엇을 돌려주고1772625600과 몇 초 차이 나나요?- Python 3.9 의
datetime.fromisoformat은 다음 중 무엇을 받아 주고, 어느 결과가 naive 인가요?"2026-03-04T12:00:00+00:00","2026-03-04T12:00:00Z","2026-03-04 12:00:00"
정답
1970-01-21T12:23:45.600Z. 초가 밀리초로 읽혔습니다.new Date(created * 1000)이면2026-03-04T12:00:00.000Z가 됩니다. 필드 이름도created_s로 바꾸세요.1772593200.0.1772625600보다 32,400초(9시간) 이릅니다. naive 값이 서울 벽시계 시각으로 읽혔기 때문이며,tzinfo=timezone.utc를 붙이면1772625600.0이 됩니다.- 첫 번째는 통과(aware, UTC). 두 번째는 3.9 에서
ValueError: Invalid isoformat string. 세 번째는 통과하지만 naive(tzinfo=None)입니다.
다음 글
자주 묻는 질문
서버 로그 시각이 한국 시간과 9시간 차이 나는 이유는?
서버가 UTC 로 돌고 있어서 로그가 UTC 벽시계 시각으로 찍히기 때문입니다. 2026-03-04T12:00:00Z 는 한국 시간(Asia/Seoul, +09:00)으로 21:00 입니다. 저장은 UTC 그대로 두고 보여 줄 때 Asia/Seoul 로 바꾸는 것이 기본입니다.
자바스크립트 날짜가 1970년 1월로 나오는 이유는?
자바스크립트는 밀리초를 기대하는데 유닉스 초를 넘겼기 때문입니다. new Date(1772625600) 은 1970-01-21T12:23:45.600Z, new Date(1772625600 * 1000) 은 2026-03-04T12:00:00.000Z 가 나왔습니다.
파이썬 can't compare offset-naive and offset-aware datetimes 에러는 무엇인가요?
한쪽 datetime 에는 타임존 정보(tzinfo)가 있고 다른 쪽에는 없을 때 비교하면 나는 에러입니다. 둘 다 tzinfo=timezone.utc 등을 붙인 뒤 비교하세요. == 만은 에러 없이 조용히 False 를 돌려준다는 점에 주의해야 합니다.