leo.dev
backend

날짜를 담는 두 좌표계

2026.07.2710 min read backendmysqltimezonedatetypeorm

2026-07-15라는 값을 코드에서 다루는 방법은 두 가지다.

하나는 시간축의 한 점이다. “KST 기준 7월 15일 0시 0분”이라는 특정 순간을 가리키고, UTC로 쓰면 2026-07-14T15:00:00Z가 된다.

다른 하나는 달력 위의 칸이다. 시각이 없고 7월 달력에서 15라고 적힌 그 칸을 가리킬 뿐이라, 시간대라는 개념 자체가 성립하지 않는다.

둘 다 “7월 15일”이라 부르는데 타입이 다르다. 이 레포에서 하루 밀리는 버그는 대부분 이 두 타입을 섞은 코드에서 나왔다.

DB가 instant를 돌려주는 이유

날짜를 담는 컬럼이 세 종류로 흩어져 있다. 이 글을 쓰는 시점에 세어보니 date 33개, datetime 39개, timestamp 28개였다. 타입이 다른데 애플리케이션에는 셋 다 JS Date, 즉 instant로 올라온다. 왜 그런지가 이 글의 나머지를 결정한다.

MySQL은 두 타입을 다르게 다룬다. 레퍼런스 매뉴얼에 이렇게 적혀 있다.

MySQL converts TIMESTAMP values from the current time zone to UTC for storage, and back from UTC to the current time zone for retrieval. (This does not occur for other types such as DATETIME.) By default, the current time zone for each connection is the server’s time.

The DATE, DATETIME, and TIMESTAMP Types, MySQL 8.0 Reference Manual

DATETIMEDATE는 시간대 정보를 갖지 않는 벽시계 값이다. 2026-07-15 00:00:00이라고 적혀 있을 뿐, 그게 어느 지역의 0시인지는 컬럼이 모른다. 그러니 그 문자열을 instant로 바꾸려면 누군가 시간대를 정해줘야 하고, 그 일을 드라이버가 한다. TypeORM에 넘긴 mysql2 옵션이 그 지점이다.

app.module.ts
timezone: '+09:00',

이 한 줄 때문에 DATE·DATETIME 컬럼의 벽시계 값이 KST로 해석돼 올라온다. 2026-07-152026-07-14T15:00:00Z라는 instant가 되는 것이다. 날짜 전용 컬럼이라고 예외가 아니다.

여기에 parseDate('2026-07-15') 같은 함수를 쓰면 로컬 자정이 나온다. 프로세스 TZ가 UTC인 환경에서는 UTC 자정, 곧 KST 오전 9시다. DB에 있는 값과 아홉 시간 차이가 나고, 그 아홉 시간이 자정 경계를 넘기면 하루가 어긋난다. 연차 겹침 검사, 근속 1년 판정, 유효기간 만료창이 실제로 그렇게 틀어졌다.

읽을 때 하루가 이르게 나오는 경로

반대 방향도 있다. DB에서 읽은 instant를 toISOString().slice(0, 10)으로 날짜만 뽑으면 UTC로 환산돼 하루 이르게 나온다. MDN이 규정하는 대로 이 메서드는 항상 UTC(Z) 기준 문자열을 만들고, 로컬 시간대를 반영하는 옵션이 없다.

DB=2026-07-15 → instant 2026-07-14T15:00:00Z
toISOString().slice(0, 10) → '2026-07-14' 하루 이르다

이 패턴 하나 때문에 연차 캘린더, 이슈 에픽 시작·마감일, 반복 업무 종료일, 연차 계획 날짜, 구독 계약 종료일이 전부 하루 일찍 나가고 있었다.

쓸 때 아홉 시간이 어긋난 경로

읽기만 문제인 줄 알았는데 쓰기에도 같은 원인의 사고가 있었다. created_atupdated_at이 같은 행 안에서 아홉 시간 어긋났다.

원인은 드라이버 옵션의 적용 범위다. mysql2의 timezone: '+09:00'은 클라이언트 쪽 파싱과 직렬화만 KST로 맞춘다. 서버 세션의 time_zone은 건드리지 않아서 그대로 UTC다. 위 매뉴얼 인용이 말한 “현재 시간대”는 세션 변수 쪽이므로, 값을 누가 채우느냐에 따라 기준 시계가 달라진다.

save() 경로 JS가 Date를 준다 → mysql2가 KST로 직렬화 → KST로 파싱 → 왕복 일치
update() 경로 JS가 값을 안 준다 → MySQL이 CURRENT_TIMESTAMP(UTC 세션)로 채움
→ mysql2가 그 UTC 벽시계를 KST로 오해석 → 아홉 시간 이르게 읽힘

save()는 엔티티를 저장하기 직전에 TypeORM이 불러주는 훅(서브스크라이버)이 beforeInsert에서 JS Date를 주입하므로 나가는 값과 들어오는 값이 같은 시계를 쓴다. update()는 애플리케이션이 타임스탬프를 넘기지 않고 컬럼 기본값에 맡기는데, 그 기본값을 평가하는 건 MySQL이고 MySQL이 보는 시간대는 UTC다. 같은 벽시계 문자열을 한쪽은 UTC로 쓰고 다른 쪽은 KST로 읽으니 정확히 오프셋만큼 벌어진다.

실제 피해는 조사 비용으로 돌아왔다. 07-27 장애를 파는데 users.updated_at이 09:30:58에 일어난 사건을 00:30:58로 보여줘서, 토큰 발급 이력과 시각을 맞추려면 감사 쿼리마다 + INTERVAL 9 HOUR를 붙여야 했다. 데이터가 틀린 게 아니라 기준이 두 개였던 건데, 그걸 모르는 채로 보면 사건 순서가 뒤집혀 보인다.

두 경로가 같은 시각을 적는지 확인하기

두 경로가 같은 시각을 기록하는지는 Testcontainers 통합 테스트로 확인한다. save()로 만든 행과 update()로 갱신한 행을 각각 다시 읽어 현재 시각과의 차이가 5분 안인지 보는데, 아홉 시간이 벌어지면 540분이라 임계값에 한참 못 미쳐 실패한다.

이 주입이 모든 UPDATE를 덮지는 않는다. softDelete·softRemove·restore는 TypeORM의 SoftDeleteQueryBuilder를 타는데, 그 빌더는 엔티티도 patch 객체도 읽지 않고 SET deleted_at = CURRENT_TIMESTAMP, updated_at = CURRENT_TIMESTAMP를 SQL 리터럴로 박는다. 끼어들 대상이 없어서 훅을 달아도 죽은 코드가 되므로 달지 않았다. 그 경로의 두 컬럼은 지금도 아홉 시간 이르게 읽히고, 통합 테스트에 it.failing으로 박아 나중에 고쳐지면 그 테스트가 실패하며 알려주게 뒀다.

이 테스트는 한동안 CI 게이트 밖에 있었다. 파일명이 *.int-spec.ts라 기본 jest 정규식에 안 걸리고 별도 스텝도 없어서, 내가 로컬에서 돌릴 때만 돌았다. 지금은 전용 잡으로 매 배포마다 돌고, 그 잡의 TZ는 UTC로 못 박혀 있다. 이 버그가 KST인 맥에서만 안 보였던 걸 생각하면, TZ가 고정된 러너에서 도는 쪽이 로컬 실행보다 조건이 낫다.

오프셋 대신 지역 이름을 쓰는 이유

읽는 쪽은 그렇게 정리했고, 남은 건 instant에서 날짜 문자열을 뽑는 경로다. 여기에 쓰려고 만든 toYMDtoISOString과 다른 건 timeZone: 'Asia/Seoul'을 명시한다는 점 하나인데, 지역 이름 대신 +09:00을 넣어도 지금은 같은 답이 나온다. 그런데도 이름을 쓴다.

Intl.DateTimeFormat지역 이름도 오프셋도 받지만 둘은 성격이 다르다. 오프셋은 고정된 숫자고, 지역 이름은 그 지역이 시간을 어떻게 써왔는지의 이력 전체를 가리킨다. 한국도 그 이력이 평탄하지 않다. Node에 실린 tzdb로 확인해보면 이렇게 나온다.

1986-07-01 GMT+09:00
1987-07-01 GMT+10:00 ← 서머타임
1988-07-01 GMT+10:00 ← 서머타임
1989-07-01 GMT+09:00

1987년과 1988년 여름의 한국은 +09:00이 아니었다. 오프셋을 상수로 박은 코드는 그 두 해의 날짜를 한 시간 틀리게 읽는다.

우리 데이터에 1987년은 없으니 이건 지금 아픈 문제가 아니다. 그래도 이름을 쓰는 이유는 규칙의 종류가 다르기 때문이다. 오프셋을 적으면 “아홉 시간 더한다”는 계산을 코드에 새기는 것이고, 지역 이름을 적으면 “서울이 쓰는 시간으로 읽는다”는 의도를 적는 것이다. 오프셋을 박은 코드는 1987년 여름이 데이터에 들어오는 순간 조용히 한 시간을 틀리고, 지역 이름을 적은 코드는 tzdb가 그 해를 알고 있으니 그냥 맞는다.

약어는 아예 안 된다. timeZone: 'KST'RangeError: Invalid time zone specified: KST를 던진다. 이름 형식도 오프셋 형식도 아니기 때문인데, 실패가 예외로 나는 쪽이라 조용히 틀리는 것보다 낫다.

좌표계를 바꾸는 지점

그렇게 만든 함수가 몇 개 쌓였고, 어느 걸 쓸지는 좌표계를 바꾸는 지점이 어디인지로 정한다.

하려는 일쓰는 것
DB에 쓰거나 DB instant와 비교kstMidnightOf(순수 YYYY-MM-DD) · kstMidnightOfDatePart(ISO 허용)
달력 그리드 셀 같은 civil 날짜 순회parseDate · toCivilDate
DB instant에서 날짜 문자열 뽑기toYMD(KST 명시)

시간대를 명시해야 하는 이유가 운영 환경에서 한 겹 더 있다. 운영 런타임의 TZ는 환경변수 한 줄로 KST인데 그 줄이 빠진 환경은 Node 기본값인 UTC로 떨어지고, 개발 맥은 KST다. 로컬 포맷터를 믿으면 환경마다 다른 답이 나온다.

프론트도 같다. 이 날짜 유틸이 프론트와 백엔드가 같은 파일을 import하는 공유 패키지에 있는 것도 그래서다. DB에서 받은 instant를 로컬 getDay()로 바로 읽으면 음수 오프셋 브라우저에서 요일과 날짜 키(그 날짜로 셀을 찾는 값)가 전날로 밀리므로, toYMD로 날짜를 먼저 확정한 뒤 civil 좌표에서 순회한다.

그리고 두 좌표계를 한 루프에서 섞지 않는다. 달력을 그리면서 그 안에서 DB instant와 비교하면 루프 변수는 civil인데 비교 대상은 instant다. 어느 쪽으로 변환할지를 루프 밖에서 한 번 정하고 들어간다.

정적 검사로 막기

이건 내가 매번 기억할 종류가 아니라서 toISOString()으로 날짜를 뽑는 패턴을 정적 테스트로 금지했다. 규칙을 문서에만 적으면 다음 사람이 toISOString().slice(0, 10)을 다시 쓴다. 짧고 익숙하기 때문이다.

두 오용은 성격이 다르다. parseDate를 DB 비교에 쓰면 아홉 시간이 밀리므로 자정 근처에서만 틀리고, 로컬에서 낮에 테스트하면 통과한다. 반면 toISOString()으로 날짜를 뽑는 건 KST 자정 instant가 언제나 전날 15시 UTC라 항상 하루 이르다.

항상 틀리는 쪽이 오래 안 잡혔다는 게 이상해 보이는데, 화면에 뜬 날짜가 하루 이르다는 걸 알아보려면 그 값의 원본을 알아야 한다. 캘린더에 7월 14일로 뜬 일정이 원래 15일인지는 DB를 열어봐야 안다. 앞의 타임스탬프 스큐도 같은 이유로 오래 남아 있었다. updated_at이 00:30이라고 뜨면 그게 틀린 값인지 그 시각에 뭔가 돌았는지를 화면만 보고는 구분할 수 없다.

한 행에 now()를 두 번 부를 때

한 행에 날짜와 시각을 같이 적을 때, 두 값을 각자 now()로 만들면 자정 경계에서 하루 어긋난다.

// 위험 — 두 호출 사이에 자정이 지나면 날짜와 시각이 다른 날을 가리킨다
transferDate: DateUtils.now(),
completedAt: DateUtils.now(),

같은 순간을 뜻하는 값이면 const now = DateUtils.now() 한 번에서 둘 다 뽑는다. 이체 기록에서 실제로 어긋난 적이 있다.

더 나은 처방은 날짜를 아예 입력에서 빼는 것이다. 날짜를 시각에서 파생시키는 계산을 소유 모듈 안에 두면 호출자가 날짜를 넘길 수 없고, 그러면 두 값이 어긋날 경로 자체가 사라진다.

이 규칙과 앞의 타임스탬프 스큐가 같은 모양이라는 걸 나중에 알았다. 둘 다 같은 뜻을 가진 값을 서로 다른 시계가 채운 것이다. 한쪽은 now() 호출이 두 번이라 둘 사이에 자정이 끼었고, 다른 쪽은 JS와 MySQL이 각자의 시간대로 값을 채웠다. 처방도 같은 계열이라 채우는 주체를 하나로 모은다.

두 계열이 나눠 맡은 범위

DB 적재·비교는 kstMidnightOf 계열이 담당하고, 달력 순회는 parseDate가 담당한다. toISOString() 날짜 추출은 정적 테스트가 막는다. 이 테스트는 DB 없이 파일만 읽어 패턴을 찾으니 turbo test에 그대로 실려 돌고, 회귀가 자동으로 걸린다. save()update() 두 경로의 타임스탬프를 비교하는 통합 테스트도 전용 잡으로 게이트에 들어가, 배포마다 UTC 러너에서 다시 확인된다.

급여 지급일, 구독 갱신일, 계약 시작일처럼 날짜와 시각을 함께 적는 컬럼은 하나의 now()에서 파생한다. save()repo.update()가 남긴 시각을 읽는 감사 쿼리에서는 + INTERVAL 9 HOUR 보정을 걷어냈고, soft-delete가 찍은 deleted_at을 읽을 때는 아직 붙인다.

↑↓ 이동 열기esc 닫기