new Date(2026, 3, 6)의 9시간 드리프트: 테스트 픽스처는 왜 반드시 Date.UTC()여야 하는가
CI는 초록불인데 로컬에선 빨간불이 떴다. 원인은 테스트 픽스처의 new Date(2026, 3, 6) 한 줄. 로컬 타임존 생성자는 개발자 KST와 CI UTC 사이에서 9시간 어긋나고, 주차 경계 테스트를 조용히 깨뜨렸다.
1. 문제 상황
1.1 초록불과 빨간불이 다른 이유
PR을 올리고 CI를 기다렸더니 전부 통과. "이제 머지만 하면 되겠다" 싶었는데, 로컬에서 한 번 더 돌려봤습니다.
$ pnpm vitest run src/__tests__/api/weekly-report.test.ts
FAIL src/__tests__/api/weekly-report.test.ts > getOrCreateReport > 해당 주에 진행중 전환된 태스크를 포함
expected 1 task, got 0
같은 코드, 같은 테스트 파일인데 CI는 통과하고 로컬은 실패합니다. 가장 먼저 의심한 건 환경 변수 차이였지만 맞지 않았고, 랜덤성(Date.now 등) 도 아니었습니다.
로그를 더 찍어보니 테스트 픽스처의 날짜가 9시간 어긋나있었습니다.
1.2 재현
테스트 픽스처는 이랬습니다:
// 2026년 4월 6일 월요일 10시에 태스크를 진행중으로 전환
await prisma.task.create({
data: {
// ...
inProgressAt: new Date(2026, 3, 6, 10, 0),
},
});
// 해당 주(2026-W15)의 리포트를 조회
const report = await getOrCreateReport(orgId, 2026, 15, "Asia/Seoul");
getOrCreateReport는 UTC 일원화 이후 2026-W15 = 2026-04-05T15:00:00Z ~ 2026-04-12T14:59:59.999Z (KST 월~일)를 반환합니다.
이제 new Date(2026, 3, 6, 10, 0)을 환경별로 해석해봅시다:
| 환경 | new Date(2026, 3, 6, 10, 0) |
UTC 변환 |
|---|---|---|
| KST 개발자 로컬 | KST 2026-04-06 10:00 | 2026-04-06T01:00:00Z ✅ W15 안 |
| UTC CI | UTC 2026-04-06 10:00 | 2026-04-06T10:00:00Z ✅ W15 안 |
얼핏 둘 다 W15 안에 들어가는 것 같지만, 경계 근처에서는 상황이 다릅니다. 다른 테스트 케이스에서는:
// 2026년 4월 5일 일요일 23시 — 한국 시간 W14의 마지막 순간
inProgressAt: new Date(2026, 3, 5, 23, 0),
| 환경 | UTC 변환 | KST 주차 |
|---|---|---|
| KST 로컬 | 2026-04-05T14:00:00Z | W14 (KST 일 23시) |
| UTC CI | 2026-04-05T23:00:00Z | W14 (UTC 일 23시이지만 KST 월요일 아침 8시 = W15에 가까움) |
더 큰 문제는 테스트가 "Asia/Seoul" 타임존으로 경계를 계산한다는 점입니다. UTC CI의 new Date(2026, 3, 5, 23, 0)은 UTC 기준 23시지만, KST 경계 계산 결과인 2026-04-05T15:00:00Z(= KST 월요일 00:00, W15 시작)를 기준으로 보면 이미 W15에 포함됩니다. 반면 KST 로컬의 new Date(2026, 3, 5, 23, 0) = 2026-04-05T14:00:00Z는 아직 W14에 있습니다.
1.3 이런 걸 어떻게 미리 발견하나
로컬에서는 "내 타임존에서 돌려보면 당연히 맞음"이고, CI에서는 "서버 타임존에서 당연히 맞음"입니다. 둘 다 맞는데 서로 다른 결과가 나오는 이 상황은, 결국 "한 번만 돌려보고 통과했다"로는 알 수 없는 버그입니다.
2. 원인 분석
2.1 JavaScript Date 생성자 복습
Date 생성자는 인자 형태에 따라 해석이 다릅니다:
new Date("2026-04-06") // UTC 해석 → 2026-04-06T00:00:00Z
new Date("2026-04-06T10:00:00") // 로컬 해석 → 서버 TZ에 따라 다름
new Date("2026-04-06T10:00:00Z") // UTC 해석 → 2026-04-06T10:00:00Z
new Date(2026, 3, 6) // 로컬 해석 → 서버 TZ 자정
new Date(2026, 3, 6, 10, 0) // 로컬 해석 → 서버 TZ 10시
new Date(Date.UTC(2026, 3, 6, 10, 0)) // UTC 해석 → 2026-04-06T10:00:00Z
이전 글 JavaScript 타임존 함정 피하기에서 다룬 내용입니다. 테스트 픽스처에서 숫자 인자 생성자를 쓰는 순간 서버 타임존에 의존하게 됩니다.
2.2 왜 애플리케이션 코드보다 테스트가 더 취약한가
실제 애플리케이션 코드에서는 보통 inProgressAt: new Date()(현재 시각)을 쓰거나, DB에서 가져온 Date를 다룹니다. 개발자가 특정 날짜 리터럴을 코드에 박는 경우는 드뭅니다.
하지만 테스트는 다릅니다. 결정론적 픽스처를 위해 "2026년 4월 6일에 태스크가 시작됐다고 가정하자"고 쓰는 상황이 매우 많습니다. 즉:
- 애플리케이션 코드:
new Date()~ DB 왕복 → 타임존 영향 작음 - 테스트 코드:
new Date(Y, M, D, h, m)리터럴이 도처에 → 타임존 영향 큼
테스트는 애플리케이션보다 타임존 함정에 더 크게 노출됩니다.
2.3 CI 환경과 개발 환경의 근본 격차
이 프로젝트의 환경:
- CI (GitHub Actions, Docker 컨테이너): 기본 타임존 UTC
- 개발자 로컬 (macOS):
Asia/Seoul(KST, UTC+9)
이 9시간의 격차가 new Date(2026, 3, 5, 23, 0) 같은 리터럴에서 9시간 차이로 튀어나옵니다. 일요일 밤이 화요일 아침으로 바뀌는 극단적인 경계 쉬프트가 발생합니다.
3. 해결 방법
3.1 원칙: 테스트 픽스처의 모든 Date는 UTC
규칙은 단순합니다:
테스트 픽스처의 모든 Date 리터럴은
Date.UTC()로 명시한다.
// ❌ Before: 로컬 타임존 의존
inProgressAt: new Date(2026, 3, 6, 10, 0),
// ✅ After: 환경 무관
inProgressAt: new Date(Date.UTC(2026, 3, 6, 10, 0)),
3.2 리팩토링 전/후
수정 커밋의 diff 요약 (0731313 — "test(reports): use Date.UTC in test fixtures for timezone consistency"):
- const startedAt = new Date(2026, 3, 6, 10, 0);
- const completedAt = new Date(2026, 3, 8, 16, 30);
+ const startedAt = new Date(Date.UTC(2026, 3, 6, 10, 0));
+ const completedAt = new Date(Date.UTC(2026, 3, 8, 16, 30));
- changedAt: new Date(2026, 2, 30), // 상태 로그 시점
+ changedAt: new Date(Date.UTC(2026, 2, 30)),
- weekStart: new Date(2026, 3, 6),
+ weekStart: new Date(Date.UTC(2026, 3, 6)),
대상 파일 3개, 리터럴 약 30개. 모두 Date.UTC로 통일하는 건 기계적인 작업이었지만 한 번만 누락해도 플레이키 테스트가 됩니다.
3.3 Assertion도 UTC 기반으로
픽스처뿐 아니라 assertion도 일관성 있게 UTC를 써야 합니다.
// ❌ Before: 로컬 타임존 의존
expect(startDate.getDate()).toBe(6);
expect(startDate.getHours()).toBe(0);
// ✅ After: UTC 기반 assertion
expect(startDate.toISOString()).toBe("2026-04-05T15:00:00.000Z");
// 또는
expect(startDate.getUTCDate()).toBe(5);
expect(startDate.getUTCHours()).toBe(15);
getDate() / getHours() 계열도 로컬 타임존 기반입니다. getUTCDate() / getUTCHours() 또는 toISOString() 비교로 교체해야 환경 불변성이 유지됩니다.
3.4 자동화 가능한 ESLint 규칙
수동 유지보수가 부담스럽다면 ESLint 커스텀 룰로 강제할 수 있습니다:
// eslint-plugin-project/rules/no-local-date-in-tests.js
module.exports = {
meta: { type: "problem", docs: { description: "Test fixtures must use Date.UTC()" } },
create(context) {
return {
NewExpression(node) {
if (
node.callee.name === "Date" &&
node.arguments.length >= 2 && // (y, m, d, ...)
!(node.arguments[0].type === "CallExpression" &&
node.arguments[0].callee.object?.name === "Date" &&
node.arguments[0].callee.property?.name === "UTC")
) {
context.report({
node,
message: "Use Date.UTC() in test fixtures to avoid timezone drift",
});
}
},
};
},
};
적용 범위를 src/__tests__/**/*.ts로 좁히고, 애플리케이션 코드는 제외해 오탐을 줄일 수 있습니다.
4. 관련 함정들
4.1 new Date("YYYY-MM-DD") vs new Date("YYYY-MM-DDTHH:mm:ss")
같은 문자열 형태인데 해석이 다릅니다:
new Date("2026-04-06") // UTC 자정
new Date("2026-04-06T10:00:00") // 로컬 10시 ← 타임존 의존!
new Date("2026-04-06T10:00:00Z") // UTC 10시
new Date("2026-04-06T10:00:00+09:00") // KST 10시 = UTC 01시
테스트에서 시간까지 포함한 리터럴을 쓸 때는 항상 Z 또는 offset을 붙이거나, Date.UTC를 씁니다.
4.2 toISOString vs toString
const d = new Date(Date.UTC(2026, 3, 6, 10, 0));
d.toISOString(); // "2026-04-06T10:00:00.000Z" — 환경 무관
d.toString(); // "Mon Apr 06 2026 19:00:00 GMT+0900 (...)" — 로컬 의존
테스트 로그나 스냅샷 비교는 toISOString으로. 디버깅할 때 console.log(d)가 로컬 타임존으로 찍혀 혼동을 주기 쉽습니다.
4.3 Snapshot 테스트의 Date 직렬화
Jest/Vitest의 toMatchInlineSnapshot에 Date 객체를 그대로 넣으면 직렬화 방식이 환경에 따라 다를 수 있습니다. toISOString()으로 변환한 뒤 비교하거나, 스냅샷 serializer를 커스터마이즈하세요.
// ❌ 환경 의존
expect(task).toMatchInlineSnapshot(`
{
"inProgressAt": 2026-04-06T01:00:00.000Z, // 혹은 2026-04-06T10:00:00.000Z?
}
`);
// ✅ 환경 무관
expect({ ...task, inProgressAt: task.inProgressAt.toISOString() }).toMatchInlineSnapshot(`
{
"inProgressAt": "2026-04-06T10:00:00.000Z",
}
`);
4.4 vi.setSystemTime 사용 시
가짜 시간을 쓸 때도 UTC 기반으로:
import { vi } from "vitest";
beforeEach(() => {
vi.useFakeTimers();
vi.setSystemTime(new Date(Date.UTC(2026, 3, 6, 12, 0))); // ✅ UTC 정오
});
afterEach(() => {
vi.useRealTimers();
});
5. 핵심 개념 정리
5.1 Date 생성자 해석 테이블
| 입력 | 해석 | 환경 의존? |
|---|---|---|
new Date() |
현재 시각 (내부는 UTC) | ❌ |
new Date(ms) |
UTC | ❌ |
new Date("YYYY-MM-DD") |
UTC 자정 | ❌ |
new Date("YYYY-MM-DDTHH:mm:ss") |
로컬 시각 | ✅ 의존 |
new Date("YYYY-MM-DDTHH:mm:ssZ") |
UTC 시각 | ❌ |
new Date(y, m, d) |
로컬 자정 | ✅ 의존 |
new Date(y, m, d, h, m) |
로컬 시각 | ✅ 의존 |
new Date(Date.UTC(y, m, d, h, m)) |
UTC 시각 | ❌ |
의존 체크: 이 표에서 "의존" 칸이 ✅인 형태를 테스트에서 쓰는 건 위험 신호입니다.
5.2 Date 읽기 메서드 테이블
| 메서드 | 반환 기준 |
|---|---|
getDate() |
로컬 |
getUTCDate() |
UTC |
getHours() |
로컬 |
getUTCHours() |
UTC |
toString() |
로컬 (포맷 불안정) |
toISOString() |
UTC |
toLocaleString() |
로컬 (포맷/언어 의존) |
valueOf() / getTime() |
epoch ms (환경 무관) |
원칙: 테스트의 assertion은 getUTC* 또는 toISOString() 또는 getTime()만 사용.
5.3 왜 UTC를 "서버 표준"으로 두나
- DB가 보통 UTC로 저장 (PostgreSQL
timestamp은 타임존 제거 저장,timestamptz는 UTC로 정규화) - CI/배포 컨테이너가 UTC (리눅스 기본)
- 로그 타임스탬프가 UTC여야 팀 간 협업 용이
- 국경 넘는 사용자의 시각 계산 기준이 되기 쉬움
사용자에게 보여주는 시점에만 로컬 타임존 변환을 하고, 그 외 모든 저장/처리/테스트는 UTC. 이 분리가 일관되면 위와 같은 버그가 사라집니다.
6. 베스트 프랙티스
6.1 체크리스트
- [ ] 테스트 파일에서
new Date(Y, M, D, ...)숫자 인자 형태 금지 →Date.UTC()사용 - [ ] Date 비교는
toISOString()또는getTime()기반 - [ ]
getDate/getHours등 로컬 getter 지양 - [ ]
vi.setSystemTime도 UTC 기반 Date - [ ] CI와 로컬 타임존이 다르다면 적어도 한 번은 UTC 환경에서 실행 (
TZ=UTC pnpm test) - [ ]
timezone-safe-date-handling글의 "정오(12:00) 패턴"이 필요한 코드는 애플리케이션 유틸로 별도 분리
6.2 로컬에서 UTC로 강제 실행
로컬 개발 환경에서 CI와 동일한 조건으로 테스트를 돌리고 싶다면:
TZ=UTC pnpm vitest run
TZ 환경 변수는 Node.js가 Date 연산에 사용하는 로컬 타임존을 임시로 덮어씁니다. 이걸 package.json 스크립트에 넣어둘 수도 있습니다:
{
"scripts": {
"test:utc": "TZ=UTC vitest run"
}
}
6.3 환경 인식 가능한 헬퍼
픽스처를 자주 만드는 프로젝트라면 작은 헬퍼가 유용합니다:
// src/__tests__/helpers/time.ts
export function utcDate(
year: number, month: number, day: number,
hour: number = 0, minute: number = 0, second: number = 0,
): Date {
return new Date(Date.UTC(year, month - 1, day, hour, minute, second));
}
// 사용
import { utcDate } from "../helpers/time";
inProgressAt: utcDate(2026, 4, 6, 10, 0), // 월 1-indexed로 덜 헷갈림
month - 1로 한 번만 조정하면 호출부는 사람이 읽기 자연스러운 "4월 = 4"를 쓸 수 있습니다.
7. FAQ
Q. Date.UTC()가 아니라 new Date(Date.UTC(...))를 쓰는 이유는?
A. Date.UTC(y, m, d, h, m, s)는 ms 숫자를 반환합니다. Date 객체가 필요하면 한 번 더 감싸야 합니다. 헷갈리기 쉬운 부분이라 타입 실수가 종종 납니다:
Date.UTC(2026, 3, 6) // 1775030400000 (number)
new Date(Date.UTC(2026, 3, 6)) // Date object
Q. 애플리케이션 코드에서도 Date.UTC()를 쓸까요?
A. 경우에 따라 다릅니다. 서버 측 비즈니스 로직은 대부분 UTC로 통일하는 게 안전합니다. 사용자 입력 파싱(YYYY-MM-DD 같은 날짜 문자열)은 지난 글의 정오 패턴처럼 상황에 맞는 해법을 써야 합니다. UI 표시는 조직/사용자 타임존 기반이고, 저장은 UTC가 기본.
Q. 테스트 파일이 수백 개인데 다 고쳐야 하나요?
A. 정확히 같은 문제를 일으키는 파일만 고치면 됩니다. 보통 날짜/시간 로직과 직접 연관된 테스트가 전체의 10% 이하입니다. 나머지는 new Date() 같은 "아무 시점이면 됨" 픽스처라 타임존 영향이 없습니다. 수동으로 훑기보다 grep -rn "new Date(" src/__tests__ 후 숫자 인자 형태만 걸러내는 것이 효율적입니다.
Q. 왜 CI에서도 KST로 맞추지 않나요?
A. 가능하긴 합니다 (TZ=Asia/Seoul를 CI workflow에 설정). 하지만:
- 팀에 해외 개발자가 있으면 또 다른 격차 생성
- UTC가 모든 환경의 공통 분모라서 "표준"으로 삼기 좋음
- 로그/DB/API 응답이 UTC인 것과 일치하여 디버깅 용이
타임존을 맞추는 방향보다, 코드가 타임존에 의존하지 않게 만드는 쪽이 더 견고한 해법입니다.
Q. Vitest와 Jest의 차이가 있나요?
A. 없습니다. 둘 다 JavaScript Date 객체를 그대로 쓰므로 서버 타임존 의존성은 동일합니다. Jest의 jest.useFakeTimers()와 Vitest의 vi.useFakeTimers()도 동일한 방식으로 동작.
8. 참고 자료
- MDN - Date.UTC
- MDN - Date Constructor
- Node.js - TZ environment variable
- 이전 글: JavaScript 타임존 함정 피하기: UTC vs Local 날짜 처리
- 이전 글: Docker는 UTC, 사용자는 KST: Intl.DateTimeFormat만으로 만든 tz-aware ISO Week
9. 다음 단계
테스트 픽스처를 UTC로 통일하고 나면, 다음 단계는 실제 애플리케이션 로직에서 "사용자 타임존"이 필요한 부분을 분리하는 것입니다. 주간 리포트의 월요일 00:00처럼 경계 계산이 tz-aware여야 하는 곳은 명시적 timezone 파라미터를 받고, 내부 저장/비교는 UTC를 유지합니다. 두 레이어의 경계를 분명히 하면 타임존 버그의 80%가 사라집니다.