mysql2 의 timezone 과 dateStrings 가 같이 켜져 있으면 저장할 때마다 9 시간이 쌓인다
두 옵션이 따로는 합리적인데 합치면 round-trip 마다 +9h. KST 환경의 Node 백엔드에서 자주 마주치는 케이스

운영 화면에서 같은 값을 저장만 했는데도 시간이 점점 늘어난다는 신고였습니다. 사용자가 입력한 시각이 18:01(UTC) 인데 첫 저장 후 화면에는 18:10, 다시 저장하면 19:10, 또 저장하면 20:10. 매 round-trip 마다 정확히 9 시간씩 늘었습니다.
처음엔 화면 단의 표시 함수(UTC ↔ KST 변환) 를 의심했습니다. 그런데 DB 에 들어간 raw 값을 직접 보니 이미 한 번 저장된 행에 +9h 가 박혀 있었습니다. 표시가 잘못된 게 아니라 저장이 잘못되고 있었습니다.
DB 풀의 mysql2 설정은 이랬습니다.
mysql.createPool({
...
timezone: '+09:00',
dateStrings: true,
})
두 옵션 다 따로 보면 합리적인 선택입니다. timezone: '+09:00' 은 "이 풀로 들어오는 시각은 KST 로 적재한다" 는 선언이고, dateStrings: true 는 "DATETIME 컬럼을 Date 로 변환하지 말고 문자열 원본 그대로 돌려달라" 는 옵션입니다. 표시 단에서 문자열을 그대로 슬라이스해서 쓰고 싶을 때 편합니다. 문제는 둘이 동시에 켜져 있고, 애플리케이션이 Date 객체를 INSERT 에 넘길 때 생깁니다.
무엇이 일어나는가
세 단계로 풀면 이렇습니다.
- INSERT / UPDATE.
Date객체는 내부적으로 UTC ms 입니다.timezone: '+09:00'때문에 mysql2 드라이버가 이 UTC 값을 KST 시간으로 포매팅해서 쿼리 문자열에 박습니다.Date(UTC: 2026-04-01 18:01:00)는 드라이버 안에서'2026-04-02 03:01:00'라는 문자열이 되어 INSERT 됩니다. 컬럼 입장에선 KST 로 적힌 셈입니다. - SELECT.
dateStrings: true라서 DATETIME 컬럼이 변환 없이 raw 문자열로 반환됩니다.'2026-04-02 03:01:00'가 그대로 들어옵니다. - 애플리케이션. 코드는 이 문자열을 UTC 로 가정하고
slice(11, 13)로 시간 부분을 떼서 쓰고 있었습니다. 저장은 KST 로 했는데 읽을 때는 UTC 로 해석합니다.
결국 입력은 UTC 로, 저장은 KST 로, 조회는 UTC 로 해석합니다. 매 저장 round-trip 마다 정확히 KST offset 만큼(+9h) 드리프트가 쌓이는 이유가 여기 있습니다.
픽스
가장 빠른 픽스는 Date 객체를 드라이버에 넘기지 않는 것이었습니다. UTC 컴포넌트로 직접 문자열을 만들어 INSERT 하면, mysql2 는 string 인자에는 timezone 변환을 적용하지 않고 그대로 보냅니다. 풀 설정은 그대로 두고 INSERT 만 우회하는 셈입니다.
export function dateToMysqlUtcString(d: Date | null): string | null {
if (!d) return null;
return (
d.getUTCFullYear() + '-' +
pad(d.getUTCMonth() + 1) + '-' +
pad(d.getUTCDate()) + ' ' +
pad(d.getUTCHours()) + ':' +
pad(d.getUTCMinutes()) + ':' +
pad(d.getUTCSeconds())
);
}
핸들러에서는 이렇게 씁니다.
await connection.execute(
`UPDATE <table> SET <utc_column> = ? WHERE id = ?`,
[dateToMysqlUtcString(parsedDate), id]
);
이 헬퍼 하나로 해당 컬럼의 INSERT / UPDATE 경로를 모두 같은 패턴으로 정렬했습니다.
근본 해결은 따로 있습니다. 풀 설정에서 timezone 을 빼거나 'Z' 로 두는 것입니다. 다만 그러면 그동안 KST 로 잘못 저장된 기존 행과의 호환이 깨집니다. 그래서 풀 옵션 변경은 기존 KST 적재 행을 UTC 로 보정하는 마이그레이션과, 같은 풀을 쓰는 다른 라우트·배치·ORM 모든 경로의 동작 점검을 동반해야 합니다. 당장의 사고는 헬퍼 한 줄로 막고 풀 설정 변경은 별개 작업으로 분리한 건 그래서입니다. 대신 새 DATETIME 컬럼을 INSERT 하는 자리에서는 이 헬퍼를 거치는 패턴을 코드 리뷰 룰로 추가했습니다.
운영 메타데이터는 별개다
같은 DB 안에서도 created_at, updated_at, deleted_at 같은 운영 메타데이터 컬럼은 이 사고와 무관합니다. 이쪽은 컬럼 디폴트의 CURRENT_TIMESTAMP 와 NOW() 가 직접 채우는데, 클러스터 세션 TZ 자체가 이미 Asia/Seoul 로 잡혀 있어서 KST 로 자연스럽게 적재됩니다. mysql2 풀의 timezone 옵션은 드라이버가 Date 를 문자열로 포매팅할 때만 적용되지, 서버측 함수(NOW() 등) 의 결과에는 관여하지 않습니다.
그래서 같은 한 DB 안에서 정책이 두 종류로 나뉩니다. 항공편 시각, 운영 OOOI 처럼 UTC 가 기준인 컬럼은 애플리케이션이 명시적으로 UTC 문자열을 INSERT 하고(위 헬퍼), audit 성 메타데이터는 컬럼 디폴트와 NOW() 에 맡깁니다(세션 TZ 가 KST 라 자동으로 KST 적재). 이 분리를 명시적으로 두면 두 종류의 컬럼이 같은 풀 옵션 아래에서 각자 정확히 동작합니다.
비슷한 증상이면 이 네 개를 같이 봅니다. 같은 행을 같은 값으로 저장만 했는데 round-trip 마다 시간이 정확히 어떤 timezone offset 만큼 늘거나 줄고, 드라이버가 mysql2(Node) 이고, 풀 설정에 timezone 과 dateStrings: true 가 같이 켜져 있고, 애플리케이션이 INSERT 에 Date 객체를 직접 넘깁니다. 이 네 개가 동시에 성립하면 거의 이 케이스입니다.
mysql2 만의 문제도 아닙니다. 다른 ORM / 드라이버에서도 timezone 옵션과 "원본 문자열로 받기" 옵션을 같이 켜면 같은 모양으로 깨집니다. 옵션 두 개가 따로는 합리적인데 합치면 어긋나는 케이스는 종종 있으니, 새 풀을 설정할 때 왜 두 옵션을 같이 켜는지 PR 본문에 한 줄이라도 적어 두면 한 달 뒤의 누군가가 같은 일을 반복하지 않습니다.