Swift String은 "정수로 인덱싱할 수 없는 컬렉션"이라는 점에서 iOS 개발자를 가장 자주 걸려 넘어뜨리는 타입이다. 이 챕터는 그 이유를 하나씩 풀어간다 — Character가 왜 가변 길이인지, String.Index가 왜 불투명한지, Swift 5가 왜 UTF-16을 버리고 UTF-8을 택했는지, 조합형·분해형 문자와 이모지가 count를 어떻게 속이는지, 그리고 문자열 비교에 로케일이 언제 끼어드는지. 아래 수치는 전부 Swift 6.2.1(arm64, macOS 26)에서 직접 실행해 얻은 값이다.
Q1. String.count는 왜 O(n)이고 Array.count는 왜 O(1)인가?
Array는 RandomAccessCollection이라 count가 이미 저장된 정수 필드 하나를 읽는 것으로 끝나 O(1)이다. String의 문자 단위인 Character는 확장 그래핌 클러스터(extended grapheme cluster)인데, 이건 한 글자가 차지하는 바이트 수가 저마다 달라서 그 개수 자체가 캐시되지 않는다. count를 부르면 처음부터 끝까지 유니코드 경계 규칙을 따라 하나씩 세야 해서 O(n)이 된다. 실제로 같은 문자열에서 count를 세 번 연달아 불러도 두 번째, 세 번째가 빨라지지 않는다 — 매번 처음부터 다시 센다는 뜻이다.
원리
Sequence·Collection·BidirectionalCollection·RandomAccessCollection 계층은 "무엇이 상수 시간이어야 하는가"에 대한 성능 계약이다. Collection은 서브스크립트와 다회 순회를 O(1)로 보장하지만, count까지 O(1)이라고 보장하는 건 인덱스 간 거리 계산이 O(1)인 RandomAccessCollection뿐이다. String(정확히는 Character 뷰)은 BidirectionalCollection까지만 채택한다 — 앞뒤로 한 칸씩 이동은 O(1)이지만 "임의 위치까지 거리"는 보장하지 않는다.
근본 원인은 Character의 정의 자체에 있다. The Swift Programming Language는 Character를 "하나 이상의 유니코드 스칼라가 결합해 사람이 하나의 글자로 인식하는 단위"로 정의한다. é 하나가 스칼라 1개(조합형)일 수도 2개(분해형)일 수도 있고, 이모지는 스칼라 7개가 뭉쳐 한 글자가 되기도 한다(Q4·Q5). 이 결합 규칙(UAX #29)은 본질적으로 순차적이라, 앞의 스칼라들을 보지 않고는 다음 그래핌 경계가 어디인지 알 수 없다.
내부 동작
String 값 자체는 16바이트 구조체(_StringObject)다. 그 안의 _countAndFlagsBits 필드는 하위 48비트에 UTF-8 code unit(바이트) 개수를 담고, 상위 비트는 ASCII 여부·정규화 여부 같은 플래그다(Swift Forums). 여기 캐시된 건 "바이트 개수"이지 "그래핌 개수"가 아니다. 그래서 isEmpty(바이트 수가 0인지만 확인)는 O(1)인데, count는 그래핌 경계를 처음부터 다시 계산해야 해서 O(n)이다.
이걸 직접 측정하면 캐시가 없다는 게 바로 드러난다. 200만 바이트짜리 문자열에서 .count를 세 번 연달아 호출하면 각각 약 1.8ms · 1.8ms · 1.6ms로 거의 동일했다(캐시됐다면 두 번째부터 확 빨라져야 한다). 같은 문자열의 .isEmpty는 0ms(측정 오차 이하)였다.
| 연산 | 실제로 확인하는 것 | 복잡도 |
|---|---|---|
Array.count | 저장된 원소 개수 필드 | O(1) |
String.utf8.count | _countAndFlagsBits에 캐시된 바이트 수 | O(1) |
String.isEmpty | 바이트 수 == 0 | O(1) |
String.count | 그래핌 클러스터 경계(캐시 안 됨, 매번 재계산) | O(n) |
실험 · 도구
가장 흔한 함정은 for i in 0..<s.count { s[s.index(s.startIndex, offsetBy: i)] }다. offsetBy: i 자체가 매번 처음부터 i번 경계를 세는 O(i) 연산인데 이걸 count번 반복하니 전체가 O(n²)이 된다. 아래 코드로 실측하면 원소가 2배 늘 때 시간이 약 4배로 뛰는 전형적인 이차 곡선이 보인다.
import Foundation
func timeIt(_ label: String, _ block: () -> Void) {
let start = Date()
block()
print("\(label): \(Date().timeIntervalSince(start))s")
}
for n in [5_000, 10_000, 20_000, 40_000] {
let s = String(repeating: "a", count: n)
timeIt("offsetBy n=\(n)") {
var i = 0
while i < s.count {
_ = s[s.index(s.startIndex, offsetBy: i)]
i += 1
}
}
timeIt("for-in n=\(n)") {
for c in s { _ = c }
}
}
// 실측 결과:
// offsetBy n=5000 : 0.0411s for-in n=5000 : 0.0000s
// offsetBy n=10000 : 0.1536s for-in n=10000 : 0.0000s
// offsetBy n=20000 : 0.6084s for-in n=20000 : 0.0001s
// offsetBy n=40000 : 2.4788s for-in n=40000 : 0.0002s
// n이 2배 될 때 offsetBy 시간은 약 4배(O(n²)) — for-in은 사실상 그대로(O(n))count가 정말 캐시되지 않는지도 직접 확인할 수 있다: 같은 문자열에서 .count를 반복 호출해도 두 번째 호출이 빨라지지 않으면(위 실측처럼 세 번 모두 ~1.8ms) 캐시가 없다는 뜻이다.
프로젝트 적용
① 인덱스로 순차 접근해야 한다면 offsetBy를 반복 호출하지 말고 인덱스 변수를 index(after:)로 한 걸음씩만 전진시키거나, 애초에 for c in s를 써라. 두 방식 다 O(n)이다.
let s = "대용량 로그 문자열..."
// ❌ i번째 원소에 접근할 때마다 처음부터 offsetBy만큼 다시 걸어간다 — 전체 O(n²)
func badScan(_ s: String) {
for i in 0..<s.count {
let idx = s.index(s.startIndex, offsetBy: i)
if s[idx] == "\n" { print("줄바꿈 at \(i)") }
}
}
// ✅ 인덱스를 한 번만 순차 전진 — 전체 O(n)
func goodScan(_ s: String) {
var idx = s.startIndex
var i = 0
while idx < s.endIndex {
if s[idx] == "\n" { print("줄바꿈 at \(i)") }
idx = s.index(after: idx)
i += 1
}
}
// ✅ 더 간단하게 — 위치 번호가 필요 없으면 그냥 이걸로 충분
func simplestScan(_ s: String) {
for c in s where c == "\n" { print("줄바꿈 발견") }
}② "앞에서 N글자만" 같은 요구에는 직접 루프 대신 Collection이 이미 제공하는 prefix(_:)/dropFirst(_:)를 써라. 내부적으로 한 번의 순차 이동만 한다.
func firstNCharacters(_ s: String, _ n: Int) -> Substring {
s.prefix(n) // 내부에서 index(after:)로 최대 n번만 전진, O(min(n, s.count))
}"String.count는 유니코드 스칼라 개수를 센다"는 틀렸다 — Character(확장 그래핌 클러스터) 개수를 센다. 스칼라 개수가 필요하면 .unicodeScalars.count를 따로 불러야 한다(이것도 O(n)). 또 "한 번 count를 부르면 다음부터는 캐시돼서 빠르다"도 틀렸다 — 위 실측처럼 매번 처음부터 다시 센다. 캐시되는 건 바이트 수(utf8.count류)뿐이다.
Array는 물건을 담은 상자에 "지금 몇 개 들었는지" 적힌 꼬리표가 항상 붙어 있는 것과 같다. 몇 개냐고 물으면 꼬리표만 보면 끝(O(1))이다. 그런데 String은 글자마다 크기가 다른 레고 블록을 한 줄로 이어붙인 것과 비슷하다. "글자가 몇 개냐"고 물으면 꼬리표가 없어서 처음부터 블록 하나하나의 경계를 짚어가며 세야 한다(O(n)). 비유가 깨지는 곳: 실제로 String도 "바이트가 몇 개냐"는 꼬리표(캐시)가 있다 — 다만 그건 "블록(글자) 개수"가 아니라 "블록을 이루는 작은 조각(바이트) 개수"라서, 글자 개수를 세는 데는 도움이 안 된다.
꼬리 질문
String.count와 String.utf8.count 중 어느 쪽이 더 빠른가? 왜 그런가?
utf8.count가 훨씬 빠르다. _StringObject의 _countAndFlagsBits에 UTF-8 바이트 개수가 이미 저장돼 있어서 필드 하나만 읽으면 되는 O(1)이다. 반면 count(Character 개수)는 그 필드를 캐시하지 않고, 매 호출마다 UAX #29 그래핌 경계 규칙을 처음부터 다시 적용한다. 즉 "바이트 수를 세는 것"과 "글자 수를 세는 것"은 전혀 다른 비용의 연산이다.NSString의 length는 String.count와 같은 값인가?
NSString.length는 UTF-16 code unit 개수를 센다(레거시 ObjC 관례). Swift의 String.count는 Character(그래핌 클러스터) 개수다. 조합형 é 하나만 있어도 length는 1, count도 1로 같을 수 있지만, 이모지처럼 서로게이트 쌍이나 ZWJ 시퀀스가 섞이면 length는 여러 개, count는 1로 크게 달라진다(Q5 참고). 두 값을 같은 것으로 착각하고 문자열 길이 제한 같은 로직을 짜면 바로 버그가 된다.String이 RandomAccessCollection을 채택하도록 만들 수는 없는가?
Q2. String.Index는 왜 정수가 아닌가? 언제 무효화되는가?
같은 위치라도 String을 UTF-8/UTF-16/유니코드 스칼라/Character 중 어느 뷰로 보느냐에 따라 정수 오프셋이 완전히 달라지기 때문에, String.Index는 정수가 아니라 불투명 값이다(SE-0180). 내부적으로는 UTF-8 바이트 오프셋 + 트랜스코딩 힌트를 담고 있지만, 그 인덱스는 그 인덱스를 만들어낸 특정 문자열 상태에 종속된다. 문자열이 재할당되거나 앞부분이 바뀌면 이전 인덱스는 범위를 벗어나 트랩을 내거나, 범위 안에 우연히 남아 있으면 전혀 다른 위치를 조용히 가리킨다.
원리
스칼라 하나는 UTF-8에서 1~4바이트, UTF-16에서 1~2코드유닛을 차지할 수 있다. 그래서 "5번째 위치"라는 말 자체가 뷰마다 다른 정수를 의미한다. SE-0180 이전에는 String, String.UTF8View, String.UTF16View, String.UnicodeScalarView가 각자 다른 인덱스 타입을 가졌는데, 이 제안 이후 넷 다 동일한 String.Index 타입을 공유하도록 통일됐다. 뷰 간 변환은 실패 가능한(failable) 이니셜라이저로 제공되며, UTF-8 continuation byte 같은 중간 위치를 다른 뷰의 경계로 변환하려 하면 nil을 돌려준다.
이 설계는 "정수 산술을 인덱스에 직접 하지 못하게" 만드는 효과도 낸다. idx + 1 같은 연산이 없으므로 반드시 index(after:)/index(_:offsetBy:)로만 이동해야 하고, 그 API들이 그래핌·스칼라 경계를 검증하며 이동한다.
내부 동작
인덱스는 만들어질 당시 문자열의 버퍼 구조(그리고 ASCII 전용이면 생략되는 "breadcrumbing" 캐시)에 묶인다. 이 인덱스가 다른 문자열 인스턴스나 그 이후 재할당된 같은 변수에 다시 쓰이면, 표준 라이브러리는 그게 유효한 위치인지 범위만 검사할 수 있을 뿐 "원래 의도한 위치"인지는 알 수 없다. 실제로 실행해보면 두 가지 실패 모드가 다 나온다: 범위를 완전히 벗어나면 트랩, 우연히 범위 안에 남으면 조용히 틀린 값.
직접 재현한 결과: s1 = "hello world"에서 얻은 idx(‘w’ 위치, 바이트 오프셋 6)를 그대로 둔 채 s1 = "hi w"로 재할당하면 idx는 새 문자열의 endIndex(바이트 길이 4)를 넘어선다. 이 상태에서 s1[idx]를 서브스크립트하면 다음 오류로 즉시 종료됐다.
var s1 = "hello world"
let idx = s1.firstIndex(of: "w")!
s1 = "hi"
print(s1[idx])
// 실행 결과(Swift 6.2.1, arm64):
// Swift/StringIndexValidation.swift:121: Fatal error: String index is out of bounds반대로 strA = "café"에서 얻은 인덱스(오프셋 3)를 strB = "hello"에 그대로 쓰면, 오프셋 3이 strB 범위 안에 있어서 트랩 없이 "lo"가 나왔다 — strA에서 의도한 위치와는 아무 상관 없는, 조용히 틀린 결과다.
실험 · 도구
위 두 시나리오는 그대로 실행해보는 게 가장 빠르다. swiftc -O index_bug.swift -o index_bug && ./index_bug로 컴파일·실행하면 첫 번째는 Fatal error: String index is out of bounds로 즉시 종료되고(exit code 133), 두 번째는 크래시 없이 "lo"를 출력한다. 흥미로운 점은 append만으로는 기존 인덱스가 항상 무효화되지는 않는다는 것 — var s2 = "abc"에서 얻은 마지막 문자 인덱스는 뒤에 문자를 더 붙여도(s2.append("defg...")) 앞부분 바이트 배치가 그대로라 여전히 'c'를 가리켰다. "인덱스는 언제나 무효화된다"가 아니라 "인덱스가 가리키던 바이트 위치보다 앞쪽 내용이 바뀌면 무효화된다"가 정확한 규칙이다.
프로젝트 적용
① 인덱스를 문자열과 분리해서 오래 들고 있지 마라. 인덱스가 필요한 계산은 그 문자열의 현재 값을 기준으로 그 자리에서 끝내라.
// ❌ 인덱스와 문자열을 따로 들고 다니다 문자열이 바뀌면 인덱스가 죽는다
struct BadCursor {
var text: String
var position: String.Index
}
// ✅ 필요한 순간 즉시 계산하고, 저장해야 한다면 인덱스가 아니라
// "처음부터 몇 번째"처럼 재계산 가능한 값(정수 오프셋)으로 저장한다
struct SafeCursor {
var text: String
var offsetFromStart: Int // String.Index 대신 재계산 가능한 정수
var currentIndex: String.Index {
text.index(text.startIndex, offsetBy: offsetFromStart)
}
}② 여러 뷰(문자·UTF-8·유니코드 스칼라)를 오가며 위치를 옮겨야 한다면, 실패 가능한 이니셜라이저로 변환하고 반드시 nil을 처리하라.
let s = "café"
let utf8Idx = s.utf8.index(s.utf8.startIndex, offsetBy: 3) // é의 두 번째 바이트(continuation byte)
// ❌ 강제 변환하면 continuation byte 위치에서 nil이 나올 수 있음을 무시하게 된다
// let charIdx = String.Index(utf8Idx, within: s)!
// ✅ 실패를 명시적으로 처리
if let charIdx = String.Index(utf8Idx, within: s) {
print(s[charIdx])
} else {
print("그 UTF-8 위치는 문자 경계가 아님")
}"String.Index는 그냥 Int를 감싼 것뿐이라 idx + 1 같은 산술을 할 수 있다"는 틀렸다 — 정수 연산자가 정의돼 있지 않아 컴파일조차 안 된다. "한 String에서 얻은 인덱스를 다른 String이나 mutation 이후의 같은 변수에 재사용해도 값이 맞으면 안전하다"도 틀렸다 — 위 실측처럼 트랩이 나거나(범위 밖) 조용히 틀린 위치를 가리킨다(범위 안에 우연히 남음). "어떤 mutation이든 기존 인덱스를 무조건 무효화한다"도 부정확하다 — append처럼 인덱스가 가리키던 위치보다 뒤쪽만 바뀌면 그 인덱스는 여전히 유효하다.
String.Index는 "이 책의 127쪽 셋째 줄"이라고 적어둔 책갈피와 같다. 그 책갈피는 그 책, 그 판본에서만 의미가 있다. 책을 완전히 다른 얇은 책으로 바꿔치기하면(문자열 재할당) 127쪽이 아예 없을 수도 있고(트랩), 우연히 127쪽이 있어도 전혀 다른 내용이 적혀 있을 수 있다(조용히 틀림). 다른 책(다른 문자열)에 그 책갈피를 꽂아도 마찬가지다. 비유가 깨지는 곳: 책 뒤쪽에 페이지를 더 추가하는 것(append)은 앞쪽 페이지 번호를 안 바꾸므로, 그 경우엔 책갈피가 여전히 맞는 자리를 가리킨다 — "아무 수정에나 책갈피가 무효"라고 외우면 틀린다.
꼬리 질문
String.Index.encodedOffset은 왜 deprecated 됐는가?
encodedOffset은 문자열 위치를 정수로 뽑아 저장했다가 나중에 그 정수로 인덱스를 재구성할 수 있게 해주는 API였는데, 실무에서 "이 정수가 여전히 같은 인코딩·같은 문자열을 가리킨다"는 전제를 깨뜨리는 오용이 잦았다. Swift 5의 UTF-8 네이티브 전환으로 그 위험이 더 커지자 SE-0241이 이를 deprecated 처리하고, 더 명시적인 대안(뷰별 index(_:offsetBy:), 실패 가능한 뷰 간 변환)으로 유도했다.ASCII 전용 문자열에서는 String.Index 이동이 더 빠른가?
Substring의 인덱스는 원본 String의 인덱스와 호환되는가?
String.Index 타입을 쓰고 같은 버퍼를 공유하므로, 원본에서 얻은 인덱스를 그 Substring에 그대로 써도 된다(범위 안에 있다면). 반대로 Substring에서 얻은 인덱스도 원본 String에 그대로 쓸 수 있다. 다만 Substring을 String(substring)으로 새 String에 복사하면 그 순간부터는 별개의 버퍼이므로 이 호환성이 깨진다.Q3. Swift 5의 UTF-8 네이티브 저장과 15바이트 스몰 스트링 최적화란?
Swift 5부터 네이티브 String은 항상 검증된 UTF-8 바이트를 단일 연속 버퍼에 저장한다(예전엔 UTF-16 기반이었다). 그리고 64비트 플랫폼에서 15 UTF-8 바이트 이하면 힙 할당 없이 String 값 자체(16바이트 구조체) 안에 인라인으로 담는다(32비트는 10바이트). 직접 벤치마크로 그 경계도 확인된다 — 14바이트 문자열에 한 글자를 더해 15바이트가 되는 건 여전히 빠르지만, 15바이트에 한 글자를 더해 16바이트가 되는 순간 약 6배 느려진다(힙 전환).
원리
String의 스토리지 형식은 언어 스펙이 강제하지 않는 구현 세부사항이지만, 두 가지는 안정적으로 참일 필요가 있다. 하나는 "짧은 문자열은 힙 할당 없이 값처럼 다뤄질 수 있어야 성능이 예측 가능하다"는 것이고, 다른 하나는 "소스 코드·네트워크·파일 시스템 대부분이 이미 UTF-8"이라는 사실이다. swift.org의 UTF-8 String 발표는 ASCII 콘텐츠 기준으로 UTF-8이 UTF-16보다 메모리를 약 50% 절약하고, 성능이 중요한 문자열 워크로드 대부분이 ASCII 위주라는 점을 근거로 든다.
내부 동작
Swift 5 이전에는 String이 내부적으로 UTF-16 기반 표현을 오갔고, 문자열 메서드마다 표현별로 두 벌 구현을 둬야 하는 복잡성이 있었다. Swift 5부터는 모든 네이티브 문자열이 검증된 UTF-8 바이트를 단일 연속 버퍼에 저장하는 방식으로 통일됐고, 실제로 이 전환이 실측 성능 이득으로 이어진 사례가 SwiftNIO다 — Swift 5로 올리기만 했는데 UTF-16 트랜스코딩을 건너뛰어 swift.org 홈페이지 서빙 속도가 20% 개선됐다.
String 구조체(_StringObject)는 항상 16바이트다. 직접 확인한 MemoryLayout<String>.size·.stride는 13, 14, 15, 16, 17, 20바이트짜리 문자열 전부에서 동일하게 16 / 16(alignment 8)이었다 — 길이와 무관하게 값 타입의 겉모습은 항상 같다. 내부적으로는 두 가지 형태로 나뉜다: 인라인 스몰 스트링(≤15바이트 데이터 + 1바이트 태그)과 힙 문자열(8바이트 count&flags + 8바이트 버퍼 포인터). 어느 쪽인지는 태그 비트로 구분되며, 인라인 쪽은 힙 할당 자체가 없어 생성·복사·소멸이 스택 값 복사 수준으로 싸다.
for n in [13, 14, 15, 16, 17, 20] {
let s = String(repeating: "a", count: n)
_ = s
}
print(MemoryLayout<String>.size) // 16 — 위 모든 길이에서 동일
print(MemoryLayout<String>.stride) // 16
print(MemoryLayout<String>.alignment) // 8실험 · 도구
스몰 스트링 경계는 값 복사 없이도 append 벤치마크로 직접 체감할 수 있다. 문자를 하나 추가했을 때 결과가 15바이트를 넘지 않으면 여전히 인라인이라 빠르고, 16바이트를 넘으면 힙 할당이 끼어들어 느려진다.
import Foundation
func bench(_ s: String, iterations: Int) -> TimeInterval {
let start = Date()
for _ in 0..<iterations {
var copy = s
copy.append("!") // 원본 바이트 수 + 1
_ = copy
}
return Date().timeIntervalSince(start)
}
for n in [1, 14, 15, 16, 17, 32, 100] {
let s = String(repeating: "a", count: n)
print("bytes=\(n): \(bench(s, iterations: 20_000_000))s")
}
// 실측 결과(20,000,000회):
// bytes=1 : 0.1301s (153.8M ops/s)
// bytes=14 : 0.1166s (171.5M ops/s) ← +1글자 = 15바이트, 여전히 인라인
// bytes=15 : 0.7192s ( 27.8M ops/s) ← +1글자 = 16바이트, 힙 전환(약 6배 느림)
// bytes=16 : 0.7301s
// bytes=17 : 0.7470s
// bytes=32 : 0.7809s
// bytes=100: 0.9665s ( 20.7M ops/s)한글처럼 멀티바이트 글자가 섞이면 15바이트 경계가 훨씬 빨리 온다는 것도 실측으로 확인했다: 한글 한 글자는 UTF-8 3바이트라 5글자(15바이트)가 정확히 경계이고, 6글자(18바이트)는 이미 힙으로 넘어간다 — "짧은 문자열"이라는 말이 "글자 수"가 아니라 "바이트 수" 기준임을 보여주는 예다.
프로젝트 적용
① ID·태그·짧은 상수 문자열처럼 15바이트 안팎을 오가는 값을 대량으로 다룬다면, 멀티바이트 문자(이모지·한글)를 섞으면 스몰 스트링 경계를 훨씬 쉽게 넘는다는 걸 감안하라. 로그 태그나 캐시 키는 ASCII로 짧게 유지하는 편이 힙 할당을 피하는 데 유리하다.
// ❌ 매 요청마다 만드는 로그 태그에 이모지를 섞으면 15바이트를 훨씬 빨리 넘는다
func badTag(_ id: Int) -> String { "🔥req-\(id)🔥" } // 이모지만 4바이트씩
// ✅ ASCII 위주로 짧게 유지 — 대부분 인라인에 남는다
func goodTag(_ id: Int) -> String { "req-\(id)" }② Swift 문자열을 C나 ObjC API로 넘길 일이 잦다면, 네이티브 문자열이 항상 null-terminated로 유지된다는 사실과 NSString과의 lazy bridging(유효한 UTF-8을 연속 메모리로 줄 수 있으면 복사 없이 그대로 가리킴)을 활용해 불필요한 변환을 줄여라.
func passToC(_ s: String) {
s.withCString { cString in
// cString은 이 클로저 동안만 유효한 null-terminated UTF-8 포인터
puts(cString)
}
}"Swift String은 내부적으로 UTF-16이다(ObjC NSString처럼)"는 Swift 5부터 틀렸다 — 네이티브 저장은 UTF-8이고, UTF-16은 .utf16 뷰로 필요할 때 계산해서 보여주는 것이지 기본 저장 형식이 아니다. "모든 짧은 문자열은 항상 힙 할당 없이 저장된다"도 부정확하다 — 64비트에서 정확히 15바이트 이하일 때만 그렇고, 이는 "글자 수"가 아니라 "UTF-8 바이트 수" 기준이라 멀티바이트 문자가 섞이면 훨씬 짧은 글자 수에서도 힙으로 넘어간다.
지갑에 카드 15장까지는 지갑 자체에 바로 꽂아 넣을 수 있는데(인라인), 16장째부터는 지갑이 못 담아서 따로 작은 파우치(힙 버퍼)를 사서 그 파우치를 가리키는 메모만 지갑에 넣어두는 것과 같다. 지갑(String 값 자체)의 크기는 카드가 몇 장이든 항상 똑같다(16바이트) — 다만 안에 카드를 직접 넣었는지, 파우치 위치 메모만 넣었는지가 다를 뿐이다. 비유가 깨지는 곳: 카드 한 장(=한 글자)의 두께가 다 다르다 — 영어 알파벳 카드는 얇고(1바이트), 이모지 카드는 두껍다(최대 4바이트). 그래서 "카드 몇 장"이 아니라 "두께 합이 15를 넘는지"가 기준이다.
꼬리 질문
스몰 스트링과 Substring/힙 문자열을 값 복사하면 비용이 같은가?
NSString에서 Swift String으로 브릿징할 때는 항상 복사가 일어나는가?
32비트 플랫폼에서는 왜 스몰 스트링 한계가 15바이트가 아니라 10바이트인가?
Q4. 조합형과 분해형 "é"가 ==로는 같은데 utf8.count는 다른 이유는?
Swift의 ==는 바이트 비교가 아니라 정준 동등성(canonical equivalence) 비교다. 조합형 é(U+00E9, 스칼라 1개)와 분해형 e+combining acute(U+0065 U+0301, 스칼라 2개)는 사람 눈엔 같은 글자라 ==가 true를 내고 count도 둘 다 1이다. 하지만 실제 바이트 표현은 다르다 — 실측으로 조합형은 utf8.count 2, 분해형은 3이었다. 한글도 완성형 "가"(NFC, 3바이트)와 조합형 ㄱ+ㅏ(NFD, 6바이트)가 정확히 같은 패턴을 보인다.
원리
유니코드는 시각적으로 동일한 문자를 여러 스칼라 시퀀스로 표현할 수 있게 허용하면서, 그 시퀀스들이 "정준적으로 동등하다"고 정의한다. Swift의 String Manifesto는 이 설계를 명시적으로 채택한다 — "정규화가 같은 문자열은 콜레이션도 같고, 정규화가 다른 문자열은 콜레이션도 항상 다르다." 즉 동등성·해싱 판단은 정규화만 맞으면 되고, 전체 콜레이션 알고리즘까지 돌릴 필요는 없다는 게 근거다.
내부 동작
실제로 두 표현을 만들어 비교하면 ==와 count는 같고, 그 아래 뷰들은 정확히 다르다.
let composed = "\u{E9}" // é, 단일 스칼라(NFC)
let decomposed = "e\u{301}" // e + combining acute accent(NFD)
print(composed == decomposed) // true
print(composed.count, decomposed.count) // 1 1
print(composed.unicodeScalars.count, decomposed.unicodeScalars.count) // 1 2
print(Array(composed.utf8).count, Array(decomposed.utf8).count) // 2 3
print(Array(composed.utf16).count, Array(decomposed.utf16).count) // 1 2
print(composed.hashValue == decomposed.hashValue) // true(Hashable 계약)한글도 같은 구조다: 완성형 "가"(U+AC00, 스칼라 1개)와 조합형 ㄱ+ㅏ(U+1100 U+1161, 스칼라 2개)는 ==가 true, count도 둘 다 1이지만, utf8.count는 각각 3바이트 vs 6바이트였다. macOS의 HFS+/APFS 파일 시스템은 파일명을 저장할 때 종종 NFD(조합형)로 정규화한다 — 그래서 서버가 NFC(완성형)로 보낸 한글 파일명과 로컬에 저장된 파일명을 바이트 단위로 비교하면 Swift의 ==는 같다고 하는데 서버 쪽 단순 바이트 비교 로직은 다르다고 판단하는 불일치가 생길 수 있다. 이럴 때 Foundation의 precomposedStringWithCanonicalMapping으로 NFC로 강제 정규화하면 바이트 단위 비교·해싱도 일치시킬 수 있다 — 실제로 분해형을 이 메서드로 변환하면 조합형과 바이트가 완전히 같아짐을 확인했다.
import Foundation
let hangulNFC = "\u{AC00}" // 가 (완성형)
let hangulNFD = "\u{1100}\u{1161}" // ㄱ + ㅏ (조합형, 자모 분리)
print(hangulNFC == hangulNFD) // true
print(hangulNFC.count, hangulNFD.count) // 1 1
print(Array(hangulNFC.utf8).count, Array(hangulNFD.utf8).count) // 3 6
// 서버·외부 소스가 NFD로 보낸 텍스트를 NFC로 강제 정규화
let decomposed = "e\u{301}"
let precomposed = (decomposed as NSString).precomposedStringWithCanonicalMapping
print(Array(precomposed.utf8).count) // 2 — 조합형(composed)과 바이트가 같아짐실험 · 도구
정준 동등성이 ==에만 적용되고 다른 비교 방식에는 적용되지 않는다는 것도 실측으로 확인된다. 기본 compare(_:)는 .orderedSame을 내지만, compare(_:options: .literal)로 바이트 그대로 비교를 지시하면 조합형·분해형이 다르다고 나온다(.orderedDescending) — 즉 "문자열이 같다"는 판단은 어떤 API를 쓰느냐에 따라 달라질 수 있는 선택이다.
let a = "café" // NFC
let b = "cafe\u{301}" // NFD
print(a == b) // true — 정준 동등성
print(a.compare(b) == .orderedSame) // true — 기본 compare도 정준 동등
print(a.compare(b, options: .literal)) // .orderedDescending — 바이트 그대로 보면 다르다프로젝트 적용
① 서버·클라이언트 간 텍스트를 바이트 단위로 비교하거나 해시해 전송하는 경우, 어느 쪽이든 정규화 형태를 맞추지 않으면 Swift에서는 같다고 판단한 문자열이 서버 로직에서는 다르다고 나올 수 있다. 전송·해싱 전에 명시적으로 정규화하라.
import Foundation
func normalizeForWire(_ s: String) -> String {
// 어떤 정규화 형태로 들어와도 NFC(완성형)로 통일 —
// 서버가 바이트 단위 비교/해시를 한다면 반드시 필요하다.
(s as NSString).precomposedStringWithCanonicalMapping
}
let fileName = readFileNameFromAPFS() // NFD로 올 수 있음
let normalized = normalizeForWire(fileName)
uploadToServer(normalized)② Dictionary 키·Set 멤버로 문자열을 쓸 때는 ==·hashValue가 정준 동등성 기준이라는 걸 활용하되, 그 결과를 그대로 바이트로 직렬화하는 다른 시스템에 넘길 때는 정규화 형태가 보존되는지 확인하라.
var seen = Set<String>()
seen.insert("\u{E9}") // 조합형 삽입
print(seen.contains("e\u{301}")) // true — 분해형으로 조회해도 찾아진다"겉보기에 같은 문자면 utf8 바이트 수도 항상 같다"는 틀렸다 — 조합형·분해형 표현이 다르면 utf8.count·unicodeScalars.count가 다를 수 있다(count, 즉 Character 개수만 같다). "Swift의 ==는 바이트 단위 비교다"도 틀렸다 — 정준 동등성 비교라서 조합형·분해형이 섞여 있어도 같다고 판단하며, 이 점이 NSString의 단순 코드유닛 비교와 다르다.
"café"라는 단어를 손으로 쓰는 방법이 두 가지 있다고 생각해보자. 하나는 é를 한 번에 그리는 것(조합형), 다른 하나는 e를 그리고 그 위에 억양 표시를 따로 얹는 것(분해형). 둘 다 보기엔 완전히 같은 café지만, "그리는 데 붓질이 몇 번 들었는지" 세보면(바이트 수) 답이 다르다. Swift는 그림(눈에 보이는 결과)만 보고 같다고 판단하지만, 붓질 수를 그대로 세는 방식(바이트 비교)으로 보면 다르게 나온다. 비유가 깨지는 곳: 실제로는 둘 다 최종 결과물이 픽셀 단위로 100% 똑같이 생겼다는 보장까지는 없다 — 렌더링은 폰트·시스템에 따라 미세하게 다를 수 있고, 유니코드가 보장하는 건 "같은 문자로 취급해야 한다"는 규칙이지 "픽셀이 같다"는 게 아니다.
꼬리 질문
정준 동등성(canonical equivalence)과 호환 동등성(compatibility equivalence)은 무엇이 다른가?
==는 정준 동등성만 다루고, 더 넓은 호환 정규화(NFKC/NFKD)가 필요하면 Foundation의 별도 정규화 API를 명시적으로 써야 한다.왜 macOS 파일 시스템은 하필 NFD로 정규화해서 저장하는가?
Hashable 계약에서 정준 동등성은 왜 중요한가?
a == b이면 반드시 a.hashValue == b.hashValue"다. Swift의 String이 ==를 정준 동등성으로 정의했으므로, 해시 함수도 그 동등성 기준에 맞춰 "같다고 판단되는 모든 표현이 같은 해시를 낸다"는 걸 보장해야 한다. 실제로 확인한 대로 조합형·분해형 é는 hashValue가 같았고, 그래서 Set에 조합형으로 넣고 분해형으로 조회해도 찾아진다. 이 계약이 깨지면 Set/Dictionary가 같은 값을 다른 버킷에 넣어 중복 삽입이나 조회 실패로 이어진다.Q5. 가족 이모지(👨👩👧👦)의 count가 1인 이유는? ZWJ 시퀀스란?
👨👩👧👦는 Man·Woman·Girl·Boy 4개 이모지를 ZWJ(Zero Width Joiner, U+200D) 3개로 이어붙인 시퀀스다. 실측하면 스칼라 7개, UTF-8 25바이트, UTF-16 11코드유닛인데도 count는 1이다 — Character의 정의(확장 그래핌 클러스터)가 "사람이 하나의 글자로 인식하는 단위"이고, 유니코드 그래핌 경계 규칙(UAX #29)이 ZWJ로 이어진 이모지 시퀀스를 하나로 묶기 때문이다. 스킨톤 수정자, 국기(regional indicator 쌍)도 같은 원리로 스칼라 여러 개가 Character 1개로 묶인다.
원리
이모지가 무엇인지 정의하는 Unicode Emoji 스펙과, 텍스트를 그래핌 클러스터로 나누는 규칙을 정의하는 UAX #29는 원래 별개로 발전했다. 한동안 UAX #29의 세그멘테이션 규칙이 새로운 이모지 ZWJ 시퀀스·스킨톤 수정자를 반영하지 못했는데, Unicode 9.0에서 이 규칙이 새 이모지 시퀀스를 하나의 그래핌 클러스터로 묶도록 개정됐다. Swift의 그래핌 클러스터 알고리즘은 이 개정된 규칙을 그대로 구현한다.
내부 동작
가족 이모지를 스칼라 단위로 풀어보면 Man(U+1F468) + ZWJ + Woman(U+1F469) + ZWJ + Girl(U+1F467) + ZWJ + Boy(U+1F466), 총 스칼라 7개다. 각 이모지는 기본 다국어 평면 밖(supplementary plane)에 있어 UTF-8로 4바이트, UTF-16으로 서로게이트 쌍 2코드유닛을 쓰고, ZWJ는 U+200D라 UTF-8로 3바이트, UTF-16으로 1코드유닛이다. 그래서 총 UTF-8 바이트는 4×4 + 3×3 = 25, UTF-16 코드유닛은 2×4 + 1×3 = 11이다 — 실측 결과와 정확히 일치한다.
let family = "\u{1F468}\u{200D}\u{1F469}\u{200D}\u{1F467}\u{200D}\u{1F466}" // 👨👩👧👦
print(family.count) // 1 — Character(그래핌) 기준
print(family.unicodeScalars.count) // 7 — Man,ZWJ,Woman,ZWJ,Girl,ZWJ,Boy
print(Array(family.utf8).count) // 25 — 이모지 4개×4B + ZWJ 3개×3B
print(Array(family.utf16).count) // 11 — 이모지 4개×2유닛 + ZWJ 3개×1유닛
let wave = "\u{1F44B}\u{1F3FD}" // 👋🏽 손흔들기 + 중간 피부톤 수정자
print(wave.count, wave.unicodeScalars.count) // 1 2
let flagKR = "\u{1F1F0}\u{1F1F7}" // 🇰🇷 regional indicator 쌍(K+R)
print(flagKR.count, flagKR.unicodeScalars.count) // 1 2스킨톤 수정자(Fitzpatrick modifier)도 같은 패턴이다 — 👋🏽는 기본 이모지 스칼라 뒤에 피부톤 스칼라 하나가 붙어 스칼라 2개, Character 1개다. 국기 이모지는 조금 다른 메커니즘인데, 알파벳 두 글자를 매핑한 regional indicator 스칼라 쌍(예: 🇰=U+1F1F0, 🇷=U+1F1F7)을 이어붙여 표시하며, 이 역시 그래핌 클러스터 규칙에 의해 스칼라 2개가 Character 1개로 묶인다.
실험 · 도구
다른 이모지로도 같은 패턴을 바로 확인할 수 있다: 단순 이모지 😀(스칼라 1개)는 count 1, unicodeScalars.count 1, utf8.count 4, utf16.count 2로 "정상적인" 1:1:4:2 비율을 보이지만, 가족 이모지처럼 ZWJ로 결합되거나 스킨톤·국기처럼 스칼라 2개 이상이 얽히면 스칼라·바이트 수만 불어나고 count는 여전히 1이다. 실무에서 서버 API의 "닉네임 20자 이하" 같은 제약을 count로 검사하면, 이런 이모지 몇 개만으로도 실제 전송 바이트는 예상보다 훨씬 커질 수 있다.
프로젝트 적용
① 사용자 입력에 바이트 길이 제한(예: DB 컬럼, 네트워크 페이로드 상한)이 걸려 있다면 count가 아니라 utf8.count로 검사하라. count는 "글자처럼 보이는 개수"일 뿐 실제로 전송·저장되는 바이트 수와 무관하다.
// ❌ count는 이모지 하나가 25바이트를 잡아먹어도 그냥 1로 센다
func badValidate(_ nickname: String) -> Bool {
nickname.count <= 20 // 가족 이모지 20개 = 500바이트인데 통과시킴
}
// ✅ 실제 저장/전송 단위(UTF-8 바이트)로 제한
func validate(_ nickname: String, maxBytes: Int = 60) -> Bool {
Array(nickname.utf8).count <= maxBytes
}② 텍스트를 자를 때(말줄임, 미리보기)는 항상 Character 단위(prefix(_:))로 잘라야 한다. UTF-16이나 UTF-8 오프셋으로 임의로 자르면 ZWJ 시퀀스 중간이 끊겨 깨진 이모지 조각(💔 대신 고아 👨나 홀로 남은 ZWJ)이 나온다.
// ✅ prefix는 Character(그래핌) 경계에서만 자른다 — 가족 이모지가 중간에 끊기지 않는다
func preview(_ s: String, limit: Int) -> String {
String(s.prefix(limit))
}"이모지 하나는 항상 스칼라 하나다"는 틀렸다 — ZWJ 결합, 스킨톤 수정자, 국기 이모지는 모두 스칼라 여러 개로 구성되지만 여전히 Character 1개로 센다. "family.count == family.unicodeScalars.count"도 대부분의 복합 이모지에서 성립하지 않는다 — 실측처럼 1 vs 7로 크게 벌어진다.
가족 이모지는 사진 한 장처럼 보이지만 사실은 스티커 4개(아빠·엄마·딸·아들)를 투명 테이프(ZWJ) 3조각으로 이어붙여 만든 콜라주다. 멀리서 보면(사람이 인식하면) "가족 사진 1장"(count=1)이지만, 재료를 낱개로 세면(스칼라) 스티커 4개 + 테이프 3조각 = 7개다. 그리고 그 재료들을 우편으로 보내려면(UTF-8 바이트) 스티커 하나에 4바이트, 테이프 하나에 3바이트씩 포장비가 들어 총 25바이트가 필요하다. 비유가 깨지는 곳: 실제 콜라주는 테이프를 떼면 스티커가 따로따로 남지만, ZWJ 시퀀스는 중간에서 자르면 "따로 떨어진 정상 이모지"가 아니라 깨진 표시(고아 문자)로 보일 수 있다.
꼬리 질문
ZWJ 시퀀스를 지원하지 않는 오래된 시스템에서는 가족 이모지가 어떻게 보이는가?
국기 이모지(regional indicator)는 ZWJ 이모지와 메커니즘이 같은가?
왜 실무에서 count로 이모지를 세는 로직이 특히 위험한가?
count는 여전히 낮게 나오지만, 실제 저장·전송 바이트는 그 수 배로 뛸 수 있기 때문이다. 예를 들어 가족 이모지 20개는 count로는 20이지만 UTF-8로는 500바이트다. DB 컬럼이 VARCHAR(20)이 아니라 바이트 기준 제한을 쓴다면, count 기준 검증을 통과한 입력이 실제 저장 단계에서 잘리거나 에러를 낼 수 있다.Q6. ==, compare(options:), localizedStandardCompare는 각각 언제 쓰는가? Turkish-I 문제란?
==는 로케일과 무관한 정준 동등성 비교, compare(options:)는 .literal·.caseInsensitive 등 명시적 옵션으로 세밀히 제어하는 비교, localizedStandardCompare는 Finder가 파일을 정렬하는 방식(숫자를 값으로 인식, 대소문자·발음부호 무시, 현재 로케일 반영)이다. 사용자에게 보여줄 목록 정렬에는 localizedStandardCompare가 기본값이어야 한다. Turkish-I 문제는 "I".lowercased()가 항상 "i"인 것과 달리, 터키어 로케일을 명시하면 "ı"(점 없는 i)가 나오는 현상이다 — 로케일 불변 매칭이 필요한 곳(프로토콜 토큰, 딕셔너리 키)에 로케일 의존적인 API를 잘못 쓰면 생기는 버그의 대표 사례다.
원리
문자열 비교는 "무엇을 같다고 볼 것인가"와 "누구 기준으로 순서를 매길 것인가"라는 두 축이 독립적이다. ==는 유니코드 정준 동등성만 보고 로케일이 끼어들 자리가 없다. 반면 사람이 보는 정렬·검색은 문화권마다 다른 관습을 따른다 — 어떤 언어는 대소문자를 무시하고, 어떤 언어는 특정 알파벳의 대소문자 대응 자체가 영어와 다르다. Apple은 localizedStandardCompare(_:)를 "Finder가 정렬하는 방식대로" 비교하는 API로 명시하고, 파일명 등 리스트 정렬에 이를 권장한다. 반대로 localizedCaseInsensitiveCompare(_:)는 대소문자 무시 "매칭"에는 쓰되 정렬 목적으로는 쓰지 말라고 문서에 명시돼 있다.
내부 동작
Turkish-I 문제를 직접 재현하면 두 가지가 뚜렷이 갈린다. Swift 고유의 String.lowercased()는 로케일 파라미터 자체가 없어 항상 유니코드 기본 케이스 폴딩을 따른다 — 실행 환경의 시스템 로케일이 터키어여도 "I".lowercased()는 그대로 "i"였다. 로케일을 반영하려면 Foundation의 NSString API로 명시적으로 로케일을 지정해야 한다.
import Foundation
print("I".lowercased())
// "i" — Swift String.lowercased()는 로케일 파라미터가 없어 항상 이 결과
print(("I" as NSString).lowercased(with: Locale(identifier: "tr")))
// "ı" — 터키어 로케일을 NSString API로 명시하면 점 없는 i
let dottedI = "\u{0130}" // İ (터키어 대문자 점 있는 I)
print(dottedI.lowercased())
// "i̇" — 기본 유니코드 케이스 폴딩: i + combining dot above (두 스칼라)
print((dottedI as NSString).lowercased(with: Locale(identifier: "tr")))
// "i" — 터키어 로케일에서는 점 없는 평범한 i (한 스칼라)
print((dottedI as NSString).lowercased(with: Locale(identifier: "en_US")))
// "i̇" — en_US는 기본 유니코드 매핑과 동일정렬 비교도 실측으로 차이가 뚜렷했다. 파일명 ["file10.txt", "file2.txt", "file1.txt", "File20.txt"]를 일반 <로 정렬하면 대문자 F가 소문자보다 코드 포인트가 작아 앞으로 오고 "10"이 "2"보다 문자열로는 작아 ["File20.txt", "file1.txt", "file10.txt", "file2.txt"]라는, 사람이 기대하는 것과 다른 순서가 나온다. 같은 배열을 localizedStandardCompare로 정렬하면 숫자를 값으로 인식해 ["file1.txt", "file2.txt", "file10.txt", "File20.txt"] — Finder에서 보던 바로 그 순서가 나왔다.
| 메서드 | 기준 | 로케일 영향 | 언제 쓰나 |
|---|---|---|---|
== | 정준 동등성(canonical equivalence) | 없음 | 값 비교·Dictionary 키·프로토콜/와이어 토큰 |
compare(_:options:) | 옵션으로 명시(.literal, .caseInsensitive 등) | 옵션에 없으면 없음, locale 파라미터로 명시 가능 | 바이트 그대로 비교, 세밀한 제어가 필요할 때 |
localizedStandardCompare | Finder 스타일(숫자 인식 + 로케일) | 있음(Locale.current) | 파일명 등 사용자에게 보여줄 리스트 정렬 |
localizedCaseInsensitiveCompare | 로케일 반영 대소문자 무시 | 있음 | 대소문자 무시 매칭(정렬 목적엔 비권장) |
lowercased() | 유니코드 기본 케이스 폴딩 | 없음(Swift String 고유 API) | 프로토콜 토큰·와이어 포맷 정규화 |
(_ as NSString).lowercased(with:) | 지정 로케일의 케이스 매핑 | 있음 | 사용자에게 보여줄 대소문자 변환 |
실험 · 도구
위 표의 각 행은 그대로 실행해 확인할 수 있다. 특히 compare(_:options: .literal)과 기본 compare(_:)을 나란히 찍어보면, Q4의 조합형/분해형 é 사례처럼 "정준 동등"과 "바이트 동등"이 서로 다른 결론을 낼 수 있다는 게 이 챕터 전체를 관통하는 하나의 주제임이 드러난다.
let names = ["file10.txt", "file2.txt", "file1.txt", "File20.txt"]
print(names.sorted { $0 < $1 })
// ["File20.txt", "file1.txt", "file10.txt", "file2.txt"] — 사람 기대와 다름
print(names.sorted { $0.localizedStandardCompare($1) == .orderedAscending })
// ["file1.txt", "file2.txt", "file10.txt", "File20.txt"] — Finder와 같은 순서프로젝트 적용
① 서버로 보내는 프로토콜 토큰, enum rawValue, API 파라미터처럼 기기 로케일과 무관하게 항상 같은 결과가 나와야 하는 곳에는 Swift 고유의 lowercased()를 쓰고, NSString의 lowercased(with:)는 화면에 보여줄 때만 써라.
import Foundation
// ✅ 서버에 보낼 키는 로케일 무관 lowercased() — 터키어 기기에서도 항상 같은 문자열
func normalizeQueryKey(_ raw: String) -> String {
raw.lowercased()
}
// ✅ 사용자에게 보여줄 대소문자 변환은 로케일 반영이 자연스러움
func displayLowercase(_ raw: String, locale: Locale) -> String {
(raw as NSString).lowercased(with: locale)
}② 파일명·연락처처럼 사용자가 눈으로 훑는 리스트는 <나 단순 compare 대신 localizedStandardCompare로 정렬하라. 숫자가 섞인 이름일수록 차이가 크게 드러난다.
let fileNames = ["문서2.pdf", "문서10.pdf", "문서1.pdf"]
let sorted = fileNames.sorted { $0.localizedStandardCompare($1) == .orderedAscending }
// Finder와 같은 순서로 문서1, 문서2, 문서10 순으로 나온다"lowercased()는 로케일과 무관하게 항상 같은 결과를 낸다"는 Swift 고유 API에 한해서만 맞다 — NSString의 lowercased(with:)로 로케일을 명시하면 터키어처럼 다른 결과가 나온다. "localizedCaseInsensitiveCompare를 쓰면 사용자 친화적으로 정렬된다"도 부정확하다 — Apple 문서가 정렬 목적에는 이 메서드 대신 localizedStandardCompare를 쓰라고 명시적으로 권고한다.
"I"라는 알파벳을 소문자로 바꾸는 규칙이 나라마다 미묘하게 다르다고 생각해보자. 대부분 나라에서는 "i"가 되지만, 터키에서는 "I"의 짝이 원래 "ı"(점 없는 i)이고 "i"의 짝은 따로 "İ"(점 있는 I)다. 그래서 "어느 나라 규칙으로 바꿀지" 미리 정해두지 않은 코드가 터키 사용자 기기에서 갑자기 다른 결과를 내면, 그건 버그가 아니라 "규칙을 안 정했다"는 신호다. 비유가 깨지는 곳: Swift의 lowercased()는 애초에 "어느 나라 규칙"을 물어보지 않는 API라서, 기기가 터키어여도 흔들리지 않는다 — 이 함정은 오직 로케일을 직접 지정하는 NSString API를 쓸 때만 생긴다.
꼬리 질문
== 와 compare(_:) (옵션 없이)의 결과는 항상 같은가?
==와 옵션 없는 compare(_:) 모두 "같다"고 판단했다(== true, compare(_:) == .orderedSame). 둘 다 정준 동등성 기준으로 동작하기 때문이다. 다만 compare(_:options: .literal)처럼 옵션을 명시하면 결과가 갈린다 — 옵션이 곧 "무엇을 같다고 볼 것인가"의 기준을 바꾸는 스위치다.Dictionary 키로 문자열을 쓸 때 대소문자 무시 매칭이 필요하면 어떻게 하는가?
lowercased()로 정규화한 문자열을 키로 쓰는 게 가장 안전하다 — 로케일 무관이라 어느 기기에서 만든 키든 같은 결과를 보장한다. Dictionary 자체가 대소문자 무시 비교를 하도록 커스텀 Hashable 타입을 만들 수도 있지만, 그 안에서 대소문자를 접는 로직 역시 로케일 무관 API(lowercased())를 써야 Turkish-I 같은 함정을 피한다.RTL(오른쪽에서 왼쪽으로 읽는) 언어가 섞인 리스트도 localizedStandardCompare로 충분한가?
localizedStandardCompare는 정렬 "순서" 자체는 로케일의 콜레이션 규칙(숫자 인식 포함)을 반영해 정하지만, 그 결과를 화면에 어떻게 배치할지(좌우 방향)는 별개 문제다. RTL 언어가 섞인 리스트를 표시할 때는 정렬은 이 API로 하되, 레이아웃은 시스템의 자연스러운 언어 방향 처리(NSLocalizedString·SwiftUI의 자동 레이아웃 미러링 등)에 맡겨야 한다. 정렬 로직과 표시 방향은 서로 다른 축이다.