MySQL에서 시간이 9시간 어긋날 때 흔히 서버 타임존 하나만 본다. 그런데 갈리는 지점은 서버·세션·JDBC 세 곳이고, 셋이 서로 다른 값을 들고 있을 수 있다.
더 고약한 건 TIMESTAMP와 DATETIME이 다르게 움직인다는 것이다. TIMESTAMP만 세션 타임존으로 변환되고 DATETIME은 저장된 그대로 나온다.
그래서 증상이 "전부 9시간 밀림"이 아니라 "어떤 값은 밀리고 어떤 값은 안 밀림"으로 나타난다. 이게 원인 추적을 어렵게 만든다.
컬럼 타입에 따라 다르게 움직인다
이걸 먼저 알아야 나머지가 읽힌다. MySQL 문서가 명확하게 갈라 놓는다.
TIMESTAMP — 저장할 때 세션 타임존에서 UTC로 바꿔 넣고, 읽을 때 UTC에서 세션 타임존으로 되돌린다. 세션 타임존이 바뀌면 같은 행에서 다른 값이 나온다.
DATETIME · DATE · TIME — 변환하지 않는다. UTC로 저장하지도 않는다. 넣은 값이 그대로 나온다.
같은 테이블에 두 타입이 섞여 있으면 이렇게 된다.
세션이 +00:00일 때 |
세션이 +09:00일 때 |
|
|---|---|---|
TIMESTAMP 컬럼 |
2026-09-10 01:00 |
2026-09-10 10:00 |
DATETIME 컬럼 |
2026-09-10 10:00 |
2026-09-10 10:00 |
한 화면에서 두 값이 9시간 벌어져 보인다. 데이터가 깨진 게 아니라 타입이 다른 것이다.
UTC_TIMESTAMP() 같은 함수도 세션 타임존의 영향을 받지 않는다. 반면 NOW()는 받는다.
어디서 갈리는지 — 세 곳을 각각 본다
1. 서버가 인식한 OS 타임존
system_time_zone은 서버가 기동할 때 호스트에서 알아낸 값이고 읽기 전용이다. 바꾸려면 mysqld를 띄우기 전에 TZ 환경변수를 주거나 기동 옵션을 쓴다.
2. 세션 타임존
time_zone의 기본값은 SYSTEM이다. 즉 위의 system_time_zone을 따라간다. 전역과 세션이 따로 있고, 클라이언트마다 자기 세션 값을 갖는다. 접속한 뒤 바꾸면 그 연결에만 적용된다.
여기 함정이 하나 더 있다. SYSTEM으로 두면 시간 계산이 필요한 함수 호출마다 시스템 라이브러리를 부른다. 전역 뮤텍스로 보호되는 호출이라 경합이 생길 수 있다. 값을 명시해 두는 편이 낫다.
3. JDBC 드라이버
자바 앱이라면 여기서 갈리는 경우가 가장 많다. Connector/J는 세 가지 값을 본다.
connectionTimeZone LOCAL / SERVER / 특정 타임존
preserveInstants 시각(instant)을 보존할지
forceConnectionTimeZoneToSession 세션 time_zone을 실제로 바꿀지
SERVER— 서버에 세션 타임존을 물어본다. JVM과 다르고preserveInstants=true면 드라이버가 변환한다.LOCAL— 서버 세션 타임존이 JVM과 같다고 가정한다.forceConnectionTimeZoneToSession=true— 가정에 그치지 않고 세션time_zone을 실제로 바꾼다.
preserveInstants=false는 변환을 아예 안 한다. 시각이 아니라 보이는 문자열을 보존한다.
버전 경계가 하나 있다. Connector/J 8.0.23부터 serverTimezone은 connectionTimeZone의 별칭이다. 8.0.22 이하에서는 serverTimezone이 세션 타임존을 덮어쓰는 용도였다. 설정 이름은 같은데 의미가 달라졌으므로, 드라이버를 올렸다면 여기부터 본다.
진단은 이 순서로
세 곳을 각각 찍어 본다. 추측하지 않는다.
SELECT @@global.system_time_zone AS os_tz,
@@global.time_zone AS global_tz,
@@session.time_zone AS session_tz,
NOW() AS now_session,
UTC_TIMESTAMP() AS now_utc;
now_session과 now_utc가 9시간 차이면 세션이 +09:00인 것이고, 같으면 UTC다.
그다음 앱이 실제로 쓰는 연결에서 같은 쿼리를 돌린다. 콘솔에서 붙은 세션과 앱 커넥션 풀의 세션은 다른 값을 가질 수 있다. 앱에서 이 쿼리 결과를 로그로 한 번 찍어 보는 게 확실하다.
이름 있는 타임존은 테이블이 있어야 쓴다
'Asia/Seoul' 같은 이름을 쓰려면 mysql 데이터베이스의 타임존 테이블이 채워져 있어야 한다. 없으면 이렇게 거부된다.
mysql> SET time_zone = 'UTC';
ERROR 1298 (HY000): Unknown or incorrect time zone: 'UTC'
확인은 한 줄이다.
SELECT COUNT(*) FROM mysql.time_zone_name;
0이면 안 채워진 것이다. 채우는 방법은 zoneinfo가 있는 시스템이면 이렇다.
mysql_tzinfo_to_sql /usr/share/zoneinfo | mysql -u root -p mysql
넣은 뒤에는 서버를 재기동해야 한다. 캐시된 옛 타임존 데이터를 계속 쓰지 않도록.
윈도우처럼 zoneinfo가 없는 환경은 MySQL이 배포하는 SQL 패키지를 받아 넣는다.
오프셋 표기('+09:00')는 테이블 없이도 된다. 급하면 이쪽을 쓴다. 다만 서머타임이 있는 지역에서는 이름 표기를 써야 한다 — 한국은 해당 없다.
고칠 때 조심할 것
TIMESTAMP에 이미 들어간 데이터는 안 건드려도 된다. UTC로 저장돼 있으므로 세션 타임존만 맞추면 제대로 나온다.
DATETIME은 반대다. 잘못된 타임존으로 쓰인 값이 그대로 박혀 있다. 세션 설정을 고쳐도 과거 데이터는 안 바뀐다. 옮기려면 실제로 UPDATE를 쳐야 하고, 어느 구간이 어느 타임존으로 쓰였는지 먼저 갈라야 한다.
배포 시점을 경계로 두 규칙이 섞인 경우가 흔하다. 고치기 전에 경계 날짜를 찾는 게 먼저다.
로그 시각도 같은 함정이다. RDS 느린 쿼리 로그의 # Time:이 UTC라 04시 KST 배치를 전날 19시대에서 찾아야 했던 일이 있었다. DB 안의 값과 DB가 뱉는 로그가 서로 다른 기준일 수 있다.
FAQ
서버를 KST로 바꾸면 되지 않나요
되기는 한다. 다만 리전이 늘거나 해외 지점이 생기면 그때 다시 갈린다. 서버는 UTC로 두고 표시 계층에서 바꾸는 쪽이 나중에 덜 아프다. 이미 KST로 돌고 있다면 억지로 바꿀 이유는 없고, 어느 쪽이든 세 곳이 같은 값인지가 중요하다.
DATETIME 대신 TIMESTAMP를 쓰면 해결되나요
시각을 다루는 컬럼이면 맞는 방향이다. TIMESTAMP가 MySQL에서 시각(instant)을 저장하도록 설계된 유일한 타입이다. 다만 표현 범위 제한이 있고, "달력상의 날짜"(생일, 영업일)는 타임존 변환이 되면 안 되므로 DATE·DATETIME이 맞다. 값의 성격으로 고른다.
드라이버 설정만 바꾸면 되나요
앱이 하나면 된다. 여러 앱이 같은 DB를 보면 각자 다른 설정으로 붙을 수 있다. 배치, 관리자 도구, 리포트 툴까지 전부 같은 기준인지 확인해야 한다. 한 곳만 어긋나도 그 경로로 들어간 데이터가 9시간 밀린다.
확인한 것과 확인하지 못한 것
이 글의 동작은 MySQL 8.4 매뉴얼과 Connector/J 문서에서 확인했다. system_time_zone과 time_zone의 관계, time_zone 기본값이 SYSTEM인 것, TIMESTAMP만 UTC 변환을 받고 DATETIME·DATE·TIME은 안 받는 것, 이름 있는 타임존이 테이블 없이는 ERROR 1298로 거부되는 것, mysql_tzinfo_to_sql 사용법과 재기동 필요, 그리고 Connector/J 8.0.23부터 serverTimezone이 별칭이 된 것이 그렇다.
확인하지 못한 것도 적어 둔다. 관리형 DB의 기본 타임존은 문서 페이지를 읽지 못해 이 글에 적지 않았다. 관리형이라면 파라미터 그룹에서 time_zone 값을 직접 확인하는 편이 정확하다 — 어차피 위 진단 쿼리 한 줄이면 실제 값이 나온다.
추측으로 세 곳 중 하나를 고르지 않는 게 이 문제의 핵심이다. 세 곳을 각각 찍어 보면 어디서 갈렸는지 바로 보인다.
'데이터베이스' 카테고리의 다른 글
| PostgreSQL 인덱스 재구성 시점 — 달력이 아니라 밀도로 정한다 (0) | 2026.09.21 |
|---|---|
| 튜닝하다 조건을 떨어뜨렸다 — 쿼리를 고치면 결과부터 대조한다 (0) | 2026.09.16 |
| MySQL 집계 쿼리를 요약 테이블로 빼면서 정확도를 안 버리는 법 (0) | 2026.09.13 |
| Flyway MySQL 프로시저 syntax error — SQL이 아니라 파서가 문제였다 (0) | 2026.09.09 |
| 816건 넣으려고 16,268행을 잠갔다 — MySQL INSERT SELECT 락 실측 (0) | 2026.09.06 |
댓글