DATETIME 에는 시간대가 없습니다 — 드라이버 설정과 두 개의 시계

정기창·

Go 로 짜인 서버를 TypeScript 로 다시 짜며 다시 공부한 정석을 적는 연재의 여섯 번째 글입니다. 이번 요구는 어느 서버, 어느 데이터베이스에서 돌아도 같은 순간은 같은 값으로 저장돼야 한다는 것입니다.

시간은 코드에서 가장 흔하게 다루는 값인데, 막상 옮기다 보니 가장 쉽게 어긋나는 값이기도 했습니다. 같은 순간을 넣었는데 9시간 다른 값이 저장되는 일을 직접 재현해 봤습니다.

"순간"과 "벽시계 시각"은 다른 정보입니다

서울의 오전 9시와 런던의 자정(겨울 기준)은 같은 순간입니다. 벽에 걸린 시계가 가리키는 숫자는 다르지만, 두 사람이 동시에 전화를 걸면 통화는 한 번입니다. 이렇게 지구 어디서 보든 하나인 시점을 순간, 특정 지역의 시계가 가리키는 숫자를 벽시계 시각이라고 부르겠습니다.

벽시계 시각은 "어느 지역의 시계인지"를 함께 알아야 순간이 됩니다. 이때 기준으로 쓰는 것이 UTC, 세계 표준시입니다. 한국 시각은 UTC 보다 9시간 빠르므로 "UTC+9"로 적습니다.

표현담긴 정보예
순간지구 어디서나 하나인 시점2026-01-01T00:00:00Z
벽시계 시각숫자만 있고 지역은 없다2026-01-01 09:00:00
벽시계 시각 + 지역순간으로 바꿀 수 있다2026-01-01 09:00:00, 서울

MySQL 의 DATETIME 은 벽시계 시각을, TIMESTAMP 는 순간을 저장합니다

MySQL 에는 날짜와 시각을 담는 타입이 둘 있습니다. MySQL 문서에 따르면 DATETIME 은 받은 숫자를 시간대 변환 없이 그대로 저장합니다. 반면 TIMESTAMP 는 저장할 때 현재 연결의 시간대에서 UTC 로 바꾸고, 꺼낼 때 다시 연결의 시간대로 바꿔 보여 줍니다.

둘 다 "시간대를 모르는" 문자열을 받는다는 점은 같습니다. 프로그램의 날짜 객체는 드라이버가 문자열로 바꿔 보내는데, 이때 어느 지역의 벽시계로 적을지는 드라이버 설정이 정합니다. 데이터베이스는 그 설정을 모릅니다.

같은 순간을 넣었는데 9시간 다른 값이 저장됐습니다

서울 시간대로 도는 Node.js 프로세스에서 같은 순간(2026-01-01T00:00:00Z)을 두 번 넣었습니다. 한 번은 mysql2 의 기본 설정(timezone: 'local')으로, 한 번은 timezone: 'Z' 로 넣었습니다. 데이터베이스 서버의 시간대는 UTC 입니다.

연결 시간대를 UTC(+00:00)로 두고 조회
  #1 timezone 'local'  DATETIME = 2026-01-01 09:00:00.000  TIMESTAMP 가 가리키는 순간 = 1767258000
  #2 timezone 'Z'      DATETIME = 2026-01-01 00:00:00.000  TIMESTAMP 가 가리키는 순간 = 1767225600

넣으려던 순간 = 1767225600

기본 설정으로 넣은 1번 행은 DATETIME 에 서울의 벽시계 숫자 "09:00"이 들어갔습니다. 더 곤란한 것은 TIMESTAMP 입니다. 드라이버는 서울 벽시계로 "09:00"을 보냈고, UTC 로 연결된 데이터베이스는 그것을 "UTC 09:00"으로 받아들였습니다. 그 결과 저장된 순간이 32400초, 정확히 9시간 어긋났습니다.

읽을 때도 같은 일이 생깁니다. 1번 행을 기본 설정으로 읽으면 원래 순간이 나오지만, 'Z' 설정으로 읽으면 9시간 늦은 순간이 나옵니다.

#1 을 'local' 로 읽기 = 2026-01-01T00:00:00.000Z
#1 을 'Z' 로 읽기     = 2026-01-01T09:00:00.000Z

넣은 쪽과 읽은 쪽의 설정이 같으면 문제가 보이지 않습니다. 설정이 다른 두 프로그램이 같은 표를 쓰는 순간, 또는 서버를 다른 지역으로 옮기는 순간 드러납니다. 이 실험이 보여 주는 것은 설정 차이가 만드는 어긋남이고, 어떤 설정이 늘 옳다는 뜻은 아닙니다.

Go 는 시각에 위치를 붙여서 들고 다닙니다

Go 의 time.Time 은 순간과 함께 위치(Location) 정보를 들고 다닙니다. 그래서 같은 순간을 UTC 로도, 서울로도 표현할 수 있고, 둘을 비교하면 같은 순간이라고 답합니다.

같은 순간, 다른 위치: 2026-01-01 00:00:00 +0000 UTC / 2026-01-01 09:00:00 +0900 KST / 같은 순간인가? true

Go 의 MySQL 드라이버에는 이 위치를 정하는 loc 설정이 있고, 기본값은 UTC 입니다. 같은 순간을 DATETIME 에 넣어 보면 설정에 따라 저장되는 숫자가 달라집니다.

parseTime=true (loc 기본 UTC)   | 저장된 글자 = 2026-01-01 00:00:00.000 | 다시 읽은 값 = 00:00 UTC
parseTime=true&loc=Asia/Seoul   | 저장된 글자 = 2026-01-01 09:00:00.000 | 다시 읽은 값 = 09:00 KST

Go 안에서는 두 경우 모두 같은 순간으로 돌아옵니다. 하지만 표에 남은 것은 "09:00"이라는 숫자뿐이고, 그 숫자가 서울 시각이라는 사실은 Go 프로그램의 설정 안에만 있습니다. 다른 설정의 프로그램이 이 표를 읽으면 9시간을 잃습니다. 시간대 정보가 데이터가 아니라 코드 설정에 들어 있다는 점은 두 언어가 같았습니다.

그래서 약속을 환경이 아니라 코드에 고정했습니다

  • 저장은 UTC 로 합니다. DATETIME 에는 UTC 벽시계 숫자만 넣는다고 약속합니다.
  • 드라이버 설정을 명시합니다. mysql2 는 timezone: 'Z', Go 드라이버는 loc=UTC 처럼 기본값에 기대지 않고 코드에 적습니다. 서버 운영체제의 시간대가 바뀌어도 결과가 달라지지 않게 하기 위해서입니다. 이때 mysql2 의 timezone 은 'local', 'Z', '+09:00' 같은 형식만 받습니다. 'Asia/Seoul' 처럼 지역 이름을 넣으면 경고 한 줄을 찍고 'Z' 로 바꿔 버리는 것을 확인했습니다. 설정했다고 믿고 지나치기 쉬운 자리입니다.
  • 지역 시각은 보여 줄 때만 씁니다. 화면에 표시하는 순간에만 사용자의 시간대로 바꿉니다.
  • "매일 오전 9시"는 지역을 붙여 예약합니다. 반복 작업의 시각은 원래 벽시계 개념이라, 예약 도구에 시간대를 명시합니다.

시계가 둘이면 "방금 만든 것"이 미래에 있기도 합니다

시간대와 별개로 하나가 더 있었습니다. 프로그램이 도는 컴퓨터와 데이터베이스가 도는 컴퓨터는 각자 시계를 갖고 있습니다. SQL 의 NOW() 는 데이터베이스의 시계를 읽고, JavaScript 의 Date.now() 는 프로그램 쪽 시계를 읽습니다.

이 실험 환경에서는 두 시계의 차이가 1ms 였습니다. 그래서 문제가 보이지 않았습니다. MySQL 은 세션 변수 timestamp 로 그 연결의 NOW() 를 원하는 값으로 고정할 수 있어서, 데이터베이스 시계가 5초 빠른 상황을 흉내 냈습니다.

DB 가 기록한 created_at = 2026-09-29T01:52:33.317Z
앱의 지금              = 2026-09-29T01:52:28.319Z
"created_at <= 지금" 검사 = false   ← 방금 만든 행이 미래에 있다

데이터베이스 시계로 기록하고 프로그램 시계로 비교하면, 두 시계의 차이만큼 판정이 틀어집니다. 이런 검사는 시계 차이가 작은 컴퓨터에서는 늘 통과하고, 차이가 큰 컴퓨터에서만 가끔 실패합니다. 원인을 찾기 가장 어려운 종류의 실패입니다.

처방은 한 비교 안에서는 한 시계만 쓰는 것입니다. 기록도 비교도 데이터베이스의 NOW() 로 하거나, 둘 다 프로그램의 시각을 넘겨서 합니다.

정리하며

  • 순간과 벽시계 시각을 구분합니다. DATETIME 은 숫자만 저장하므로, 그 숫자가 어느 지역의 시계인지는 약속으로 정해야 합니다.
  • 약속은 코드에 적습니다. 드라이버의 시간대 설정을 기본값에 맡기지 않고 명시합니다.
  • 한 비교에는 한 시계만 씁니다. 데이터베이스 시계와 프로그램 시계를 섞어 비교하지 않습니다.

Go 가 시각에 위치를 붙여 다니는 모습을 보고 나서야, TypeScript 쪽에서 무엇을 명시해야 하는지 분명해졌다는 생각이 들었습니다. 언어를 옮기면서 오히려 두 언어가 같은 질문을 서로 다른 곳에 숨겨 두었다는 것을 보게 됐습니다.

실험 환경: MySQL 8.4.11(로컬 Docker 컨테이너, 서버 시간대 UTC) · Node.js v26.5.0(TZ=Asia/Seoul) · mysql2 3.24.4 · Go 1.25.6 · go-sql-driver/mysql v1.10.1 · 2026-09-29. 시계 차이 5초는 세션 변수로 흉내 낸 값이며 실제 서버의 측정값이 아닙니다.

MySQLDATETIMETIMESTAMP시간대mysql2GoUTC