국제화(i18n)는 흔히 "텍스트를 여러 언어로 옮기는 작업"으로만 여겨지지만, Foundation 관점에서는 로케일이라는 문화권 설정 하나가 정렬 순서·복수형 선택·언어와 지역의 표기 방식까지 통째로 갈라놓는 문제다. 이 챕터는 iOS 16이 Locale을 어떻게 구조화했는지, .strings/.stringsdict가 String Catalog로 어떻게 합쳐졌는지, "\(n)개" 같은 하드코딩이 왜 번역하는 순간 깨지는지, 그리고 사용자에게 보여줄 목록을 정렬할 때 왜 < 대신 localizedStandardCompare가 필요한지를 실측으로 확인한다. 한국어 화자에게는 특히 잘 안 보이는 함정 — 복수형 무구분, 조사(을/를 등) 선택 — 을 집중적으로 다룬다.
Q1. iOS 16의 Locale.Language / Locale.Region 분리는 무엇을 바꿨는가?
iOS 16 이전 Locale은 "ko_US"처럼 언어와 지역을 밑줄로 뭉친 평평한(flat) 문자열 식별자 하나로만 다뤄져, "UI는 한국어인데 통화·숫자 표기는 미국 지역 규칙"같은 조합을 표현하려면 어색한 식별자를 억지로 조립해야 했다. iOS 16은 Locale.Language(languageCode, script, region)와 Locale.Region(isISORegion, continent, containingRegion, subRegions 같은 계층 질의)을 별도 타입으로 도입해 언어와 지역을 독립된 두 축으로 다룰 수 있게 했다. 실무 이득은 표시 언어와 포맷 지역을 분리할 수 있다는 것 — 해외 사용자를 받는 한국 앱에 정확히 필요한 조합이다.
원리
전통적으로 로케일 식별자는 BCP-47류 규칙을 따르는 하나의 문자열(language_SCRIPT_REGION)로 표현돼 왔고, Locale(identifier:)도 여전히 그 문자열을 받는다. iOS 16이 추가한 것은 그 문자열을 파싱해서 얻는 게 아니라, 언어·스크립트·지역 각각을 처음부터 독립적으로 구성·질의할 수 있는 구조화된 타입이다. 이게 필요했던 이유는 언어와 지역이 사실 서로 독립적인 축이기 때문이다 — 캐나다에서 쓰는 프랑스어와 프랑스에서 쓰는 프랑스어는 같은 언어, 다른 지역이다.
내부 동작
실제로 macOS 26 / Swift 6.2.1에서 실행해 확인한 값이다.
import Foundation
let flat = Locale(identifier: "ko_US")
print(flat.identifier) // "ko_US"
let lang = Locale.Language(identifier: "ko")
print(lang.languageCode?.identifier ?? "nil") // "ko"
print(lang.script?.identifier ?? "nil") // "Kore" — 스크립트를 지정 안 해도 자동 추론된다
print(lang.region?.identifier ?? "nil") // "nil" — 언어 식별자만으론 지역이 없다
let region = Locale.Region("KR")
print(region.isISORegion) // true
print(region.continent?.identifier ?? "nil") // "142" (UN M49 코드 — Asia)
print(region.containingRegion?.identifier ?? "nil") // "030" (Eastern Asia)
let asia = Locale.Region("142")
print(asia.subRegions.prefix(5).map { $0.identifier })
// ["145", "143", "030", "034", "035"] — 서아시아·중앙아시아·동아시아 등 하위 지역흥미로운 점 두 가지가 실측에서 드러난다. 첫째, Locale.Language(identifier: "ko")처럼 스크립트를 지정하지 않아도 script가 nil이 아니라 "Kore"(ISO 15924 한글 스크립트 코드)로 채워진다 — 언어 코드만으로 통상적인 문자 체계를 추론해준다는 뜻이다. 둘째, Locale.Region의 continent/containingRegion은 ISO 국가 코드가 아니라 UN M49 숫자 지역 코드(142 = Asia, 030 = Eastern Asia)를 쓴다 — 이건 국가가 아니라 "지역 묶음" 개념이라 별도 코드 체계가 필요하기 때문이다.
실험 · 도구
위 코드는 그대로 swift -e '...' 한 줄이나 파일로 저장해 swift 파일명.swift로 실행하면 동일한 값을 재현할 수 있다. Locale.Region은 ISO 국가 코드뿐 아니라 "142"처럼 UN M49 지역 코드도 그대로 받아들이므로, Locale.Region("KR").continent가 반환한 값을 다시 Locale.Region에 넣어 계층을 한 단계씩 거슬러 올라갈 수 있다.
프로젝트 적용
① "UI는 한국어, 숫자·통화 표기는 다른 지역 규칙"을 원할 때는 언어와 지역을 각각 지정해 Locale을 조립한다. 이건 flat identifier 시절엔 Locale(identifier: "ko_US")처럼 실제로 존재하지 않는 조합을 억지로 문자열화해야 했던 지점이다.
// 앱 UI는 한국어로 고정하되, 통화 표기는 사용자가 실제로 있는 지역(미국) 규칙을 따르고 싶을 때
let displayLanguage = Locale.Language(identifier: "ko")
let formattingRegion = Locale.Region("US")
let locale = Locale(languageCode: displayLanguage.languageCode!, languageRegion: formattingRegion)
print(1234.56.formatted(.currency(code: "USD").locale(locale)))
// 통화 기호·자릿수 구분은 US 관례를 따르면서, 나머지 UI 텍스트는 한국어로 유지하는 구성이 가능하다② Locale.Region의 계층 질의(containingRegion, subRegions)를 쓰면 "이 지역이 특정 권역에 속하는가"를 코드로 판단할 수 있다. 예컨대 특정 대륙 단위로 다른 개인정보 처리 문구를 보여줘야 하는 경우다.
func isInContinent(_ region: Locale.Region, continentIdentifier: String) -> Bool {
var current: Locale.Region? = region
while let r = current {
if r.identifier == continentIdentifier { return true }
current = r.containingRegion
}
return false
}
print(isInContinent(Locale.Region("KR"), continentIdentifier: "142")) // true — 한국은 아시아(142) 하위"Locale은 언어랑 같은 개념이다"는 틀렸다. Locale은 언어·지역·스크립트·캘린더 선호 등을 합친 문화권 설정 묶음이고, Locale.Language는 그중 언어 축 하나만 떼어낸 타입이다. 또 "iOS 16 이후로는 flat identifier 문자열을 아예 안 쓴다"도 틀렸다 — Locale(identifier:) 생성자와 .identifier 프로퍼티는 여전히 존재하고, 구조화 타입은 그 위에 추가된 것이지 대체한 게 아니다.
예전엔 이름표에 "이름_사는동네"를 한 줄로 붙여 써야 했다면(예: "철수_서울"), iOS 16부터는 "이름"칸과 "사는 동네"칸이 따로 있는 서류를 쓸 수 있게 된 것과 비슷하다. 이름 칸만 채우고 동네 칸은 비워둘 수도 있고, 동네가 어느 더 큰 지역(도·나라·대륙)에 속하는지도 서류에서 바로 찾아볼 수 있다. 비유가 깨지는 곳: 사람 이름과 동네는 원래도 따로 관리할 수 있었지만, Locale은 정말로 iOS 16 전까지 문자열을 자르고 붙이는 것 말고는 두 개념을 API 차원에서 따로 다룰 방법이 없었다.
꼬리 질문
Locale.Language(identifier: "ko")의 script가 자동으로 "Kore"가 되는 걸 신뢰하고 코드를 짜도 되는가?
script를 직접 지정해 Locale.Language(languageCode:script:region:)로 구성하는 편이 안전하다.Locale.Region의 continent/containingRegion이 쓰는 UN M49 코드는 ISO 3166 국가 코드와 무엇이 다른가?
KR, US 같은 두 글자 코드)은 개별 국가·영토를 가리키는 코드다. UN M49(142, 030 같은 세 자리 숫자 코드)는 국가가 아니라 "동아시아", "아시아" 같은 지역 묶음을 가리키는 별도 체계라, 국가 코드 체계 안에는 애초에 대응하는 값이 없다. Locale.Region은 이 두 체계를 하나의 타입 안에서 같이 다룬다.swift-foundation 저장소의 Locale.Region.Category 제안(0028)은 왜 필요했는가?
continent·containingRegion·subRegions만으로는 "World → Continent → Subcontinent → Territory"처럼 여러 단계를 한 번에 필터링하는 계층적 카테고리 질의가 부족했다. Proposals/0028은 설정 앱의 "언어/지역 선택" 화면처럼, 사용자가 지역을 대륙 단위로 좁혀가며 고르는 UI를 만들 때 필요해서 나온 제안이다.Q2. .strings/.stringsdict에서 String Catalog(.xcstrings)로 무엇이 통합됐는가?
Xcode 15부터 쓸 수 있는 String Catalog(.xcstrings)는 번역 키-값을 담던 .strings 파일과, 복수형 변형만 따로 담던 .stringsdict plist를 JSON 기반 파일 하나로 통합했다. Xcode가 소스 코드의 String(localized:) 호출을 정적으로 스캔해 카탈로그 항목을 자동 추출하므로 예전처럼 genstrings를 손으로 돌릴 필요가 없고, 번역 상태(신규/검토 필요/번역됨)를 추적하며, 기존 .strings 프로젝트는 파일 단위로 점진적으로 옮길 수 있다 — 한 프로젝트 안에 두 형식이 공존해도 빌드가 깨지지 않는다.
원리
예전 표준 API는 NSLocalizedString(_:comment:)였고, 지역화 가능한 문자열을 담는 키가 소스 코드 곳곳에 흩어져 있어도 빌드 시점엔 아무 검증도 없었다. Swift 쪽 대체재는 String(localized:)이고, Apple은 "지역화 가능한 문자열을 표현·전달하는 대표 타입"으로 LocalizedStringResource를 권장한다. LocalizedStringResource는 iOS 16+에서 도입됐고, App Intents처럼 메인 앱과 다른 프로세스·다른 로케일 컨텍스트에서 실행될 수 있는 대상에 "지금 로드하지 않고 실행되는 컨텍스트의 로케일로 나중에 로드"할 문자열 참조를 안전하게 넘기기 위해 필요하다.
내부 동작
.xcstrings 파일은 실제로는 JSON이다 — Xcode가 아니라 일반 텍스트 에디터로 열어도 내용이 보인다. Xcode는 빌드/편집 시점에 소스 코드를 스캔해 사용된 String(localized:) 키를 카탈로그에 자동 반영하므로, 코드와 로컬라이제이션 리소스 사이의 drift(번역 안 된 키, 코드에서 이미 지운 죽은 키)를 Xcode UI에서 바로 확인할 수 있다. WWDC24 세션 10185는 String Catalog가 포맷 지정자 불일치(%@ 자리에 %d가 들어간 번역 등) 같은 흔한 오류를 감지해 원클릭으로 고쳐주고, 특정 문자열을 "번역하지 않음(Don't Translate)"으로 표시할 수 있다고 밝힌다. WWDC21 세션 10221이 소개한 자동 문법 일치(Automatic Grammar Agreement)는 복수형·성별에 따라 문장 안의 다른 단어(관사, 형용사 등)까지 자동으로 맞춰주는 별도 엔진이다.
| 항목 | .strings + .stringsdict | String Catalog(.xcstrings) |
|---|---|---|
| 저장 형식 | 키-값 텍스트 + 별도 plist(복수형용) | JSON 파일 하나 |
| 복수형 변형 | .stringsdict를 따로 관리해야 함 | 카탈로그 안 variations로 통합 |
| 키 추출 | genstrings를 수동 실행 | Xcode가 소스 정적 스캔, 자동 추출 |
| 번역 상태 추적 | 별도 도구 없이는 불가능 | 신규/검토 필요/번역됨 상태를 Xcode UI에서 추적 |
| 형식 오류 검증 | 런타임에야 드러남 | 포맷 지정자 불일치를 빌드 시점에 경고 |
실험 · 도구
String Catalog는 Xcode 프로젝트와 빌드 파이프라인에 묶인 기능이라 swift -e 한 줄로 그 추출·검증 과정을 재현할 수는 없다 — 이 항목은 위 WWDC 세션과 Apple 문서에 근거해 설명한다. 다만 String(localized:)과 NSLocalizedString 호출 자체는 순수 Swift 코드이므로 그대로 실행해서 반환 타입과 시그니처 차이를 확인할 수 있다.
import Foundation
// 예전 방식 — 여전히 동작하지만 신규 코드엔 권장되지 않는다
let s1 = NSLocalizedString("greeting", comment: "인사말")
// Swift-native 대체재 — String Catalog가 이 호출을 스캔해서 카탈로그 항목을 만든다
let s2 = String(localized: "greeting")
// App Intents처럼 다른 프로세스/로케일 컨텍스트로 "나중에 로드할" 참조를 넘겨야 할 때
let resource = LocalizedStringResource("greeting")프로젝트 적용
① 신규 코드는 NSLocalizedString 대신 String(localized:)로 시작한다. Xcode가 이 호출을 자동으로 스캔해 String Catalog 항목을 채워주므로, 번역가에게 넘길 원문 관리가 코드와 분리되지 않는다.
// ❌ String(localized:)의 첫 인자는 StaticString이라 보간을 넣을 수 없다.
// error: cannot convert value of type 'String' to expected argument type 'StaticString'
//
// func bad(itemName: String) -> String {
// String(localized: "delete_confirmation_\(itemName)", ...)
// }
// ✅ 보간이 필요하면 LocalizedStringResource를 만들어 넘긴다.
// 키는 고정하고, 보간은 defaultValue 쪽에서 처리한다.
func deleteConfirmationMessage(itemName: String) -> String {
let res = LocalizedStringResource(
"delete_confirmation",
defaultValue: "\(itemName)을(를) 삭제하시겠습니까?",
comment: "삭제 확인 알럿의 본문 텍스트")
return String(localized: res)
}
print(deleteConfirmationMessage(itemName: "사진"))
// 사진을(를) 삭제하시겠습니까? ← Swift 6.2.1 실측
// 핵심: 키를 "delete_confirmation_사진"처럼 값에 따라 바꾸면 카탈로그 엔트리가
// 무한히 늘어난다. 키는 고정하고 값만 보간하는 것이 정석이다.② .strings가 이미 있는 레거시 프로젝트는 한 번에 옮기지 않아도 된다. 파일(타겟) 단위로 String Catalog를 새로 만들고 기존 .strings는 그대로 둔 채 병행하다가, 화면 단위로 하나씩 옮기는 식으로 마이그레이션 리스크를 낮출 수 있다.
// Localizable.strings — 아직 안 옮긴 예전 화면들이 계속 참조
"legacy_screen_title" = "기존 화면";
// Localizable.xcstrings — 새로 작업하는 화면은 여기로
// (Xcode가 String(localized:) 호출을 스캔해서 자동으로 채운다, 수동 편집 불필요)".stringsdict가 여전히 최신 표준이다"는 틀렸다. Xcode 15+ 신규 프로젝트는 String Catalog가 기본이고, .stringsdict는 아직 마이그레이션하지 않은 레거시 프로젝트에서만 의미가 있다. "NSLocalizedString은 완전히 폐기(deprecated)됐다"도 부정확하다 — 공식적으로 deprecated 마킹된 게 아니라 여전히 동작하며, Apple이 신규 코드에는 String(localized:)/LocalizedStringResource를 권장하는 것뿐이다.
예전엔 번역 대사가 적힌 대본(.strings)과, "숫자에 따라 대사가 달라진다"고 따로 적어둔 부록(.stringsdict) 두 권을 같이 들고 다녀야 했다. String Catalog는 이 둘을 한 권의 노트로 합치고, 그 노트에 "이 대사는 아직 번역 안 됨" 같은 포스트잇까지 자동으로 붙여준다. 비유가 깨지는 곳: 진짜 대본은 사람이 손으로 옮겨 적어야 하지만, Xcode는 코드에 적힌 대사를 스스로 찾아서 노트에 자동으로 옮겨 적어준다(자동 추출) — 이 부분은 비유보다 훨씬 편리하다.
꼬리 질문
String Catalog가 자동으로 추출하지 못하는 문자열도 있는가?
String(localized: "고정된_키") 형태를 인식하는 방식이라, 키 문자열 자체를 런타임에 변수로 조립하는 경우(예: String(localized: someVariable))는 스캐너가 그 값을 알 수 없어 카탈로그에 자동으로 잡히지 않는다. 이런 동적 키는 결국 개발자가 카탈로그에 수동으로 항목을 추가해야 한다.LocalizedStringResource와 String(localized:)는 실무적으로 언제 갈라 써야 하는가?
String(localized:)는 그 자리에서 즉시 로케일을 적용해 String 값을 만든다. LocalizedStringResource는 문자열을 아직 만들지 않고 "나중에 필요한 시점의 로케일로 만들어 달라"는 지연된 참조를 값으로 들고 다닐 때 쓴다 — 특히 App Intents처럼 앱 프로세스가 아닌 다른 컨텍스트(Shortcuts, Siri)에서 그 문자열이 최종적으로 해석돼야 하는 경우 필수적이다.String(localized:)는 "지금 당장 번역해서 말해줘"이고, LocalizedStringResource는 "번역은 나중에 필요한 사람이 알아서 하도록 쪽지만 남겨둬"에 가깝다.기존 .strings 프로젝트를 파일 단위로만 마이그레이션해도 빌드가 깨지지 않는 이유는?
.strings와 .xcstrings 리소스를 각각 독립적으로 처리해서 지역화 번들에 병합한다. 런타임에 String(localized:)/NSLocalizedString이 특정 카탈로그 파일을 지정해서 찾는 게 아니라, 번들에 병합된 지역화 리소스 전체에서 키를 찾기 때문에 두 형식이 같은 타겟에 있어도 서로 몰라도 된다.Q3. "\(n)개" 하드코딩이 왜 다국어에서 깨지는가? — CLDR 복수 규칙
CLDR(Common Locale Data Repository) 복수 카테고리는 zero/one/two/few/many/other 여섯 가지이고, 어떤 카테고리가 실제로 존재하는지는 언어마다 다르다. 한국어는 other 하나뿐, 영어는 one/other 둘, 아랍어는 여섯 개를 전부 쓴다. "\(n)개"가 한국어에서 항상 자연스러워 보이는 건 한국어가 우연히 복수를 구분하지 않는 언어라서일 뿐이고, 그대로 영어로 번역하면 n=1일 때 "1 items"처럼 깨진다. 그리고 이 CLDR 규칙은 한국어 조사(을/를, 은/는, 이/가) 선택 문제는 전혀 풀어주지 않는다 — 그건 완전히 다른 문제다.
원리
영어 같은 언어는 수량을 one(정확히 1개)과 other(그 외 전부) 두 갈래로만 나누지만, 언어마다 이 갈래 수 자체가 다르다. 러시아어·폴란드어는 끝자리·법칙에 따라 서너 갈래, 아랍어는 zero/one/two/few/many/other 여섯 갈래를 전부 쓴다. 한국어·일본어·중국어는 복수 구분이 아예 없어 other 카테고리 하나로 모든 수량을 표현한다. String Catalog와 .stringsdict는 대상 로케일에 맞는 카테고리 목록을 자동으로 보여주고 개발자가 그 각각을 채우게 한다 — 즉 카테고리가 몇 개인지 직접 외울 필요는 없지만, "숫자를 문장에 그냥 끼워넣는" 접근으로는 애초에 이 구조를 쓸 수 없다는 게 핵심이다.
내부 동작
세 언어의 CLDR 복수 카테고리 커버리지와, n = 0, 1, 2, 11에 대해 실제로 어떤 카테고리가 선택되는지는 Unicode CLDR 복수 규칙 명세에 정의돼 있다.
| n | 한국어 | 영어 | 아랍어 |
|---|---|---|---|
| 0 | other — "0개" | other — "0 items" | zero — 전용 문장 |
| 1 | other — "1개" | one — "1 item" (복수형 아님) | one — 전용 문장 |
| 2 | other — "2개" | other — "2 items" | two — 전용 문장 |
| 11 | other — "11개" | other — "11 items" | many — 3~10과도, 100 이상과도 다른 문장 |
한국어 칸은 네 줄 다 other라서 사실상 눈에 띄는 변화가 없다 — 이게 바로 "\(n)개" 하드코딩이 한국어 개발 중엔 절대 버그처럼 안 보이는 이유다. 영어는 n=1에서만 갈라지고, 아랍어는 0/1/2/11 네 개 값 전부가 서로 다른 카테고리에 떨어진다.
실험 · 도구
CLDR 복수 카테고리 선택 자체는 String Catalog/ICU 내부에 있어 swift -e로 직접 조회할 API는 없지만, 복수형과는 완전히 다른 축인 한국어 조사 선택은 실제로 코드로 검증할 수 있다. 한글 음절(가~힣, U+AC00~U+D7A3)은 (초성×21 + 중성)×28 + 종성 + 0xAC00 공식으로 조립돼 있어서, (코드포인트 - 0xAC00) % 28 == 0이면 그 글자엔 받침이 없다는 뜻이다. 이 성질로 을/를을 실제로 골라봤다.
import Foundation
func hasBatchim(_ word: String) -> Bool? {
guard let last = word.unicodeScalars.last,
(0xAC00...0xD7A3).contains(last.value) else { return nil }
return (last.value - 0xAC00) % 28 != 0
}
func josa(_ word: String, withBatchim: String, withoutBatchim: String) -> String {
guard let b = hasBatchim(word) else { return word + withoutBatchim }
return word + (b ? withBatchim : withoutBatchim)
}
for w in ["사과", "책", "우유", "빵"] {
print(w, "->", josa(w, withBatchim: "을", withoutBatchim: "를"))
}
// 실행 결과:
// 사과 -> 사과를 (받침 없음)
// 책 -> 책을 (받침 ㄱ)
// 우유 -> 우유를 (받침 없음)
// 빵 -> 빵을 (받침 ㅇ)네 단어 모두 실제 한국어 어법과 정확히 일치하는 조사가 골라졌다. 중요한 건 CLDR의 복수 규칙 어디에도 이 계산이 들어 있지 않다는 점이다 — 조사는 "수량"이 아니라 "받침 유무"라는 완전히 다른 조건을 따르므로, String Catalog의 plural variation을 아무리 정교하게 채워도 조사 문제는 저절로 풀리지 않는다.
프로젝트 적용
① 수량이 들어가는 문장은 하드코딩 보간 대신 String Catalog의 plural variation으로 선언한다. 카테고리별 문장은 카탈로그 UI에서 채우고, 코드는 값만 넘긴다.
// String Catalog에 "item_count" 키를 만들고 plural variation(one/other 등)을 등록해두면
// 코드는 값만 넘기면 된다 — 카테고리 판단은 카탈로그·런타임이 로케일에 맞춰 대신 한다.
//
// ⚠️ 키에 n을 끼워넣지 마라. "item_count_\(n)"으로 쓰면 값마다 다른 키가 되어
// 카탈로그가 복수 규칙을 적용할 기회 자체가 사라진다(엔트리도 무한히 늘어난다).
// 키는 하나로 고정하고, 보간은 defaultValue에서만 한다.
func itemCountText(_ n: Int) -> String {
let res = LocalizedStringResource(
"item_count",
defaultValue: "\(n)개",
comment: "장바구니에 담긴 아이템 개수 — 언어별 복수형 variation 필요")
return String(localized: res)
}
// 한국어(ko)에서의 실행 결과 — Swift 6.2.1 실측
for n in [0, 1, 2, 11] { print(n, "→", itemCountText(n)) }
// 0 → 0개 1 → 1개 2 → 2개 11 → 11개
//
// 한국어는 CLDR 복수 범주가 'other' 하나뿐이라 넷이 모두 같은 문장을 쓴다.
// 같은 코드를 영어로 번역하면 카탈로그의 one/other variation이 갈라져
// "1 item" / "2 items"가 되고, 러시아어에서는 one/few/many 셋으로 갈라진다.② 조사가 들어간 문장은 애초에 문자열을 이어붙이지 말고 재구성하거나, 위 josa 유틸로 받침을 직접 감지한다. 가장 안전한 건 조사를 아예 문장에서 빼는 재구성이다.
// ❌ 상품명이 어떤 단어로 오든 조사가 고정돼 있어 절반은 어색해진다
func badMessage(item: String) -> String { "\(item)를 삭제했습니다" } // "책를" ✗
// ✅ 조사가 필요 없는 구조로 재구성 — 어떤 상품명이 와도 안전하다
func safeMessage(item: String) -> String { "삭제됨: \(item)" }
// ✅ 재구성이 어렵다면 받침 감지 유틸로 조사를 직접 고른다
func preciseMessage(item: String) -> String { "\(josa(item, withBatchim: "을", withoutBatchim: "를")) 삭제했습니다" }"복수형만 처리하면 다국어 문장이 완성된다"는 틀렸다. CLDR 복수 규칙은 "수량 표현"이라는 좁은 영역만 다루고, 조사·성별·격변화처럼 언어마다 다른 문법 요소는 각각 별도의 문제다. 한국어는 복수형이 없어 겉보기엔 국제화가 쉬워 보이지만, 실제로는 영어권에 없는 조사 문제를 새로 마주하게 된다 — "복수형 없는 언어라 국제화가 간단하다"는 결론은 성급하다.
물건 개수를 부르는 노래책이 있다고 하자. 영어 노래책은 "한 개일 때 가사"와 "두 개 이상일 때 가사"가 따로 있고, 아랍어 노래책은 가사가 여섯 가지나 있다. 한국어 노래책은 가사가 하나뿐이라 몇 개든 그냥 그 가사를 부르면 된다. 비유가 깨지는 곳: "그를 삭제했습니다"에서 "그를"의 "를"을 고르는 건 이 노래책(복수형 규칙)이 전혀 신경 쓰지 않는, 완전히 다른 책(받침 규칙)에서 찾아야 하는 문제다.
꼬리 질문
영어의 "one/other" 두 카테고리는 정말 "단수/복수"와 같은 개념인가?
one은 문법적 단수와 항상 일치하지는 않는다. 예를 들어 "1.0 items"처럼 소수를 쓰는 언어 규칙에서는 값이 1이어도 one이 아니라 other로 분류되는 경우가 있다 — CLDR 규칙은 표면적인 문법 단수/복수가 아니라 언어별로 정의된 수학적 조건(정수인지, 끝자리가 무엇인지 등)을 기준으로 카테고리를 정한다.은/는, 이/가도 을/를과 같은 받침 규칙으로 고르면 되는가?
hasBatchim 함수 하나로 셋 다 처리할 수 있다. 다만 예외적으로 숫자·로마자·기호로 끝나는 문자열(예: "iPhone", "3")은 한글 음절 블록 밖이라 hasBatchim이 nil을 반환하므로, 그 경우엔 별도 규칙(대개 받침 있는 쪽으로 가정하거나 조사를 아예 빼는 재구성)이 필요하다.아랍어 CLDR 규칙에서 n=11이 few가 아니라 many로 분류되는 이유는?
n mod 100) 값을 기준으로 나뉜다. 3~10 구간은 few, 11~99 구간(3~10과 정확히 나뉘는 배수 제외)은 many로 분류된다. n=11은 100으로 나눈 나머지가 11이라 few 구간(3~10)에 들지 않고 many 구간에 떨어진다 — 이는 "11개"처럼 사람이 직관적으로 셈하는 방식과는 다른, 언어 자체의 통계적 굴절 규칙을 그대로 반영한 것이다.Q4. 사용자에게 보여줄 목록 정렬은 어떻게 해야 하는가? — localizedStandardCompare와 RTL
String의 <는 순수 코드포인트 이진 비교라 사람이 기대하는 순서와 다르게 나온다. localizedStandardCompare(_:)는 Finder가 파일 이름을 정렬할 때 쓰는 바로 그 비교로, 로케일 인식 + 대소문자·발음구별기호 무시 + 숫자를 값으로 비교하는 옵션들을 조합한 것과 같아서 "파일2"가 "파일10"보다 앞에 온다. 세 방식(<, localizedCompare, localizedStandardCompare)을 같은 배열에 직접 돌려보면 차이가 바로 드러난다. 사람에게 보여줄 목록은 localizedStandardCompare, 값이 정확히 같은지만 봐야 하는 키·식별자 비교는 ==가 기준이다.
원리
<는 Comparable이 정의하는 대로 각 문자의 유니코드 코드포인트를 그대로 비교한다 — 언어나 사용자 설정과 무관한 순수 값 비교다. localizedCompare(_:)는 현재 Locale의 콜레이션(collation, 정렬 규칙)을 따라 "그 언어권 사람이 자연스럽다고 느끼는 순서"로 비교한다. localizedStandardCompare(_:)는 여기에 더해 대소문자·폭(width)을 무시하고 숫자를 문자열이 아니라 값으로 비교하는 옵션까지 얹은, Finder와 동일한 비교 방식이다.
내부 동작
다음 배열을 macOS 26 / Swift 6.2.1(프로세스 로케일 ko_KR)에서 실제로 세 방식으로 정렬한 결과다.
import Foundation
let names = ["파일10", "파일2", "파일1", "가방", "Apple", "banana", "각오", "간식"]
let byLess = names.sorted { $0 < $1 }
let byLocalized = names.sorted { $0.localizedCompare($1) == .orderedAscending }
let byStandard = names.sorted { $0.localizedStandardCompare($1) == .orderedAscending }
print(byLess)
// ["Apple", "banana", "가방", "각오", "간식", "파일1", "파일10", "파일2"]
print(byLocalized)
// ["가방", "각오", "간식", "파일1", "파일10", "파일2", "Apple", "banana"]
print(byStandard)
// ["가방", "각오", "간식", "파일1", "파일2", "파일10", "Apple", "banana"]세 결과가 갈라지는 지점을 뜯어보면 각 방식이 실제로 뭘 하는지 드러난다. <는 라틴 알파벳(U+0041~)이 한글 음절(U+AC00~)보다 코드포인트가 작아 Apple/banana가 무조건 맨 앞에 오고, "파일1"/"파일10"/"파일2"는 세 번째 글자에서 '1' < '2'라 자릿수를 무시한 채 "파일10"이 "파일2"보다 앞에 놓인다. localizedCompare는 프로세스 로케일(ko_KR)의 콜레이션을 따라 한글이 먼저 오도록 순서가 통째로 바뀌지만, 숫자는 여전히 문자로 비교해 "파일10"이 "파일2"보다 앞에 남아 있다 — 즉 로케일 인식은 하지만 숫자 인식은 하지 않는다. localizedStandardCompare만 "파일1", "파일2", "파일10" 순서로 사람이 기대하는 자연수 순서를 만든다.
한글 정렬 부분도 짚을 만하다. "가방", "각오", "간식"은 모두 초성 ㄱ + 중성 ㅏ로 시작하지만 첫 글자의 받침이 각각 없음 · ㄱ · ㄴ으로 다르다. 로케일 인식 정렬 두 방식 모두 "받침 없음 < ㄱ받침 < ㄴ받침" 순서를 정확히 지켜 "가방" → "각오" → "간식" 순으로 놓았다 — 이게 한글 콜레이션이 초성·중성뿐 아니라 종성(받침)까지 정해진 순서로 비교한다는 실측 증거다.
실험 · 도구
위 코드는 그대로 swift 파일명.swift로 실행하면 재현된다. 단, localizedCompare/localizedStandardCompare는 명시적 로케일 인자를 받지 않고 Locale.current(프로세스 로케일)를 암묵적으로 쓰므로, 실행 환경의 로케일이 ko_KR이 아니라 en_US 등이면 한글/영문이 갈라지는 순서 자체가 달라질 수 있다 — 이건 결함이 아니라 "로케일 인식 정렬"이라는 기능이 정확히 의도한 동작이다.
프로젝트 적용
① 사용자에게 보여줄 파일명·제목·연락처 같은 목록은 항상 localizedStandardCompare로 정렬한다.
struct Document { let title: String }
func sortedForDisplay(_ docs: [Document]) -> [Document] {
docs.sorted { $0.title.localizedStandardCompare($1.title) == .orderedAscending }
}
// "챕터2", "챕터10" 같은 제목이 섞여 있어도 사람이 기대하는 순서로 나온다② RTL(아랍어·히브리어 등) 레이아웃을 지원해야 한다면 .left/.right 대신 .leading/.trailing을 쓰고, UIKit에서는 UIView.semanticContentAttribute로 뷰별 방향을 제어한다. SwiftUI는 environment의 layoutDirection 값으로 현재 방향을 읽는다.
// ❌ 왼쪽 정렬을 하드코딩 — RTL 언어에서 글자가 반대편에 붙어버린다
label.textAlignment = .left
// ✅ leading은 LTR에서 왼쪽, RTL에서 오른쪽으로 시스템이 알아서 뒤집는다
label.textAlignment = .natural
@Environment(\.layoutDirection) var layoutDirection // SwiftUI에서 현재 방향 확인Xcode 스킴의 "Right to Left Pseudolanguage" 애플리케이션 언어 옵션을 켜면 실제 RTL 번역 없이도 레이아웃이 뒤집히는지 미리 확인할 수 있다.
"문자열 정렬은 그냥 <로 충분하다"는 실측으로 반박된다 — 영문이 한글보다 항상 앞서고 "파일10"이 "파일2"보다 앞에 오는, 사람 눈엔 명백히 이상한 순서가 나온다. "localizedCompare를 쓰면 다 해결된다"도 틀렸다 — 로케일 인식은 하지만 숫자 인식(numeric-aware) 옵션이 없어서 파일명 정렬에는 여전히 부적합하고, 그 역할은 localizedStandardCompare의 몫이다.
<는 사전 순서가 아니라 "자판 위 글자표 번호" 순서로 그냥 줄을 세우는 것과 같다. localizedStandardCompare는 정리정돈을 잘하는 사람이 서류철을 정리할 때처럼 숫자는 숫자대로 세고, 대소문자는 신경 쓰지 않는 방식이다. 비유가 깨지는 곳: 사람이 한 번 정한 정리 규칙은 그 사람이 바뀌지 않는 한 계속 똑같지만, 컴퓨터의 로케일 인식 정렬은 같은 코드라도 기기의 언어 설정이 바뀌면 결과 순서가 통째로 달라질 수 있다.
꼬리 질문
<와 localizedCompare가 같은 결과를 내는 경우도 있는가?
RTL 언어에서 .left/.right 대신 .leading/.trailing을 써야 하는 이유는?
.left/.right는 화면상의 물리적 방향을 고정해서 가리키지만, .leading/.trailing은 "글이 시작하는 쪽/끝나는 쪽"이라는 상대적 개념이라 텍스트 방향(LTR/RTL)에 따라 시스템이 자동으로 물리적 위치를 바꿔준다. .left를 하드코딩하면 RTL 언어에서는 문장이 끝나는 쪽에 붙어버려 시각적으로 어색해진다.localizedStandardCompare 결과가 실행 환경(로케일)에 따라 달라질 수 있는데, 테스트는 어떻게 안정적으로 검증하는가?
localizedStandardCompare 자체는 로케일을 인자로 받지 않고 항상 Locale.current를 암묵적으로 쓴다. 결정적인(deterministic) 테스트가 필요하다면 로케일을 명시적으로 지정할 수 있는 compare(_:options:range:locale:)에 .caseInsensitive/.numeric 등 동등한 옵션 조합과 고정된 Locale 인스턴스를 넘겨 재현 가능하게 만들거나, 테스트 실행 자체를 특정 로케일로 고정해야 한다.