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 타입을 더 안전하게 만들어보세요.

시리즈 목차:

  1. TypeScript Branded Types로 타입 안전성 높이기
  2. 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를 만들어 전송하면 됩니다. 핵심은 자정이 아닌 정오를 사용하는 것입니다.