날짜와 숫자가 현지화가 필요한 이유

OpenL Team 10/2/2025

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,234 vs 1.234 vs 1 234), 인도식 그룹화 (1,23,456), 비분리 공백, 마이너스/플러스 스타일 (하이픈 vs 진정한 마이너스 ).
  • 통화: 기호 vs ISO 코드, 기호 배치 (접두사/접미사), 좁은 vs 넓은 간격, 소수점 정밀도 (0, 2, 또는 3+), 회계 부정 (예: (1 234,56 €)).
  • 퍼센트 및 단위: 간격 및 부호 배치 (50% vs 50 %), 단위의 지역 이름, 미터법 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, 주문 번호 또는 사용자가 로케일 간에 통신해야 할 수 있는 참조는 로케일별 형식을 피해야 합니다.

기본 원칙: 사람의 콘텐츠 읽기를 위해 로컬라이즈하고, 데이터 처리 기계를 위해 표준 형식을 사용하십시오.

구현 계획

  1. 감사 표면: UI, 이메일, PDF, CSV/Excel 내보내기, 분석, 로그에서 날짜, 시간, 숫자, 통화의 인벤토리.
  2. 로케일 정의: 지원되는 로케일과 기본 폴백 목록; 로케일별 12/24시간 정책 지정.
  3. 유틸리티 구축: Intl.* API(또는 라이브러리)를 공유 헬퍼 및 테스트와 함께 래핑; 포매터 캐싱 구현.
  4. 입력 전략: 양식을 업데이트하여 로케일 형식을 수락하고 검증; 원시 입력과 함께 표준 값을 저장.
  5. 콘텐츠 규칙: 짧은/긴 날짜, 상대 날짜, 통화 표시 및 백분율 형식에 대한 스타일 문서화.
  6. 롤아웃: 트래픽이 가장 많고 위험이 가장 높은 표면을 먼저 변환; 필요시 기능 플래그 뒤에 배포.

테스트 전략

수동 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의 표준 라이브러리는 제한된 로케일 지원을 제공합니다. 실제 사용 시 고려할 사항:

유용한 자료

마무리

날짜와 숫자를 정확하게 처리하는 것은 낮은 노력으로 높은 효과를 얻는 방법입니다: 신뢰도 향상, 오류 감소, 원활한 글로벌 경험. 형식을 중앙 집중화하고, 표준 값을 저장하며, 사용자가 시간, 돈 또는 숫자를 읽거나 입력하는 곳마다 지역 인식 API를 사용하세요. 기계가 읽을 수 있는 데이터에 대해서는 로컬라이제이션을 생략할 때를 알아야 합니다. 적절한 캐싱과 테스트를 통해 성능을 희생하지 않고 지역에 걸쳐 확장 가능한 견고한 시스템을 구축할 수 있습니다.