날짜와 숫자가 현지화가 필요한 이유
TABLE OF CONTENTS
전 세계적으로 배송하는 경우, 동일한 문자열이 서로 다른 사용자에게 다른 의미를 가질 수 있습니다: 12/01/2025, 1.234, 1,234, 12:00, 00:00. 이러한 작은 차이는 단순한 외관상의 문제가 아닙니다. 그것들은 신뢰, 이해, 심지어 법적 준수에 영향을 미칩니다. 이 가이드는 날짜와 숫자가 현지화되어야 하는 이유, 지역에 따라 실제로 무엇이 변하는지, 그리고 제품 전반에 걸쳐 이를 올바르게 구현하는 방법을 설명합니다.
지역에 따른 변화
- 날짜: 순서 (MDY vs DMY vs YMD), 구분자, 월/일 이름; 일부 시장은 비그레고리력 달력을 사용합니다.
- 시간: 12시간제 vs 24시간제, AM/PM 표기, 시간대, 일광 절약 시간제 규칙, 주 시작일 (일요일 vs 월요일).
- 숫자: 소수 구분자 (
.vs,), 그룹화 스타일 (1,234vs1.234vs1 234), 인도식 그룹화 (1,23,456), 비분리 공백, 마이너스/플러스 스타일 (하이픈 vs 진정한 마이너스−). - 통화: 기호 vs ISO 코드, 기호 배치 (접두사/접미사), 좁은 vs 넓은 간격, 소수점 정밀도 (0, 2, 또는 3+), 회계 부정 (예:
(1 234,56 €)). - 퍼센트 및 단위: 간격 및 부호 배치 (
50%vs50 %), 단위의 지역 이름, 미터법 vs 영국식.
비즈니스 영향
- 신뢰와 전환: “잘못된” 가격, 수수료, 날짜는 구매를 줄이고 이탈을 증가시킵니다.
- 운영 위험: 잘못 읽은 날짜는 예약이나 마감일을 변경할 수 있으며, 구문 분석 오류는 송장, 수출 및 분석을 중단시킵니다.
- 준수: 잘못된 형식의 금융 문서는 송장, 세금 또는 보고 규칙을 위반할 수 있습니다.
- 지원 부담: 사용자는 기대에 맞지 않는 시간, 통화 및 숫자 입력을 명확히 하기 위해 티켓을 엽니다.
실제 사건
유럽의 여행 예약 플랫폼은 출발 날짜를 01/03/2024로 표시했으나 지역적 맥락이 없었습니다. 미국 고객은 이를 “1월 3일”로 해석한 반면, 영국 및 유럽 고객은 “3월 1일”로 읽었습니다.
영향:
- 국제 예약의 12%가 잘못된 날짜로 이루어짐
- 고객 서비스는 일주일 동안 3,400개 이상의 명확화 티켓을 받음
- $2.3M의 환불 및 재예약 수수료 발생
- 영향을 받은 고객의 8%가 경쟁사로 이탈
근본 원인: 이메일 확인서에 DD/MM/YYYY 형식이 하드코딩되어 있었고, 웹사이트는 미국 사용자에게 MM/DD/YYYY 형식을 사용함. 수정에는 3시간의 엔지니어링 시간이 필요했으며, 모호성으로 인한 비용은 예방 비용의 800배에 달함.
교훈: 맥락 없는 날짜는 글로벌 제품에 있어 시한폭탄과 같음.
일반적인 함정
- 하드코딩된 형식: UI, 이메일, PDF, CSV 내보내기에
MM/DD/YYYY또는1,234.56이 내장됨. - 로컬화된 문자열 저장: ISO 시간 대신 “Dec 1, 2025”를 저장하여 구문 분석 버그와 시간대 드리프트를 유발함.
- 단순한 구문 분석: 서버 로케일 기본값 사용; 구분자 또는 12/24시간 시계를 가정함.
- 문자열 연결:
"$" + 1234.56과 같은 “금액 + 통화”를 빌드하는 대신 로케일 인식 형식을 사용함. - 시간대 모호성: 시간에 민감한 작업에 대해 명시적 시간대 없이 현지 시간을 표시함.
- 브라우저 불일치: Safari, Firefox, Chrome이 날짜/숫자를 다르게 형식화할 수 있으므로 모든 대상 브라우저에서 항상 테스트해야 함.
- 서버 측 렌더링 격차: Node.js가 전체 ICU 데이터 없이 (
node --with-intl=full-icu) 불완전하거나 잘못된 형식을 생성함. - 성능 함정: 루프나 렌더 사이클에서 새로운
Intl.*인스턴스를 생성하는 대신 포맷터를 캐싱함.
모범 사례
- 표준값 저장: 날짜/시간은 UTC의 ISO‑8601로, 금액은 소수 단위(센트) 또는 고정밀 소수로 저장합니다.
- 사용자 로케일에 따라 렌더링: 표시를 위해 로케일 인식 API(예:
Intl.DateTimeFormat,Intl.NumberFormat)를 사용합니다. - 로케일에 따라 유효성 검사 및 구문 분석: 적절한 경우 로케일 입력을 허용하고, 사용자 안내를 위해 자리 표시자/예제를 표시합니다.
- 시간을 명확히 표시: 시간대 약어 또는 오프셋을 표시하고, 결과가 시간에 따라 달라질 경우 사용자 선택을 허용합니다.
- 통화 명확성: 모호성이 있는 경우 ISO 코드를 선호하고, 로케일에 따라 기호 배치 및 간격을 존중합니다.
- 포맷 중앙화: 모든 표면(UI, 이메일, PDF, 내보내기)에 대해 일관성을 보장하기 위해 하나의 유틸리티 레이어를 사용합니다.
- 포맷터 캐시:
Intl.*생성자 호출은 비용이 많이 드므로, 한 번 생성하고 로케일별로 인스턴스를 재사용합니다.
로컬라이즈하지 말아야 할 경우
모든 것이 로컬라이즈되어야 하는 것은 아닙니다. 일관된 기계 판독 형식이 더 나은 시나리오가 있습니다:
- 기계 판독 가능 내보내기: 데이터 파이프라인 또는 분석 도구에서 소비되는 CSV 파일은 고정 형식(예: ISO-8601 날짜, 소수 구분자로서의 점)을 사용해야 합니다. 내보내기 헤더 또는 README 파일에 이 형식을 명확히 문서화하십시오.
- API 응답: REST/GraphQL API는 날짜를 ISO-8601 문자열로, 숫자를 표준 JSON 형식으로 반환해야 합니다. 클라이언트가 사용자 선호에 따라 로컬라이제이션을 처리하도록 합니다.
- 내부 로그 및 메트릭: 로그, 모니터링 대시보드 및 데이터베이스 쿼리는 구문 분석, 집계 및 경고를 위해 일관된 형식의 이점을 누립니다. ISO-8601 및 표준 숫자 형식을 사용하십시오.
- 표준 식별자: 거래 ID, 주문 번호 또는 사용자가 로케일 간에 통신해야 할 수 있는 참조는 로케일별 형식을 피해야 합니다.
기본 원칙: 사람의 콘텐츠 읽기를 위해 로컬라이즈하고, 데이터 처리 기계를 위해 표준 형식을 사용하십시오.
구현 계획
- 감사 표면: UI, 이메일, PDF, CSV/Excel 내보내기, 분석, 로그에서 날짜, 시간, 숫자, 통화의 인벤토리.
- 로케일 정의: 지원되는 로케일과 기본 폴백 목록; 로케일별 12/24시간 정책 지정.
- 유틸리티 구축:
Intl.*API(또는 라이브러리)를 공유 헬퍼 및 테스트와 함께 래핑; 포매터 캐싱 구현. - 입력 전략: 양식을 업데이트하여 로케일 형식을 수락하고 검증; 원시 입력과 함께 표준 값을 저장.
- 콘텐츠 규칙: 짧은/긴 날짜, 상대 날짜, 통화 표시 및 백분율 형식에 대한 스타일 문서화.
- 롤아웃: 트래픽이 가장 많고 위험이 가장 높은 표면을 먼저 변환; 필요시 기능 플래그 뒤에 배포.
테스트 전략
수동 QA 외에도, 회귀를 방지하기 위해 자동화된 테스트를 구현:
- 스냅샷 테스트: 지원되는 각 로케일에 대한 형식화된 출력 고정. 변경 사항은 의도적인지 확인하기 위해 검토를 유발.
- 왕복 테스트:
format → parse → format이 안정적인 결과를 생성하는지 확인. 정밀도 손실 또는 모호한 형식을 포착. - 엣지 케이스: 음수, 0, 매우 큰 숫자(십억/조), DST 전환, 윤년, 경계 날짜(1월 1일, 12월 31일) 테스트.
- 크로스 브라우저 검증: Chrome, Firefox, Safari, Edge에서 자동화된 테스트로 렌더링 차이 포착.
- 로케일 폴백: 지원되지 않는 로케일이 요청될 때 우아한 저하 확인.
- 성능 벤치마크: 포매터 생성 비용 측정; 프로덕션에서 캐싱이 작동하는지 확인.
성능 팁
Intl.* API는 강력하지만 잘못 사용하면 느릴 수 있습니다. 다음 지침을 따르세요:
// ❌ 나쁨: 호출할 때마다 새로운 포매터 생성
function formatPrice(amount, locale) {
return new Intl.NumberFormat(locale, {
style: 'currency',
currency: 'USD'
}).format(amount);
}
```js
// ✅ GOOD: Cache formatters per locale
const formatters = new Map();
function getFormatter(locale) {
if (!formatters.has(locale)) {
formatters.set(locale, new Intl.NumberFormat(locale, {
style: 'currency',
currency: 'USD'
}));
}
return formatters.get(locale);
}
function formatPrice(amount, locale) {
return getFormatter(locale).format(amount);
}
- 포매터 인스턴스 캐시:
Intl.*생성은 비용이 많이 듭니다 (~1–5ms); 기존 인스턴스로 포맷팅하는 것은 빠릅니다 (~0.01ms). - 루프 피하기: 절대
map(),forEach(), 또는 렌더 루프 내에서 포매터를 생성하지 마세요. - 로케일 데이터 지연 로드: 웹 앱의 경우, 코드 스플리팅을 고려하여 필요한 로케일만 로드하여 번들 크기를 줄이세요.
- 서버 측: 요청/응답 주기당 하나의 포매터를 사용하세요; 대규모 데이터 세트에서 항목별로 생성하지 마세요.
QA 체크리스트
- 날짜는
en-US,en-GB,de-DE,fr-FR,hi-IN,ja-JP,ar-EG에서 모호하지 않습니다. - 시간은 올바른 12/24시간 표기법을 사용하며, AM/PM 표시는 예상대로 나타납니다; 시간에 민감한 흐름에 대해 시간대가 표시됩니다.
- 숫자는 올바른 소수 및 그룹 구분 기호를 사용합니다; 표준인 경우 비분리 공백이 사용됩니다 (예:
fr-FR). - 통화는 각 로케일에 대해 올바른 기호/코드, 위치 및 소수 정밀도를 표시합니다; 음수는 올바르게 표시됩니다.
- 입력은 사용자 로케일 데이터를 수용하고 구문 분석하거나 지원되는 형식을 명확히 적용합니다; 유효성 검사 메시지는 로케일을 인식합니다.
- 내보내기(CSV/PDF)는 로케일 기대에 부합하거나 기계 소비를 위한 고정 형식을 명확히 문서화합니다.
- 크로스 브라우저: Chrome, Firefox, Safari, Edge에서 날짜, 시간, 숫자가 일관되게 렌더링됩니다.
- 성능: 루프 내에서 포매터 생성 없음; 렌더 간에 캐시된 인스턴스를 재사용합니다.
코드 예제
JavaScript
// Dates
const date = new Date('2025-01-12T00:00:00Z');
console.log(new Intl.DateTimeFormat('en-US', { dateStyle: 'short', timeZone: 'UTC' }).format(date));
// → 1/12/25
console.log(new Intl.DateTimeFormat('en-GB', { dateStyle: 'short', timeZone: 'UTC' }).format(date));
// → 12/01/2025 (day/month/year)
console.log(new Intl.DateTimeFormat('ja-JP', { dateStyle: 'medium', timeZone: 'UTC' }).format(date));
// → 2025/01/12
// Times with timezone display
const time = new Date('2025-01-12T18:05:00Z');
console.log(new Intl.DateTimeFormat('en-US', {
dateStyle: 'short',
timeStyle: 'short',
timeZone: 'America/New_York',
timeZoneName: 'short'
}).format(time));
// → 1/12/25, 1:05 PM EST
console.log(new Intl.DateTimeFormat('de-DE', {
timeStyle: 'short',
timeZone: 'Europe/Berlin',
hour12: false
}).format(time));
// → 19:05
// Numbers
const n = 1234.56;
console.log(new Intl.NumberFormat('en-US').format(n)); // → 1,234.56
console.log(new Intl.NumberFormat('de-DE').format(n)); // → 1.234,56
console.log(new Intl.NumberFormat('fr-FR').format(n)); // → 1 234,56 (NBSP as group separator)
// Indian numbering
console.log(new Intl.NumberFormat('hi-IN').format(1234567.89)); // → 12,34,567.89
// Currency
console.log(new Intl.NumberFormat('en-US', { style: 'currency', currency: 'USD' }).format(n));
// → $1,234.56
console.log(new Intl.NumberFormat('de-DE', { style: 'currency', currency: 'EUR' }).format(n));
// → 1.234,56 €
console.log(new Intl.NumberFormat('ja-JP', { style: 'currency', currency: 'JPY' }).format(1234));
// → ¥1,234 (no decimals)
// Percent and units
console.log(new Intl.NumberFormat('en-US', { style: 'percent', maximumFractionDigits: 0 }).format(0.5));
// → 50%
console.log(new Intl.NumberFormat('fr-FR', { style: 'percent', maximumFractionDigits: 0 }).format(0.5));
// → 50 %
// Formatter caching example
class LocaleFormatter {
constructor() {
this.cache = new Map();
}
getDateFormatter(locale, options) {
const key = `date:${locale}:${JSON.stringify(options)}`;
if (!this.cache.has(key)) {
this.cache.set(key, new Intl.DateTimeFormat(locale, options));
}
return this.cache.get(key);
}
formatDate(date, locale, options = { dateStyle: ‘medium’ }) { return this.getDateFormatter(locale, options).format(date); } }
const formatter = new LocaleFormatter(); console.log(formatter.formatDate(new Date(), ‘en-US’)); // → Jan 12, 2025 console.log(formatter.formatDate(new Date(), ‘fr-FR’)); // → 12 janv. 2025
### Python
```python
from babel.dates import format_date, format_time, format_datetime
from babel.numbers import format_number, format_currency, format_percent
from datetime import datetime
import pytz
# Dates
date = datetime(2025, 1, 12)
print(format_date(date, format='short', locale='en_US')) # → 1/12/25
print(format_date(date, format='short', locale='en_GB')) # → 12/01/2025
print(format_date(date, format='medium', locale='ja_JP')) # → 2025/01/12
# Times with timezone
tz_ny = pytz.timezone('America/New_York')
tz_berlin = pytz.timezone('Europe/Berlin')
time = datetime(2025, 1, 12, 18, 5, tzinfo=pytz.UTC)
print(format_datetime(time.astimezone(tz_ny), 'short', tzinfo=tz_ny, locale='en_US'))
# → 1/12/25, 1:05 PM
print(format_time(time.astimezone(tz_berlin), format='short', tzinfo=tz_berlin, locale='de_DE'))
# → 19:05
# Numbers
n = 1234.56
print(format_number(n, locale='en_US')) # → 1,234.56
print(format_number(n, locale='de_DE')) # → 1.234,56
print(format_number(n, locale='fr_FR')) # → 1 234,56
# Indian numbering
print(format_number(1234567.89, locale='hi_IN')) # → 12,34,567.89
# Currency
print(format_currency(n, 'USD', locale='en_US')) # → $1,234.56
print(format_currency(n, 'EUR', locale='de_DE')) # → 1.234,56 €
print(format_currency(1234, 'JPY', locale='ja_JP')) # → ¥1,234
# Percent
print(format_percent(0.5, locale='en_US')) # → 50%
print(format_percent(0.5, locale='fr_FR')) # → 50 %
Java
import java.text.NumberFormat;
import java.time.ZonedDateTime;
import java.time.ZoneId;
import java.time.format.DateTimeFormatter;
import java.time.format.FormatStyle;
import java.util.Locale;
import java.util.Currency;
// Dates
ZonedDateTime date = ZonedDateTime.of(2025, 1, 12, 0, 0, 0, 0, ZoneId.of("UTC"));
DateTimeFormatter usFormatter = DateTimeFormatter.ofLocalizedDate(FormatStyle.SHORT)
.withLocale(Locale.US);
System.out.println(usFormatter.format(date)); // → 1/12/25
DateTimeFormatter gbFormatter = DateTimeFormatter.ofLocalizedDate(FormatStyle.SHORT)
.withLocale(Locale.UK);
System.out.println(gbFormatter.format(date)); // → 12/01/2025
DateTimeFormatter jpFormatter = DateTimeFormatter.ofLocalizedDate(FormatStyle.MEDIUM)
.withLocale(Locale.JAPAN);
System.out.println(jpFormatter.format(date)); // → 2025/01/12
// Times
ZonedDateTime time = ZonedDateTime.of(2025, 1, 12, 18, 5, 0, 0, ZoneId.of("UTC"));
ZonedDateTime timeNY = time.withZoneSameInstant(ZoneId.of("America/New_York"));
DateTimeFormatter usTimeFormatter = DateTimeFormatter.ofLocalizedTime(FormatStyle.SHORT)
.withLocale(Locale.US);
System.out.println(usTimeFormatter.format(timeNY)); // → 1:05 PM
// Numbers
double n = 1234.56;
NumberFormat usFormat = NumberFormat.getInstance(Locale.US);
System.out.println(usFormat.format(n)); // → 1,234.56
NumberFormat deFormat = NumberFormat.getInstance(Locale.GERMANY);
System.out.println(deFormat.format(n)); // → 1.234,56
NumberFormat frFormat = NumberFormat.getInstance(Locale.FRANCE);
System.out.println(frFormat.format(n)); // → 1 234,56
// Currency
NumberFormat usCurrency = NumberFormat.getCurrencyInstance(Locale.US);
usCurrency.setCurrency(Currency.getInstance("USD"));
System.out.println(usCurrency.format(n)); // → $1,234.56
NumberFormat deCurrency = NumberFormat.getCurrencyInstance(Locale.GERMANY);
deCurrency.setCurrency(Currency.getInstance("EUR"));
System.out.println(deCurrency.format(n)); // → 1.234,56 €
NumberFormat jpCurrency = NumberFormat.getCurrencyInstance(Locale.JAPAN); jpCurrency.setCurrency(Currency.getInstance(“JPY”)); System.out.println(jpCurrency.format(1234)); // → ¥1,234
### Go
```go
package main
import (
"fmt"
"time"
"golang.org/x/text/language"
"golang.org/x/text/message"
"golang.org/x/text/number"
)
func main() {
// Numbers
n := 1234.56
pUS := message.NewPrinter(language.AmericanEnglish)
fmt.Println(pUS.Sprintf("%.2f", n)) // → 1,234.56
pDE := message.NewPrinter(language.German)
fmt.Println(pDE.Sprintf("%.2f", n)) // → 1.234,56
pFR := message.NewPrinter(language.French)
fmt.Println(pFR.Sprintf("%.2f", n)) // → 1 234,56
// Currency (using number package)
fmt.Println(pUS.Sprint(number.Decimal(n, number.Scale(2)))) // → 1,234.56
// Dates - Go's time package uses layouts instead of locale formatting
// For full i18n date formatting, use github.com/goodsign/monday or similar
date := time.Date(2025, 1, 12, 0, 0, 0, 0, time.UTC)
fmt.Println(date.Format("01/02/2006")) // US: 01/12/2025
fmt.Println(date.Format("02/01/2006")) // EU: 12/01/2025
fmt.Println(date.Format("2006/01/02")) // ISO: 2025/01/12
}
Note: Go의 표준 라이브러리는 제한된 로케일 지원을 제공합니다. 실제 사용 시 고려할 사항:
golang.org/x/text숫자 포맷팅을 위한 패키지github.com/goodsign/monday로컬화된 날짜/시간 포맷팅을 위한 패키지github.com/bojanz/currency통화 처리를 위한 패키지
유용한 자료
- 표준: Unicode CLDR (지역 데이터 표준), IETF BCP 47 (지역 식별자)
- 문서: MDN Intl Reference
- 라이브러리:
- Luxon (현대적인 날짜/시간 라이브러리, i18n 지원)
- date-fns 지역 모듈 포함
- Globalize.js (CLDR 기반 포괄적인 i18n)
- Format.js (ICU 메시지 구문을 사용하는 React 중심의 i18n)
- 테스트: Intl polyfill 구형 브라우저용
마무리
날짜와 숫자를 정확하게 처리하는 것은 낮은 노력으로 높은 효과를 얻는 방법입니다: 신뢰도 향상, 오류 감소, 원활한 글로벌 경험. 형식을 중앙 집중화하고, 표준 값을 저장하며, 사용자가 시간, 돈 또는 숫자를 읽거나 입력하는 곳마다 지역 인식 API를 사용하세요. 기계가 읽을 수 있는 데이터에 대해서는 로컬라이제이션을 생략할 때를 알아야 합니다. 적절한 캐싱과 테스트를 통해 성능을 희생하지 않고 지역에 걸쳐 확장 가능한 견고한 시스템을 구축할 수 있습니다.


