시간대 관리와 글로벌 서비스 운영법
전 세계 사용자를 대상으로 하는 서비스에서 시간대 관리는 필수적입니다. 정확한 시간대 처리를 통해 사용자 경험을 향상시키고, 글로벌 비즈니스의 효율성을 극대화할 수 있습니다.
1. 시간대의 기본 이해
시간대 시스템의 구조
협정 세계시 (UTC)
- 기준점: 그리니치 천문대 (경도 0°)
- 표기법: UTC+0, UTC+9 (한국), UTC-5 (뉴욕, 겨울)
- 특징: 윤초 조정, 일광절약시간 영향 없음
- 용도: 서버 시간, 데이터베이스 저장, API 통신
표준시와 일광절약시간
표준시 (Standard Time):
- 각 지역의 기본 시간대
- 연중 고정된 UTC 오프셋
- 예: KST (Korean Standard Time) = UTC+9
일광절약시간 (Daylight Saving Time, DST):
- 여름철 시계를 1시간 앞당김
- 에너지 절약과 일조시간 활용 목적
- 시작/종료일이 해마다 변경 가능
- 예: EDT (Eastern Daylight Time) = UTC-4
시간대 데이터베이스 (IANA tzdata)
구조: 대륙/도시 형식
예시:
- Asia/Seoul (한국)
- America/New_York (미국 동부)
- Europe/London (영국)
- Australia/Sydney (호주 동부)
- Pacific/Auckland (뉴질랜드)
장점:
- 역사적 변경사항 추적
- DST 규칙 자동 적용
- 정치적 변경사항 반영
- 표준화된 식별자 제공
시간대 복잡성 이해
예외적인 시간대들
const unusualTimezones = {
// 30분 오프셋
'Asia/Kolkata': 'UTC+5:30', // 인도
'Asia/Kathmandu': 'UTC+5:45', // 네팔
'Australia/Adelaide': 'UTC+9:30', // 호주 중부
// 45분 오프셋
'Asia/Kathmandu': 'UTC+5:45', // 네팔
// 큰 오프셋
'Pacific/Kiritimati': 'UTC+14', // 키리바시 (가장 빠름)
'Pacific/Honolulu': 'UTC-10', // 하와이
'Pacific/Marquesas': 'UTC-9:30', // 마르키즈 제도
// DST 적용 안함
'Asia/Seoul': 'UTC+9 (no DST)',
'Asia/Tokyo': 'UTC+9 (no DST)',
'Asia/Shanghai': 'UTC+8 (no DST)'
};
연도별 DST 변경 사례
// 미국 DST 규칙 변경 예시
const dstRuleChanges = {
2006: {
start: 'First Sunday in April',
end: 'Last Sunday in October'
},
2007: {
start: 'Second Sunday in March', // 변경됨
end: 'First Sunday in November' // 변경됨
},
current: {
start: 'Second Sunday in March',
end: 'First Sunday in November'
}
};
2. JavaScript를 활용한 시간대 처리
네이티브 JavaScript Date 객체
기본 시간대 조작
class TimezoneHandler {
// 현재 사용자의 시간대 감지
static getUserTimezone() {
return Intl.DateTimeFormat().resolvedOptions().timeZone;
}
// 특정 시간대로 날짜 변환
static convertToTimezone(date, timezone) {
return new Date(date.toLocaleString("en-US", {timeZone: timezone}));
}
// UTC 오프셋 계산
static getTimezoneOffset(timezone, date = new Date()) {
const utc = new Date(date.getTime() + (date.getTimezoneOffset() * 60000));
const local = new Date(utc.toLocaleString("en-US", {timeZone: timezone}));
return (local.getTime() - utc.getTime()) / 60000; // 분 단위
}
// 시간대별 현재 시간 표시
static getCurrentTimeIn(timezone) {
const now = new Date();
const options = {
timeZone: timezone,
year: 'numeric',
month: '2-digit',
day: '2-digit',
hour: '2-digit',
minute: '2-digit',
second: '2-digit',
hour12: false
};
return {
timezone: timezone,
localTime: now.toLocaleString('ko-KR', options),
utcTime: now.toISOString(),
timestamp: now.getTime()
};
}
}
// 사용 예시
console.log(TimezoneHandler.getUserTimezone()); // "Asia/Seoul"
console.log(TimezoneHandler.getCurrentTimeIn('America/New_York'));
Intl.DateTimeFormat 활용
고급 날짜 형식화
class InternationalDateFormatter {
constructor(locale = 'ko-KR') {
this.locale = locale;
}
// 시간대별 다양한 형식으로 표시
formatForTimezone(date, timezone, style = 'full') {
const styles = {
full: {
weekday: 'long',
year: 'numeric',
month: 'long',
day: 'numeric',
hour: '2-digit',
minute: '2-digit',
timeZoneName: 'long'
},
short: {
year: '2-digit',
month: '2-digit',
day: '2-digit',
hour: '2-digit',
minute: '2-digit',
timeZoneName: 'short'
},
time_only: {
hour: '2-digit',
minute: '2-digit',
timeZoneName: 'short'
}
};
return new Intl.DateTimeFormat(this.locale, {
...styles[style],
timeZone: timezone
}).format(date);
}
// 여러 시간대 동시 표시
formatMultipleTimezones(date, timezones) {
return timezones.map(tz => ({
timezone: tz,
formatted: this.formatForTimezone(date, tz, 'short'),
cityName: this.getCityName(tz)
}));
}
getCityName(timezone) {
const cityNames = {
'Asia/Seoul': '서울',
'America/New_York': '뉴욕',
'Europe/London': '런던',
'Asia/Tokyo': '도쿄',
'Australia/Sydney': '시드니',
'America/Los_Angeles': '로스앤젤레스',
'Europe/Paris': '파리',
'Asia/Shanghai': '상하이'
};
return cityNames[timezone] || timezone.split('/')[1];
}
}
// 전 세계 주요 도시 시간 표시
const formatter = new InternationalDateFormatter();
const majorTimezones = [
'Asia/Seoul', 'America/New_York', 'Europe/London',
'Asia/Tokyo', 'Australia/Sydney'
];
const worldClock = formatter.formatMultipleTimezones(new Date(), majorTimezones);
console.log(worldClock);
라이브러리 활용 (Moment.js, Day.js, date-fns)
Day.js를 이용한 고급 시간대 처리
// Day.js with timezone plugin
import dayjs from 'dayjs';
import utc from 'dayjs/plugin/utc';
import timezone from 'dayjs/plugin/timezone';
dayjs.extend(utc);
dayjs.extend(timezone);
class AdvancedTimezoneManager {
constructor() {
this.userTimezone = dayjs.tz.guess(); // 사용자 시간대 자동 감지
}
// 시간대 간 변환
convertBetweenTimezones(dateString, fromTz, toTz) {
return dayjs.tz(dateString, fromTz).tz(toTz);
}
// 비즈니스 시간 계산
getBusinessHours(timezone, date = dayjs()) {
const businessStart = dayjs.tz(date, timezone).hour(9).minute(0);
const businessEnd = dayjs.tz(date, timezone).hour(18).minute(0);
return {
start: businessStart,
end: businessEnd,
isBusinessHours: dayjs.tz(dayjs(), timezone).isBetween(businessStart, businessEnd),
hoursUntilOpen: businessStart.diff(dayjs.tz(dayjs(), timezone), 'hour'),
hoursUntilClose: businessEnd.diff(dayjs.tz(dayjs(), timezone), 'hour')
};
}
// 회의 시간 최적화
findOptimalMeetingTime(timezones, preferredHours = [9, 10, 11, 14, 15, 16]) {
const suggestions = [];
for (let hour of preferredHours) {
const meetingTimes = timezones.map(tz => {
const meetingTime = dayjs().tz(tz).hour(hour).minute(0);
return {
timezone: tz,
time: meetingTime,
hour: meetingTime.hour(),
isBusinessHours: meetingTime.hour() >= 9 && meetingTime.hour() <= 18
};
});
const businessHoursCount = meetingTimes.filter(mt => mt.isBusinessHours).length;
const score = businessHoursCount / timezones.length;
suggestions.push({
utcHour: hour,
meetings: meetingTimes,
score: score,
feasible: score >= 0.7 // 70% 이상이 업무시간
});
}
return suggestions.sort((a, b) => b.score - a.score);
}
// DST 전환 감지
detectDSTTransition(timezone, year = dayjs().year()) {
const transitions = [];
// 3월과 11월 주변에서 DST 전환 확인
for (let month of [3, 11]) {
for (let day = 1; day <= 31; day++) {
try {
const date = dayjs().year(year).month(month - 1).date(day);
const before = dayjs.tz(date.subtract(1, 'day'), timezone);
const after = dayjs.tz(date, timezone);
const offsetBefore = before.utcOffset();
const offsetAfter = after.utcOffset();
if (offsetBefore !== offsetAfter) {
transitions.push({
date: date.format('YYYY-MM-DD'),
type: offsetAfter > offsetBefore ? 'spring_forward' : 'fall_back',
offsetChange: offsetAfter - offsetBefore
});
}
} catch (e) {
// 유효하지 않은 날짜 무시
}
}
}
return transitions;
}
}
3. 글로벌 서비스 아키텍처
데이터베이스 시간 저장 전략
UTC 기준 저장 원칙
-- 올바른 방법: UTC로 저장
CREATE TABLE events (
id SERIAL PRIMARY KEY,
title VARCHAR(255),
event_time TIMESTAMP WITH TIME ZONE, -- UTC로 저장
timezone VARCHAR(50), -- 'Asia/Seoul' 형태
created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
updated_at TIMESTAMP WITH TIME ZONE DEFAULT NOW()
);
-- 사용자 이벤트 생성 예시
INSERT INTO events (title, event_time, timezone) VALUES (
'월간 팀 미팅',
'2024-08-15 02:00:00+00', -- UTC 시간 (한국 시간 11:00 AM)
'Asia/Seoul'
);
-- 사용자의 시간대로 변환하여 조회
SELECT
title,
event_time AT TIME ZONE timezone as local_time,
timezone
FROM events
WHERE user_id = $1;
시간대 정보 관리
// 사용자 시간대 정보 스키마
const userTimezoneSchema = {
userId: 'string',
timezone: 'string', // 'Asia/Seoul'
autoDetect: 'boolean', // 자동 감지 여부
lastUpdated: 'timestamp', // 마지막 업데이트
source: 'string' // 'manual', 'browser', 'ip_location'
};
class UserTimezoneManager {
async updateUserTimezone(userId, timezone, source = 'manual') {
// 시간대 유효성 검증
if (!this.isValidTimezone(timezone)) {
throw new Error(`Invalid timezone: ${timezone}`);
}
await this.database.users.update(userId, {
timezone: timezone,
timezone_source: source,
timezone_updated_at: new Date()
});
// 사용자의 기존 이벤트들을 새 시간대로 재계산할지 확인
await this.notifyTimezoneChange(userId, timezone);
}
isValidTimezone(timezone) {
try {
Intl.DateTimeFormat(undefined, { timeZone: timezone });
return true;
} catch {
return false;
}
}
async getUserLocalTime(userId) {
const user = await this.database.users.findById(userId);
const timezone = user.timezone || this.guessTimezoneFromIP(user.last_ip);
return {
timezone: timezone,
localTime: dayjs().tz(timezone),
utcTime: dayjs().utc(),
confidence: user.timezone ? 'high' : 'medium'
};
}
}
API 시간 처리 표준
RESTful API 시간 형식
// Express.js API 예시
app.use((req, res, next) => {
// 클라이언트 시간대 헤더 처리
const clientTimezone = req.headers['x-timezone'] ||
req.headers['timezone'] ||
'UTC';
// 유효한 시간대인지 검증
try {
Intl.DateTimeFormat(undefined, { timeZone: clientTimezone });
req.clientTimezone = clientTimezone;
} catch {
req.clientTimezone = 'UTC';
}
next();
});
// 이벤트 생성 API
app.post('/api/events', async (req, res) => {
const { title, datetime, timezone } = req.body;
// 클라이언트에서 받은 로컬 시간을 UTC로 변환
const utcTime = dayjs.tz(datetime, timezone).utc().toISOString();
const event = await Event.create({
title,
event_time: utcTime,
timezone: timezone,
user_id: req.user.id
});
// 응답할 때는 클라이언트 시간대로 변환
const localTime = dayjs.utc(event.event_time).tz(req.clientTimezone);
res.json({
...event,
local_time: localTime.format(),
client_timezone: req.clientTimezone
});
});
// 이벤트 목록 조회 API
app.get('/api/events', async (req, res) => {
const events = await Event.findByUserId(req.user.id);
const localizedEvents = events.map(event => ({
...event,
local_time: dayjs.utc(event.event_time).tz(req.clientTimezone).format(),
original_timezone: event.timezone,
client_timezone: req.clientTimezone
}));
res.json(localizedEvents);
});
실시간 시간 동기화
WebSocket을 이용한 실시간 시계
class RealTimeWorldClock {
constructor() {
this.clients = new Map();
this.timezones = [
'Asia/Seoul', 'America/New_York', 'Europe/London',
'Asia/Tokyo', 'Australia/Sydney', 'America/Los_Angeles'
];
// 1초마다 모든 클라이언트에게 시간 전송
setInterval(() => {
this.broadcastTime();
}, 1000);
}
addClient(ws, userId, selectedTimezones = this.timezones) {
this.clients.set(ws, {
userId,
timezones: selectedTimezones,
connected: Date.now()
});
// 연결 즉시 현재 시간 전송
this.sendTimeToClient(ws);
}
sendTimeToClient(ws) {
const client = this.clients.get(ws);
if (!client) return;
const timeData = client.timezones.map(tz => ({
timezone: tz,
time: dayjs().tz(tz).format('YYYY-MM-DD HH:mm:ss'),
city: this.getCityName(tz),
utcOffset: dayjs().tz(tz).format('Z')
}));
ws.send(JSON.stringify({
type: 'time_update',
data: timeData,
timestamp: Date.now()
}));
}
broadcastTime() {
this.clients.forEach((client, ws) => {
if (ws.readyState === WebSocket.OPEN) {
this.sendTimeToClient(ws);
} else {
this.clients.delete(ws);
}
});
}
}
// 클라이언트 측 실시간 시계
class ClientWorldClock {
constructor(wsUrl, timezones) {
this.ws = new WebSocket(wsUrl);
this.timezones = timezones;
this.clockElements = new Map();
this.setupWebSocket();
this.createClockElements();
}
setupWebSocket() {
this.ws.onmessage = (event) => {
const message = JSON.parse(event.data);
if (message.type === 'time_update') {
this.updateClocks(message.data);
}
};
this.ws.onopen = () => {
// 원하는 시간대 목록 전송
this.ws.send(JSON.stringify({
type: 'set_timezones',
timezones: this.timezones
}));
};
}
updateClocks(timeData) {
timeData.forEach(({ timezone, time, city, utcOffset }) => {
const element = this.clockElements.get(timezone);
if (element) {
element.querySelector('.time').textContent = time;
element.querySelector('.offset').textContent = utcOffset;
}
});
}
}