1. 개요

영문 사이트라 손님이 지구 어디에나 있다. 미주에서 접속한 손님이 캘린더에서 12월 1일을 골랐는데 확인 화면에 11월 30일이 떴다. 원인은 시간대이고, 결론은 “시각이 없는 날짜는 Date 를 거치지 않는다” 다. n8n 봇에서 겪은 것과 같은 교훈을 다른 층에서 다시 배웠다.


2. 핵심 내용

2-1. new Date("2026-12-01") 은 UTC 자정이다

자바스크립트에서 날짜만 있는 ISO 문자열을 new Date() 에 넣으면 UTC 자정 으로 해석된다. 미국 동부(UTC-5)에서 이걸 로컬로 표시하면 11월 30일 19시다. 날짜만 뽑으면 11월 30일. 한국(UTC+9)에서는 12월 1일 09시라 문제가 안 보인다. 개발자가 한국에 있으면 영원히 못 본다.

date-fns 의 parseISO("2026-12-01") 은 반대로 로컬 자정 이다. 어디서 열어도 12월 1일이다.

2-2. 한 화면에 두 시간 기준

문제가 꼬인 이유는 이 둘이 같은 화면에 섞여 있었다 는 것이다. 표시는 parseISO(로컬 자정), 선택과 검증은 new Date(UTC 자정). 미주 브라우저에서 캘린더 하이라이트가 하루 어긋나고, 선택한 날과 표시된 날이 달랐다.

어떻게 섞였나. PR 여러 개를 병렬로 작업하다 하나가 표시 코드를 parseISO 로 바꿨고, 다른 하나가 검증 코드를 new Date 로 새로 추가했다. 서로 다른 파일이라 git 충돌은 없었다. 각각은 초록이었다. 합치니 어긋났다. git 충돌 부재는 의미적 양립이 아니다.

2-3. 수정: parseDateOnly 하나로

// lib/dates.ts
export const parseDateOnly = (s: string) => parseISO(s)  // 로컬 자정

날짜만 있는 문자열은 전부 이 함수로 파싱한다. 시각이 포함된 문자열(캘린더 이벤트의 start/end)은 그대로 new Date 다. 그건 진짜 시각이니까. 세 지점을 교체하고 grep 으로 new Date( 를 훑어 남은 곳이 없는지 확인했다.

초안 만료 판정(sessionStorage 에 저장한 날짜가 오늘보다 과거인가)은 Date 를 아예 거치지 않고 yyyy-MM-dd 문자열 비교 로 한다. "2026-11-30" < "2026-12-01" 은 사전순으로 맞는다. 시간대가 끼어들 틈이 없다.

백엔드도 같은 원칙이다. 체크인·체크아웃은 DB 에 VARCHAR yyyy-MM-dd 로 저장한다. DATE 타입이 더 정확해 보이지만 JDBC 드라이버와 JVM 시간대 설정에 따라 하루가 밀리는 사고를 봤다. 시각이 없는 날짜에 시간대를 붙일 이유가 없다.

2-4. 테스트가 UTC 러너에서 껍데기가 된다

수정을 테스트로 잠갔다. 그런데 이 테스트는 CI 러너의 시간대가 UTC 면 통과하고, 로컬(KST)에서도 통과한다. UTC 자정 = 로컬 자정이면 두 파싱이 같으니까. 버그가 있어도 초록이다.

// dates.test.ts
beforeAll(() => { process.env.TZ = "America/New_York" })

테스트가 시간대를 고정 해야 한다. UTC-5 로 고정하면 new Date("2026-12-01") 의 날짜가 11월 30일로 나와 버그를 잡는다. 시간대 의존 코드의 테스트는 러너 시간대에 의존하면 안 된다. 이건 “테스트 env 가 운영을 재현하는가” 의 한 변형이다.

2-5. 교차 PR 회귀의 다른 하나: 훅 시그니처

같은 라운드에서 비슷한 모양의 회귀가 하나 더 있었다. useUnsavedChanges(hasChanges, message: string) 를 옵션 객체로 바꾼 PR 과, 옛 시그니처로 새 호출부를 추가한 PR. 다른 파일, 충돌 없음, 각각 초록. 리베이스하니 TS2559.

그 뒤로 병렬 PR 을 머지할 때 규칙을 셋 뒀다.

  1. 머지 순서를 파일 교차 기준으로 다시 짠다. 같은 헬퍼를 건드리는 PR 은 연달아.
  2. 리베이스마다 CI 를 다시 본다. 리베이스 전 초록은 낡은 초록이다.
  3. 머지 뒤 같은 도메인의 헬퍼(dates, hooks)를 grep 으로 훑는다.

3. 마무리

요약

  • new Date("yyyy-MM-dd") 는 UTC 자정, parseISO 는 로컬 자정. 한 화면에 섞지 않는다.
  • 시각이 없는 날짜는 Date 를 거치지 않는다. 문자열로 저장하고 문자열로 비교한다.
  • 시간대 테스트는 TZ 를 고정한다. 러너가 UTC 면 껍데기다.
  • git 충돌 부재는 의미적 양립이 아니다. 병렬 PR 은 파일 교차로 순서를 짠다.

마지막 편은 운영 감사 라운드다.