서버 시간대와 업무 날짜: UTC 기준 오늘이 하루 밀리는 문제
제조업 ERP 데모에서 날짜 칸의 기본값을 만드는 코드는 한 줄이었습니다.
<input name="date" type="date" defaultValue={isoDay(new Date())} />isoDay 는 d.toISOString().slice(0, 10) 입니다. 오후에 화면을 열면 오늘 날짜가 들어 있습니다. 한국 시각 아침 8시에 열면 어제 날짜가 들어 있습니다.
날짜 표시는 맞았고 「오늘」만 틀린 구조
이 문제가 눈에 띄지 않은 이유는 화면에 찍히는 날짜가 멀쩡했기 때문입니다. 저장된 날짜를 보여 주는 포맷터는 한국 시각 기준으로 되어 있었습니다. 목록과 상세 화면의 날짜는 늘 맞았습니다.
틀린 것은 「오늘이 며칠인가」를 새로 구하는 곳들이었습니다. 실적, 재고 조정, 출하, 입고, 검수, 반품 폼의 날짜 기본값이 모두 isoDay(new Date()) 였습니다. 알림은 납기가 지났는지를 따질 때 이렇게 오늘을 구했습니다.
const today = new Date(Date.UTC(new Date().getUTCFullYear(), new Date().getUTCMonth(), new Date().getUTCDate()));이런 자리가 대시보드, 마감 화면, 세금계산서 분기 합계, 창고 월 집계, 문서 번호까지 퍼져 있었습니다. 대조해 보니 22곳 모두 UTC 날짜였습니다.
한국 시각 0시부터 9시 사이에 생기는 일
toISOString() 은 늘 UTC 로 표현합니다. 한국은 UTC 보다 9시간 빠르니, 한국 시각 0시부터 9시 전까지는 UTC 로 아직 전날입니다.
그 9시간 동안 폼을 열면 날짜 칸에 어제가 들어갑니다. 야간 조가 새벽에 실적을 찍거나, 출근하자마자 출하를 등록하면 어제 날짜로 저장됩니다. 사용자가 날짜 칸을 확인하지 않으면 그대로 남습니다.
하루 틀리는 것으로 끝나지 않는 날이 있습니다. 월초 1일 아침에는 전달 말일이 들어갑니다. 그러면 실적과 전표가 전달 집계에 잡히고, 전달을 이미 마감했다면 전표 저장이 마감 오류로 막힙니다. 문서 번호가 연도를 쓰면 1월 1일 아침에 등록한 문서가 작년 번호를 받습니다.
업무 날짜를 달력의 하루로 보는 약속
이 데모에는 날짜를 저장하는 약속이 있습니다. 수주일, 실적일, 전표일 같은 업무 날짜는 시각이 없는 「달력의 하루」입니다. 데이터베이스에는 그 날짜의 UTC 자정으로 넣고, 연월일은 getUTC* 로 읽습니다.
이 약속 안에서는 9월 18일이 2026-09-18T00:00:00Z 하나로 표현됩니다. 한국 시각으로 몇 시에 등록했든 같은 값입니다. 저장 형식 자체에는 문제가 없었습니다.
문제는 그 약속에 들어갈 「오늘」을 서버 시계에서 바로 꺼낸 것이었습니다. 서버 시계의 UTC 날짜는 한국 달력의 오늘이 아닙니다. 필요한 것은 한국 달력의 오늘을 구한 다음, 그 날짜를 UTC 자정으로 만든 값이었습니다.
한국 달력의 오늘을 UTC 자정으로 만드는 함수
Intl.DateTimeFormat 에 timeZone: "Asia/Seoul" 을 주면 그 시간대의 연월일을 얻습니다.
const kstDay = new Intl.DateTimeFormat("en-CA", { timeZone: "Asia/Seoul", year: "numeric", month: "2-digit", day: "2-digit" });
export const businessDay = (at = new Date()) => {
const [y, m, d] = kstDay.format(at).split("-").map(Number);
return new Date(Date.UTC(y, m - 1, d));
};
export const today = () => businessDay();그리고 22곳을 전부 today() 나 businessDay() 로 바꿨습니다. 폼 기본값, 날짜를 못 읽었을 때의 폴백, 알림, 대시보드, 마감 달, 분기 연도, 창고 월 집계, 문서 번호 연도입니다. 함수 주석에는 「new Date() 를 바로 쓰지 않는다」고 적었습니다.
en-CA 로케일을 고른 것은 이 로케일의 날짜 모양이 YYYY-MM-DD 라서입니다. 다만 로케일별 출력 모양은 ECMAScript 국제화 명세가 고정하는 값이 아니라 로케일 데이터가 정합니다. 저라면 오래 둘 코드에서는 formatToParts() 로 year, month, day 조각을 이름으로 꺼내는 쪽을 택하겠습니다. 글자 모양에 기대지 않아도 되기 때문입니다.
서버를 한국 시간대로 두면 끝나지 않는 이유
가장 쉬운 우회는 서버의 TZ 를 Asia/Seoul 로 두는 것입니다. 그러면 new Date().getDate() 같은 로컬 메서드가 한국 날짜를 줍니다.
저는 이 방법을 권하지 않습니다. 이 코드의 오늘은 getUTC* 와 toISOString() 으로 구해서, 서버 시간대를 바꿔도 결과가 그대로입니다. 로컬 메서드로 바꿔 쓰는 순간에는 결과가 배포 환경의 설정에 달리게 됩니다. 개발자 노트북, 컨테이너, 서버리스 함수의 시간대가 하나라도 다르면 같은 코드가 다른 날짜를 냅니다. 한국 달력이라는 업무 규칙은 코드에 적혀 있어야 환경이 바뀌어도 따라옵니다.
22곳이 생긴 이유와 남긴 규칙
new Date() 는 어디서든 부를 수 있는 가장 가까운 「지금」입니다. 날짜 칸이 생길 때마다 그 자리에서 오늘을 새로 구했고, 그렇게 22곳이 됐습니다. 처음부터 한 곳에서 구했다면 한 곳만 고치면 됐을 일입니다.
그래서 이번에 고친 것은 계산식이라기보다 입구입니다. 업무 날짜의 오늘은 today() 하나에서만 나옵니다. 다음에 날짜 칸을 더할 때 참고하도록 그 규칙을 함수 주석에 적어 두었습니다.
시각이 필요한 곳은 다릅니다. 기록이 만들어진 순간(createdAt)은 달력의 하루가 아니라 시점이라서 그대로 new Date() 가 맞습니다. 이 구분이 흐려지면 같은 실수가 반대 방향으로 생깁니다.
우리 서비스에 적용할 계획이 있으신가요?
홈페이지·웹서비스 개발 살펴보기 →함께 읽기
- 홈페이지 플랫폼 이전 체크리스트: 도메인, SEO, 콘텐츠, 폼, 분석 데이터홈페이지 플랫폼 이전은 새 화면을 만드는 작업만이 아닙니다. 기존 URL과 검색 노출, 도메인과 회사 메일, 게시물과 이미지, 문의 폼, 분석 태그까지 함께 옮겨야 비로소 이전이 끝납니다.
- Supabase와 직접 구성한 PostgreSQL: 사내 업무 시스템 선택 기준사내 업무 시스템을 만들 때 데이터베이스를 고르는 질문은 대개 이렇게 나옵니다. 「그냥 PostgreSQL 쓰면 되지 않나요? Supabase 는 뭐가 다른가요?」
- Threads API 자동 게시기: 승인 없이 하루 10개를 올릴 때 넣은 안전장치회사 Threads 계정에 올릴 글을 매번 제가 승인하다가, 어느 날 승인을 없애기로 했습니다. 하루 10개 안팎을 사람 손 없이 올리는 구조를 하루 만에 만들었고, 이 글은 승인이라는 안전장치를 빼는 대신 무엇을 넣었는지에 대한 기록입니다.
- 콜드 스타트 문제: 지역 커뮤니티 서비스가 첫 사용자를 얻는 방법운영 서비스가 약 40시간 동안 500 을 내고 있었습니다. 그것을 알린 것은 사용자가 아니라 크론 감시였습니다. 코드는 배포되고, 데이터베이스는 살아 있고, 도메인은 열려 있는데, 그 40시간 동안 화면을 열어 본 사람이 없었다는 뜻입니다.
- 문서 번호 참조와 외래 키: 되돌리기 코드를 번호로 짤 때의 문제제조업 ERP 데모에서 시드로 만든 「수금 완료」 매출 전표에서 수금 취소를 누르면 이상한 일이 생기는 구조를 찾았습니다. 원 전표는 미수로 돌아오는데 수금 전표는 지워지지 않고 남습니다. 수금 합계에는 받지 않은 돈이 이중으로 잡힙니다.