13장. DATETIME vs TIMESTAMP — MySQL은 시간을 어떻게 저장하나

만화로 보는 요약 — 먼저 읽어보세요
그대로 보관하는 DATETIME → 세션 time_zone을 거쳐 UTC로 왕복하는 TIMESTAMP → 저장 의도에 맞는 타입 선택.
면접 실전 질문: ① DATETIME과 TIMESTAMP는 저장 방식이 근본적으로 어떻게 다른가? ② 세션 time_zone을 바꾸면 어느 타입의 값이 왜 달라지는가? ③ TIMESTAMP가 2038년 한계를 갖는 이유는?
DATETIME은 적어놓은 벽시계 숫자를 그대로 돌려주지만, TIMESTAMP는 내부에 UTC를 감춰두고 읽을 때마다 그 커넥션의 시간대로 다시 계산해 보여준다 — 같은 행이 접속마다 다른 시각으로 읽힌다.
면접 실전 질문: ①
DATETIME과TIMESTAMP는 저장 방식이 근본적으로 어떻게 다른가? ② 세션time_zone을 바꾸면 두 타입 중 어느 쪽 값이 바뀌는가, 왜인가? ③TIMESTAMP가DATETIME과 달리 2038년 한계를 갖는 이유는?
배경 — 재시작을 견뎌야 하는 값
12장은 질문 하나를 남기고 끝났습니다. Instant든 ZonedDateTime이든, 로케일과 zone 함정을 다 피해 문자열로 올바르게 찍었든, 그 값은 애플리케이션이 재시작되면 사라집니다. 실무 시스템은 이 값이 데이터베이스 컬럼에 들어가야 살아남습니다. 그런데 MySQL은 이름마저 헷갈리는 두 시간 타입을 갖고 있습니다. DATETIME과 TIMESTAMP.
9장은 이미 이 문제의 어휘를 다 준비해뒀습니다. Instant는 시간축 위의 점 — 어디서 읽어도 같은 값입니다. LocalDateTime은 벽시계 눈금 — 어느 지역인지 모르면 의미가 없는 숫자입니다. 4장은 여기에 한 가지를 더 예고했습니다. MySQL TIMESTAMP는 32비트 정수로 초를 세는 time_t와 같은 폭을 물려받아 2038년 한계를 갖고, DATETIME은 이 정수와 무관해 훨씬 넓은 범위를 갖는다고요. 이 장은 그 예고를 마저 풀고, 두 컬럼 타입이 9장의 어휘 중 정확히 어느 쪽에 대응하는지를 실측으로 확인합니다.
스토리 — 같은 행이 접속마다 다른 시각으로 읽힌다
테이블 하나에 DATETIME 컬럼과 TIMESTAMP 컬럼을 나란히 두고, 같은 값을 한 번만 넣은 다음, 세션의 time_zone을 세 번 바꿔가며 같은 행을 다시 읽어봤습니다. 이 장의 출력은 전부 MySQL 8.4.11(Docker mysql:8) 에서 확인한 값입니다 — 8.x의 다른 마이너 버전에서는 에러 메시지 문구나 기본값이 조금씩 다를 수 있으니, 숫자보다 동작의 방향을 보시면 됩니다.
CREATE TABLE events (
id INT PRIMARY KEY AUTO_INCREMENT,
happened_at DATETIME,
happened_at_ts TIMESTAMP
);
SET time_zone = '+09:00';
INSERT INTO events (happened_at, happened_at_ts)
VALUES ('2026-07-31 10:00:00', '2026-07-31 10:00:00');
SELECT @@session.time_zone AS session_tz, happened_at, happened_at_ts FROM events;
SET time_zone = '+00:00';
SELECT @@session.time_zone AS session_tz, happened_at, happened_at_ts FROM events;
SET time_zone = '-05:00';
SELECT @@session.time_zone AS session_tz, happened_at, happened_at_ts FROM events;session_tz happened_at happened_at_ts
+09:00 2026-07-31 10:00:00 2026-07-31 10:00:00
session_tz happened_at happened_at_ts
+00:00 2026-07-31 10:00:00 2026-07-31 01:00:00
session_tz happened_at happened_at_ts
-05:00 2026-07-31 10:00:00 2026-07-30 20:00:00행은 딱 하나뿐이고, INSERT 이후로 아무도 값을 바꾸지 않았습니다. 그런데 happened_at_ts는 세션을 바꿀 때마다 다른 문자열을 냅니다. +09:00에서 넣은 10:00:00이 +00:00에서는 01:00:00으로, -05:00에서는 전날 20:00:00으로 읽힙니다. 세 값 모두 같은 순간을 가리키는 다른 표현이라는 건 계산해보면 맞습니다만, 문자열만 보면 완전히 다른 시각입니다. 반면 happened_at은 세 번 다 2026-07-31 10:00:00 그대로입니다. 세션이 뭐라고 하든 신경 쓰지 않습니다.
이게 12장 첫머리에서 본 “예외 없이 조용히 틀린 텍스트만 남는” 패턴의 데이터베이스 판입니다. 다른 점은, 12장은 애플리케이션 코드 한 줄(withZone() 누락)이 원인이었지만 여기서는 커넥션 풀에 물린 세션의 time_zone 설정이 원인이라는 겁니다. 커넥션 풀의 두 커넥션이 서로 다른 time_zone으로 초기화돼 있다면 — 흔한 사고 원인은 애플리케이션 서버 시간대와 DB 세션 기본값이 어긋난 경우입니다 — 같은 TIMESTAMP 행이 어느 커넥션으로 읽느냐에 따라 다른 시각으로 나타납니다.
핵심 — 저장 방식, 범위, 그리고 두 타입이 매핑되는 자리
무엇을 어떻게 저장하는가
DATETIME은 연·월·일·시·분·초를 문자 그대로 저장합니다. 내부적으로도 zone 개념이 없고, 변환도 하지 않습니다. 넣은 값이 나오는 값입니다. 9장의 어휘로 말하면 DATETIME은 LocalDateTime 입니다 — 벽시계 눈금 하나, 어느 지역인지는 컬럼 자체가 모릅니다.
TIMESTAMP는 다릅니다. 저장은 UTC 기준 epoch 초(4장의 그 정수)로 하고, INSERT될 때는 “세션 time_zone 기준으로 입력값을 UTC로 변환해서 저장”하고, SELECT될 때는 “저장된 UTC를 세션 time_zone 기준 지역 시각으로 변환해서 반환”합니다. 방금 실측한 결과가 정확히 이 왕복입니다. +09:00 세션이 넣은 10:00:00은 저장 시점에 UTC 01:00:00으로 바뀌었고, 이후 +00:00 세션이 그 UTC 값을 그대로 읽었고, -05:00 세션은 UTC에서 5시간을 빼 전날 20:00:00을 만들어 보여준 것입니다.
이 변환은 9장의 Instant.atZone()과 정확히 같은 모양입니다. 순간 하나에 규칙(zone)을 적용해 숫자를 읽는 연산이니까요. 그래서 TIMESTAMP는 Instant에 가깝습니다 — 다만 결정적인 차이가 하나 있습니다. 자바의 Instant는 어떤 스레드가 읽든 항상 같은 epochSecond를 돌려줍니다. Instant 자체엔 “이 값을 읽는 쪽의 zone”이라는 숨은 입력이 없습니다. TIMESTAMP는 다릅니다. 읽는 쪽의 세션 time_zone이 결과에 끼어드는 숨은 입력입니다. TIMESTAMP는 순수한 Instant가 아니라, “매번 현재 세션의 렌즈를 통과해야만 값을 볼 수 있는 Instant”에 가깝습니다. 방금 본 실측이 그 렌즈의 정체입니다.
저장 크기는 MySQL 매뉴얼 기준으로 DATETIME이 5바이트(+ 정밀도에 따라 03바이트), 3바이트)입니다. TIMESTAMP가 4바이트(+ 정밀도에 따라 0TIMESTAMP가 1바이트 더 작은 이유가 그대로 구조를 보여줍니다 — 연·월·일·시·분·초 6개 필드를 담는 DATETIME보다, epoch 초 정수 하나만 담는 TIMESTAMP가 더 압축적입니다.
세션 time_zone이 하는 일
time_zone 세션 변수는 커넥션 하나에 묶입니다. SET time_zone = '+09:00'처럼 고정 오프셋을 줄 수도 있고, SET time_zone = 'Asia/Seoul'처럼 IANA zone 이름(5장)을 줄 수도 있습니다(단, zone 이름을 쓰려면 서버에 mysql.time_zone* 테이블이 로드돼 있어야 합니다 — mysql_tzinfo_to_sql로 채웁니다). 애플리케이션이 별도로 지정하지 않으면 서버의 time_zone 전역 설정(기본값 SYSTEM, 즉 OS 시간대)을 물려받습니다. DATETIME은 이 변수를 아예 참조하지 않습니다. TIMESTAMP만 입력·출력 양쪽에서 이 변수를 거칩니다.
범위와 2038년 경계
4장이 예고한 값을 실측으로 확인했습니다. TIMESTAMP는 UTC 기준 1970-01-01 00:00:01부터 2038-01-19 03:14:07(초 단위 정밀도 기준 — 소수점까지 쓰는 TIMESTAMP(6)은 2038-01-19 03:14:07.999999까지)까지만 받습니다. 그 경계를 세션 +00:00에서 직접 건드려봤습니다.
SET time_zone = '+00:00';
CREATE TABLE boundary_ts (id INT PRIMARY KEY AUTO_INCREMENT, t TIMESTAMP NULL);
INSERT INTO boundary_ts (t) VALUES ('2038-01-19 03:14:07'); -- 성공
INSERT INTO boundary_ts (t) VALUES ('2038-01-19 03:14:08'); -- 실패mysql> INSERT INTO boundary_ts (t) VALUES ('2038-01-19 03:14:07');
Query OK, 1 row affected (0.01 sec)
mysql> INSERT INTO boundary_ts (t) VALUES ('2038-01-19 03:14:08');
ERROR 1292 (22007): Incorrect datetime value: '2038-01-19 03:14:08' for column 't' at row 11초 차이로 정상 삽입과 에러가 갈립니다. 하한도 마찬가지입니다. 1970-01-01 00:00:01은 들어가지만, 1970-01-01 00:00:00(epoch 정각)은 같은 ERROR 1292 (22007)로 거부됩니다. 반면 같은 값을 DATETIME 컬럼에 넣으면 아무 문제가 없습니다.
여기서 문서와 실측이 어긋나 보이는 대목을 짚고 갑니다. MySQL 매뉴얼은 TIMESTAMP의 상한을 2038-01-19 03:14:07.499999라고 적어두는데, 방금 TIMESTAMP(6)에는 .999999까지 들어갔죠. 둘 다 맞습니다. 소수 자릿수를 지정하지 않은 TIMESTAMP는 넣는 값을 반올림하기 때문입니다.
mysql> INSERT INTO t0 VALUES ('2038-01-19 03:14:07.499999'); -- 내림 → 03:14:07 저장
mysql> INSERT INTO t0 VALUES ('2038-01-19 03:14:07.500000');
ERROR 1292 (22007): Incorrect datetime value: '2038-01-19 03:14:07.500000' for column 'ts' at row 1.5부터는 반올림 결과가 03:14:08이 되어 경계를 넘어버립니다. 그래서 **기본 정밀도 컬럼에 먹일 수 있는 최댓값이 .499999**이고, 이게 매뉴얼에 적힌 숫자입니다. 반면 TIMESTAMP(6)은 반올림 없이 그대로 저장하니 .999999까지 갑니다. 같은 상한을 “넣을 수 있는 값”으로 재느냐 “담을 수 있는 값”으로 재느냐의 차이일 뿐입니다.
CREATE TABLE boundary_dt (id INT PRIMARY KEY AUTO_INCREMENT, d DATETIME);
INSERT INTO boundary_dt (d) VALUES
('2038-01-19 03:14:08'), ('9999-12-31 23:59:59'), ('1000-01-01 00:00:00');id d
1 2038-01-19 03:14:08
2 9999-12-31 23:59:59
3 1000-01-01 00:00:00TIMESTAMP가 거부한 값도, MySQL 문서가 DATETIME의 공식 상한으로 못 박은 9999-12-31 23:59:59도, 하한인 1000-01-01 00:00:00도 실측으로 그대로 통과했습니다. DATETIME은 정수 폭에 묶여 있지 않고 문자 그대로 필드를 저장하니, 이 폭 자체가 존재하지 않습니다. 4장에서 time_t가 32비트일 때 겪은 오버플로가 정확히 TIMESTAMP의 상한에서 재현되는 셈입니다 — 다른 점은 자바의 32비트 int 캐스팅처럼 조용히 엉뚱한 과거로 튕기는 게 아니라, MySQL은 strict SQL 모드(8.0 기본값)에서 이 값을 ERROR 1292로 명시적으로 거부한다는 것입니다. 실패가 소리를 낸다는 점에서는 반가운 차이지만, 애플리케이션이 이 에러를 미처 처리해두지 않았다면 저장 자체가 실패하는 건 똑같습니다.
소수점 이하 자릿수 — 기본값은 0
DATETIME과 TIMESTAMP 둘 다 소수점 이하 초를 담을 수 있지만, 기본 정밀도는 0입니다. DATETIME(6)처럼 자릿수를 명시하지 않으면 마이크로초는 조용히 잘려나갑니다.
CREATE TABLE frac_test (
id INT PRIMARY KEY AUTO_INCREMENT,
d DATETIME, d6 DATETIME(6),
t TIMESTAMP NULL, t6 TIMESTAMP(6) NULL
);
INSERT INTO frac_test (d, d6, t, t6) VALUES
('2026-07-31 10:00:00.123456', '2026-07-31 10:00:00.123456',
'2026-07-31 10:00:00.123456', '2026-07-31 10:00:00.123456');
SELECT * FROM frac_test;id d d6 t t6
1 2026-07-31 10:00:00 2026-07-31 10:00:00.123456 2026-07-31 10:00:00 2026-07-31 10:00:00.123456같은 입력값인데 정밀도를 안 준 d·t 컬럼은 .123456을 통째로 버리고 반올림도 없이 초 단위로 잘라냈습니다. 에러도, 경고도 없습니다. 밀리초 단위로 순서를 가려야 하는 이벤트 로그를 정밀도 없는 DATETIME에 담으면, 같은 초 안에서 일어난 이벤트들은 컬럼값만으로는 순서를 되살릴 수 없습니다.
CURRENT_TIMESTAMP 기본값과 ON UPDATE
TIMESTAMP가 이만큼 퍼진 데는 실용적인 이유도 있습니다. DEFAULT CURRENT_TIMESTAMP와 ON UPDATE CURRENT_TIMESTAMP를 함께 쓰면, 행이 생성될 때와 수정될 때의 시각을 애플리케이션 코드 없이 서버가 자동으로 채워줍니다.
CREATE TABLE audit_test (
id INT PRIMARY KEY AUTO_INCREMENT,
name VARCHAR(20),
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP
);
INSERT INTO audit_test (name) VALUES ('first');
-- 2초 대기 후
UPDATE audit_test SET name = 'second' WHERE id = 1;
SELECT * FROM audit_test;id name created_at updated_at
1 second 2026-07-31 01:57:17 2026-07-31 01:57:19created_at은 최초 삽입 시각에 고정된 채 남고, updated_at만 수정 시각으로 다시 채워졌습니다. 이 편의가 예전 MySQL(5.6 이전)에서는 TIMESTAMP 컬럼에만 있는 전용 기능이었고, 그게 많은 팀이 created_at·updated_at을 습관적으로 TIMESTAMP로 잡아온 역사적인 이유입니다. 다만 지금은 사정이 다릅니다. 같은 구문을 DATETIME 컬럼에 그대로 써봐도 동일하게 동작합니다(실측 확인, MySQL 5.6.5부터 DATETIME도 지원). CURRENT_TIMESTAMP 자동 채움은 더 이상 TIMESTAMP만의 특권이 아닙니다. 타입을 고를 때 이 편의 하나만 보고 TIMESTAMP를 택할 이유는 없어졌다는 뜻입니다.
9장의 결정 표에 다시 올려보면
9장은 저장할 값이 “이미 일어난 사실”인지 “아직 오지 않은 벽시계 약속”인지로 타입을 갈랐습니다. MySQL 컬럼도 같은 질문으로 고릅니다.
| 저장할 값 | MySQL 컬럼 | 이유 |
|---|---|---|
이미 일어난 사실 (로그, created_at, 결제 확정 시각) | TIMESTAMP 또는 UTC로 정규화해 넣은 DATETIME | TIMESTAMP는 자동 UTC 변환이 편리하지만 2038 한계와 세션 의존성을 안고 간다. 그 위험을 피하려면 애플리케이션이 UTC로 직접 정규화해 DATETIME에 넣고, 세션 time_zone을 신경 쓰지 않는 편이 오히려 안전하다 |
| 아직 오지 않은, 사람에게 한 벽시계 약속 (예약 시각) | DATETIME + zone id를 담는 별도 컬럼 | MySQL엔 zone 규칙 자체를 들고 다니는 컬럼 타입이 없다. ZonedDateTime에 대응하는 타입이 없으므로, 벽시계 값과 zone 문자열을 나란히 저장해야 한다 |
이 표의 두 번째 줄이 이 장에서 가장 중요하게 짚어야 할 한계입니다. 9장은 미래의 벽시계 약속을 LocalDateTime + ZoneId로 저장하라고 했는데, MySQL은 ZoneId 자체를 담을 컬럼 타입을 제공하지 않습니다. TIMESTAMP는 저장 시점에 이미 UTC로 확정해버리니 후보가 아니고, DATETIME은 zone 정보를 아예 갖지 않습니다. 그래서 실무에서는 DATETIME(벽시계 값)과 VARCHAR(예: 'Asia/Seoul') 컬럼을 한 쌍으로 두고, 애플리케이션 계층에서 두 값을 합쳐 ZonedDateTime으로 복원합니다. 이 매핑을 JPA 엔티티에서 실제로 어떻게 표현하는지는 다음 장에서 다룹니다.
참고로 PostgreSQL의 timestamptz도 내부적으로는 UTC를 저장하고 세션 시간대를 기준으로 변환한다는 점에서 MySQL TIMESTAMP와 메커니즘이 같습니다. 다만 8바이트 마이크로초 정수를 쓰기 때문에 2038년 같은 폭 문제가 없고, 표현 범위도 훨씬 넓습니다. 같은 이름의 함정이라도 벤더마다 구현 디테일은 다시 확인해야 합니다.
정리
DATETIME은 벽시계 값을 문자 그대로 저장한다. zone도, 변환도 없다. 9장의 어휘로LocalDateTime이다. 실측: 세션time_zone을+09:00→+00:00→-05:00으로 세 번 바꿔도 값이 그대로였다.TIMESTAMP는 UTC epoch 초로 저장하고, 입출력 시점마다 세션time_zone을 거쳐 변환한다. 9장의Instant에 가깝지만, 순수한Instant와 달리 읽는 세션이라는 숨은 입력이 결과에 끼어든다. 같은 행이+09:00에서는10:00:00,+00:00에서는01:00:00,-05:00에서는 전날20:00:00으로 읽혔다(실측 확인).TIMESTAMP는 4장이 예고한 대로 2038년 한계를 그대로 갖는다. UTC2038-01-19 03:14:07까지 성공하고 그 1초 뒤는ERROR 1292 (22007): Incorrect datetime value로 거부된다(실측: 하한1970-01-01 00:00:01도 동일하게 검증).DATETIME은 같은 값도,9999-12-31 23:59:59·1000-01-01 00:00:00도 문제없이 저장한다(실측 확인).- 소수점 이하 정밀도의 기본값은 0이다.
DATETIME(6)·TIMESTAMP(6)처럼 자릿수를 명시하지 않으면 마이크로초는 에러도 경고도 없이 조용히 버려진다(실측:.123456입력 →.000000으로 저장). DEFAULT CURRENT_TIMESTAMP/ON UPDATE CURRENT_TIMESTAMP는 더 이상TIMESTAMP만의 기능이 아니다. MySQL 5.6.5부터DATETIME도 동일하게 지원한다(실측 확인). 과거 이 편의가TIMESTAMP를 널리 퍼뜨린 역사적 이유였을 뿐, 지금은 타입 선택의 근거가 되지 못한다.- MySQL엔
ZonedDateTime에 대응하는 컬럼 타입이 없다. 이미 일어난 사실은TIMESTAMP또는 UTC로 정규화한DATETIME으로, 아직 오지 않은 벽시계 약속은DATETIME+ zone id를 담는 별도 컬럼으로 나눠 저장해야 한다.
생각해볼 질문: 이 장에서 정리한 규칙 — 사실은 UTC 계열로, 약속은 벽시계 값 + zone id로 — 를 안다고 해도, 그 규칙을 매 INSERT·SELECT마다 애플리케이션 코드가 손으로 지키기는 번거롭습니다. 실무에서는 대개 JPA 같은 ORM이 엔티티 필드와 컬럼 사이를 매핑합니다. LocalDateTime 필드를 TIMESTAMP 컬럼에, Instant 필드를 DATETIME 컬럼에 잘못 매핑하면 무슨 일이 벌어질까요? 그리고 Hibernate는 이 매핑에서 어떤 시간대를 기준으로 변환을 수행할까요 — JVM 기본 zone일까요, 커넥션의 세션 time_zone일까요, 아니면 둘 다 무시하는 제3의 값일까요?