Article
타임존 버그, 저장은 UTC 표시할 때 변환이 기본이다

“생일이 하루 전으로 보여요.” “밤 11시에 쓴 글이 다음 날짜로 나와요.” 이런 제보는 거의 항상 타임존 처리에서 나옵니다. 공통점이 하나 있습니다. 재현하려면 브라우저나 서버의 타임존을 바꿔야 해서 정작 개발 중에는 잘 안 보이고, 해외 사용자나 새벽 시간대 사용자에게서 먼저 터진다는 것입니다. 국내만 보고 만든 기능이 서버 리전을 옮긴 뒤에야 어긋나는 걸, 나도 여러 번 겪었습니다.
원칙 자체는 한 줄입니다.
시각은 UTC로 저장하고, 사용자 타임존으로의 변환은 표시하는 순간에만 합니다. 그리고 생일처럼 날짜만 의미 있는 값은 시각과 완전히 다른 타입으로 다룹니다.
이 글은 MDN의 Date 문서와 Intl.DateTimeFormat 문서, IANA 타임존 데이터베이스 안내를 바탕으로 정리했습니다.
원칙: 저장은 UTC, 변환은 표시 계층에서
타임존 버그의 뿌리는 “이 값이 어느 타임존 기준인지”가 계층마다 다르게 해석되는 것입니다. 서버는 서울 시각으로 저장했는데 다른 서버는 UTC로 읽는 식입니다. 서버를 다른 리전으로 옮기고 나서야 이 모호함이 어디에 숨어 있었는지 드러나는 경우를 여러 번 겪었습니다. 이 모호함을 없애는 가장 단순한 규칙이 저장 시점 UTC 통일입니다.
- 데이터베이스에는 UTC 기준 시각을 저장합니다. PostgreSQL이라면
timestamptz가 입력을 UTC로 정규화해 저장합니다. - 서버 간 통신(API)에서는 ISO 8601 형식에 오프셋을 명시합니다.
2026-05-18T03:00:00Z처럼Z(UTC)를 붙이면 해석의 여지가 없습니다. - 사용자 타임존으로의 변환은 최종 표시 계층(브라우저, 알림 발송 직전)에서만 합니다.
이 규칙이 지켜지면 “이 컬럼 어느 기준이지?”라는 질문 자체가 사라집니다. 반대로 서버 로컬 타임존에 의존하면, 서버를 다른 리전으로 옮기거나 컨테이너 기본값(대부분 UTC)으로 실행하는 순간 데이터 해석이 달라집니다.
JavaScript Date의 실제 동작
JavaScript의 Date 객체는 타임존을 저장하지 않습니다. MDN 문서에 따르면 Date는 UTC 1970년 1월 1일 자정으로부터의 밀리초 하나를 담는 값이고, 출력 메서드가 실행 환경의 타임존을 적용할 뿐입니다. (출처: MDN - Date)
const d = new Date("2026-05-18T03:00:00Z");
d.toISOString(); // "2026-05-18T03:00:00.000Z" — 항상 UTC
d.toString(); // 실행 환경 타임존으로 출력 (서울이면 12:00)
d.getHours(); // 실행 환경 타임존 기준 시(hour)
d.getUTCHours(); // 3 — UTC 기준
버그가 자주 나는 지점은 파싱입니다. 오프셋 없는 문자열은 형식에 따라 해석이 달라집니다. new Date("2026-05-18")은 UTC 자정으로, new Date("2026-05-18T00:00:00")은 로컬 자정으로 해석됩니다. 날짜만 있는 형식과 시각 포함 형식의 기준이 다르다는 것인데, 이 차이가 “하루 밀림”의 단골 원인입니다. 서버와 주고받는 문자열에는 항상 오프셋(Z 또는 +09:00)을 명시하는 것이 안전합니다.
특정 타임존으로 표시할 때는 문자열을 직접 자르지 말고 Intl.DateTimeFormat을 씁니다. IANA 타임존 이름을 지정하면 서머타임까지 반영해 변환해줍니다. (출처: MDN - Intl.DateTimeFormat)
new Intl.DateTimeFormat("ko-KR", {
timeZone: "Asia/Seoul",
dateStyle: "medium",
timeStyle: "short",
}).format(d); // "2026. 5. 18. 오후 12:00"
타임존 식별자는 Asia/Seoul 같은 IANA 이름을 써야 합니다. KST 같은 약어는 중복과 모호함이 있어 표준 식별자가 아닙니다. IANA 타임존 데이터베이스가 전 세계 타임존 규칙 변경 이력을 관리하는 사실상의 표준입니다. (출처: IANA - Time Zone Database)
날짜만 있는 값은 다른 타입이다

생일, 기념일, 휴무일 같은 값은 “시각”이 아니라 “달력상의 날짜”입니다. 서울에서도 뉴욕에서도 5월 18일은 5월 18일이어야 합니다. 이런 값을 timestamp로 저장하면 어떻게 되는지 보겠습니다.
- 서울 사용자가 생일을 5월 18일로 입력합니다.
- 프론트엔드가 로컬 자정
2026-05-18T00:00:00+09:00으로 만들고, UTC로 변환해2026-05-17T15:00:00Z를 저장합니다. - 뉴욕 사용자가 이 프로필을 보면 현지 시각으로 변환되어 5월 17일로 표시됩니다.
저장·변환 각 단계가 규칙대로 동작했는데 결과는 틀렸습니다. 날짜만 의미 있는 값에 시각 규칙을 적용한 것 자체가 잘못이기 때문입니다. 나는 “생일이 하루 전으로 보인다”는 제보를 처음 받았을 때 변환 코드부터 뒤졌는데, 정작 문제는 타입 선택이었습니다. 이런 값은 데이터베이스의 DATE 타입, API에서는 "2026-05-18" 같은 날짜 문자열로, 타임존 변환 없이 다뤄야 합니다.
같은 이유로 “우리 서비스 기준 하루”(정산, 출석 등)는 어느 타임존의 자정을 하루의 경계로 삼는지 명시적으로 정해야 합니다. UTC 자정과 서울 자정은 9시간 다르고, 이 정의가 코드 곳곳에 흩어져 있으면 정산 금액이 맞지 않는 사고로 이어집니다.
미래 시각은 UTC 저장이 오히려 위험할 수 있다
과거에 일어난 일의 기록은 UTC가 정답입니다. 그런데 “2027년 3월 10일 오전 9시에 회의” 같은 미래의 벽시계 시각은 사정이 다릅니다. 이 약속의 의미는 “그 지역 시계로 9시”입니다. 지금 UTC로 변환해 저장했는데 그 사이 해당 국가가 서머타임 규칙이나 표준 오프셋을 바꾸면, 저장된 UTC 시각이 가리키는 현지 시각이 달라집니다. 실제로 타임존 규칙 변경은 드물지 않아서 IANA 데이터베이스는 연중 수차례 갱신됩니다.
그래서 캘린더류 도메인에서는 미래 일정을 “현지 시각 + IANA 타임존 이름”(2027-03-10T09:00, Asia/Seoul)으로 저장하고, 실행 시점에 그때의 규칙으로 UTC를 계산하는 방식이 권장됩니다. 모든 서비스에 필요한 수준은 아니지만, 예약·알림·일정 도메인이라면 알고 설계해야 하는 지점입니다.
자주 묻는 질문
Q. 국내 사용자만 있는 서비스인데도 UTC로 저장해야 하나요? 권장합니다. 한국은 현재 서머타임이 없어 KST 저장도 당장은 동작하지만, 서버·컨테이너 기본 타임존과 어긋나는 순간 해석 버그가 생기고, 로그·외부 API와의 대조도 번거로워집니다. 통일 비용은 초기에 가장 쌉니다.
Q. 데이터베이스의 timestamp와 timestamptz 중 무엇을 쓰나요?
PostgreSQL 기준으로 시각에는 timestamptz를 권합니다. 입력을 UTC로 정규화해 저장하므로 모호함이 없습니다. timestamp(without time zone)는 “어느 기준인지” 정보가 없어, 앞서 말한 해석 불일치가 그대로 발생할 수 있습니다.
Q. 라이브러리는 뭘 쓰는 게 좋나요?
표시·변환은 표준 Intl API로 충분한 경우가 많습니다. 날짜 연산이 많다면 타임존을 명시적으로 다루는 라이브러리(예: date-fns-tz, Luxon)를 쓰되, “저장은 UTC, 날짜는 날짜 타입” 원칙이 라이브러리보다 먼저입니다. 도구가 원칙을 대신해 주지는 않습니다.
정리하면
- 시각은 UTC로 저장하고 오프셋 명시된 ISO 8601로 주고받으며, 변환은 표시 계층에서만 합니다.
- JavaScript
Date는 타임존 없는 UTC 밀리초입니다. 오프셋 없는 문자열 파싱이 버그의 단골 지점입니다. - 생일처럼 날짜만 의미 있는 값은
DATE타입과 날짜 문자열로, 변환 없이 다룹니다. - 미래의 벽시계 시각은 현지 시각 + IANA 타임존 이름으로 저장하는 것을 검토합니다.
댓글