Tutorial

일광 절약 시간 변경 처리: 튜토리얼 완료

소개

일광 절약 시간(DST) 전환은 타임스탬프 작업에서 가장 어려운 측면 중 하나입니다. 1년에 두 번, 시계가 "앞으로 튀어나오거나" "후퇴"하여 버그, 데이터 손실 및 잘못된 계산을 유발할 수 있는 극단적인 경우가 발생합니다. 이 튜토리얼에서는 애플리케이션에서 DST 전환을 올바르게 처리하는 방법을 알려줍니다.

DST 전환 이해

DST 중에는 어떤 일이 발생하나요?

스프링 포워드(DST 시작)

  • 시계가 1시간 앞으로 이동합니다(일반적으로 오전 2시 → 오전 3시).
  • 한 시간이 "빠졌습니다" - 오전 2시 30분과 같은 시간은 존재하지 않습니다.
  • 기간: 하루는 단 23시간입니다.

폴백(DST 종료)

  • 시계가 1시간 뒤로 이동합니다(일반적으로 오전 2시 → 오전 1시).
  • 1시간이 "중복"됩니다. - 오전 1시 30분이 두 번 발생합니다.
  • 기간: 하루는 25시간입니다.

실제 영향

// Spring Forward - March 10, 2024 (US)
// Problem: Scheduled task at 2:30 AM doesn't execute
const scheduled = new Date('2024-03-10T02:30:00');
// This time doesn't exist! JavaScript may interpret it as 3:30 AM

// Fall Back - November 3, 2024 (US)
// Problem: Log entries at 1:30 AM appear twice
const firstOccurrence = new Date('2024-11-03T01:30:00-04:00');  // EDT
const secondOccurrence = new Date('2024-11-03T01:30:00-05:00'); // EST
// Same wall clock time, different actual times!

일반적인 DST 문제

문제 1: 누락된 시간(봄 앞으로)

시계가 앞으로 나아갈 때 누락된 시간에 타임스탬프를 생성하려고 하면 예기치 않은 동작이 발생할 수 있습니다.

자바스크립트 동작

// March 10, 2024, 2:30 AM in New York doesn't exist
const date = new Date('2024-03-10T02:30:00');

console.log(date.toLocaleString('en-US', {
  timeZone: 'America/New_York',
  hour12: false
}));
// Output varies by browser/environment
// Most will interpret as 3:30 AM or 1:30 AM

Python 동작

from datetime import datetime
import pytz

ny_tz = pytz.timezone('America/New_York')

# Naive datetime in missing hour
try:
    dt = ny_tz.localize(datetime(2024, 3, 10, 2, 30))
    print(dt)
except pytz.exceptions.NonExistentTimeError as e:
    print(f"Error: {e}")
    # Error: 2024-03-10 02:30:00

해결책: 존재하지 않는 시간을 명시적으로 처리

# Option 1: Use is_dst parameter
dt = ny_tz.localize(datetime(2024, 3, 10, 2, 30), is_dst=None)
# Raises exception for ambiguous time

# Option 2: Normalize after creation
naive_dt = datetime(2024, 3, 10, 2, 30)
dt = ny_tz.normalize(ny_tz.localize(naive_dt, is_dst=False))
print(dt)
# 2024-03-10 03:30:00 EDT (adjusted forward)

문제 2: 시간 중복(대체)

시계가 뒤로 떨어지면 동일한 벽시계 시간이 두 번 발생하여 모호성을 유발합니다.

자바스크립트 예

// November 3, 2024, 1:30 AM in New York occurs twice
// First occurrence (before fall back)
const first = new Date('2024-11-03T01:30:00-04:00');  // EDT

// Second occurrence (after fall back)
const second = new Date('2024-11-03T01:30:00-05:00'); // EST

console.log(first.getTime());  // 1730616600000
console.log(second.getTime()); // 1730620200000
console.log(second - first);   // 3600000 (1 hour difference)

파이썬 예제

from datetime import datetime
import pytz

ny_tz = pytz.timezone('America/New_York')

# Ambiguous time - which occurrence?
try:
    dt = ny_tz.localize(datetime(2024, 11, 3, 1, 30))
except pytz.exceptions.AmbiguousTimeError as e:
    print(f"Ambiguous: {e}")

# Specify which occurrence
first = ny_tz.localize(datetime(2024, 11, 3, 1, 30), is_dst=True)   # Before fall back
second = ny_tz.localize(datetime(2024, 11, 3, 1, 30), is_dst=False) # After fall back

print(first)   # 2024-11-03 01:30:00 EDT-0400
print(second)  # 2024-11-03 01:30:00 EST-0500

문제 3: 잘못된 기간 계산

DST 전환은 달력 기반 산술을 사용할 때 기간 계산에 영향을 줍니다.

// Calculate hours between midnight on DST transition days

// Spring Forward Day (23 hours)
const springStart = new Date('2024-03-10T00:00:00');
const springEnd = new Date('2024-03-10T23:59:59');
const springHours = (springEnd - springStart) / 3600000;
console.log(springHours); // 23.999... hours (not 24!)

// Fall Back Day (25 hours)
const fallStart = new Date('2024-11-03T00:00:00');
const fallEnd = new Date('2024-11-03T23:59:59');
const fallHours = (fallEnd - fallStart) / 3600000;
console.log(fallHours); // 24.999... hours (appears normal but day is 25 hours)

DST 처리 모범 사례

1. 항상 시간대 인식 날짜/시간을 사용하세요.

date-fns-tz를 사용하는 JavaScript

import { zonedTimeToUtc, utcToZonedTime, format } from 'date-fns-tz';

// Always work in UTC internally
const utcDate = zonedTimeToUtc('2024-03-10 02:30', 'America/New_York');

// Convert to local only for display
const nyDate = utcToZonedTime(utcDate, 'America/New_York');
console.log(format(nyDate, 'yyyy-MM-dd HH:mm:ss zzz', { timeZone: 'America/New_York' }));

pytz를 사용한 Python

from datetime import datetime
import pytz

# Always work with timezone-aware datetimes
utc = pytz.UTC
ny_tz = pytz.timezone('America/New_York')

# Create timezone-aware datetime
dt_utc = datetime(2024, 3, 10, 7, 30, tzinfo=utc)  # UTC time
dt_ny = dt_utc.astimezone(ny_tz)  # Convert to NY time

print(dt_ny)  # 2024-03-10 03:30:00 EDT (automatically adjusted for DST)

2. DST 전환 감지

자바스크립트: 날짜가 DST인지 확인하세요

function isDST(date, timezone) {
  const jan = new Date(date.getFullYear(), 0, 1);
  const jul = new Date(date.getFullYear(), 6, 1);

  const janOffset = jan.getTimezoneOffset();
  const julOffset = jul.getTimezoneOffset();

  const stdOffset = Math.max(janOffset, julOffset);
  const currentOffset = date.getTimezoneOffset();

  return currentOffset < stdOffset;
}

const winterDate = new Date('2024-01-15T12:00:00');
const summerDate = new Date('2024-07-15T12:00:00');

console.log(isDST(winterDate)); // false
console.log(isDST(summerDate)); // true

Python: DST 전환 날짜 찾기

from datetime import datetime, timedelta
import pytz

def find_dst_transitions(year, timezone_name):
    """Find DST transition dates for a given year and timezone."""
    tz = pytz.timezone(timezone_name)
    transitions = []

    # Check each day of the year
    for day in range(365):
        date = datetime(year, 1, 1) + timedelta(days=day)
        today = tz.localize(datetime(year, 1, 1) + timedelta(days=day), is_dst=None)
        tomorrow = tz.localize(datetime(year, 1, 1) + timedelta(days=day + 1), is_dst=None)

        # Check if UTC offset changed
        if today.utcoffset() != tomorrow.utcoffset():
            transitions.append({
                'date': date.strftime('%Y-%m-%d'),
                'from_offset': str(today.utcoffset()),
                'to_offset': str(tomorrow.utcoffset()),
                'type': 'Spring Forward' if today.utcoffset() < tomorrow.utcoffset() else 'Fall Back'
            })

    return transitions

# Find 2024 DST transitions in New York
transitions = find_dst_transitions(2024, 'America/New_York')
for t in transitions:
    print(f"{t['date']}: {t['type']} ({t['from_offset']} → {t['to_offset']})")
# Output:
# 2024-03-10: Spring Forward (-5:00:00 → -4:00:00)
# 2024-11-03: Fall Back (-4:00:00 → -5:00:00)

3. 누락된 시간을 적절하게 처리

자바스크립트: 유효한 시간으로 정규화

function normalizeToValidTime(dateString, timezone) {
  try {
    // Attempt to create date
    const date = new Date(dateString);

    // Check if time exists by comparing round-trip conversion
    const formatted = date.toLocaleString('en-US', {
      timeZone: timezone,
      year: 'numeric',
      month: '2-digit',
      day: '2-digit',
      hour: '2-digit',
      minute: '2-digit',
      second: '2-digit',
      hour12: false
    });

    // If times don't match, time was adjusted
    return {
      original: dateString,
      normalized: date.toISOString(),
      wasAdjusted: !dateString.includes(formatted.split(',')[1].trim())
    };
  } catch (error) {
    return { error: error.message };
  }
}

const result = normalizeToValidTime('2024-03-10T02:30:00', 'America/New_York');
console.log(result);
// { original: '2024-03-10T02:30:00', normalized: '2024-03-10T07:30:00.000Z', wasAdjusted: true }

Python: 명시적인 DST 처리

def safe_localize(tz, dt, prefer_dst=True):
    """
    Safely localize datetime, handling DST transitions.

    Args:
        tz: pytz timezone
        dt: naive datetime
        prefer_dst: If True, prefer DST time during ambiguous hour

    Returns:
        Localized datetime
    """
    try:
        # Try to localize normally
        return tz.localize(dt, is_dst=None)
    except pytz.exceptions.AmbiguousTimeError:
        # Ambiguous time (fall back) - specify preference
        return tz.localize(dt, is_dst=prefer_dst)
    except pytz.exceptions.NonExistentTimeError:
        # Non-existent time (spring forward) - normalize forward
        return tz.normalize(tz.localize(dt, is_dst=False))

# Usage
ny_tz = pytz.timezone('America/New_York')

# Missing hour
missing = safe_localize(ny_tz, datetime(2024, 3, 10, 2, 30))
print(missing)  # 2024-03-10 03:30:00 EDT (adjusted forward)

# Duplicate hour
duplicate = safe_localize(ny_tz, datetime(2024, 11, 3, 1, 30), prefer_dst=True)
print(duplicate)  # 2024-11-03 01:30:00 EDT (first occurrence)

4. 타임스탬프를 UTC로 저장

타임스탬프는 항상 UTC로 저장하고 표시용으로만 현지 시간으로 변환하세요.

// Database storage pattern
class EventScheduler {
  // Store in UTC
  scheduleEvent(localDateString, timezone) {
    const localDate = new Date(localDateString);
    const utcTimestamp = localDate.getTime();

    // Save to database
    return {
      utc_timestamp: utcTimestamp,
      utc_iso: new Date(utcTimestamp).toISOString(),
      original_timezone: timezone
    };
  }

  // Retrieve and display in local time
  getEventInTimezone(utcTimestamp, timezone) {
    const date = new Date(utcTimestamp);
    return date.toLocaleString('en-US', {
      timeZone: timezone,
      dateStyle: 'full',
      timeStyle: 'long'
    });
  }
}

const scheduler = new EventScheduler();

// Schedule event
const event = scheduler.scheduleEvent('2024-03-10T02:30:00', 'America/New_York');
console.log(event);
// { utc_timestamp: 1710054600000, utc_iso: '2024-03-10T07:30:00.000Z', ... }

// Display in different timezones
console.log(scheduler.getEventInTimezone(event.utc_timestamp, 'America/New_York'));
console.log(scheduler.getEventInTimezone(event.utc_timestamp, 'Europe/London'));

5. DST 엣지 케이스 테스트

항상 DST 전환 날짜를 사용하여 코드를 테스트하세요.

// Test suite for DST handling
describe('DST Transition Tests', () => {
  const dstDates = {
    springForward: '2024-03-10',
    fallBack: '2024-11-03',
    missingHour: '2024-03-10T02:30:00',
    duplicateHour: '2024-11-03T01:30:00'
  };

  test('handles missing hour in spring forward', () => {
    const result = normalizeToValidTime(
      dstDates.missingHour,
      'America/New_York'
    );
    expect(result.wasAdjusted).toBe(true);
  });

  test('duration calculation on spring forward day', () => {
    const start = new Date(`${dstDates.springForward}T00:00:00`);
    const end = new Date(`${dstDates.springForward}T23:59:59`);
    const hours = (end - start) / 3600000;
    expect(hours).toBeCloseTo(23, 0); // 23 hours, not 24
  });

  test('duration calculation on fall back day', () => {
    const start = new Date(`${dstDates.fallBack}T00:00:00`);
    const end = new Date(`${dstDates.fallBack}T23:59:59`);
    const hours = (end - start) / 3600000;
    expect(hours).toBeCloseTo(24, 0); // Appears as 24 but day is 25 hours
  });
});

실제 시나리오

시나리오 1: 반복 이벤트 예약

from datetime import datetime, timedelta
import pytz

def schedule_daily_task(start_date, local_time, timezone_name, days=30):
    """
    Schedule a task at the same local time each day, accounting for DST.
    """
    tz = pytz.timezone(timezone_name)
    events = []

    for day in range(days):
        # Create naive datetime for each day
        date = start_date + timedelta(days=day)
        naive_dt = datetime.combine(date, local_time)

        # Safely localize (handles DST transitions)
        try:
            localized = tz.localize(naive_dt, is_dst=None)
        except pytz.exceptions.NonExistentTimeError:
            # Time doesn't exist (spring forward), adjust forward
            localized = tz.normalize(tz.localize(naive_dt, is_dst=False))
        except pytz.exceptions.AmbiguousTimeError:
            # Time occurs twice (fall back), use first occurrence
            localized = tz.localize(naive_dt, is_dst=True)

        events.append({
            'local_time': localized.strftime('%Y-%m-%d %H:%M:%S %Z'),
            'utc_time': localized.astimezone(pytz.UTC).strftime('%Y-%m-%d %H:%M:%S UTC'),
            'timestamp': int(localized.timestamp())
        })

    return events

# Schedule daily task at 2:00 AM, crossing DST boundary
from datetime import time, date
events = schedule_daily_task(
    start_date=date(2024, 3, 8),
    local_time=time(2, 0, 0),
    timezone_name='America/New_York',
    days=5
)

for event in events:
    print(f"{event['local_time']} → {event['utc_time']}")
# Output shows how UTC time shifts on DST transition day

시나리오 2: DST에 따른 업무 시간 계산

function calculateBusinessHours(startDate, endDate, timezone) {
  const businessHoursPerDay = 8; // 9 AM - 5 PM
  const businessStart = 9;
  const businessEnd = 17;

  let totalHours = 0;
  let currentDate = new Date(startDate);

  while (currentDate <= endDate) {
    const dayOfWeek = currentDate.getDay();

    // Skip weekends
    if (dayOfWeek !== 0 && dayOfWeek !== 6) {
      // Check if it's a DST transition day
      const dayStart = new Date(currentDate);
      dayStart.setHours(0, 0, 0, 0);
      const dayEnd = new Date(currentDate);
      dayEnd.setHours(23, 59, 59, 999);

      const dayLength = (dayEnd - dayStart) / 3600000;

      if (dayLength < 24) {
        // Spring forward - missing hour might affect business hours
        console.log(`Spring forward on ${currentDate.toDateString()}`);
        totalHours += Math.min(businessHoursPerDay, dayLength - (24 - dayLength));
      } else if (dayLength > 24) {
        // Fall back - extra hour
        console.log(`Fall back on ${currentDate.toDateString()}`);
        totalHours += businessHoursPerDay; // Business hours unchanged
      } else {
        totalHours += businessHoursPerDay;
      }
    }

    currentDate.setDate(currentDate.getDate() + 1);
  }

  return totalHours;
}

// Calculate business hours March 1-15, 2024 (includes DST transition)
const hours = calculateBusinessHours(
  new Date('2024-03-01'),
  new Date('2024-03-15'),
  'America/New_York'
);
console.log(`Total business hours: ${hours}`);

일반적인 함정

❌ 이러지 마세요

// Bad: Assuming all days are 24 hours
const tomorrow = new Date(today);
tomorrow.setDate(tomorrow.getDate() + 1);
const hours = (tomorrow - today) / 3600000; // Won't be exactly 24 on DST days!

// Bad: Using string concatenation for times
const timeString = `${year}-${month}-${day} 02:30:00`;
const date = new Date(timeString); // Might not exist on spring forward day!

// Bad: Ignoring timezone in calculations
const event1 = new Date('2024-11-03T01:30:00'); // Which occurrence?

✅ 대신 이렇게 하세요

// Good: Use UTC for calculations
const tomorrow = new Date(today.getTime() + 86400000); // Always exactly 24 hours

// Good: Use timezone-aware libraries
import { zonedTimeToUtc } from 'date-fns-tz';
const safeDate = zonedTimeToUtc('2024-03-10 02:30', 'America/New_York');

// Good: Always include timezone offset
const event1 = new Date('2024-11-03T01:30:00-04:00'); // First occurrence (EDT)
const event2 = new Date('2024-11-03T01:30:00-05:00'); // Second occurrence (EST)

요약 및 모범 사례

주요 내용

  1. UTC로 저장 - 타임스탬프는 항상 UTC로 저장하고 표시용으로만 로컬로 변환합니다.
  2. 시간대 인식 라이브러리 사용 - DST를 수동으로 처리하려고 하지 마세요.
  3. 특정 사례 검증 - 스프링 포워드 및 폴백 날짜를 사용하여 테스트
  4. 모호함을 명시적으로 처리 - 중복된 영업시간이 무엇인지 지정하세요.
  5. 누락된 시간 정규화 - 존재하지 않는 시간을 앞으로 조정하거나 가장 가까운 유효 시간을 사용합니다.

추천 라이브러리

자바스크립트:

  • date-fns-tz - date-fns에 대한 시간대 지원
  • luxon - 탁월한 DST 처리 기능을 갖춘 최신 날짜/시간 라이브러리
  • moment-timezone - 포괄적인 시간대 데이터베이스(유지 관리 모드)

파이썬:

  • pytz - Python용 표준 시간대 라이브러리
  • dateutil - 우수한 DST 지원을 갖춘 대안
  • zoneinfo(Python 3.9+) - 내장 시간대 지원

빠른 참조

문제솔루션
누락된 시간(봄 앞으로)다음 유효 시간으로 정규화(1시간 앞으로 이동)
시간 중복(대체)시간대 오프셋을 사용하여 발생 항목 지정
기간 계산현지 시간이 아닌 UTC 타임스탬프 사용
반복되는 이벤트각 항목을 개별적으로 현지화
테스트항상 DST 전환 날짜로 테스트

관련 리소스