27장이 문자열 그 자체의 표현을 다뤘다면, 이 챕터는 그 문자열을 사람에게 보여주고 사람의 기준으로 비교할 때 생기는 문제를 다룬다.
이 영역의 버그에는 공통된 성질이 하나 있다 — 개발자의 기기에서는 절대 재현되지 않는다. 한국어 환경에서 개발하면 복수형 버그가 눈에 안 보이고, 24시간제를 쓰면 AM/PM 문제가 안 나타나며, 터키어 사용자가 없으면 대소문자 변환 버그를 평생 못 만난다.
그래서 이 챕터의 실험 · 도구 절은 대부분 다른 로케일을 강제로 흉내 내는 방법이다. 재현할 수 없으면 고칠 수도 없다.
Q1. 로케일은 무엇을 결정하는가?
로케일은 언어가 아니라 "문화적 관습의 묶음"이다. 언어·지역·스크립트·달력 선호가 각각 독립된 축이고, 이 축들이 문자열 정렬 순서, 숫자·통화·날짜 서식, 복수형 규칙, 대소문자 변환 규칙, 주의 시작 요일까지 결정한다. iOS 16부터는 이 축들이 Locale.Language(languageCode·script·region)와 Locale.Region이라는 독립 타입으로 분리됐다 — 그전에는 "ko_US"처럼 밑줄로 뭉친 평평한 문자열 하나뿐이었다. 다만 Locale(identifier:)도 그대로 남아 있어, 구조화 타입은 대체가 아니라 추가다.
CS 원리
로케일은 매개변수화(parameterization)의 한 사례다. 프로그램의 동작 중 문화권마다 달라지는 부분을 코드에서 분리해 데이터로 빼낸 것이다.
이 데이터의 표준 저장소가 CLDR(Common Locale Data Repository)이다. 정렬 순서, 복수형 규칙, 날짜 필드 순서, 통화 기호 위치 같은 것들이 언어·지역별로 정리돼 있고, Apple·Google·Java 등 대부분의 플랫폼이 이 데이터를 쓴다. 그래서 "iOS가 정한 규칙"이 아니라 "업계가 공유하는 데이터"다.
설계에서 중요한 점은 축이 여러 개이고 서로 독립이라는 것이다.
- 언어 — 어떤 말로 쓰는가. 문자열 선택, 복수형 규칙.
- 지역 — 어디에 있는가. 통화, 도량형, 날짜 순서(월/일 vs 일/월).
- 스크립트 — 어떤 문자 체계인가. 같은 언어라도 다를 수 있다(세르비아어의 키릴/라틴).
- 달력·시간대 — 그레고리력인가 불교력인가, 12시간제인가 24시간제인가.
이 축들이 독립이라는 점이 실무에서 자주 간과된다. "한국어를 쓰는 미국 거주자"(ko_US)는 이상한 조합이 아니라 흔한 조합이다 — 앱은 한국어로 보되 통화는 달러여야 한다.
iOS에서는
iOS 16부터 축이 타입으로 분리됐다. 평평한 문자열을 쪼개 파싱하던 코드가 사라진다.
let loc = Locale(identifier: "ko_US")
// 구조화 접근 (iOS 16+)
loc.language.languageCode?.identifier // "ko"
loc.language.script?.identifier // "Kore" ← 지정 안 해도 추론된다
loc.region?.identifier // "US"
loc.currency?.identifier // "USD" ← 지역을 따른다
// 상위 지역은 ISO 국가코드가 아니라 UN M49 숫자 코드다
Locale.Region("KR").containingRegion?.identifier // "030" (동아시아)
Locale.Region("KR").continent?.identifier // "142" (아시아)
// 평평한 생성자도 그대로 남아 있다 — 대체가 아니라 추가
Locale(identifier: "ko_US").identifier // "ko_US"
실무에서 자주 쓰는 조회는 이렇다.
// 사용자가 선호하는 언어 목록 — 첫 번째가 앱이 실제로 쓸 언어와 다를 수 있다
Locale.preferredLanguages // ["ko-KR", "en-US"]
// 앱이 실제로 고른 언어 (번역이 있는 것 중에서 선택된다)
Bundle.main.preferredLocalizations // ["ko"]
// 지역 기반 결정 — 언어가 아니라 지역을 봐야 한다
Locale.current.region?.identifier // "US"
Locale.current.measurementSystem // .us / .metric / .uk
Locale.current.firstDayOfWeek // .sunday / .monday
실험 · 도구
다른 로케일을 강제로 흉내 내는 것이 이 챕터 전체의 핵심 기법이다. 스킴 인자로 앱 전체를 바꿀 수 있다.
# Xcode 스킴 › Run › Arguments › Arguments Passed On Launch
-AppleLanguages "(ar)" # 아랍어 — RTL·복수형 6종
-AppleLocale "ar_SA"
-AppleTextDirection YES # RTL 강제
# 시뮬레이터를 명령줄로 바꿔도 된다
xcrun simctl spawn booted defaults write -g AppleLanguages -array ar
xcrun simctl spawn booted defaults write -g AppleLocale -string ar_SA
xcrun simctl shutdown booted && xcrun simctl boot booted
코드에서 특정 로케일을 직접 만들어 비교하는 편이 테스트에는 더 낫다 — 기기 설정에 의존하지 않기 때문이다.
let cases = ["ko_KR", "en_US", "ar_SA", "th_TH", "tr_TR"]
for id in cases {
let l = Locale(identifier: id)
let price = 1234.5.formatted(.currency(code: l.currency?.identifier ?? "USD").locale(l))
let date = Date().formatted(.dateTime.year().month().day().locale(l))
print(String(format: "%-8@ %-14@ %@", id as NSString, price as NSString, date as NSString))
}
// ko_KR ₩1,235 2026. 8. 15.
// en_US $1,234.50 Aug 15, 2026
// ar_SA ١٬٢٣٤٫٥٠ ر.س. ١٥/٨/٢٠٢٦ ← 숫자 자체가 다른 문자다
// th_TH ฿1,234.50 15 ส.ค. 2569 ← 불교력이라 연도가 다르다
프로젝트 적용
- "언어"로 판단하던 자리를 "지역"으로 바꾼다. 통화·도량형·주 시작 요일은 언어가 아니라 지역이 결정한다.
preferredLanguages.first로 통화를 고르면ko_US사용자에게 원화를 보여주게 된다. - 로케일 값을 캐시하지 않는다. 사용자가 설정에서 바꾸면 앱이 살아 있는 동안에도 바뀐다.
NSLocale.currentLocaleDidChangeNotification을 관찰한다. - 서버로 보낼 값과 화면에 보여줄 값을 분리한다. 저장·전송은 로케일 무관 형식(ISO 8601, 통화 코드, 원시 숫자), 표시만 로케일을 따른다(Q7).
- 불교력·힌두력을 테스트에 넣는다. 태국(
th_TH)은 연도가 543년 차이가 난다. 연도를 문자열로 잘라 쓰는 코드가 여기서 깨진다. - 지원 언어 목록을 명시적으로 관리한다.
Bundle.main.preferredLocalizations로 앱이 실제 고른 언어를 확인한다 — 사용자 선호 1순위와 다를 수 있다.
"로케일은 언어와 같다"는 틀렸다 — 언어는 여러 축 중 하나다. "ko_US 같은 조합은 비정상이라 무시해도 된다"도 틀렸다 — 해외 거주 사용자에게 정상적인 조합이다. "Locale.current.identifier를 파싱해 언어를 얻으면 된다"도 이제는 나쁜 습관이다 — language.languageCode로 직접 얻는다. "지역 코드는 항상 두 글자 국가 코드"도 틀렸다 — 상위 지역은 UN M49 숫자 코드(142=아시아)다. "구조화 타입이 나왔으니 Locale(identifier:)는 폐기됐다"도 틀렸다 — 그대로 쓸 수 있다.
외국에서 온 손님을 맞는다고 생각해 보자. 알아야 할 것이 한 가지가 아니다.
어떤 말을 쓰는지 — 메뉴판을 어느 나라 말로 줄지 정한다.
어디서 왔는지 — 돈을 어느 화폐로 계산할지, 길이를 미터로 잴지 피트로 잴지 정한다.
어떤 글자를 쓰는지 — 같은 말이라도 글자가 다를 수 있다.
어떤 달력을 쓰는지 — 올해가 몇 년인지가 나라마다 다르다. 태국에서는 지금이 2569년이다.
이 네 가지는 따로 논다. "한국말을 쓰는데 미국에 사는 사람"은 이상한 사람이 아니다. 메뉴판은 한국어로 주되 가격은 달러로 적어야 한다.
그런데 개발자가 한국에서 한국어로만 앱을 만들면, 이 네 가지가 전부 한 덩어리처럼 보인다. 다 같이 "한국"이니까. 그래서 하나로 뭉쳐서 처리하는 코드를 쓰게 되고, 외국에 사는 한국인 사용자에게서 처음 터진다.
꼬리 질문
앱 안에서만 언어를 바꾸는 기능(인앱 언어 선택)은 어떻게 구현하는가?
iOS 13 이후에는 시스템이 지원하는 방식을 쓰는 것이 원칙이다. 예전처럼 UserDefaults의 AppleLanguages를 직접 덮어쓰고 재시작을 요구하는 방식은 부작용이 크다.
권장 방식은 앱별 언어 설정을 시스템에 노출하는 것이다. Info.plist에 지원 언어를 선언하면 설정 앱 안에 우리 앱 전용 언어 항목이 생기고, 사용자가 거기서 바꾸면 시스템이 알아서 처리한다.
앱 안에 선택 UI를 두고 싶다면 그 화면에서 시스템 설정으로 보내는 것이 안전하다.
if let url = URL(string: UIApplication.openSettingsURLString) {
await UIApplication.shared.open(url)
}굳이 앱 내부에서 즉시 바꿔야 한다면 번들을 직접 고르는 방식이 있지만, 대가가 있다.
- 시스템 UI(날짜 선택기, 공유 시트, 권한 알림)는 여전히 시스템 언어를 쓴다 — 화면이 섞인다.
Locale.current가 바뀌지 않으므로 서식은 직접 로케일을 넘겨야 한다.- 모든 문자열 조회 경로를 우리가 만든 함수로 통일해야 한다.
정리하면 시스템 방식이 기본, 인앱 방식은 요구가 명확할 때만이다.
서버가 로케일별로 다른 응답을 주려면 무엇을 보내야 하는가?
Accept-Language 헤더가 표준이고, 여기에 우선순위(q값)를 담아 보낸다. URLSession은 기본적으로 자동으로 붙여 준다.
// 기본 동작 — 시스템이 preferredLanguages 를 기반으로 채운다
// Accept-Language: ko-KR;q=1.0, en-US;q=0.9
// 명시하고 싶다면
var req = URLRequest(url: url)
req.setValue(Locale.preferredLanguages.prefix(3)
.enumerated()
.map { "\($0.element);q=\(1.0 - Double($0.offset) * 0.1)" }
.joined(separator: ", "),
forHTTPHeaderField: "Accept-Language")여기서 언어와 지역을 나눠 보내야 하는 경우가 있다. 통화·세금·배송 정책은 지역이 결정하는데 Accept-Language만으로는 지역이 모호하다. 그래서 실무에서는 별도 파라미터를 함께 보낸다.
Accept-Language— 표시 언어- 지역/국가 코드 — 통화·정책 (사용자가 선택한 배송지 등 비즈니스 규칙을 따르는 것이 보통이고, 기기 지역 설정을 그대로 믿지 않는다)
- 시간대 — 서버가 시각을 렌더링해야 한다면(29장)
주의할 점은 기기 지역을 신뢰 경계 안으로 들이지 않는 것이다. 사용자가 임의로 바꿀 수 있으므로 결제 통화 같은 결정은 서버가 계정 정보로 판단해야 한다(P0 16장 Q4의 "믿을 값은 서버"와 같은 원칙).
로케일 관련 버그를 자동 테스트로 잡으려면 무엇을 검사해야 하는가?
기기 설정에 의존하지 않는 테스트를 만드는 것이 첫 번째다. Locale.current를 쓰는 코드는 테스트에서 결과가 흔들린다.
(1) 로케일을 주입 가능하게 만든다. 서식·비교 함수가 로케일을 인자로 받게 바꾼다. 이건 P0 17장의 의존성 주입이 국제화에 적용된 형태다.
// ❌ 테스트가 기기 설정에 좌우된다
func priceText(_ v: Decimal) -> String { v.formatted(.currency(code: "KRW")) }
// ✅ 로케일을 받는다
func priceText(_ v: Decimal, locale: Locale = .current) -> String {
v.formatted(.currency(code: "KRW").locale(locale))
}(2) 대표 로케일 집합으로 파라미터화한다. 각 로케일이 서로 다른 위험을 대표하게 고른다.
| 로케일 | 검사하는 위험 |
|---|---|
ar_SA | RTL, 복수형 6종, 아라비아 인도 숫자 |
tr_TR | 대소문자 변환(Turkish-I, Q3) |
th_TH | 불교력 — 연도가 543 차이 |
de_DE | 긴 단어 — 레이아웃 넘침 |
en_US | 12시간제, 월/일 순서 |
(3) 스냅샷보다 값을 검사한다. 서식 결과 문자열을 그대로 단언하면 OS 업데이트로 CLDR 데이터가 바뀔 때 대량으로 깨진다. "통화 기호가 포함되는가", "숫자가 파싱 가능한가" 같은 성질을 검사하는 편이 안정적이다.
(4) 의사 로케일로 UI를 훑는다. Xcode의 Double-Length Pseudolanguage·Right-to-Left Pseudolanguage로 실행하면 번역 없이도 레이아웃 문제가 드러난다.
Q2. 문자열 비교와 정렬은 왜 로케일에 따라 달라지는가?
"순서"는 문자 코드가 아니라 문화가 정하는 것이기 때문이다. Swift에는 목적이 다른 비교가 셋 있다 — ==·<는 로케일 무관(정준 동등성, 코드포인트 순서), compare(_:options:)는 옵션을 명시하는 비교, localizedStandardCompare는 Finder와 같은 방식(로케일 인식 + 대소문자·폭 무시 + 숫자를 값으로)이다. 실측으로 일반 < 정렬은 ["File20.txt", "file1.txt", "file10.txt", "file2.txt"]가 되지만, localizedStandardCompare는 ["file1.txt", "file2.txt", "file10.txt", "File20.txt"]가 된다. 사용자에게 보여줄 목록 정렬의 기본값은 후자여야 한다.
CS 원리
정렬에는 비교자(comparator)가 필요하고, 비교자는 전순서(total order)를 정의해야 한다. 그런데 "문자열의 순서"에는 정답이 없다 — 무엇을 기준으로 삼느냐에 따라 여러 개의 유효한 순서가 존재한다.
| 기준 | 순서를 정하는 방식 | 용도 |
|---|---|---|
| 코드포인트 | 유니코드 번호 크기 | 키·식별자 비교, 이진 탐색 |
| 대조(collation) | 언어별 사전 순서 규칙 | 사용자에게 보여줄 목록 |
| 자연(natural) | 숫자 부분을 값으로 비교 | 파일명·항목 번호 |
코드포인트 순서가 사용자 기대와 어긋나는 이유는 두 가지다.
- 대문자가 소문자보다 앞이다. ASCII에서
Z(90)가a(97)보다 작다. 그래서File20이file1보다 앞에 온다. - 숫자를 글자로 비교한다.
"10"과"2"를 비교하면 첫 글자1과2를 보고10이 작다고 판정한다.
여기서 중요한 성질이 하나 나온다 — 로케일 인식 비교자는 언어에 따라 결과가 바뀌므로, 그것으로 만든 순서를 저장하거나 캐시하면 안 된다. 사용자가 언어를 바꾸면 순서가 달라진다. 또한 로케일 비교자는 엄격 약순서(strict weak ordering)를 위반하는 경우가 드물게 있어, 정렬 알고리즘이 예측 불가능한 동작을 할 수 있다. 그래서 표시용 정렬과 저장용 순서를 분리하는 것이 원칙이다.
iOS에서는
세 비교를 목적에 맞게 나눠 쓰는 것이 전부다.
let files = ["file10.txt", "File20.txt", "file2.txt", "file1.txt"]
// ❌ 코드포인트 순서 — 사용자 기대와 다르다
files.sorted()
// ["File20.txt", "file1.txt", "file10.txt", "file2.txt"]
// ✅ Finder 와 같은 순서
files.sorted { $0.localizedStandardCompare($1) == .orderedAscending }
// ["file1.txt", "file2.txt", "file10.txt", "File20.txt"]
// 옵션을 직접 고르고 싶다면
files.sorted {
$0.compare($1, options: [.caseInsensitive, .numeric],
range: nil, locale: .current) == .orderedAscending
}
한글 목록에서도 차이가 난다 — 코드포인트 순서는 영문이 한글보다 항상 앞이지만, 로케일 인식 비교는 언어 설정에 따라 한글을 앞에 둔다.
let names = ["Zoe", "가나", "Alice", "다라"]
names.sorted()
// ["Alice", "Zoe", "가나", "다라"] ← 영문이 항상 먼저
names.sorted { $0.localizedStandardCompare($1) == .orderedAscending }
// ko_KR 에서: ["가나", "다라", "Alice", "Zoe"] ← 한글이 먼저
검색·필터에는 또 다른 선택이 필요하다. 사용자가 "seoul"로 검색했을 때 "Seoul"과 "Séoul"이 모두 걸려야 한다면 진단 기호 무시 옵션이 필요하다.
extension String {
/// 검색용 느슨한 포함 판정 — 대소문자·진단기호·폭을 무시한다
func fuzzyContains(_ needle: String) -> Bool {
range(of: needle,
options: [.caseInsensitive, .diacriticInsensitive, .widthInsensitive],
locale: .current) != nil
}
}
"Séoul".fuzzyContains("seoul") // true
실험 · 도구
비교자별 결과를 나란히 찍어 보면 선택 기준이 분명해진다.
let items = ["file10.txt", "File20.txt", "file2.txt", "file1.txt"]
func show(_ label: String, _ sorted: [String]) {
print(String(format: "%-26@ %@", label as NSString, sorted.joined(separator: " ") as NSString))
}
show("sorted()", items.sorted())
show("localizedCompare",
items.sorted { $0.localizedCompare($1) == .orderedAscending })
show("localizedStandardCompare",
items.sorted { $0.localizedStandardCompare($1) == .orderedAscending })
// sorted() File20.txt file1.txt file10.txt file2.txt
// localizedCompare file1.txt file10.txt file2.txt File20.txt
// ← 대소문자는 정리됐지만 숫자는 여전히 글자로 비교한다
// localizedStandardCompare file1.txt file2.txt file10.txt File20.txt
// ← 숫자를 값으로 읽는다. 이것만 사람 기대와 맞는다
로케일을 바꿔 가며 한글·영문 혼합 정렬이 어떻게 달라지는지도 확인한다.
let names = ["Zoe", "가나", "Alice", "다라", "Ärger"]
for id in ["ko_KR", "en_US", "de_DE", "sv_SE"] {
let l = Locale(identifier: id)
let s = names.sorted {
$0.compare($1, options: [], range: nil, locale: l) == .orderedAscending
}
print("\(id): \(s.joined(separator: " "))")
}
// de_DE 와 sv_SE 에서 "Ärger" 의 위치가 다르다 —
// 독일어는 A 와 함께, 스웨덴어는 Z 뒤에 놓는 관습이다
프로젝트 적용
- 사용자에게 보이는 모든 목록 정렬을
localizedStandardCompare로 바꾼다. 파일·앨범·연락처·태그 등. 기본sorted()가 남아 있으면 그게 버그다. - 저장·전송하는 순서에는 로케일 비교를 쓰지 않는다. 언어가 바뀌면 순서가 달라지므로 재현되지 않는다. 서버가 정렬하거나 명시적
sortOrder필드를 쓴다. - 키·토큰 비교에는
==를 쓴다. 로케일 인식 비교는 다른 문자를 같다고 판정할 수 있다(Q3). - 검색에는
.diacriticInsensitive를 고려한다. 사용자는 악센트를 입력하지 않는다. - 정렬 결과를 스냅샷 테스트로 고정하지 않는다. OS 업데이트로 CLDR 데이터가 바뀌면 대량으로 깨진다. 성질(예: "숫자가 값 순서인가")을 검사한다.
"localizedCaseInsensitiveCompare로 정렬하면 사용자 친화적이다"는 부정확하다 — Apple 문서는 정렬 목적에는 localizedStandardCompare를 쓰라고 명시한다. 전자는 숫자를 값으로 읽지 않는다. "sorted()는 알파벳 순이다"도 틀렸다 — 코드포인트 순이라 대문자가 전부 앞에 온다. "로케일 비교는 느리니 피해야 한다"도 과장이다 — 목록 정렬 규모에서는 대개 무시할 만하고, 틀린 순서를 보여주는 비용이 훨씬 크다. "한 번 정렬해 저장하면 된다"도 위험하다 — 언어가 바뀌면 순서가 바뀐다.
이름표를 순서대로 꽂는다고 해 보자. "순서"가 뭘 기준으로인지 정해야 한다.
기계는 글자마다 붙은 번호로 센다. 그런데 그 번호는 대문자가 소문자보다 작게 매겨져 있다. 그래서 "File20"이 "file1"보다 앞에 온다. 사람 눈에는 이상하다.
더 이상한 게 하나 더 있다. 기계는 "10"과 "2"를 글자로 비교한다. 첫 글자만 보고 1이 2보다 작으니까 10이 먼저라고 한다. 그래서 파일이 1, 10, 2, 20 순으로 늘어선다.
사람이 기대하는 건 1, 2, 10, 20이다. 숫자는 숫자로 읽어야 하니까.
그리고 나라마다 순서 관습이 다르다. 독일에서는 "Ärger"를 A 근처에 두는데, 스웨덴에서는 맨 뒤에 둔다. 같은 글자인데 사는 나라에 따라 자리가 다른 것이다.
그래서 보여주는 순서는 그 나라 관습을 따르고, 기계끼리 주고받는 순서는 관습과 무관한 번호 순으로 하는 게 맞다. 둘을 섞으면 나라를 바꿀 때마다 순서가 흔들린다.
꼬리 질문
페이지네이션이 걸린 목록을 로케일 정렬하면 어떤 문제가 생기는가?
클라이언트 정렬과 서버 정렬이 어긋나 항목이 중복되거나 빠진다.
서버가 ORDER BY name으로 100개씩 잘라 보내는데 클라이언트가 받은 뒤 localizedStandardCompare로 다시 정렬하면, 페이지 경계의 항목들이 뒤섞인다. 2페이지의 첫 항목이 1페이지 중간에 들어가야 할 수도 있다. 그러면 스크롤할 때 같은 항목이 두 번 보이거나, 사라진다.
대응은 셋 중 하나다.
- 서버가 정렬을 책임진다. 클라이언트는 받은 순서를 그대로 표시한다. 서버 DB의 대조(collation)를 로케일에 맞게 설정하고, 요청에 로케일을 함께 보낸다.
- 전체를 받아 클라이언트가 정렬한다. 항목 수가 적을 때만 가능하다.
- 정렬 키를 서버가 계산해 내려준다. 로케일별 정렬 키(collation key)를 미리 만들어 필드로 주면, 클라이언트는 그 키로 정렬하고 서버도 같은 키로 페이지를 자른다.
P0 06장 Q7에서 본 페이지네이션 병합 문제와 같은 구조다 — 정렬 기준이 양쪽에서 일치해야 병합이 성립한다.
대량 목록을 로케일 비교로 정렬하면 성능이 문제되지 않는가?
항목이 수만 개를 넘어가면 체감된다. 로케일 비교는 단순 바이트 비교보다 훨씬 무겁기 때문이다 — 대조 규칙 적용, 정규화, 숫자 인식이 매 비교마다 일어난다.
정렬은 비교를 O(n log n)회 수행하므로, 비교 하나가 비싸면 그 배수만큼 늘어난다.
대응 기법이 정렬 키 사전 계산이다. 각 항목의 비교 키를 한 번만 만들어 두고, 정렬은 그 키로 한다. 비교 횟수가 O(n log n)이어도 키 생성은 O(n)이다.
// ❌ 매 비교마다 로케일 규칙을 적용한다
items.sorted { $0.name.localizedStandardCompare($1.name) == .orderedAscending }
// ✅ 키를 먼저 만들고 그 키로 정렬 (decorate-sort-undecorate)
let decorated = items.map { (key: $0.name.folding(
options: [.caseInsensitive, .diacriticInsensitive], locale: .current), item: $0) }
let sorted = decorated
.sorted { $0.key.localizedStandardCompare($1.key) == .orderedAscending }
.map(\.item)더 근본적인 대응은 정렬을 메인 스레드에서 하지 않는 것이다(20장 Q5). 수만 건 정렬은 프레임 예산을 훌쩍 넘긴다.
그리고 측정 먼저다. 목록이 수백 개 수준이면 이 최적화는 의미가 없고, 코드만 복잡해진다.
정렬 결과가 기기마다 미묘하게 다르다. 버그인가?
대개 버그가 아니라 데이터 버전 차이다.
로케일 대조 규칙은 CLDR 데이터에서 오고, 이 데이터는 OS 버전마다 갱신된다. 새 문자가 추가되거나 특정 언어의 정렬 관습이 수정되면 같은 코드가 다른 순서를 낼 수 있다.
그래서 다음을 구분해야 한다.
- 정상 — OS 버전이 다른 기기 사이의 미세한 순서 차이. 특히 이모지·기호·희귀 문자 주변.
- 버그 — 같은 기기·같은 OS에서 실행할 때마다 순서가 다른 경우. 이건 비교자가 엄격 약순서를 위반했다는 신호다.
두 번째는 실제로 위험하다. 정렬 알고리즘은 비교자가 일관된 순서를 준다고 가정하므로, 위반하면 결과가 예측 불가능해진다. 대표적인 원인은 비교자 안에서 로케일을 매번 다시 읽거나, 여러 필드를 조합하며 동률 처리를 빠뜨린 경우다.
// ❌ 동률일 때 순서가 불안정하다
items.sorted { $0.priority > $1.priority }
// ✅ 동률을 깨는 안정적 기준을 붙인다
items.sorted {
if $0.priority != $1.priority { return $0.priority > $1.priority }
return $0.id < $1.id // 로케일 무관 · 유일한 값
}테스트에서는 정확한 순서를 단언하지 말고 성질을 검사한다 — OS 업데이트마다 깨지는 테스트는 유지되지 않는다.
Q3. 로케일 불변 매칭은 왜 따로 필요한가?
로케일 의존 변환이 "같은 글자"를 다르게 바꾸기 때문이다. 가장 유명한 사례가 Turkish-I다 — 터키어에는 점 없는 ı와 점 있는 i가 별개 글자라, "I"를 소문자로 바꾸면 "ı"가 된다. 프로토콜 토큰·딕셔너리 키·파일 확장자를 로케일 의존 API로 소문자화하면 터키어 기기에서만 매칭이 실패한다. 다행히 Swift 고유의 lowercased()는 로케일 파라미터가 없어 항상 "i"이고 기기가 터키어여도 흔들리지 않는다(실측). 위험한 것은 NSString의 로케일 인자를 받는 계열이다.
CS 원리
이 문제의 뿌리는 같은 연산 이름이 두 가지 다른 목적에 쓰인다는 것이다.
- 표시용 변환 — 사람에게 보여줄 텍스트를 그 문화의 관습대로 바꾼다. 로케일을 따라야 한다.
- 식별용 정규화 — 두 값이 같은 것을 가리키는지 판정하기 위해 형태를 통일한다. 어디서 돌려도 같은 결과가 나와야 한다.
두 번째를 로케일 불변(locale-invariant) 연산이라 부른다. 프로토콜·형식·저장소를 다루는 코드는 전부 여기 속한다. HTTP 헤더 이름, 파일 확장자, JSON 키, 열거형 원시값 — 이것들은 사용자의 문화와 무관한 기계 사이의 약속이다.
같은 원리가 숫자 파싱에도 적용된다. "1,234.5"는 영어권에서 천 단위 구분이지만 독일어권에서는 "1.234,5"다. 서버가 준 숫자 문자열을 로케일 인식 파서로 읽으면 기기 설정에 따라 값이 달라진다.
정리하면 경계마다 어느 쪽인지 정해야 한다 — 사람을 향하면 로케일, 기계를 향하면 불변.
iOS에서는
Swift 고유 API는 대체로 안전한 쪽 기본값을 갖는다. 위험은 NSString 계열과 섞일 때 온다.
import Foundation
let s = "ID"
// ✅ Swift 고유 — 로케일 파라미터가 없다. 항상 "id"
s.lowercased()
// ⚠️ NSString 계열 — 로케일을 명시하면 그 규칙을 따른다
(s as NSString).lowercased(with: Locale(identifier: "tr")) // "ıd"
(s as NSString).lowercased(with: nil) // "id" (불변)
// 비교도 마찬가지다
"ID".caseInsensitiveCompare("id") // .orderedSame (불변)
"ID".localizedCaseInsensitiveCompare("id") // 로케일을 탄다
숫자 파싱에서도 같은 구분이 필요하다.
// ❌ 서버가 준 값을 로케일 인식으로 파싱 — 기기 설정에 좌우된다
let n1 = NumberFormatter().number(from: "1234.5") // de_DE 에서 1234.5 가 아닐 수 있다
// ✅ 기계용 값은 로케일 무관 파싱
let n2 = Double("1234.5") // 항상 1234.5
let n3 = Decimal(string: "1234.5", locale: nil) // 로케일 무관
// ✅ 사람에게 보여줄 때만 로케일을 적용
n2?.formatted(.number.locale(.current))
실무에서 이 버그가 숨는 자리를 정리하면 이렇다.
- HTTP 헤더 이름 비교 —
"Content-Type".lowercased()로 정규화하는 코드. - 파일 확장자 판정 —
url.pathExtension.lowercased() == "jpeg". - 딕셔너리 키 정규화 — 사용자 입력을 소문자화해 키로 쓰는 경우.
- 열거형 원시값 매칭 — 서버 문자열을 소문자화해
init(rawValue:)에 넘기는 경우.
실험 · 도구
터키어 로케일로 직접 확인하는 것이 가장 빠르다.
let samples = ["ID", "Istanbul", "IMAGE.JPEG"]
let tr = Locale(identifier: "tr_TR")
for s in samples {
let swiftWay = s.lowercased()
let nsWay = (s as NSString).lowercased(with: tr)
let same = swiftWay == nsWay ? "동일" : "❌ 다름"
print(String(format: "%-12@ swift=%-12@ tr=%-12@ %@",
s as NSString, swiftWay as NSString, nsWay as NSString, same as NSString))
}
// ID swift=id tr=ıd ❌ 다름
// Istanbul swift=istanbul tr=ıstanbul ❌ 다름
// IMAGE.JPEG swift=image.jpeg tr=ımage.jpeg ❌ 다름
기기 전체를 터키어로 돌려 회귀를 훑을 수도 있다.
# 스킴 인자로 앱만 터키어로 실행
-AppleLanguages "(tr)" -AppleLocale "tr_TR"
# 코드베이스에서 위험한 호출을 찾는다
grep -rn "lowercased(with:\|uppercased(with:\|localizedCaseInsensitive" \
--include=*.swift Sources/
# 로케일 인식 숫자 파싱이 기계용 값에 쓰이는지 확인
grep -rn "NumberFormatter()" --include=*.swift Sources/ | head -20
프로젝트 적용
- 기계용 문자열 정규화에는 Swift 고유 API만 쓴다.
lowercased()·==·caseInsensitiveCompare. 로케일 인자를 받는 것은 표시용에만. - 서버가 준 숫자·날짜 문자열은 로케일 무관으로 파싱한다.
Double(_:), ISO 8601 파서,en_US_POSIX(Q7). - 가능하면 문자열 비교 자체를 없앤다. 열거형·
UTType·전용 타입으로 바꾸면 이 문제군 전체가 사라진다. - 대소문자 정규화 지점을 한 곳으로 모은다. 곳곳에 흩어진
lowercased()는 나중에 감사(audit)가 불가능하다. - 터키어를 로케일 테스트 매트릭스에 반드시 넣는다. 이 계열 버그는 다른 어떤 로케일로도 재현되지 않는다.
"lowercased()는 로케일과 무관하게 항상 같다"는 Swift 고유 API에 한해서만 맞다 — NSString의 로케일 인자 계열은 다르다. "Turkish-I는 옛날 이야기다"도 틀렸다 — 터키어는 지금도 쓰이고, 이 버그는 그 사용자에게만 나타나 발견이 늦다. "대소문자 무시 비교면 다 안전하다"도 틀렸다 — localizedCaseInsensitiveCompare는 로케일을 탄다. "우리 앱은 한국어 전용이라 상관없다"도 위험하다 — 기기 언어가 터키어인 한국인 사용자가 있을 수 있고, 로케일은 앱 언어가 아니라 기기 설정을 따른다.
터키어에는 점 있는 i와 점 없는 ı가 서로 다른 글자다. 우리로 치면 "ㅏ"와 "ㅓ"처럼 아예 다른 글자인 것이다.
그래서 터키에서 대문자 "I"를 소문자로 바꾸면 점 없는 ı가 된다. 그 나라에서는 그게 맞다.
문제는 사람이 읽을 글자가 아니라 기계끼리 주고받는 암호를 그렇게 바꿀 때다. 자물쇠 번호를 "id"로 맞춰 놨는데 터키에서만 "ıd"로 바뀌어 문이 안 열린다.
그래서 규칙이 하나 필요하다 — 사람에게 보여줄 글자는 그 나라 방식대로, 기계가 맞춰볼 암호는 어디서나 같은 방식으로.
이 버그가 무서운 건 터키 사용자에게만 생긴다는 점이다. 한국에서 아무리 테스트해도 절대 안 나온다. 그래서 일부러 터키어로 켜서 확인해 봐야 한다.
꼬리 질문
사용자 입력을 검색 키로 정규화할 때는 어느 쪽을 써야 하는가?
둘 다 필요하고, 용도를 나눠야 한다.
검색 매칭은 사람을 향하는 연산이다. 사용자가 악센트 없이 입력해도 찾아져야 하므로 로케일 인식 + 관대한 옵션이 맞다.
중복 판정·저장 키는 기계를 향한다. 어디서 돌려도 같은 결과여야 하므로 불변 정규화가 맞다.
extension String {
/// 저장·중복 판정용 — 로케일 무관, 재현 가능
var storageKey: String {
precomposedStringWithCanonicalMapping // NFC (27장 Q2)
.lowercased() // Swift 고유 — 불변
.trimmingCharacters(in: .whitespacesAndNewlines)
}
/// 검색 매칭용 — 사용자 관대함 우선
func matchesSearch(_ q: String) -> Bool {
range(of: q, options: [.caseInsensitive, .diacriticInsensitive,
.widthInsensitive], locale: .current) != nil
}
}두 함수를 섞어 쓰지 않는 것이 핵심이다. 검색 결과를 저장 키로 쓰거나, 저장 키로 검색하면 각각의 장점이 사라진다.
HTTP 헤더 이름을 소문자로 정규화하는 코드가 있다. 안전한가?
Swift의 lowercased()를 쓴다면 안전하다. 로케일을 타지 않기 때문이다. 위험한 것은 NSString의 로케일 인자 계열이나, 다른 언어에서 포팅한 코드다.
다만 더 나은 접근이 있다. HTTP 헤더 이름은 대소문자를 구분하지 않는다고 명세가 정하고 있으므로, 문자열을 소문자화해 비교하는 대신 대소문자 무시 비교를 쓰거나 아예 전용 타입을 만든다.
// 최선 — 타입으로 만들면 정규화 위치가 한 곳으로 모인다
struct HeaderName: Hashable {
let raw: String
private let normalized: String
init(_ raw: String) {
self.raw = raw
self.normalized = raw.lowercased() // Swift 고유 — 불변
}
static func == (l: Self, r: Self) -> Bool { l.normalized == r.normalized }
func hash(into h: inout Hasher) { h.combine(normalized) }
}이렇게 하면 비교 규칙이 타입 안에 갇혀 호출부에서 실수할 수 없다. 27장 Q2에서 본 "경계에서 정규화한다"와 같은 원칙이고, P0 17장의 정보 은닉이 국제화에 적용된 형태다.
URLRequest를 쓴다면 이미 setValue(_:forHTTPHeaderField:)가 대소문자를 알아서 처리하므로 직접 정규화할 일 자체가 줄어든다.
다른 언어(서버·웹)와 정규화 규칙을 맞추려면 무엇을 확인해야 하는가?
세 가지 축이 전부 일치해야 같은 판정이 나온다.
- 유니코드 정규화 형식 — NFC인지 NFD인지(27장 Q2). 대부분의 웹·서버는 NFC를 기대한다.
- 대소문자 변환 규칙 — 로케일 의존인지 불변인지. JavaScript의
toLowerCase()는 불변이고toLocaleLowerCase()는 로케일을 탄다. Java는toLowerCase()가 기본 로케일을 따르므로 명시적으로Locale.ROOT를 넘겨야 한다 — 이 차이가 크로스 플랫폼 버그의 단골이다. - 공백·보이지 않는 문자 처리 — 어느 문자를 제거하는지.
실무에서는 정규화 규칙을 문서로 고정하고 양쪽에 같은 테스트 벡터를 넣는 것이 확실하다.
// 공유 테스트 벡터 — 서버·웹·앱이 같은 결과를 내야 한다
let vectors: [(input: String, expected: String)] = [
(" Hello ", "hello"),
("İstanbul", "i̇stanbul"), // 터키 대문자 I with dot
("가\u{0301}", "가\u{0301}"), // 결합 문자 유지 여부
("ABC", "abc"), // 전각 → 반각
]여기서 정답을 한쪽이 정하고 나머지가 맞추는 것이 중요하다. 셋이 각자 "합리적인" 규칙을 쓰면 셋 다 조금씩 다르다.
Q4. 복수형 규칙은 왜 언어마다 다른가?
언어마다 수를 문법적으로 구분하는 방식이 다르기 때문이다. CLDR은 복수 카테고리를 zero·one·two·few·many·other 여섯 가지로 정의하고, 언어마다 실제로 쓰는 개수가 다르다 — 한국어는 other 하나뿐, 영어는 one/other 둘, 아랍어는 여섯 개를 전부 쓴다. 그래서 "\(n)개" 하드코딩이 한국어 개발 중에는 절대 버그처럼 보이지 않는다 — n이 0이든 1이든 2든 11이든 전부 other로 떨어져 아무 변화가 없다. 그리고 중요한 함정 하나 — 이 규칙은 한국어 조사(을/를·은/는) 문제를 전혀 풀어주지 않는다.
CS 원리
복수형 처리의 본질은 수 → 문법 형태로의 사상(mapping)이다. 순진한 구현은 n == 1 ? 단수 : 복수인데, 이건 영어의 규칙을 세계에 강요하는 것이다.
CLDR은 이 사상을 언어별 규칙 데이터로 분리했다. 각 언어는 "어떤 수가 어떤 카테고리에 속하는가"를 규칙으로 선언하고, 앱은 카테고리별 문자열만 제공한다.
| 언어 | 쓰는 카테고리 | 예 |
|---|---|---|
| 한국어 | other 하나 | 0개 · 1개 · 2개 · 11개 — 전부 같은 형태 |
| 영어 | one · other | 1 item / 0 items · 2 items |
| 러시아어 | one · few · many · other | 끝자리에 따라 갈린다 |
| 아랍어 | 여섯 개 전부 | n=11은 few가 아니라 끝자리 규칙상 many |
여기서 두 가지 원리가 나온다.
- 카테고리는 수가 아니라 규칙이 정한다. "2면
two"가 아니다 — 언어가two카테고리를 쓰지 않으면 2도other다. 그래서 앱이 직접if n == 2로 분기하면 안 된다. - 규칙은 정수만의 문제가 아니다. 소수(1.5개)와 유효 자릿수도 카테고리에 영향을 준다. CLDR 규칙은 이를 위해
i·v·f같은 피연산자를 정의한다.
그리고 복수형이 풀지 못하는 축이 있다는 점이 중요하다. 한국어의 조사 선택은 수가 아니라 앞 글자의 받침 유무를 따른다 — 완전히 다른 차원이다. CLDR 복수 규칙을 아무리 정교하게 채워도 "사과를" / "책을"은 해결되지 않는다.
iOS에서는
String Catalog(.xcstrings)에서 Vary by Plural을 켜면 카테고리별 문자열을 채울 수 있다. 코드는 그냥 값을 넘긴다.
// 코드는 카테고리를 몰라도 된다 — 시스템이 로케일 규칙으로 고른다
let text = String(localized: "item_count",
defaultValue: "\(count) items")
// 카탈로그에서 count 에 대해 Vary by Plural 을 켜고
// one → "1 item"
// other → "%lld items"
// 아랍어 번역에는 zero/one/two/few/many/other 여섯 칸이 생긴다
직접 분기하면 안 된다는 점이 핵심이다.
// ❌ 영어 규칙을 하드코딩한 것 — 다른 언어에서 전부 틀린다
let bad = count == 1 ? "1 item" : "\(count) items"
// ❌ 카테고리를 직접 판정하는 것도 틀린다 — 언어가 안 쓰는 카테고리가 있다
if count == 2 { … } // 한국어·영어에는 two 카테고리가 없다
한국어 조사는 별도 로직이 필요하다. 받침 유무로 갈리므로 유니코드 한글 음절 블록의 산술로 판정할 수 있다.
extension String {
/// 마지막 글자에 받침(종성)이 있는가
var hasFinalConsonant: Bool? {
guard let scalar = unicodeScalars.last?.value else { return nil }
// 한글 음절 영역: U+AC00 ~ U+D7A3, 종성 인덱스 = (코드 - 0xAC00) % 28
guard (0xAC00...0xD7A3).contains(scalar) else { return nil } // 한글이 아니면 판정 불가
return (scalar - 0xAC00) % 28 != 0
}
/// 조사를 붙인다 — 한글이 아니면 앞 형태를 기본으로 쓴다
func withParticle(_ withJong: String, _ withoutJong: String) -> String {
guard let has = hasFinalConsonant else { return self + withoutJong }
return self + (has ? withJong : withoutJong)
}
}
"사과".withParticle("을", "를") // "사과를" (받침 없음)
"책".withParticle("을", "를") // "책을" (받침 있음)
"iPhone".withParticle("을", "를") // "iPhone를" ← 한글이 아니라 판정 불가
마지막 줄이 이 접근의 한계다. 영문·숫자로 끝나면 발음 기준으로 판정해야 하는데, 이건 규칙이 아니라 발음 사전의 영역이다. 그래서 실무에서는 조사를 피하는 문장 설계가 더 안전하다 — "사과를 삭제했습니다" 대신 "삭제됨: 사과"처럼.
실험 · 도구
어떤 언어가 어떤 카테고리를 쓰는지 직접 확인할 수 있다.
import Foundation
// 특정 수가 로케일에서 어느 카테고리로 떨어지는지 본다
func category(_ n: Int, _ id: String) -> String {
// 카탈로그 없이 확인하려면 각 카테고리 문자열을 만들어 비교하는 방식이 쓰인다.
// 여기서는 시스템 포맷터로 결과 문자열만 관찰한다.
let f = NumberFormatter()
f.locale = Locale(identifier: id)
return f.string(from: NSNumber(value: n)) ?? "-"
}
for id in ["ko_KR", "en_US", "ru_RU", "ar_SA"] {
let row = [0, 1, 2, 11, 21].map { category($0, id) }.joined(separator: " | ")
print(String(format: "%-8@ %@", id as NSString, row as NSString))
}
// ar_SA 에서는 숫자 자체가 아라비아 인도 숫자로 나온다 — 문자열 길이 가정이 깨진다
복수형 누락은 의사 언어로 훑는 것이 가장 빠르다.
# 아랍어로 실행 — 여섯 카테고리가 전부 필요한 언어라 누락이 바로 드러난다
-AppleLanguages "(ar)" -AppleLocale "ar_SA"
# 카탈로그에서 복수 variation 이 없는 항목 찾기
python3 - <<'PY'
import json, glob
for p in glob.glob("**/*.xcstrings", recursive=True):
d = json.load(open(p))
for key, v in d.get("strings", {}).items():
for lang, loc in v.get("localizations", {}).items():
if "variations" not in loc and any(c in key for c in ["count", "개", "item"]):
print(f"{p} {lang} {key} ← 복수 variation 없음")
PY
프로젝트 적용
- 수가 들어가는 모든 문자열에 복수 variation을 켠다. 한국어만 지원해도 켜 둔다 — 나중에 언어를 추가할 때 문자열을 다시 훑지 않아도 된다.
n == 1분기를 코드에서 없앤다. 이건 영어 규칙의 하드코딩이다.- 조사는 복수형과 별개로 처리한다. 가능하면 조사가 필요 없는 문장 구조로 바꾼다.
- 아랍어를 테스트 매트릭스에 넣는다. 여섯 카테고리를 전부 쓰는 언어라 누락이 즉시 드러난다.
- 숫자를 문자열로 조립하지 않는다. 아라비아 인도 숫자처럼 숫자 문자 자체가 다른 로케일이 있다. 서식은 시스템에 맡긴다.
"한국어는 복수형이 없으니 신경 안 써도 된다"는 지금은 맞지만 나중에 틀린다 — 언어를 추가하는 순간 모든 문자열을 다시 훑어야 한다. "2면 two 카테고리다"도 틀렸다 — 언어가 그 카테고리를 쓸 때만 그렇다. "n=11은 few다"도 틀렸다 — 아랍어에서는 끝자리 규칙상 many다. "복수형 variation을 채우면 조사도 해결된다"가 가장 위험한 오해다 — 완전히 다른 축이다. "복수형은 정수만의 문제"도 틀렸다 — 소수와 유효 자릿수도 카테고리에 영향을 준다.
물건 개수를 말하는 방식이 나라마다 다르다.
한국어는 편하다. 0개, 1개, 2개, 11개 — 전부 "개"다. 숫자만 바뀐다.
영어는 두 가지다. 1개일 때만 "item", 나머지는 "items".
아랍어는 여섯 가지다. 0일 때, 1일 때, 2일 때, 조금일 때, 많을 때, 나머지일 때 말이 전부 다르다. 게다가 11개는 "조금"이 아니라 "많음"에 들어간다. 끝자리를 보고 정하는 규칙이 따로 있기 때문이다.
여기서 문제가 생긴다. 한국에서 앱을 만들면서 "3개"처럼 숫자에 '개'만 붙이는 방식으로 코드를 짜면, 한국어로는 아무 문제가 없다. 그래서 버그인 줄도 모른다. 그런데 영어나 아랍어를 추가하는 순간 문장이 전부 어색해진다.
마지막으로 헷갈리기 쉬운 게 하나 있다. 한국어의 "을/를"은 이 문제와 완전히 다른 문제다. 개수가 아니라 앞 글자에 받침이 있는지로 정해지기 때문이다. "사과를", "책을" — 개수와 아무 상관이 없다. 그래서 개수 기능을 아무리 잘 채워도 이건 따로 해결해야 한다.
꼬리 질문
"3개 항목이 선택됨" 같은 문장에서 숫자를 강조 표시하려면 어떻게 하는가?
문자열을 쪼개서 이어 붙이면 안 된다. 언어마다 숫자의 위치가 다르기 때문이다.
// ❌ 순서를 하드코딩했다 — 아랍어·일본어에서 어순이 다르다
Text(bold(count)) + Text(" 개 항목이 선택됨")올바른 방법은 완성된 지역화 문자열을 만든 뒤, 그 안에서 숫자 부분을 찾아 속성을 입히는 것이다. AttributedString이 이를 지원한다.
var s = AttributedString(localized: "\(count) items selected")
// 마크다운으로 강조 범위를 번역자가 지정하게 하는 방법도 있다
// "**\(count)** items selected" → 번역자가 위치를 옮길 수 있다
if let r = s.range(of: count.formatted()) {
s[r].font = .body.bold()
s[r].foregroundColor = .accentColor
}더 나은 방법은 마크다운 강조를 번역 문자열 안에 두는 것이다. 그러면 번역자가 자기 언어의 어순에 맞게 강조 위치를 옮길 수 있다. 개발자가 위치를 정하지 않는 것이 핵심이다.
서버가 보낸 문자열에 개수가 들어 있으면 복수형을 어떻게 처리하는가?
서버가 완성된 문장을 보내는 구조 자체를 재검토해야 한다. 복수형·조사·어순은 표시 계층의 문제인데, 서버가 문장을 만들면 그 지식이 서버로 새어 간다.
선택지가 셋이다.
- 서버는 데이터만 보내고 앱이 문장을 만든다.
{"type": "items_selected", "count": 3}. 가장 깨끗하고, 언어 추가가 앱 배포만으로 끝난다. 기본 선택지다. - 서버가 로케일별 문장을 보낸다. 서버가
Accept-Language를 보고 완성 문장을 만든다. 앱 배포 없이 문구를 바꿀 수 있지만, 서버가 모든 언어의 복수 규칙을 알아야 한다. - 서버가 템플릿을, 앱이 값을 채운다. 절충안이지만 복수형 처리가 애매해진다 — 템플릿에 카테고리별 변형을 담아야 하므로 결국 (2)에 가까워진다.
실무에서 (2)를 고르는 흔한 이유는 "문구를 빨리 바꾸고 싶다"인데, 이건 String Catalog를 원격 설정과 결합해도 해결된다. 언어 지식을 서버로 옮기는 대가가 더 크다.
이건 P1 23장 Q5에서 본 "구버전 앱이 영원히 남는다"와도 얽힌다 — 서버가 문장을 보내면 구버전 앱에도 새 문구가 즉시 반영되지만, 대신 구버전이 이해 못 하는 형식을 보낼 위험이 생긴다.
한국어 조사 문제를 근본적으로 피하는 문장 설계는 어떤 것인가?
조사가 붙는 자리에 변수를 두지 않는 것이 핵심이다. 몇 가지 패턴이 있다.
| 피할 것 | 대안 |
|---|---|
| "{이름}을 삭제했습니다" | "삭제됨: {이름}" · "{이름} — 삭제됨" |
| "{파일}이 업로드되었습니다" | "업로드 완료" + 별도 줄에 파일명 |
| "{항목}으로 이동" | "이동 대상: {항목}" |
이 방식은 부수 효과가 좋다. 레이아웃에서 이름이 길어져도 문장이 깨지지 않고, 이름 부분만 다른 스타일을 주기도 쉽다. 그리고 다른 언어로 번역할 때도 어순 문제가 줄어든다.
조사를 꼭 써야 한다면 규칙을 한 곳에 모으고, 한글이 아닌 경우의 기본값을 정해 둔다.
// 영문·숫자로 끝나는 경우의 기본값을 명시적으로 정한다
extension String {
func 을를() -> String { withParticle("을", "를") }
func 이가() -> String { withParticle("이", "가") }
func 은는() -> String { withParticle("은", "는") }
}
// "iPhone" 처럼 한글이 아닌 값은 발음 기준이라 규칙으로 못 푼다.
// → 이런 값이 들어올 수 있으면 애초에 조사를 피하는 설계가 맞다.Q5. String Catalog는 무엇을 보장하고 무엇은 못 하는가?
추출·추적·검증을 자동화하지만 번역의 정확성은 보장하지 못한다. String Catalog(.xcstrings)는 Xcode 15부터 .strings와 .stringsdict를 하나의 JSON 파일로 통합한 포맷이다. Xcode가 소스의 String(localized:) 호출을 정적 스캔해 항목을 자동 추출하고, 번역 상태(신규/검토 필요/번역됨)를 추적하며, 포맷 지정자 불일치를 빌드 시점에 경고한다. 함정은 String(localized:)의 첫 인자가 StaticString이라 보간이 안 된다는 점이다 — 키를 값에 따라 바꾸면 카탈로그 엔트리가 무한히 늘어난다.
CS 원리
지역화 리소스 관리의 근본 문제는 코드와 데이터의 동기화다. 문자열 키는 코드에 있고 번역은 별도 파일에 있으므로, 둘이 어긋나면 런타임에야 발견된다 — 키가 그대로 화면에 찍히는 형태로.
이 문제를 다루는 접근이 셋 있다.
- 수동 관리 — 개발자가 키를 양쪽에 적는다. 어긋남이 필연적이다.
- 정적 추출 — 도구가 소스를 스캔해 키 목록을 만든다. String Catalog의 방식. 코드가 진실의 원천이 된다.
- 코드 생성 — 리소스에서 타입 안전한 심볼을 생성한다. 오타가 컴파일 단계에서 잡힌다.
정적 추출이 성립하려면 키가 컴파일 타임에 결정돼야 한다. 그래서 String(localized:)의 첫 인자가 StaticString인 것이다 — 런타임에 조립되는 키는 스캐너가 볼 수 없다. 이것은 제약이 아니라 정적 분석이 가능하기 위한 전제다.
동시에 이 방식의 한계도 분명하다. 도구가 검증할 수 있는 것은 구조(키 존재, 포맷 지정자 개수·타입, 복수 카테고리 누락)뿐이고, 의미(번역이 맞는가, 문맥에 적절한가, 잘리지 않는가)는 검증하지 못한다.
iOS에서는
기본 사용은 단순하다. 문자열을 String(localized:)로 감싸면 Xcode가 카탈로그에 자동 추가한다.
// 기본 — 키와 기본값이 같다
let title = String(localized: "Settings")
// 키와 표시값을 분리 (권장) — 나중에 문구가 바뀌어도 키가 유지된다
let msg = String(localized: "cart.empty.message",
defaultValue: "장바구니가 비어 있습니다",
comment: "장바구니 화면, 항목이 하나도 없을 때")
// SwiftUI 는 Text 가 자동으로 지역화한다
Text("Settings") // 자동 조회
Text(verbatim: "Settings") // 조회하지 않음 (고정 문자열)
보간이 필요할 때가 함정이다. 첫 인자는 StaticString이라 값을 끼워 넣을 수 없다.
// ❌ 컴파일 에러 — 키에 보간을 넣을 수 없다
// String(localized: "greeting_\(userName)")
// ❌ 동작은 하지만 카탈로그가 무한히 늘어난다
// String(localized: "\(count)개 항목") ← count 마다 새 엔트리
// ✅ 키는 고정하고 defaultValue 에서만 보간한다
let s = String(localized: "cart.item_count",
defaultValue: "\(count)개 항목",
comment: "장바구니 항목 수")
// ✅ 값을 나중에 채우려면 LocalizedStringResource
let res = LocalizedStringResource("cart.item_count",
defaultValue: "\(count)개 항목")
포맷 지정자 검증이 이 포맷의 큰 이점이다. 번역에서 %@가 빠지거나 순서가 바뀌면 빌드 경고가 뜬다 — 예전에는 런타임 크래시로만 발견됐다.
// 번역자가 순서를 바꿔야 하는 언어를 위해 위치 지정자를 쓴다
// "%1$@ 님이 %2$@ 를 공유했습니다"
// 아랍어 번역: "%2$@ تمت مشاركته بواسطة %1$@"
// ← 순서가 바뀌어도 안전하다. %@ 만 쓰면 순서를 못 바꾼다
실험 · 도구
카탈로그는 JSON이므로 스크립트로 검사할 수 있다. CI에 넣으면 미번역 항목이 릴리스에 새어 나가는 것을 막는다.
# 미번역·검토 필요 항목을 세어 임계로 관리한다
python3 - <<'PY'
import json, glob, collections
stat = collections.Counter()
missing = []
for p in glob.glob("**/*.xcstrings", recursive=True):
d = json.load(open(p))
langs = set()
for key, v in d.get("strings", {}).items():
for lang, loc in v.get("localizations", {}).items():
langs.add(lang)
state = loc.get("stringUnit", {}).get("state", "?")
stat[(lang, state)] += 1
if state == "new":
missing.append((lang, key))
for (lang, state), n in sorted(stat.items()):
print(f"{lang:6} {state:12} {n}")
print(f"\n미번역 {len(missing)}건")
for lang, key in missing[:10]:
print(f" {lang} {key}")
PY
의미 검증은 도구로 안 되므로 의사 언어로 화면을 훑는다.
# Xcode 스킴 › Run › Options › App Language
# Double-Length Pseudolanguage — 모든 문자열을 두 배로 늘려 레이아웃 넘침을 드러낸다
# Right-to-Left Pseudolanguage — RTL 레이아웃 문제를 번역 없이 확인
# Accented Pseudolanguage — 지역화되지 않은(하드코딩된) 문자열을 찾아낸다
# 명령줄에서도 지정할 수 있다
xcodebuild test -scheme App \
-testLanguage ar -testRegion SA
Accented Pseudolanguage가 특히 유용하다 — 지역화를 거친 문자열만 악센트가 붙으므로, 악센트 없이 나오는 문자열이 곧 하드코딩된 문자열이다.
프로젝트 적용
- 키와 표시 문구를 분리한다. 키를
cart.empty.message처럼 두면 문구를 바꿔도 번역이 유지된다. 영어 문장을 키로 쓰면 문구 수정이 곧 번역 초기화다. comment를 반드시 채운다. 번역자는 코드를 보지 않는다. "Save"가 동사인지 명사인지, 버튼인지 제목인지 모른다.- 위치 지정자(
%1$@)를 기본으로 쓴다. 인자가 둘 이상이면 순서를 바꿔야 하는 언어가 반드시 있다. - 미번역 항목 수를 CI 지표로 관리한다. 릴리스 전 임계를 넘으면 실패시킨다 — P1 21장 Q6의 "조용한 실패를 빌드 실패로"와 같은 구조다.
- Accented Pseudolanguage로 하드코딩 문자열을 정기적으로 훑는다. 새로 추가된 화면에서 가장 잘 빠진다.
"NSLocalizedString은 폐기됐다"는 틀렸다 — 여전히 동작하며 신규 코드에 권장되지 않을 뿐이다. "카탈로그가 자동 추출하니 관리가 필요 없다"도 틀렸다 — 추출은 키만 하고 번역·검토 상태는 사람이 관리한다. "빌드 경고가 없으면 번역이 정상이다"도 위험하다 — 도구는 구조만 본다. "키에 보간을 쓰면 편하다"는 카탈로그를 폭발시킨다 — 키는 고정하고 defaultValue에서 보간한다. "영어 문장을 키로 쓰면 기본값이 필요 없어 편하다"도 대가가 있다 — 문구 한 글자만 바꿔도 모든 언어의 번역이 새 항목이 된다.
번역가에게 일을 맡기는 상황을 생각해 보자.
예전에는 번역할 문장 목록을 사람이 손으로 뽑아서 보냈다. 새 문장을 추가하고 목록에 넣는 걸 깜빡하면, 앱에는 번역 안 된 이상한 글자가 그대로 나온다.
지금은 도구가 코드를 훑어서 목록을 자동으로 만든다. 빠뜨릴 수가 없다. 그리고 "이건 아직 번역 안 됨", "이건 다시 봐야 함" 같은 상태까지 추적해 준다.
도구가 잡아주는 게 하나 더 있다. 원문에 빈칸이 두 개인데 번역문에 하나만 있으면 미리 알려준다. 예전에는 이게 앱이 죽고 나서야 발견됐다.
그런데 도구가 못 하는 일이 있다. 번역이 맞는지는 모른다. "저장"을 "보관"으로 잘못 번역해도 도구는 아무 말도 안 한다. 글자가 길어져서 화면 밖으로 삐져나가는 것도 모른다.
그래서 결국 사람이 그 나라 말로 켜 놓고 화면을 봐야 한다. 도구는 "빠뜨린 것"을 잡아주고, 사람은 "틀린 것"을 잡는다.
꼬리 질문
번역을 앱 배포 없이 바꾸려면 어떤 구조가 필요한가?
원격 문자열 저장소 + 로컬 폴백 구조가 표준이다. 다만 기본 번역은 반드시 앱에 포함해야 한다.
구성은 이렇다.
- 앱 번들의 카탈로그가 기본값이다. 네트워크가 없거나 원격 데이터가 오기 전에도 화면이 정상이어야 한다.
- 원격에서 받은 사전을 우선 조회한다. 키가 있으면 원격 값을, 없으면 번들 값을 쓴다.
- 버전과 로케일을 함께 관리한다. 앱 버전이 모르는 키가 오거나, 반대로 앱이 쓰는 키가 원격에 없을 수 있다.
enum L10n {
nonisolated(unsafe) static var remote: [String: String] = [:]
static func t(_ key: String.LocalizationValue, fallback: String) -> String {
// 원격 우선, 없으면 번들 카탈로그
if let s = remote[String(describing: key)] { return s }
return String(localized: key, defaultValue: "\(fallback)")
}
}대가도 분명하다 — 정적 검증을 잃는다. Xcode가 원격 문자열의 포맷 지정자를 검사해 주지 않으므로, 서버 쪽에 같은 검증을 구현해야 한다. 포맷 지정자가 어긋난 문자열이 내려오면 런타임 크래시가 날 수 있다.
그래서 실무 절충은 "자주 바뀌는 마케팅 문구만 원격, 기능 문구는 번들"이다. 전부 원격으로 옮기면 얻는 것보다 잃는 것이 많다.
번역자에게 문맥을 전달하는 좋은 방법은 무엇인가?
comment 한 줄이 가장 효율이 높지만, 무엇을 적느냐가 중요하다.
번역자가 실제로 궁금해하는 것은 셋이다.
- 품사·역할 — "Save"가 버튼인가 제목인가. 동사인가 명사인가. 이것 하나로 번역이 달라진다.
- 공간 제약 — 버튼 안에 들어가야 하는지, 몇 글자까지 가능한지.
- 변수의 의미 —
%@에 무엇이 들어오는지. 사람 이름인지 파일명인지에 따라 조사·관사가 달라진다.
// ❌ 도움이 안 되는 주석
String(localized: "save", defaultValue: "저장", comment: "저장 버튼")
// ✅ 번역자가 판단할 수 있는 주석
String(localized: "editor.save.button",
defaultValue: "저장",
comment: "문서 편집 화면 하단 버튼(동사). 최대 6자 권장. 옆에 '취소' 버튼이 나란히 놓임")
String(localized: "share.by_user",
defaultValue: "\(name) 님이 공유했습니다",
comment: "알림 목록 한 줄. %1$@ 는 사용자 표시 이름(한글·영문·이모지 가능)")그 다음으로 효과가 큰 것이 스크린샷이다. 번역 도구가 지원한다면 키에 화면 캡처를 연결한다. 문맥 설명 열 줄보다 이미지 하나가 낫다.
그리고 번역자에게 앱을 실제로 써 보게 하는 것이 가장 확실하다. TestFlight로 해당 언어 빌드를 주면 어색한 문장이 즉시 드러난다.
지역화되지 않은 하드코딩 문자열을 자동으로 잡을 수 있는가?
완전 자동은 어렵지만 대부분은 잡을 수 있다. 세 가지를 겹쳐 쓴다.
(1) Accented Pseudolanguage. 가장 효과가 큰 방법이다. 지역화를 거친 문자열에만 악센트가 붙으므로, 화면을 훑으며 평범하게 보이는 글자를 찾으면 그게 하드코딩이다. 수동이지만 확실하다.
(2) 정적 검사(린트 규칙). UI에 들어가는 문자열 리터럴을 금지하는 규칙을 만든다.
# SwiftLint 커스텀 규칙 예 — Text/Label 에 리터럴을 직접 넣는 것을 잡는다
custom_rules:
hardcoded_ui_string:
name: "지역화되지 않은 UI 문자열"
regex: 'Text\(\s*"[^"]*[가-힣A-Za-z]{2,}'
match_kinds: [identifier, string]
message: "Text(...) 에 리터럴 대신 String(localized:) 또는 LocalizedStringKey 를 쓰세요"
severity: warning다만 오탐이 많다 — Text(verbatim:)이나 디버그 문자열까지 걸린다. 예외를 관리해야 실용적이다.
(3) 카탈로그 대조. 빌드된 앱의 문자열 테이블과 화면에 실제로 나타난 문자열을 비교하는 방식도 있지만, 구현 비용이 크다.
실무 조합은 (1)을 릴리스 전 체크리스트에, (2)를 PR 단계에 두는 것이다. (2)가 새로 들어오는 것을 막고, (1)이 이미 있는 것을 찾는다.
Q6. FormatStyle은 DateFormatter와 무엇이 다른가?
문자열 설정을 타입 있는 값 조립으로 바꾼 것이다. DateFormatter는 dateFormat 문자열로 설정하는 가변 참조 타입이라 YYYY/yyyy 같은 글자 하나 오타가 런타임에야 드러나고, 스레드 간 공유가 위험하며, 생성 비용 때문에 캐싱이 필요하다. FormatStyle 계열은 .dateTime.year().month()처럼 타입 있는 심볼을 체이닝하는 값 타입이라 오타가 컴파일되지 않고, 매 호출이 독립 복사본이라 경합이 없으며, static let 캐싱이 굳이 필요 없다. 그리고 같은 스타일을 ParseStrategy로 재사용해 출력↔파싱 대칭이 타입으로 보장된다.
CS 원리
이 차이는 문자열 기반 설정 vs 타입 기반 설정이라는 오래된 대비의 한 사례다.
문자열 설정은 유연하다 — 어떤 조합이든 표현할 수 있다. 대신 검증이 런타임으로 미뤄진다. 컴파일러는 "yyyy-MM-dd"가 올바른 패턴인지 알 수 없고, "YYYY"(ISO 주 기준 연도)와 "yyyy"(달력 연도)의 차이는 연말 며칠 동안만 드러난다. 12월 31일이 다음 해 첫 주에 속하면 YYYY가 내년을 출력한다.
타입 기반 설정은 표현력을 조금 포기하는 대신 잘못된 상태를 표현 불가능하게 만든다. 필드가 문자가 아니라 열거형 케이스이므로, 존재하지 않는 필드를 쓸 방법이 없다.
두 번째 축은 값 타입 vs 참조 타입이다.
| DateFormatter | FormatStyle | |
|---|---|---|
| 종류 | 가변 참조 타입(클래스) | 값 타입 |
| 설정 | 문자열 dateFormat | 타입 있는 체이닝 |
| 오타 검출 | 런타임(또는 안 남) | 컴파일 타임 |
| 동시 사용 | 공유 시 위험 — 캐싱과 충돌 | 복사본이라 안전 |
| 파싱 대칭 | 개발자가 dateFormat을 맞춰야 성립 | 타입으로 보장 |
세 번째로 왕복(round-trip) 대칭이 중요하다. DateFormatter의 string(from:)과 date(from:)이 서로 역함수인지는 개발자가 같은 dateFormat을 썼는지에만 달려 있다. FormatStyle은 같은 스타일 값을 ParseStrategy로 재사용하므로 이 대칭이 타입 시스템에 담긴다.
iOS에서는
같은 계열 설계가 날짜뿐 아니라 숫자·통화·기간·측정값에 일관되게 적용된다.
let d = Date()
// 날짜 — 필드를 골라 조립한다
d.formatted(.dateTime.year().month().day()) // 2026. 8. 15.
d.formatted(.dateTime.hour().minute()) // 오후 2:30
d.formatted(.dateTime.weekday(.wide)) // 토요일
d.formatted(.relative(presentation: .named)) // 어제 · 3분 전
// 숫자·통화·퍼센트
1234.5.formatted(.number.precision(.fractionLength(1))) // 1,234.5
1234.5.formatted(.currency(code: "KRW")) // ₩1,235
0.756.formatted(.percent.precision(.fractionLength(1))) // 75.6%
// 기간·측정값
Duration.seconds(3725).formatted(.time(pattern: .hourMinuteSecond)) // 1:02:05
Measurement(value: 5, unit: UnitLength.kilometers).formatted() // 5km
파싱 대칭이 실무에서 특히 유용하다. 같은 스타일 값으로 출력하고 다시 읽는다.
let style = Date.ISO8601FormatStyle()
let text = d.formatted(style) // "2026-08-15T14:30:00Z"
let back = try Date(text, strategy: style) // 같은 스타일로 되읽는다
// DateFormatter 였다면 dateFormat 을 양쪽에 똑같이 적었는지에 달려 있다
스레드 안전성도 달라진다. DateFormatter는 가변 참조 타입이라 여러 스레드가 공유하며 설정을 바꾸면 위험한데, 생성 비용 때문에 공유하고 싶은 유인이 생기는 것이 문제였다.
// ❌ 흔한 패턴 — 캐싱하려다 공유 가변 상태가 된다
final class Legacy {
static let shared = DateFormatter() // 여러 스레드가 dateFormat 을 바꾸면 위험
}
// ✅ FormatStyle 은 값 타입이라 그냥 만들어 쓴다
let s = d.formatted(.dateTime.year().month())
성능도 개선됐다. swift-foundation 재작성에서 FormatStyle 기반 날짜 포매팅이 150% 개선(약 2.5배)됐다고 공식 벤치마크가 밝히고 있다. 다만 이는 특정 표준 벤치마크 맥락의 수치라 모든 로케일·필드 조합에 일반화하면 안 된다.
실험 · 도구
YYYY/yyyy 차이를 직접 재현해 보면 왜 타입 기반이 나은지 분명해진다.
import Foundation
var cal = Calendar(identifier: .gregorian)
cal.timeZone = TimeZone(identifier: "Asia/Seoul")!
let wrong = DateFormatter(); wrong.dateFormat = "YYYY-MM-dd" // ISO 주 기준 연도
let right = DateFormatter(); right.dateFormat = "yyyy-MM-dd" // 달력 연도
for f in [wrong, right] { f.locale = Locale(identifier: "en_US_POSIX"); f.timeZone = cal.timeZone }
for day in [1, 15, 29, 30, 31] {
let date = cal.date(from: DateComponents(year: 2026, month: 12, day: day))!
print("12/\(day): YYYY→\(wrong.string(from: date)) yyyy→\(right.string(from: date))")
}
// 12/1: YYYY→2026-12-01 yyyy→2026-12-01
// 12/31: YYYY→2027-12-31 yyyy→2026-12-31 ← 연도가 다르다
// 해당 날짜가 다음 해 첫 주에 속하면 YYYY 가 내년을 낸다.
// 1년 중 며칠만 틀리므로 테스트를 통과하고 릴리스된다.
기존 코드에서 위험한 패턴을 훑는다.
# 대문자 YYYY 를 쓰는 곳 — 의도적으로 ISO 주 연도를 원한 게 아니면 버그다
grep -rn 'dateFormat.*YYYY' --include=*.swift . | grep -v 'yyyy'
# DateFormatter 를 정적 프로퍼티로 공유하는 곳
grep -rn 'static.*DateFormatter\|static.*NumberFormatter' --include=*.swift .
# 로케일을 지정하지 않은 고정 포맷 (Q7 에서 다룬다)
grep -rn -A3 'dateFormat = ' --include=*.swift . | grep -B1 -v 'locale'
프로젝트 적용
- 새 코드는
FormatStyle을 기본으로 쓴다. 오타가 컴파일 단계에서 잡히고 스레드 문제가 사라진다. - 기존
DateFormatter는YYYY검색부터 한다. 이 하나만 잡아도 연말 장애를 막는다. - 정적 포매터 캐싱을 걷어낸다. 값 타입으로 옮기면 캐싱 자체가 불필요하다.
- 출력과 파싱에 같은 스타일 값을 쓴다. 왕복 대칭을 타입에 맡긴다.
Measurement.formatted()의 기본 반올림에 주의한다. 기본값은 "정확한 값 보존"보다 "보기 좋은 반올림"을 우선하므로, 정밀도가 필요하면numberFormatStyle을 명시한다.
"YYYY와 yyyy는 같다"가 가장 비싼 오해다 — 전자는 ISO 주 기준 연도라 연말에 다른 값을 낸다. "DateFormatter는 느리니 무조건 캐싱해야 한다"도 이제는 낡았다 — FormatStyle은 생성 비용 자체가 작아 캐싱이 불필요하다. "FormatStyle이 항상 2.5배 빠르다"도 과장이다 — 공식 벤치마크의 150% 개선은 특정 맥락의 수치다. "Measurement.formatted()는 값을 그대로 보여준다"도 틀렸다 — 기본값이 보기 좋게 반올림한다.
날짜를 어떻게 쓸지 기계에게 알려주는 방법이 두 가지다.
옛날 방식은 암호 같은 문자열을 적어 주는 것이었다. "yyyy-MM-dd" 같은 식이다. 문제는 대문자와 소문자가 다른 뜻이라는 것이다. "yyyy"는 우리가 아는 연도인데, "YYYY"는 주(week)를 세는 특별한 연도다.
이걸 잘못 쓰면 1년 중 364일은 멀쩡하다가 12월 마지막 며칠에만 연도가 내년으로 나온다. 테스트도 통과하고, 몇 달 잘 돌아가다가, 연말에 갑자기 터진다.
새 방식은 메뉴에서 골라 담는 것이다. "연도 넣기", "월 넣기", "일 넣기"를 차례로 고른다. 없는 메뉴는 고를 수가 없으니 오타 자체가 불가능하다.
또 하나 좋아진 게 있다. 옛날 방식은 도구를 하나 만들어서 여러 사람이 돌려 쓰는 구조라, 한 사람이 설정을 바꾸면 다른 사람 것도 바뀌었다. 새 방식은 쓸 때마다 자기 것을 만드는 방식이라 서로 간섭하지 않는다.
꼬리 질문
기존 DateFormatter 코드를 전부 옮겨야 하는가?
아니다. 위험도 순으로 나눠서 옮긴다.
(1) 즉시 고쳐야 하는 것 — YYYY를 쓰는 곳. 이건 이전이 아니라 버그 수정이다. yyyy로 바꾸는 것만으로 끝난다.
(2) 우선순위 높음 — 여러 스레드에서 공유되는 정적 포매터. 값 타입으로 옮기면 문제가 사라진다.
(3) 기회가 될 때 — 화면 표시용 포매팅. 동작에 문제가 없으므로 해당 코드를 만질 때 함께 옮긴다.
(4) 옮기지 않아도 되는 것 — 고정 wire format 파싱. en_US_POSIX로 잘 고정된 코드는 이미 안전하고, ISO8601FormatStyle로 옮길 수 있으면 옮기되 급하지 않다.
주의할 점은 출력 문자열이 미묘하게 달라질 수 있다는 것이다. 스냅샷 테스트나 서버 계약이 걸린 곳은 이전 전후 출력을 대조해야 한다.
// 이전 전후 대조 — 실제 값이 같은지 확인한다
let old = legacyFormatter.string(from: date)
let new = date.formatted(.dateTime.year().month().day())
assert(old == new, "출력이 달라졌다: \(old) vs \(new)")커스텀 포맷이 필요한데 FormatStyle로 표현이 안 되면 어떻게 하는가?
세 단계로 접근한다.
(1) 정말 커스텀이 필요한지 다시 본다. "우리 앱만의 날짜 형식"을 만드는 것은 대개 사용자 기대에 어긋난다. 사용자는 시스템 전체와 같은 형식을 기대한다.
(2) 템플릿 기반 API를 쓴다. 필드만 지정하고 순서와 구분자는 로케일에 맡기는 방식이 있다. 이게 커스텀과 로케일 존중의 절충이다.
// 필드만 요청하고 배치는 로케일에 맡긴다
let f = DateFormatter()
f.setLocalizedDateFormatFromTemplate("yMMMd")
// en_US → "Aug 15, 2026" ko_KR → "2026년 8월 15일"
// 순서·구분자·12/24시간을 로케일이 정한다(3) 그래도 고정 형식이 필요하면 그건 기계용일 가능성이 높다. 로그·파일명·서버 전송이라면 en_US_POSIX로 고정하는 것이 정답이다(Q7). 사람에게 보여줄 것이 아니라면 로케일을 따를 이유가 없다.
마지막 수단으로 FormatStyle을 직접 구현할 수도 있다 — 프로토콜을 채택하면 기존 API와 같은 방식으로 쓸 수 있다.
struct ShortRelative: FormatStyle {
func format(_ value: Date) -> String {
let s = Int(Date.now.timeIntervalSince(value))
switch s {
case ..<60: return "방금"
case ..<3600: return "\(s / 60)분"
default: return value.formatted(.dateTime.month().day())
}
}
}
Date().formatted(ShortRelative())서버에 보낼 날짜와 화면에 보여줄 날짜를 어떻게 분리해 관리하는가?
타입으로 갈라 두는 것이 가장 확실하다. 문자열 하나로 다니면 어디서 어떤 형식인지 알 수 없다.
// 모델은 Date 로만 다룬다 — 문자열을 모델에 담지 않는다
struct Order: Codable {
let id: String
let placedAt: Date // 디코딩 시 ISO 8601 로 파싱
}
// 디코딩·인코딩은 한 곳에서 고정한다
let decoder = JSONDecoder()
decoder.dateDecodingStrategy = .iso8601 // 기계용 — 로케일 무관
// 표시는 뷰 계층에서만, 로케일을 따른다
Text(order.placedAt.formatted(.dateTime.year().month().day()))원칙은 셋이다.
- 모델은
Date(절대 시각)만 갖는다. 이미 서식된 문자열을 모델에 담으면 로케일이 바뀌어도 안 바뀐다. - 파싱·직렬화는 경계 한 곳에서 한다.
JSONDecoder설정이나 전용 함수. - 서식은 표시 직전에 한다. 그래야 로케일 변경이 즉시 반영된다.
이건 27장 Q2에서 본 "경계에서 정규화한다"와 같은 원칙이고, P0 17장의 관심사 분리가 국제화에 적용된 형태다. 29장에서 시각 자체의 표현 문제를 더 다룬다.
Q7. en_US_POSIX는 언제 쓰고 언제 쓰면 안 되는가?
기계용 고정 형식에는 반드시 쓰고, 화면 표시에는 절대 쓰면 안 된다. en_US_POSIX는 특정 문화권의 관습이 아니라 고정되고 기기 설정과 무관한 로케일이다. OS 업데이트나 사용자의 지역 설정이 바뀌어도 항상 같은 방식으로 해석·출력된다는 보장이 있어, 서버 wire format·로그·파일명처럼 "무조건 이 표기 그대로"여야 하는 곳에 쓴다. 반대로 표시용 화면에 고정하면 모든 사용자에게 미국식 표기를 강요하게 된다 — 표시는 setLocalizedDateFormatFromTemplate(_:)로 필드만 요청하고 순서와 12/24시간 여부는 로케일에 맡긴다.
CS 원리
이것은 내부 표현과 외부 표현의 분리라는 일반 원칙의 사례다.
시스템은 두 종류의 텍스트를 다룬다.
- 기계 사이의 약속 — 파싱될 것을 전제로 한 형식. 재현 가능해야 하고, 어느 기기에서 만들어도 같아야 한다. 사람이 읽기 편할 필요는 없다.
- 사람을 위한 표현 — 읽기 쉬워야 하고, 그 문화의 관습을 따라야 한다. 재현 가능성은 필요 없다.
두 요구는 정면으로 충돌한다. 재현 가능하려면 고정돼야 하고, 문화를 따르려면 변해야 한다. 그래서 한 형식으로 둘 다 만족시킬 수 없고, 반드시 갈라야 한다.
en_US_POSIX는 첫 번째를 위한 고정점이다. 이름에 en_US가 들어가지만 미국 사용자를 위한 것이 아니라, POSIX/C 표준을 흉내 낸 불변 기준선이다. 실제 미국 로케일(en_US)의 관습이 바뀌어도 이것은 바뀌지 않는다.
여기서 자주 오해되는 지점이 하나 있다. 고정 포맷에서 AM/PM이 섞이는 진짜 원인은 "12시간제가 흔한 나라의 로케일을 골라서"가 아니다. 실측하면 en_US·ko_KR·en_GB 어느 것이든 명시적으로 지정하기만 하면 HH:mm:ss가 24시간제로 지켜진다. 문제가 되는 것은 로케일을 아예 지정하지 않아 기기의 시간 표기 선호(hourCycle)에 위임되는 경우뿐이다.
iOS에서는
고정 포맷에는 로케일·시간대·달력 셋 다 고정하는 것이 안전하다.
// ✅ 기계용 — 세 축을 모두 고정한다
let wire = DateFormatter()
wire.locale = Locale(identifier: "en_US_POSIX") // 표기 규칙 고정
wire.timeZone = TimeZone(secondsFromGMT: 0) // UTC 고정
wire.calendar = Calendar(identifier: .gregorian) // 불교력 등 방지
wire.dateFormat = "yyyy-MM-dd'T'HH:mm:ssZ"
// ✅ 더 나은 방법 — ISO 8601 전용 타입을 쓴다
let iso = ISO8601DateFormatter()
let s = iso.string(from: Date())
// 또는 FormatStyle
let s2 = Date().formatted(.iso8601)
표시용은 정반대다. 형식을 고정하지 않고 필드만 요청한다.
// ❌ 표시용에 고정 포맷 — 모든 사용자에게 미국식을 강요한다
let bad = DateFormatter()
bad.locale = Locale(identifier: "en_US_POSIX")
bad.dateFormat = "MM/dd/yyyy"
// ✅ 필드만 요청하고 배치는 로케일에 맡긴다
let good = DateFormatter()
good.setLocalizedDateFormatFromTemplate("yMMMd")
// ko_KR → 2026년 8월 15일 en_US → Aug 15, 2026 de_DE → 15. Aug. 2026
// ✅ FormatStyle 이면 더 간단하다
Date().formatted(.dateTime.year().month().day())
불교력 함정도 여기서 나온다. 태국 사용자의 기본 달력은 불교력이라 연도가 543년 차이 난다. 고정 포맷에 달력을 지정하지 않으면 서버로 2569가 나갈 수 있다.
실험 · 도구
로케일 지정 여부가 실제로 무엇을 가르는지 확인해 보자.
import Foundation
let date = Calendar(identifier: .gregorian)
.date(from: DateComponents(year: 2026, month: 8, day: 15, hour: 14, minute: 30))!
func check(_ label: String, _ configure: (DateFormatter) -> Void) {
let f = DateFormatter(); f.dateFormat = "HH:mm:ss"; configure(f)
print(String(format: "%-16@ %@", label as NSString, f.string(from: date) as NSString))
}
check("지정 안 함") { _ in } // 기기 설정에 위임
check("en_US") { $0.locale = Locale(identifier: "en_US") }
check("ko_KR") { $0.locale = Locale(identifier: "ko_KR") }
check("en_US_POSIX") { $0.locale = Locale(identifier: "en_US_POSIX") }
// 기기를 12시간제로 설정하면 "지정 안 함"만 2:30:00 PM 이 되고
// 나머지 셋은 전부 14:30:00 을 유지한다
불교력 영향도 재현된다.
# 태국 로케일로 실행해 연도가 543 차이 나는지 확인
-AppleLanguages "(th)" -AppleLocale "th_TH"
# 코드베이스에서 달력·시간대를 고정하지 않은 고정 포맷 찾기
grep -rn -B2 -A4 "dateFormat = " --include=*.swift . \
| grep -L "calendar\|timeZone" | head -20
프로젝트 적용
- 고정 포맷에는 로케일·시간대·달력 셋을 모두 지정한다. 하나라도 빠지면 기기 설정이 새어 든다.
- 가능하면
ISO8601DateFormatter나.iso8601스타일을 쓴다. 직접 패턴을 적을 일이 없어진다. - 표시용에는 절대
en_US_POSIX를 쓰지 않는다. 템플릿 API나FormatStyle로 필드만 요청한다. - 로그·파일명에도 고정 형식을 쓴다. 로그 타임스탬프가 기기 로케일을 타면 파싱·정렬이 깨진다(P1 18장).
- 태국·아랍 로케일로 회귀 테스트한다. 달력·숫자 문자가 다른 대표 사례다.
"en_US_POSIX는 미국 사용자를 위한 것"이라는 이름에서 오는 오해가 있다 — 문화권 로케일이 아니라 고정 기준선이다. "HH를 쓰면 24시간제가 보장된다"도 틀렸다 — 로케일을 지정하지 않으면 기기의 hourCycle 선호가 이긴다. "12시간제 문제는 en_US를 골라서 생긴다"도 실측과 다르다 — 어떤 로케일이든 명시하기만 하면 지켜진다. "표시용에도 en_US_POSIX를 쓰면 일관돼서 좋다"는 정반대다 — 모든 사용자에게 낯선 표기를 강요한다. "시간대만 UTC로 맞추면 된다"도 부족하다 — 달력을 고정하지 않으면 불교력이 새어 든다.
날짜를 적는 방법이 두 종류 필요하다.
하나는 기계끼리 주고받는 방식이다. 이건 어디서 적어도 똑같아야 한다. 서울에서 적든 방콕에서 적든 같은 글자가 나와야 나중에 읽을 수 있다. 사람이 읽기 예쁠 필요는 전혀 없다.
다른 하나는 사람에게 보여주는 방식이다. 이건 그 나라 방식이어야 한다. 한국 사람에게는 "2026년 8월 15일", 미국 사람에게는 "Aug 15, 2026"으로 보여야 자연스럽다.
en_US_POSIX는 첫 번째용 고정 자다. 이름에 "US"가 들어 있어서 미국식처럼 보이지만, 사실은 "아무 나라 관습도 따르지 않고 항상 똑같이"라는 뜻이다.
여기서 흔히 잘못 아는 게 하나 있다. "시간이 오후 2시 30분으로 나오는 건 미국 로케일을 골라서"라고 생각하는데, 실제로는 아무 로케일도 안 골라서 생기는 일이다. 뭐라도 골라 두면 — 미국이든 한국이든 — 14:30으로 잘 나온다. 안 고르면 기기 설정을 따라가는 것이 문제다.
그리고 태국을 조심해야 한다. 그 나라 기본 달력으로는 올해가 2569년이다. 달력을 안 정해 두면 서버에 2569가 날아간다.
꼬리 질문
ISO 8601 문자열을 파싱하는데 일부 응답에서만 실패한다. 왜인가?
ISO 8601은 하나의 형식이 아니라 형식군이라, 서버가 보내는 변형과 파서 설정이 어긋나면 실패한다.
대표적인 변형이 이렇다.
- 소수 초 —
2026-08-15T14:30:00.123Z. 기본ISO8601DateFormatter는 소수 초를 포함하지 않는 설정이 기본이라 실패한다. - 시간대 표기 —
Zvs+09:00vs+0900. - 시간 생략 —
2026-08-15만 오는 경우.
// 소수 초가 섞여 오는 경우 — 두 파서를 준비해 폴백한다
let withFraction = ISO8601DateFormatter()
withFraction.formatOptions = [.withInternetDateTime, .withFractionalSeconds]
let plain = ISO8601DateFormatter()
plain.formatOptions = [.withInternetDateTime]
func parseISO(_ s: String) -> Date? {
withFraction.date(from: s) ?? plain.date(from: s)
}더 나은 해법은 서버와 형식을 하나로 합의하는 것이다. "소수 초를 항상 포함한다" 또는 "항상 제외한다"를 계약에 명시하면 폴백이 필요 없다. 형식이 흔들리는 것 자체가 계약이 없다는 신호다.
이건 31장에서 다룰 스키마 계약의 한 사례이기도 하다.
로그 타임스탬프는 어느 시간대로 남겨야 하는가?
UTC로 남기고, 볼 때만 지역 시간으로 변환하는 것이 원칙이다.
이유가 셋이다.
- 정렬·병합 — 여러 기기·서버의 로그를 합칠 때 시간대가 섞여 있으면 순서를 못 맞춘다.
- DST — 서머타임이 있는 지역의 지역 시간은 같은 시각이 두 번 나타나거나 존재하지 않는 시각이 생긴다(29장). 정렬이 무너진다.
- 사용자 설정 변경 — 사용자가 여행 중 시간대를 바꾸면 과거 로그의 의미가 흔들린다.
// 저장은 UTC · 고정 형식
let logTime = ISO8601DateFormatter()
logTime.timeZone = TimeZone(secondsFromGMT: 0)
logTime.formatOptions = [.withInternetDateTime, .withFractionalSeconds]
// 표시는 사용자 시간대
Text(entry.timestamp.formatted(.dateTime.hour().minute().second()))다만 사용자 시간대를 함께 기록해 두면 조사에 도움이 된다. "사용자에게는 새벽 3시였다"는 사실이 버그 재현에 결정적일 수 있다. UTC 시각과 당시 시간대 식별자를 같이 남기면 둘 다 복원된다.
P1 18장에서 본 관측 데이터 설계와 같은 원칙이다 — 원본은 정규 형식으로, 맥락은 필드로.
사용자가 기기 언어를 바꿨는데 앱 안의 날짜 표기가 안 바뀐다. 어디를 보는가?
포매터나 결과 문자열을 캐시하고 있는지를 먼저 본다. 가장 흔한 원인이다.
// ❌ 포매터를 정적으로 캐시 — 생성 시점의 로케일에 묶인다
enum Fmt {
static let display: DateFormatter = {
let f = DateFormatter()
f.dateStyle = .long // 이때의 Locale.current 가 굳어진다
return f
}()
}
// ✅ 값 타입이라 캐시할 이유가 없다
Date().formatted(.dateTime.year().month().day())
// ✅ 굳이 캐시한다면 로케일 변경 알림에서 무효화한다
NotificationCenter.default.addObserver(
forName: NSLocale.currentLocaleDidChangeNotification, object: nil, queue: .main
) { _ in Fmt.invalidate() }그다음 볼 것은 모델에 이미 서식된 문자열을 담아 둔 경우다. 서버 응답의 "2026년 8월 15일" 같은 문자열을 그대로 보관하면 로케일이 바뀌어도 그대로다. 모델은 Date를 갖고 표시 직전에 서식해야 한다(Q6 꼬리질문).
세 번째는 SwiftUI 뷰가 갱신되지 않는 경우다. 로케일 변경이 뷰 갱신을 유발하지 않으면 화면이 그대로 남는다. @Environment(\.locale)을 읽는 뷰는 자동으로 갱신되지만, 계산된 문자열을 상태에 저장해 뒀다면 다시 계산되지 않는다.