donghakim.dev — zsh
← ls ../blog

mysql2 의 timezone 과 dateStrings 가 같이 켜져 있으면 저장할 때마다 9 시간이 쌓입니다

두 옵션이 따로는 합리적인데 합치면 round-trip 마다 +9h. KST 환경의 Node 백엔드에서 자주 마주치는 함정

mysql2 의 timezone 과 dateStrings 가 같이 켜져 있으면 저장할 때마다 9 시간이 쌓입니다 · cover

한 줄 룰: mysql2 풀에서 timezone: '+09:00'dateStrings: true 를 같이 켜고 Date 객체를 INSERT 하면, 매 저장마다 +9h 가 쌓입니다. Date 객체를 그대로 넘기지 말고 YYYY-MM-DD HH:MM:SS UTC 문자열로 만들어서 넘기세요.


시작

운영 화면에서 같은 값을 저장만 했는데도 시간이 점점 늘어난다는 신고였습니다. 사용자가 입력한 시각이 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 에 넘길 때 일어납니다.


무엇이 일어나는가

세 단계로 풀면 이렇습니다.

  1. 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 로 적힌 셈입니다.
  2. SELECT 단계. dateStrings: true 라서 DATETIME 컬럼이 변환 없이 raw 문자열로 반환됩니다. '2026-04-02 03:01:00' 가 그대로 들어옵니다.
  3. 애플리케이션 단계. 코드는 이 문자열을 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 경로를 모두 같은 패턴으로 정렬했습니다.


근본 해결은 따로 있습니다

위 픽스는 기존 데이터 호환을 깨지 않으면서 새 INSERT 만 정상화하는 보수적인 방법입니다. 근본 해결은 풀 설정에서 timezone 을 빼거나 'Z' 로 두는 것입니다. 다만 그렇게 하면 그동안 KST 로 잘못 저장된 기존 행과의 호환이 깨집니다. 즉, 풀 옵션 변경은 다음을 동반해야 합니다.

  • 기존 KST 적재 행을 UTC 로 보정하는 마이그레이션.
  • 같은 풀을 쓰는 다른 라우트, 배치, ORM 등 모든 경로의 동작 점검.

당장의 사고는 헬퍼 한 줄로 막고, 풀 설정 변경은 별개 작업으로 분리한 것은 그래서였습니다. 단, 새 DATETIME 컬럼을 INSERT 하는 자리에서는 이 헬퍼를 거치는 패턴을 코드 리뷰 룰로 추가했습니다.


운영 메타데이터는 별개입니다

같은 DB 안에서도 created_at, updated_at, deleted_at 같은 운영 메타데이터 컬럼은 이 사고와 무관합니다. 이쪽은 컬럼 디폴트의 CURRENT_TIMESTAMPNOW() 가 직접 채우는데, 클러스터 세션 TZ 자체가 이미 Asia/Seoul 로 잡혀 있어서 KST 로 자연스럽게 적재됩니다. mysql2 풀의 timezone 옵션은 드라이버가 Date 를 문자열로 포매팅할 때만 적용되지, 서버측 함수 (NOW() 등) 의 결과에는 관여하지 않습니다.

요약하면 같은 한 DB 안에서 정책이 두 종류로 나뉘는 셈입니다.

  • 항공편 시각, 운영 OOOI 등 UTC 가 기준인 컬럼: 애플리케이션이 명시적으로 UTC 문자열을 INSERT 한다 (위 헬퍼).
  • audit 성 메타데이터: 컬럼 디폴트와 NOW() 에 맡긴다. 세션 TZ 가 KST 라 자동으로 KST 적재.

이 분리를 명시적으로 두면, 두 종류의 컬럼이 같은 풀 옵션 아래에서 각자 정확하게 동작합니다.


진단 시그니처

비슷한 증상으로 도착한 사람한테 빠른 가이드를 적어 둡니다.

  • 같은 행을 같은 값으로 저장만 했는데 round-trip 마다 시간이 정확히 어떤 timezone offset 만큼 늘거나 줄어든다.
  • 드라이버는 mysql2 (Node) 이고, 풀 설정에 timezonedateStrings: true 가 같이 켜져 있다.
  • 애플리케이션이 INSERT 에 Date 객체를 직접 넘긴다.
  • 같은 컬럼을 SELECT 한 문자열을 코드가 UTC 라고 가정해 슬라이스한다.

이 네 가지가 동시에 성립하면 100% 이 케이스입니다. mysql2 만의 함정도 아니에요. 다른 ORM / 드라이버에서도 timezone 옵션과 "원본 문자열로 받기" 옵션을 같이 켜면 같은 모양의 사고가 가능합니다.

옵션 두 개가 따로는 합리적인데 합치면 합산이 어긋나는 케이스가 종종 있습니다. mysql2 의 timezone + dateStrings 는 그 대표 사례 중 하나입니다. 새 풀을 설정할 때 왜 두 옵션을 같이 켜는가 를 PR 본문에 한 줄이라도 적어 두면, 한 달 뒤의 누군가가 같은 사고를 한 번 더 반복하지 않을지도 모릅니다.

#datetime#kst#mysql2#nodejs#timezone#troubleshooting#utc