로그 타임스탬프가 갑자기 1901년으로 찍히거나, 임베디드 장비 날짜가 1970년으로 리셋되는 걸 본 적 있다면 Unix 타임스탬프의 한계와 마주친 거예요. 결론부터 말하면 Unix 타임스탬프는 1970년 1월 1일 0시(UTC)부터 흐른 초의 개수고, 이걸 32비트 부호 있는 정수에 저장하면 2038년 1월 19일 03:14:07 UTC 이후 숫자가 음수로 뒤집혀 1901년으로 돌아가요. 이게 "2038년 문제"(Y2K38)예요.
2000년 문제(Y2K)의 사촌 격인데, 이번엔 저장 방식이 아니라 정수의 비트 수가 원인이에요. 비트 단위로 왜 뒤집히는지 직접 계산해서 보여 드리고, 정수 폭에 따라 한계 연도가 어떻게 달라지는지, 그리고 지금 점검해 둘 만한 곳을 정리했어요.
Unix 타임스탬프: 1970년부터 센 초
Unix 타임스탬프(에포크 시간)는 기준점인 1970년 1월 1일 0시 UTC부터 지금까지 흐른 초의 개수예요. 예를 들어 1759622400 은 2025-10-05 00:00:00 UTC예요.
import datetime as dt
dt.datetime.fromtimestamp(1759622400, dt.timezone.utc)
# 2025-10-05 00:00:00+00:00
int(dt.datetime.now(dt.timezone.utc).timestamp())
# 현재 시각의 타임스탬프
핵심은 시간대와 무관한 하나의 숫자라는 점이에요. 서울이든 뉴욕이든 같은 순간에는 같은 타임스탬프를 봐요. 그래서 로그, DB, API에서 시각을 주고받을 때 기준으로 삼기 좋아요. 사람이 읽을 날짜로 바꾸는 건 보여줄 때만 하면 돼요. 이 "저장은 숫자로, 표시는 현지 시간으로" 원칙은 타임존 KST·UTC DB 저장 글에서 자세히 다뤘어요.
숫자를 날짜로, 날짜를 숫자로 바꿔 볼 땐 타임스탬프 변환기가 편해요. 10자리면 초, 13자리면 밀리초로 알아서 판별해 줘요.
2038년 문제: 비트가 뒤집히는 순간
C 언어의 전통적인 time_t 타입과 많은 옛 시스템은 타임스탬프를 32비트 부호 있는 정수에 저장했어요. 부호 있는 32비트가 담을 수 있는 최댓값은 2,147,483,647이에요. 이게 바로 2038-01-19 03:14:07 UTC예요.
여기서 1초가 더해지면 어떻게 되는지 직접 계산해 봤어요.
import datetime as dt
MAX = 2**31 - 1 # 2147483647
dt.datetime.fromtimestamp(MAX, dt.timezone.utc)
# 2038-01-19 03:14:07+00:00
overflow = MAX + 1 # 2147483648 — 32비트 부호 정수로는 표현 불가
as_signed = -(2**31) # 비트가 그대로면 -2147483648로 읽힌다
dt.datetime.fromtimestamp(as_signed, dt.timezone.utc)
# 1901-12-13 20:45:52+00:00
경계 근처 값을 32비트 부호 정수로 읽으면 어떻게 되는지 표로 보면 더 분명해요. Python의 ctypes.c_int32 로 32비트 부호 정수에 넣었을 때의 값을 직접 뽑았어요.
| 실제로 흐른 초 | 16진수 | 32비트 부호 정수로 읽은 값 | 날짜(UTC)로 바꾸면 |
|---|---|---|---|
| 2,147,483,646 | 0x7FFFFFFE | 2,147,483,646 | 2038-01-19 03:14:06 |
| 2,147,483,647 | 0x7FFFFFFF | 2,147,483,647 | 2038-01-19 03:14:07 (최댓값) |
| 2,147,483,648 | 0x80000000 | −2,147,483,648 | 1901-12-13 20:45:52 |
| 2,147,483,649 | 0x80000001 | −2,147,483,647 | 1901-12-13 20:45:53 |
| 4,294,967,295 | 0xFFFFFFFF | −1 | 1969-12-31 23:59:59 |
import ctypes, datetime as dt
epoch = dt.datetime(1970, 1, 1, tzinfo=dt.timezone.utc)
for v in [2**31 - 1, 2**31, 2**32 - 1]:
signed = ctypes.c_int32(v).value
print(v, hex(v), signed, epoch + dt.timedelta(seconds=signed))
# 2147483647 0x7fffffff 2147483647 2038-01-19 03:14:07+00:00
# 2147483648 0x80000000 -2147483648 1901-12-13 20:45:52+00:00
# 4294967295 0xffffffff -1 1969-12-31 23:59:59+00:00
부호 있는 정수는 맨 앞 비트(부호 비트)가 1이면 음수예요. 최댓값 0111...1 에 1을 더하면 1000...0 이 되는데, 이건 가장 큰 양수가 아니라 가장 작은 음수(-2,147,483,648)예요. 그래서 시간이 2038년에서 1901년으로 점프해요. 자동차 계기판이 999,999km를 넘어 0으로 돌아가는 것과 같은 자릿수 넘침(오버플로)인데, 부호 비트 때문에 0이 아니라 음수로 가는 거죠.
정수 폭에 따른 한계 연도
저장하는 정수의 크기와 부호 유무에 따라 한계가 달라져요. 각 경우의 최댓값을 날짜로 바꿔 봤어요.
- 32비트 부호 있음: 2038년. 과거(1970년 이전)도 표현하려면 부호가 필요해서 이 방식이 쓰였어요.
- 32비트 부호 없음: 2106년. 음수를 포기하는 대신 시간을 2배로 벌어요. 1970년 이전은 표현 못 해요.
- 64비트 부호 있음: 약 2,922억 년. 우주 나이의 수십 배라 사실상 영원히 안전해요.
차트에서 64비트 막대가 압도적으로 긴 건 로그 축이 아니라 실제 비율이에요. 32비트에서 64비트로 한 번만 바꾸면 이 문제는 끝나요.
왜 하필 1970년일까, 그리고 윤초 이야기
기준점이 1970년인 건 Unix가 그 무렵 벨 연구소에서 개발됐기 때문이에요. 초기 Unix는 60Hz로 째깍거리는 하드웨어 시계를 세었는데, 그 방식으로는 몇 년이면 카운터가 넘쳐서 기준점을 "최근"으로 둘 수밖에 없었어요. 이후 1초 단위로 세는 방식으로 바뀌면서 1970-01-01 00:00:00 UTC가 관례로 굳었고, 지금은 POSIX 표준이 됐어요.
한 가지 미묘한 점은 Unix 시간이 윤초를 세지 않는다는 거예요. 지구 자전이 조금씩 느려져서 가끔 국제 표준시에 1초를 더하는 윤초가 있는데, Unix 타임스탬프는 "하루는 정확히 86,400초"라고 가정하고 윤초를 건너뛰어요. 덕분에 날짜 계산은 단순해지지만, 윤초가 삽입되는 순간 같은 타임스탬프가 두 번 나타나거나 1초가 사라지는 효과가 생겨요. 대부분의 서비스는 신경 쓸 필요가 없지만, 금융 거래 체결 순서나 분산 시스템의 정밀한 시각 동기화에서는 "윤초 스미어링"(윤초를 하루에 걸쳐 조금씩 나눠 적용하는 기법)을 써서 이 불연속을 피해요.
그래서 Unix 타임스탬프는 "절대적인 물리 시간"이 아니라 "약속된 셈법"이라고 이해하는 게 정확해요. 대부분의 용도에선 이 약속이 충분히 정확하고 편리해요.
지금 우리 시스템도 위험할까
대부분의 현대 환경은 이미 안전해요. 그래도 확인해 둘 만한 곳이 있어요.
| 영역 | 상태 | 확인할 것 |
|---|---|---|
| 64비트 리눅스·맥·윈도우 | 안전 | time_t 가 64비트 |
| 최신 MySQL·PostgreSQL | 대체로 안전 | TIMESTAMP vs DATETIME (아래) |
| JavaScript | 안전 | Date 는 64비트 부동소수점(밀리초) |
Python 3, Java Instant | 안전 | 64비트 이상 |
| 32비트 임베디드·IoT | 위험할 수 있음 | 펌웨어의 time_t 폭 |
| 오래된 C 코드 | 위험할 수 있음 | int 로 시간 계산하는 곳 |
| 네트워크·파일 포맷 | 포맷마다 다름 | 32비트 시간 필드 |
언어와 DB마다 시각을 담는 방식이 달라서 표현할 수 있는 범위도 제각각이에요. 직접 실행할 수 있는 건 실행해서 확인했고, DB 범위는 각 공식 문서 기준이에요.
| 환경 · 타입 | 내부 표현 | 표현 범위 | 확인 방법 |
|---|---|---|---|
JavaScript Date | 밀리초, ±8.64×10^15 | −271821-04-20 ~ +275760-09-13 | Node.js 22에서 new Date(8.64e15) 실행 |
Python datetime | 연·월·일 필드 | 1년 ~ 9999년 | datetime.max |
SQLite datetime(x,'unixepoch') | 64비트 정수 | 2038년 이후도 정상 | 2147483648 → 2038-01-19 03:14:08 |
MySQL TIMESTAMP | 32비트 초 | 1970-01-01 00:00:01 ~ 2038-01-19 03:14:07 UTC | MySQL 공식 문서 |
MySQL DATETIME | 날짜·시각 필드 | 1000년 ~ 9999년 | MySQL 공식 문서 |
PostgreSQL timestamptz | 64비트 마이크로초 | 기원전 4713년 ~ 294276년 | PostgreSQL 날짜·시간 타입 문서 |
JavaScript Date 는 범위를 넘기면 오류 대신 NaN(Invalid Date)이 돼요. new Date(8.64e15 + 1).getTime() 은 NaN 이에요. 넘침이 조용히 1901년으로 가지 않는 대신, 계산 결과가 갑자기 Invalid Date가 되는 다른 모양의 버그가 생길 수 있어요.
특히 조심할 게 MySQL의 TIMESTAMP 타입이에요. MySQL TIMESTAMP 는 내부적으로 32비트라 2038-01-19가 상한이에요. 반면 DATETIME 은 9999년까지 담겨요. 날짜 범위가 넓어야 하는 컬럼(만기일, 예약, 보험)이라면 DATETIME 이나 더 넓은 타입을 쓰세요. 타입 선택은 타임존 KST·UTC DB 저장에서 더 비교했어요.
애플리케이션 코드에서는 이런 패턴을 찾아 두면 좋아요.
int now = time(NULL); // 위험: 2038년 이후 음수
time_t now = time(NULL); // time_t가 64비트면 안전
# 미래 날짜 계산에서 32비트 한계를 넘기는지 테스트
import datetime as dt
far = dt.datetime(2040, 1, 1, tzinfo=dt.timezone.utc)
int(far.timestamp()) # 2208988800 — 2^31을 넘는다
2038년은 생각보다 가깝다
"아직 10년도 더 남았는데"라고 넘기기 쉽지만, 만기일이 미래인 데이터는 지금도 2038년을 넘겨요. 20년, 30년짜리 주택담보대출, 장기 보험, 연금, 구독 자동 갱신 같은 시스템은 이미 2038년 이후 날짜를 계산하고 있어요. 그 계산이 32비트 경로를 지나면 지금 당장 버그가 나요.
숫자로 보면 실감이 나요. 2026년 10월 18일에 시작한 30년 만기 대출의 만기일 2056-10-18 00:00 UTC는 타임스탬프로 2,739,052,800이에요. 32비트 부호 정수 최댓값(2,147,483,647)을 이미 6억 초 가까이 넘었어요. 2038년 1월 19일까지 남은 날은 2026-10-18 기준 4,111일이에요.
운영체제 쪽 준비 상황도 확인해 둘 만해요. 리눅스용 C 라이브러리 glibc는 2.34 버전부터 x86 같은 32비트 환경에서도 _TIME_BITS=64 로 64비트 time_t 를 쓸 수 있게 했어요. 다만 32비트 환경의 기본값은 여전히 32비트이고, 이 기능은 _FILE_OFFSET_BITS=64 와 함께 켜야 하며 리눅스 커널 5.1 이상이 필요하다고 glibc 릴리스 노트(NEWS)에 적혀 있어요. 설계 배경은 glibc 위키의 Y2038 대응 설계 문서에 정리돼 있어요. 즉 32비트 장비용 프로그램은 "다시 빌드할 때 옵션을 켜야" 안전해져요.
| 점검 대상 | 위험 신호 | 고치는 방향 |
|---|---|---|
| DB 컬럼 | MySQL TIMESTAMP, INT 로 저장한 epoch | DATETIME, BIGINT, PostgreSQL timestamptz |
| C/C++ 코드 | int, long(32비트 플랫폼)에 시간 저장 | time_t(64비트 빌드), int64_t |
| 32비트 리눅스 장비 | glibc 2.34 미만, 커널 5.1 미만 | 업데이트 후 _TIME_BITS=64 로 재빌드 |
| 파일·프로토콜 | 4바이트 시간 필드 | 포맷 버전 올려 8바이트 필드 추가 |
| 테스트 데이터 | 2037년 이후 날짜가 하나도 없음 | 2040년, 2056년 날짜로 왕복 테스트 |
점검 순서를 요약하면 이래요.
- 미래 날짜를 다루는 컬럼·필드의 타입이 32비트인지 확인(특히 MySQL
TIMESTAMP). - C/C++ 코드에서
int로 시간을 담거나 계산하는 곳을 찾아time_t(64비트)나int64_t로 교체. - 외부와 주고받는 바이너리 포맷·프로토콜에 32비트 시간 필드가 있는지 확인.
- 2040년 같은 미래 날짜로 테스트 데이터를 넣어 라운드트립을 검증.
자주 묻는 질문
Unix 타임스탬프는 UTC인가요, 현지 시간인가요?
UTC 기준이에요. 1970년 1월 1일 0시 UTC부터 센 초라서 시간대와 무관한 하나의 숫자예요. 같은 순간이면 서울이든 런던이든 같은 타임스탬프를 봐요. 현지 시간은 보여줄 때 변환하는 거예요.
2038년 문제는 왜 2038년 1월 19일인가요?
32비트 부호 있는 정수의 최댓값 2,147,483,647초가 1970년 1월 1일부터 세면 2038-01-19 03:14:07 UTC이기 때문이에요. 그 다음 초에 정수가 넘쳐 음수가 되면서 1901년으로 뒤집혀요.
내 서비스가 2038년 문제에 영향을 받나요?
64비트 OS와 최신 언어·DB를 쓴다면 대부분 안전해요. 다만 MySQL TIMESTAMP 컬럼, 32비트 임베디드 장비, int 로 시간을 다루는 오래된 C 코드, 32비트 시간 필드가 있는 파일·네트워크 포맷은 확인이 필요해요. 특히 만기일이 먼 미래인 데이터가 있다면 지금 점검하세요.
32비트를 부호 없는 정수로 바꾸면 해결되나요?
2106년까지 늘어나지만 근본 해결은 아니에요. 1970년 이전 날짜를 표현할 수 없게 되고, 2106년에 같은 문제가 또 와요. 가능하면 64비트로 바꾸는 게 맞아요.
JavaScript의 Date도 2038년 문제가 있나요?
없어요. JavaScript Date 는 64비트 부동소수점에 밀리초를 담아서 약 ±27만 년까지 표현해요. 다만 밀리초 단위라 초 단위 타임스탬프와 섞으면 다른 버그가 생기는데, 이건 초와 밀리초 단위 버그에서 다뤘어요.
정리
Unix 타임스탬프는 1970년부터 센 초이고, 32비트 부호 정수에 담으면 2038년에 음수로 뒤집혀요. 64비트로 한 번만 바꾸면 사실상 영원히 안전해요. 대부분의 현대 시스템은 이미 넘어갔지만, MySQL TIMESTAMP, 임베디드 장비, 오래된 C 코드, 그리고 지금도 미래 날짜를 계산하는 장기 상품 시스템은 점검해 둘 가치가 있어요.
타임스탬프를 날짜로 바꿔 확인할 땐 타임스탬프 변환기를, 날짜 간격 계산은 날짜 차이 계산기를 쓰면 편해요.