RSS듀오랩스
웹 개발

서버 시간대와 업무 날짜: UTC 기준 오늘이 하루 밀리는 문제

작성자
듀오랩스 대표·6분 읽기

제조업 ERP 데모에서 날짜 칸의 기본값을 만드는 코드는 한 줄이었습니다.

<input name="date" type="date" defaultValue={isoDay(new Date())} />

isoDayd.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.DateTimeFormattimeZone: "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 조각을 이름으로 꺼내는 쪽을 택하겠습니다. 글자 모양에 기대지 않아도 되기 때문입니다.

서버를 한국 시간대로 두면 끝나지 않는 이유

가장 쉬운 우회는 서버의 TZAsia/Seoul 로 두는 것입니다. 그러면 new Date().getDate() 같은 로컬 메서드가 한국 날짜를 줍니다.

저는 이 방법을 권하지 않습니다. 이 코드의 오늘은 getUTC*toISOString() 으로 구해서, 서버 시간대를 바꿔도 결과가 그대로입니다. 로컬 메서드로 바꿔 쓰는 순간에는 결과가 배포 환경의 설정에 달리게 됩니다. 개발자 노트북, 컨테이너, 서버리스 함수의 시간대가 하나라도 다르면 같은 코드가 다른 날짜를 냅니다. 한국 달력이라는 업무 규칙은 코드에 적혀 있어야 환경이 바뀌어도 따라옵니다.

22곳이 생긴 이유와 남긴 규칙

new Date() 는 어디서든 부를 수 있는 가장 가까운 「지금」입니다. 날짜 칸이 생길 때마다 그 자리에서 오늘을 새로 구했고, 그렇게 22곳이 됐습니다. 처음부터 한 곳에서 구했다면 한 곳만 고치면 됐을 일입니다.

그래서 이번에 고친 것은 계산식이라기보다 입구입니다. 업무 날짜의 오늘은 today() 하나에서만 나옵니다. 다음에 날짜 칸을 더할 때 참고하도록 그 규칙을 함수 주석에 적어 두었습니다.

시각이 필요한 곳은 다릅니다. 기록이 만들어진 순간(createdAt)은 달력의 하루가 아니라 시점이라서 그대로 new Date() 가 맞습니다. 이 구분이 흐려지면 같은 실수가 반대 방향으로 생깁니다.

마지막 수정:

공유하실 때는 출처(Duolabs)와 원문 주소를 표시해 주세요.