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 경로가 모두 Date 대신 UTC 문자열을 전달하도록 변경했습니다.
근본 해결은 따로 있습니다. 풀 설정에서 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 옵션과 "원본 문자열로 받기" 옵션을 함께 활성화하면 같은 방식으로 저장 시각과 조회 시각의 해석이 일치하지 않습니다.