JavaScript 타임존 함정 피하기: UTC vs Local 날짜 처리
1월 31일을 선택했는데 1월 30일로 저장되는 버그를 발견했습니다. JavaScript Date 생성자의 타임존 해석 방식을 이해하고, 정오(12:00) 패턴으로 해결한 방법을 공유합니다.
1. 문제 상황
1.1 버그 발견
데이터 그리드에서 2026년 1월 31일을 선택했는데, 저장된 데이터가 1월 30일로 표시되는 버그를 발견했습니다.
// 사용자가 선택한 날짜
const dateStr = '2026-01-31';
// Date로 변환
const date = new Date(dateStr);
console.log(date.toISOString());
// '2026-01-30T15:00:00.000Z' ← UTC 기준!
console.log(date.getDate());
// 31 (로컬 기준으로는 맞음)
1.2 왜 문제인가?
사용자 기대:
1. 2026-01-31 선택
2. DB에 2026-01-31 저장
3. 조회 시 2026-01-31 표시
실제 동작:
1. 2026-01-31 선택
2. new Date('2026-01-31') → UTC 2026-01-31 00:00:00
3. KST 변환 → 2026-01-31 09:00:00 (표시는 맞음)
4. DB 저장 시 UTC 사용 → 2026-01-30 15:00:00
5. DB의 DATE 타입이 이를 2026-01-30으로 저장!
1.3 영향 범위
❌ 그리드 셀 클릭 시 하루 전 데이터 수정
❌ API 요청 시 잘못된 날짜로 조회/저장
❌ 월말/월초 경계에서 데이터 누락
❌ 일요일 표시 로직 오류 (토요일을 일요일로 판단)
2. 원인 분석
2.1 JavaScript Date 생성자의 동작
// 방식 1: ISO 문자열 (UTC로 해석!)
new Date('2026-01-31')
// → UTC 2026-01-31 00:00:00
// → KST 2026-01-31 09:00:00
// 방식 2: 숫자 인자 (로컬로 해석!)
new Date(2026, 0, 31)
// → 로컬 2026-01-31 00:00:00
// → UTC 2025-01-30 15:00:00 (KST 기준)
핵심: new Date('YYYY-MM-DD')는 UTC 자정으로 해석됩니다!
2.2 타임존 변환 과정
UTC 00:00 (자정)
↓ +9시간 (KST)
KST 09:00
문제:
- Date 객체는 내부적으로 UTC 타임스탬프를 저장
- 표시할 때는 로컬 타임존으로 변환
- DB에 저장할 때 다시 UTC로 변환
2.3 PostgreSQL DATE 타입과의 상호작용
// Prisma에서 Date 타입 필드에 저장
await prisma.record.create({
data: {
date: new Date('2026-01-31'), // UTC 2026-01-31 00:00:00
}
});
// PostgreSQL이 받는 값: 2026-01-31T00:00:00Z (UTC)
// DATE 타입은 시간을 무시하고 날짜만 저장: 2026-01-31 ✓
// 하지만!
await prisma.record.create({
data: {
date: new Date('2026-01-31T00:00:00'), // 로컬 해석 → UTC 2026-01-30T15:00:00Z
}
});
// DATE 타입이 저장하는 값: 2026-01-30 ✗
3. 해결 방법
3.1 핵심 아이디어
정오(12:00)로 Date를 생성하여 타임존 변환 시에도 같은 날짜 유지
KST 12:00 → UTC 03:00 (같은 날)
KST 00:00 → UTC 전날 15:00 (다른 날!)
3.2 parseLocalDate 함수
/**
* YYYY-MM-DD 문자열을 로컬 타임존 Date로 파싱
*
* 정오(12:00)로 생성하여 PostgreSQL DATE 타입 저장 시 타임존 문제 방지
* - KST 00:00 → UTC 전날 15:00 → DATE 전날로 저장 (문제!)
* - KST 12:00 → UTC 03:00 → DATE 같은 날로 저장 (OK)
*/
export function parseLocalDate(dateStr: string): Date {
const [year, month, day] = dateStr.split('-').map(Number);
return new Date(year, month - 1, day, 12, 0, 0);
}
3.3 formatLocalDate 함수
import { format as fnsFormat } from 'date-fns';
/**
* Date를 YYYY-MM-DD 문자열로 변환 (로컬 타임존 기준)
*/
export function formatLocalDate(date: Date): string {
return fnsFormat(date, 'yyyy-MM-dd');
}
3.4 전체 유틸리티 모듈
// src/lib/core/date-utils.ts
import { format as fnsFormat } from 'date-fns';
import { ko } from 'date-fns/locale';
export function parseLocalDate(dateStr: string): Date {
const [year, month, day] = dateStr.split('-').map(Number);
return new Date(year, month - 1, day, 12, 0, 0);
}
export function formatLocalDate(date: Date): string {
return fnsFormat(date, 'yyyy-MM-dd');
}
export function getLocalDayOfWeek(dateStr: string): number {
return parseLocalDate(dateStr).getDay();
}
export function isLocalSunday(dateStr: string): boolean {
return getLocalDayOfWeek(dateStr) === 0;
}
export function isLocalWeekend(dateStr: string): boolean {
const day = getLocalDayOfWeek(dateStr);
return day === 0 || day === 6;
}
export function formatKoreanDate(dateStr: string, formatStr: string): string {
return fnsFormat(parseLocalDate(dateStr), formatStr, { locale: ko });
}
export function isLocalToday(dateStr: string): boolean {
return dateStr === formatLocalDate(new Date());
}
export function getLocalMonthDates(year: number, month: number): string[] {
const dates: string[] = [];
const lastDay = new Date(year, month, 0).getDate();
for (let day = 1; day <= lastDay; day++) {
const date = new Date(year, month - 1, day);
dates.push(formatLocalDate(date));
}
return dates;
}
export function getLocalTodayStr(): string {
return formatLocalDate(new Date());
}
export function getLocalYearMonth(): { year: number; month: number } {
const today = new Date();
return {
year: today.getFullYear(),
month: today.getMonth() + 1,
};
}
4. Before/After 비교
4.1 날짜 파싱
// ❌ Before: UTC로 해석
const date = new Date('2026-01-31');
// UTC 2026-01-31T00:00:00Z → KST 2026-01-31T09:00:00
// ✅ After: 로컬 타임존으로 해석
const date = parseLocalDate('2026-01-31');
// 로컬 2026-01-31T12:00:00 → UTC 2026-01-31T03:00:00Z
4.2 요일 확인
// ❌ Before: 잘못된 요일
const date = new Date('2026-01-04'); // 일요일이어야 함
console.log(date.getDay()); // 6 (토요일로 표시될 수 있음!)
// ✅ After: 정확한 요일
console.log(isLocalSunday('2026-01-04')); // true
4.3 월의 날짜 배열
// ❌ Before: 수동 생성 (타임존 문제 가능)
const dates = [];
for (let i = 1; i <= 31; i++) {
dates.push(new Date(`2026-01-${String(i).padStart(2, '0')}`));
}
// ✅ After: 유틸 함수 사용
const dates = getLocalMonthDates(2026, 1);
// ['2026-01-01', '2026-01-02', ..., '2026-01-31']
5. 핵심 개념 정리
5.1 Date 생성자 동작 정리
| 입력 형식 | 해석 방식 | 예시 |
|---|---|---|
'YYYY-MM-DD' |
UTC | new Date('2026-01-31') → UTC 자정 |
'YYYY-MM-DDTHH:mm:ss' |
로컬 | new Date('2026-01-31T12:00:00') → 로컬 정오 |
'YYYY-MM-DDTHH:mm:ssZ' |
UTC | new Date('2026-01-31T12:00:00Z') → UTC 정오 |
(year, month, day) |
로컬 | new Date(2026, 0, 31) → 로컬 자정 |
5.2 정오(12:00)를 사용하는 이유
타임존 오프셋 범위: UTC-12 ~ UTC+14 (총 26시간)
UTC 자정 기준:
- UTC-12: 전날 12:00 (같은 날 ✗)
- UTC+14: 다음날 14:00 (같은 날 ✗)
UTC 12:00 기준:
- UTC-12: 같은 날 00:00 (같은 날 ✓)
- UTC+14: 다음날 02:00 (다른 날이지만, +14 타임존은 드묾)
로컬 12:00 기준 (KST):
- KST 12:00 → UTC 03:00 (같은 날 ✓)
- DB 저장 시에도 같은 날짜 유지
5.3 date-fns vs Day.js vs Moment.js
| 라이브러리 | 번들 크기 | 불변성 | 타임존 지원 |
|---|---|---|---|
| date-fns | 작음 (tree-shaking) | ✓ | date-fns-tz 필요 |
| Day.js | 매우 작음 | ✓ | 플러그인 필요 |
| Moment.js | 큼 | ✗ | 내장 |
6. 베스트 프랙티스
6.1 날짜 처리 체크리스트
□ new Date('YYYY-MM-DD') 대신 parseLocalDate 사용
□ 요일 확인 시 getLocalDayOfWeek 사용
□ 날짜 비교 시 문자열 비교 사용 ('2026-01-31' === '2026-01-31')
□ API 전송 시 ISO 문자열 대신 YYYY-MM-DD 문자열 사용
□ DB 저장 시 정오로 생성된 Date 사용
6.2 API에서 날짜 처리
// API Route에서
export async function GET(request: NextRequest) {
const url = new URL(request.url);
const year = Number(url.searchParams.get('year'));
const month = Number(url.searchParams.get('month'));
const dates = getLocalMonthDates(year, month);
const startDate = parseLocalDate(dates[0]);
const endDate = parseLocalDate(dates[dates.length - 1]);
const records = await prisma.record.findMany({
where: {
date: { gte: startDate, lte: endDate },
},
});
// 응답 시 날짜를 문자열로 변환
return NextResponse.json({
dates,
records: records.map(r => ({
...r,
date: formatLocalDate(r.date),
})),
});
}
6.3 클라이언트에서 날짜 처리
// 그리드 셀 클릭 시
function handleCellClick(dateStr: string) {
// 날짜 문자열을 그대로 사용
console.log('선택된 날짜:', dateStr);
// Date가 필요한 경우에만 변환
const date = parseLocalDate(dateStr);
console.log('요일:', getLocalDayOfWeek(dateStr));
}
7. 테스트
7.1 유닛 테스트
import { describe, it, expect } from 'vitest';
import {
parseLocalDate,
formatLocalDate,
getLocalMonthDates,
isLocalSunday,
} from './date-utils';
describe('parseLocalDate', () => {
it('로컬 타임존으로 날짜 파싱', () => {
const date = parseLocalDate('2026-01-31');
expect(date.getFullYear()).toBe(2026);
expect(date.getMonth()).toBe(0); // 0-indexed
expect(date.getDate()).toBe(31);
});
it('정오(12시)로 생성', () => {
const date = parseLocalDate('2026-01-31');
expect(date.getHours()).toBe(12);
});
});
describe('formatLocalDate', () => {
it('Date를 YYYY-MM-DD로 변환', () => {
const date = new Date(2026, 0, 31); // 로컬 2026-01-31
expect(formatLocalDate(date)).toBe('2026-01-31');
});
});
describe('getLocalMonthDates', () => {
it('1월 31일 배열 반환', () => {
const dates = getLocalMonthDates(2026, 1);
expect(dates.length).toBe(31);
expect(dates[0]).toBe('2026-01-01');
expect(dates[30]).toBe('2026-01-31');
});
it('윤년 2월 29일 배열 반환', () => {
const dates = getLocalMonthDates(2024, 2);
expect(dates.length).toBe(29);
});
});
describe('isLocalSunday', () => {
it('일요일 확인', () => {
// 2026-01-04는 일요일
expect(isLocalSunday('2026-01-04')).toBe(true);
expect(isLocalSunday('2026-01-05')).toBe(false);
});
});
8. 참고 자료
9. 다음 단계
날짜 처리 유틸리티를 만들었다면, Branded Types로 DateString 타입을 더 안전하게 만들어보세요.
시리즈 목차:
- TypeScript Branded Types로 타입 안전성 높이기
- JavaScript 타임존 함정 피하기: UTC vs Local 날짜 처리 ← 현재 글
10. FAQ (자주 묻는 질문)
Q: toISOString()과 formatLocalDate()의 차이는?
A: toISOString()은 항상 UTC 기준 문자열을 반환합니다. 반면 formatLocalDate()는 로컬 타임존 기준으로 변환합니다. 한국에서 new Date().toISOString()은 9시간 전 날짜를 반환할 수 있습니다.
Q: DB에 Date 대신 String으로 저장하면 안 되나요?
A: 가능하지만 권장하지 않습니다. String으로 저장하면 날짜 연산(범위 조회, 정렬)이 비효율적이고, 인덱스 활용도 제한됩니다. 대신 정오로 생성된 Date를 사용하세요.
Q: UTC 기준으로 통일하면 되지 않나요?
A: 서버/DB는 UTC가 좋지만, UI에서 사용자에게 표시할 때는 로컬 타임존이 필요합니다. 문제는 "YYYY-MM-DD"라는 날짜 문자열이 시간 정보가 없어서 UTC로 해석될 때 발생합니다.
Q: 서버 타임존이 UTC인데 어떻게 하나요?
A: 서버 타임존과 무관하게 클라이언트에서 parseLocalDate로 정오 Date를 만들어 전송하면 됩니다. 핵심은 자정이 아닌 정오를 사용하는 것입니다.