출퇴근 시간을 기록하고 업무시간을 계산해 주는 웹앱을 만들었습니다. 기능만 보면 단순합니다. 출근 버튼을 누르면 시각이 저장되고, 퇴근 버튼을 누르면 그 차이를 셉니다. 그런데 실제로 만들어 보니 시간만큼 함정이 많은 데이터가 없었습니다.
처음 만든 방식과 거기서 생긴 일
처음에는 아무 생각 없이 로컬 시각을 그대로 저장했습니다. new Date()로 만든 값을 그대로 보내고, 받아 온 값을 그대로 보여줬습니다. 내 컴퓨터에서 테스트할 때는 완벽하게 동작했습니다. 당연합니다. 저장하는 쪽과 보여주는 쪽이 같은 시간대였으니까.
문제는 서버 시간대가 UTC라는 걸 알게 되면서 드러났습니다. 밤 9시에 퇴근을 찍으면 그 기록이 다음 날로 집계되기도 하고, 자정 근처 기록이 통계에서 사라지기도 했습니다. 하루 업무시간을 합산했는데 어제 것이 섞여 들어와 있었습니다.
원칙: 저장은 UTC, 표시는 지역 시간
찾아보니 답은 이미 정해져 있는 문제였습니다. 모든 시각은 UTC로 저장하고, 화면에 그릴 때만 사용자 시간대로 변환합니다. 문장으로는 한 줄인데, 이걸 왜 지켜야 하는지는 직접 어긋나 봐야 이해가 됩니다.
이유는 간단합니다. UTC는 하나의 절대적인 시점을 가리키지만, '2024년 7월 20일 오후 9시'는 어디에서 말하느냐에 따라 다른 순간입니다. 데이터베이스에는 '언제 일어났는가'를 저장해야 하지, '누가 어떻게 읽었는가'를 저장하면 안 됩니다.
// 저장: 절대 시점 하나를 남긴다
const record = {
clockedInAt: new Date().toISOString(), // 2024-07-20T12:00:00.000Z
};
// 표시: 읽는 사람의 시간대로 변환한다
const formatted = new Intl.DateTimeFormat("ko-KR", {
timeZone: "Asia/Seoul",
dateStyle: "medium",
timeStyle: "short",
}).format(new Date(record.clockedInAt));
// → 2024. 7. 20. 오후 9:00진짜 어려운 것은 '날짜 경계'였다
UTC로 저장하는 것까지는 규칙만 지키면 됩니다. 그런데 근태 앱에는 그것만으로 풀리지 않는 문제가 하나 더 있었습니다. 이 근무는 어느 날로 세야 하는가?
- 밤 10시에 출근해서 새벽 6시에 퇴근했습니다. 이건 어제 근무인가, 오늘 근무인가?
- 주간 집계는 월요일에 시작하는가, 일요일에 시작하는가?
- 월말에 걸친 근무는 어느 달에 넣는가?
이 질문들에는 기술적으로 옳은 답이 없습니다. 도메인이 정해야 하는 규칙입니다. 그런데 나는 이걸 정하지 않은 채로 화면을 만들기 시작했습니다. 결과는 예상대로였습니다. 일간 화면과 주간 화면이 서로 다른 숫자를 보여줬습니다. 각 화면을 만들 때마다 그 순간의 판단으로 경계를 그었기 때문입니다.
규칙을 문장으로 먼저 쓰고, 함수 하나로 옮긴다
그래서 코드를 멈추고 규칙을 문장으로 적었습니다.
- 근무는 퇴근 시각이 속한 날짜로 셉니다. 밤샘 근무는 다음 날 근무입니다.
- 주는 월요일 00:00(KST)에 시작합니다.
- 월 경계는 주와 무관하게 달력 기준으로 자릅니다.
- 모든 경계 판단은 UTC를 KST로 변환한 뒤에 합니다.
그리고 이 문장들을 함수 하나로 옮겼습니다. 화면들은 이 함수만 쓰고, 각자 날짜를 계산하지 않습니다.
import { TZDate } from "@date-fns/tz";
import { format, startOfWeek } from "date-fns";
const TZ = "Asia/Seoul";
/** 규칙 1: 근무는 '퇴근 시각이 속한 날'로 센다 */
export const workDateOf = (clockedOutAtISO: string) => {
const local = new TZDate(clockedOutAtISO, TZ);
return format(local, "yyyy-MM-dd");
};
/** 규칙 2: 주는 월요일에 시작한다 */
export const weekStartOf = (clockedOutAtISO: string) => {
const local = new TZDate(clockedOutAtISO, TZ);
return format(startOfWeek(local, { weekStartsOn: 1 }), "yyyy-MM-dd");
};라이브러리를 쓰더라도 개념은 알아야 한다
date-fns든 Day.js든, 시간대 처리를 도와주는 도구는 많습니다. 그런데 도구를 쓰기 전에 '지금 이 값이 절대 시점인가, 사람이 읽는 표현인가'를 스스로 구분할 수 있어야 합니다. 그 구분이 안 되면 라이브러리를 써도 같은 자리에서 같은 방식으로 틀립니다.
실제로 나는 이 프로젝트 이후 마감일 관리 앱을 만들면서 같은 문제를 다른 형태로 다시 만났습니다. 모바일에서는 사용자가 비행기를 타고 시간대를 넘나듭니다. '내일 마감'이라고 표시된 항목이 시간대를 넘는 순간 '오늘 마감'이 되어야 하는가? 이번에는 코드를 쓰기 전에 문장부터 적었습니다.
정리
- 저장은 UTC, 표시는 지역 시간. 예외를 두지 않습니다.
- 날짜 경계 규칙은 기술이 아니라 도메인이 정합니다. 코드보다 문장이 먼저입니다.
- 경계 판단은 함수 하나에 가둡니다. 화면이 각자 계산하기 시작하면 답이 갈라집니다.
- '서버가 다루는 시간'과 '사용자가 보는 시간'은 다른 것입니다. 이 둘을 항상 구분해서 말할 수 있어야 합니다.