P2의 첫 챕터다. P1이 사용자 기기에서 도는 앱을 관측·판독하는 법이었다면, P2는 크래시 없이 조용히 틀리는 것들과 언어·런타임이 보장하는 것의 경계를 다룬다.
문자열이 그 첫 자리인 이유는 단순하다. 문자열은 모든 앱이 쓰는데 아무도 그 내부 표현을 생각하지 않는 타입이고, 그래서 버그가 테스트를 통과한 뒤 특정 사용자에게서만 터진다. 이모지를 쓰는 사용자, 한글 파일명을 올리는 사용자, 이름에 결합 문자가 들어가는 사용자에게서만.
이 챕터의 모든 문항은 "겉보기와 실제가 어긋나는 지점"을 다룬다. 길이가 하나가 아니고, 같아 보이는 문자열의 바이트가 다르고, 인덱스가 정수가 아니고, 가벼워 보이는 슬라이스가 480MB를 붙잡는다.
Q1. 문자열의 "길이"는 왜 하나가 아닌가?
텍스트에는 네 개의 계층이 있고 각각 개수가 다르기 때문이다. 바이트(UTF-8) → 코드 유닛(UTF-16) → 유니코드 스칼라 → 확장 그래프임 클러스터(사람이 한 글자로 인식하는 단위) 순으로 묶인다. Swift의 Character는 맨 위 계층이라 String.count는 "사람이 세는 글자 수"를 준다. 가족 이모지 👨👩👧👦 하나가 count 1, unicodeScalars.count 7, utf8.count 25, utf16.count 11이다. 그래서 "길이"를 말할 때는 어느 계층인지를 반드시 정해야 하고, 특히 바이트 길이 제한 검사에 count를 쓰면 안 된다.
CS 원리
이 계층 구조는 유니코드가 두 가지 요구를 동시에 만족시키려다 생긴 것이다. 하나는 "세상의 모든 문자를 표현한다", 다른 하나는 "기존 바이트 기반 시스템과 호환된다".
- 코드포인트(스칼라) — 유니코드가 문자에 붙인 번호다.
U+0041(A),U+AC00(가). 약 110만 개의 자리가 있다. - 인코딩(UTF-8 / UTF-16) — 그 번호를 실제 바이트로 적는 방식이다. UTF-8은 1~4바이트 가변, UTF-16은 2 또는 4바이트. 같은 코드포인트라도 인코딩마다 길이가 다르다.
- 그래프임 클러스터 — 여러 코드포인트가 결합해 사람이 한 글자로 인식하는 단위다. 유니코드 부속 문서 UAX #29가 경계 규칙을 정의한다.
맨 위 계층이 필요한 이유는 결합 문자 때문이다. 한 글자가 여러 코드포인트로 만들어질 수 있다 — 기본 문자 + 발음 구별 기호, 이모지 + 스킨톤 수정자, 여러 이모지 + ZWJ(zero-width joiner). 사용자에게 "글자 하나"인 것을 프로그램도 하나로 세려면 이 결합 규칙을 알아야 한다.
여기서 비용이 나온다. 결합 규칙은 본질적으로 순차적이다 — 앞의 스칼라를 보지 않고는 다음 경계가 어디인지 알 수 없다. 그래서 그래프임 개수는 처음부터 훑어야만 알 수 있고 O(n)이다. 반면 바이트 수는 버퍼 길이라 O(1)이다.
iOS에서는
Swift의 Character는 확장 그래프임 클러스터다. 이 선택이 Swift 문자열 API 전체의 성격을 결정한다.
let family = "👨👩👧👦"
family.count // 1 ← Character(확장 그래프임 클러스터)
family.unicodeScalars.count // 7 ← Man, ZWJ, Woman, ZWJ, Girl, ZWJ, Boy
family.utf16.count // 11 ← 서로게이트 쌍 4쌍 + ZWJ 3개
family.utf8.count // 25 ← 실제 바이트 수
// 국기와 스킨톤도 같은 패턴이다 — 스칼라 여러 개가 Character 하나로 묶인다
"🇰🇷".count // 1 (regional indicator 스칼라 2개)
"👍🏽".count // 1 (thumbs up + 스킨톤 수정자)
복잡도 차이가 실무에 그대로 드러난다. count는 캐시되지 않는다 — 값 안에 캐시되는 것은 _countAndFlagsBits에 담긴 UTF-8 바이트 수뿐이다. 같은 문자열에서 .count를 세 번 불러도 매번 같은 시간이 걸린다.
// ❌ 루프 조건에서 count를 반복 호출 — 매번 O(n)
for i in 0..<text.count { … } // 전체가 O(n²)가 된다
// ✅ 한 번만 세거나, 애초에 인덱스로 순회한다
let n = text.count
for ch in text { … } // 순회 자체는 O(n)
// ✅ 비어 있는지 확인은 count를 쓰지 않는다
if text.isEmpty { … } // O(1)
if text.count == 0 { … } // ❌ O(n)
실험 · 도구
네 계층이 실제로 다르게 세어지는 것을 직접 확인해 보자.
import Foundation
func inspect(_ s: String, _ label: String) {
print(String(format: "%-14@ count=%2d scalars=%2d utf16=%2d utf8=%2d",
label as NSString, s.count, s.unicodeScalars.count,
s.utf16.count, s.utf8.count))
}
inspect("A", "ASCII") // count= 1 scalars= 1 utf16= 1 utf8= 1
inspect("가", "한글 완성형") // count= 1 scalars= 1 utf16= 1 utf8= 3
inspect("👍", "이모지") // count= 1 scalars= 1 utf16= 2 utf8= 4
inspect("👍🏽", "스킨톤") // count= 1 scalars= 2 utf16= 4 utf8= 8
inspect("🇰🇷", "국기") // count= 1 scalars= 2 utf16= 4 utf8= 8
inspect("👨👩👧👦", "가족") // count= 1 scalars= 7 utf16=11 utf8=25
복잡도 차이도 재 볼 수 있다. 긴 문자열에서 두 값을 반복 계산해 비교한다.
let long = String(repeating: "가나다👨👩👧👦", count: 20_000)
var clock = ContinuousClock()
let t1 = clock.measure { for _ in 0..<3 { _ = long.count } }
let t2 = clock.measure { for _ in 0..<3 { _ = long.utf8.count } }
print("count \(t1)") // 밀리초 단위 — 3회 모두 같은 비용
print("utf8.count \(t2)") // 사실상 0 — 캐시된 값 읽기
프로젝트 적용
- "길이"를 요구하는 명세를 만나면 계층부터 확인한다. 서버가 "닉네임 20자 이하"라고 할 때 그 20이 바이트인지 글자인지를 정하지 않으면 클라이언트와 서버의 판정이 갈린다.
- 바이트 제한 검사에는
utf8.count를 쓴다. 이모지 20개는count로 20이지만 실제로는 500바이트일 수 있다. DB 컬럼이VARCHAR(255)라면 바이트 기준이다. - 사용자에게 보여줄 "남은 글자 수"에는
count를 쓴다. 사용자는 그래프임을 센다. - 뜨거운 경로에서
count를 반복 호출하지 않는다. 특히 루프 조건과count == 0비교. - 자르기(truncate)는 그래프임 단위로 한다. 바이트나 스칼라 단위로 자르면 이모지가 깨진다.
"String.count는 유니코드 스칼라 개수를 센다"는 틀렸다 — Character(확장 그래프임 클러스터) 개수다. "이모지 하나는 항상 스칼라 하나"도 틀렸다 — 가족 이모지는 스칼라 7개다. "count는 캐시되니 반복 호출해도 싸다"도 틀렸다 — 캐시되는 것은 UTF-8 바이트 수뿐이고 count는 매번 O(n)이다. "UTF-8이 항상 UTF-16보다 짧다"도 틀렸다 — 한글은 UTF-8에서 3바이트, UTF-16에서 2바이트라 한글 문서는 UTF-16이 더 짧다.
"이 글이 몇 자야?"라는 질문에 답이 여러 개인 이유를 생각해 보자.
손으로 쓴 글씨를 세는 방법이 여럿이다. 눈에 보이는 글자를 세는 것, 획을 세는 것, 잉크가 몇 방울 들었는지 세는 것. 셋 다 "길이"지만 숫자가 다르다.
이모지 👨👩👧👦는 특히 재미있다. 눈에는 그림 하나로 보이지만, 사실은 아빠 그림 + 이어붙임 표시 + 엄마 그림 + 이어붙임 표시 + 딸 + 이어붙임 + 아들, 이렇게 일곱 조각을 붙여 만든 것이다. 화면이 알아서 하나로 그려줄 뿐이다.
그래서 "글자 수 20자까지"라고 할 때, 눈으로 센 20자가 저장 공간으로는 500칸일 수 있다. 어느 기준으로 세는지 안 정하면 사고가 난다.
그리고 눈에 보이는 글자를 세는 건 항상 처음부터 다시 세야 한다. 조각들이 어디서 하나로 묶이는지는 앞에서부터 따라와야 알 수 있기 때문이다. 잉크 방울 수는 미리 적어둘 수 있어서 바로 답한다.
꼬리 질문
문자열을 N바이트로 잘라야 하는데 이모지가 깨지지 않게 하려면?
바이트 한도를 지키면서 그래프임 경계에서만 잘라야 한다. 순서는 이렇다.
(1) 그래프임 단위로 누적하며 바이트를 센다. 뒤에서 자르고 검사하는 방식은 이미 깨진 뒤라 늦다.
extension String {
/// UTF-8 기준 maxBytes 이하가 되도록 자르되, Character 경계를 지킨다
func truncatedToUTF8Bytes(_ maxBytes: Int) -> String {
var out = ""
var used = 0
for ch in self {
let n = String(ch).utf8.count
if used + n > maxBytes { break }
out.append(ch)
used += n
}
return out
}
}
"안녕하세요👨👩👧👦".truncatedToUTF8Bytes(20) // "안녕하세요" (15바이트)
// 가족 이모지는 25바이트라 통째로 빠진다 — 조각으로 깨지지 않는다(2) 말줄임표를 붙일 거면 그 바이트도 예산에 넣는다. …는 UTF-8로 3바이트다.
(3) 서버와 자르는 규칙을 맞춘다. 클라이언트가 그래프임 경계로 자르고 서버가 바이트로 자르면 왕복할 때마다 값이 달라진다.
DB 컬럼이 VARCHAR(255)일 때 클라이언트는 무엇을 기준으로 검증해야 하는가?
DB가 무엇을 세는지에 달렸고, 이게 데이터베이스마다 다르다.
- MySQL
VARCHAR(255)— 문자 수다(코드포인트 기준). 다만 행 크기 제한이 바이트라,utf8mb4에서 문자 하나가 최대 4바이트를 쓰므로 실제 저장 한도에 걸릴 수 있다. - PostgreSQL
varchar(255)— 문자 수다. - SQLite — 길이 제약을 강제하지 않는다(선언은 받아들이되 무시).
여기서 "문자 수"가 또 유니코드 스칼라 기준이라는 점이 중요하다. 그래프임이 아니다. 가족 이모지는 DB가 보기엔 7문자이고 Swift가 보기엔 1문자다.
실무 규칙은 이렇다.
- 가장 엄격한 기준으로 클라이언트가 먼저 막는다 — 보통
utf8.count. - 서버가 최종 판정을 한다(23장 Q5의 이중 방어와 같은 구조). 클라이언트 검증은 UX용이고 신뢰 경계 밖이다.
- 에러 메시지는 사용자가 이해하는 단위로 낸다. "255바이트 초과"가 아니라 "이모지를 줄여 주세요".
커서 이동·백스페이스를 직접 구현할 때 어느 단위로 움직여야 하는가?
그래프임 클러스터 단위다. 사용자가 백스페이스를 한 번 누르면 보이는 글자 하나가 사라져야 한다.
스칼라 단위로 지우면 가족 이모지에서 백스페이스를 일곱 번 눌러야 하고, 중간 단계에서는 👨👩👧 → 👨👩 처럼 가족이 줄어드는 기괴한 화면이 나온다. 실제로 이런 버그를 가진 앱이 있었다.
// ❌ 스칼라 단위 — 이모지가 조각난다
text.unicodeScalars.removeLast()
// ✅ Character 단위 — 보이는 글자 하나가 사라진다
text.removeLast()
// 커서 이동도 마찬가지
let next = text.index(after: cursor) // ✅ 그래프임 경계로 이동
let prev = text.index(before: cursor)다만 예외가 있다. 일부 언어에서는 사용자가 기대하는 편집 단위가 그래프임보다 작다. 한글 조합 중에는 자모 단위로 지워지는 것이 자연스럽고, 인도계 문자도 비슷하다. 그래서 플랫폼이 제공하는 텍스트 입력 시스템(UITextInput)을 쓰는 것이 원칙이고, 직접 구현은 그 시스템을 쓸 수 없을 때만 한다.
Q2. 겉보기에 같은 문자열이 왜 바이트가 다른가?
같은 글자를 표현하는 코드포인트 조합이 둘 이상 있기 때문이다. é는 조합형(U+00E9, 스칼라 1개)으로도, 분해형(U+0065 + 결합 악센트 U+0301, 스칼라 2개)으로도 쓸 수 있다. 유니코드는 이 둘을 정준 등가(canonical equivalence)로 규정하고, Swift의 ==는 바이트가 아니라 정준 등가로 비교하므로 true다. 문제는 바이트 단위로 비교하는 다른 시스템과 만날 때다 — macOS APFS는 파일명을 종종 NFD로 정규화해 저장하는데, 서버가 NFC로 보낸 한글 파일명과 바이트로 비교하면 다르다고 판정된다.
CS 원리
정준 등가는 유니코드가 기존 문자 집합과의 왕복 변환을 보장하려다 생긴 부채다. 라틴-1 같은 기존 인코딩에 이미 é가 한 글자로 들어 있었으므로 유니코드도 그 자리를 만들어야 했고, 동시에 결합 문자로 임의의 조합을 만드는 일반 메커니즘도 필요했다. 결과적으로 같은 것에 두 개의 표현이 생겼다.
이를 다루기 위해 유니코드는 정규화 형식을 정의한다(UAX #15).
| 형식 | 방향 | 특징 |
|---|---|---|
| NFC | 조합(composed) | 가능한 한 하나의 코드포인트로 합침. 웹·네트워크의 사실상 표준 |
| NFD | 분해(decomposed) | 기본 문자 + 결합 문자로 쪼갬. macOS 파일 시스템이 선호 |
| NFKC / NFKD | 호환 분해 | ①→1, fi→fi 처럼 의미가 달라질 수 있는 변환까지 수행. 검색 색인용 |
여기서 원리 하나가 나온다 — 동등성 판정은 계층마다 다르다. 바이트 동등, 코드포인트 동등, 정준 동등, 호환 동등, 대소문자 무시 동등, 로케일 인식 동등이 전부 다른 관계다. 어느 동등성을 쓸지 정하지 않으면 시스템 경계에서 어긋난다.
==가 같다고 해도 바이트를 직접 다루는 층(해시·DB 키·파일명 대조)에서는 갈린다.iOS에서는
Swift의 ==는 정준 동등성 비교이고, 이 판단은 Hashable 계약에 따라 hashValue에도 적용된다. 조합형으로 Set에 넣고 분해형으로 조회해도 찾아진다.
let composed = "\u{AC00}" // 가 (NFC)
let decomposed = "\u{1100}\u{1161}" // ᄀ+ᅡ (NFD)
composed == decomposed // true ← 정준 등가
composed.count // 1
decomposed.count // 1 ← 둘 다 Character 1개
composed.utf8.count // 3
decomposed.utf8.count // 6 ← 바이트는 두 배
var set: Set<String> = [composed]
set.contains(decomposed) // true ← hashValue도 정준 등가를 따른다
// 바이트 그대로 비교하려면 명시해야 한다
composed.compare(decomposed, options: .literal) == .orderedSame // false
문제가 드러나는 자리는 Swift의 ==를 거치지 않는 경로다.
- 해시·서명 — 파일명이나 문자열을 SHA로 해싱해 무결성을 검증하면 NFC/NFD가 다른 해시를 낸다.
- DB 기본 키·유니크 제약 — 데이터베이스의 대조(collation) 규칙에 따라 다르게 판정된다.
- 파일 시스템 — macOS는 파일명을 NFD로 정규화해 저장하는 경향이 있어, 서버가 준 NFC 이름으로 파일을 찾으면 없다고 나올 수 있다.
- URL 경로 — 퍼센트 인코딩되는 바이트가 달라진다(Q6).
대응은 경계에서 한 형식으로 고정하는 것이다.
import Foundation
extension String {
/// 네트워크·저장 경계에서 NFC로 통일한다
var nfc: String { precomposedStringWithCanonicalMapping }
/// 파일 시스템과 대조할 때 NFD가 필요하면
var nfd: String { decomposedStringWithCanonicalMapping }
}
// 업로드 직전 파일명을 NFC로 고정
let safeName = localFileName.nfc
// 서버가 준 이름으로 로컬 파일을 찾을 때도 같은 형식으로 맞춘다
let match = contents.first { $0.nfc == serverName.nfc }
실험 · 도구
정규화 차이는 눈으로 안 보이므로 바이트를 직접 찍어봐야 한다.
func dump(_ s: String, _ label: String) {
let bytes = s.utf8.map { String(format: "%02X", $0) }.joined(separator: " ")
let scalars = s.unicodeScalars.map { String(format: "U+%04X", $0.value) }.joined(separator: " ")
print("\(label): count=\(s.count) utf8=\(s.utf8.count)\n scalars: \(scalars)\n bytes: \(bytes)")
}
dump("가".precomposedStringWithCanonicalMapping, "NFC")
// count=1 utf8=3 scalars: U+AC00 bytes: EA B0 80
dump("가".decomposedStringWithCanonicalMapping, "NFD")
// count=1 utf8=6 scalars: U+1100 U+1161 bytes: E1 84 80 E1 85 A1
파일 시스템이 실제로 어떻게 저장하는지도 확인할 수 있다.
# 한글 이름 파일을 만들고 실제 저장된 바이트를 본다
cd "$(mktemp -d)"
touch "가.txt"
ls | xxd | head -2
# APFS 는 NFD 로 정규화해 저장하는 경우가 많다 →
# E1 84 80 E1 85 A1 (6바이트) 로 보이면 NFD,
# EA B0 80 (3바이트) 면 NFC 다.
# 서버에서 받은 이름(NFC)으로 찾으면 못 찾는 상황을 재현
python3 -c "import unicodedata,os; \
print(os.listdir('.')[0] == unicodedata.normalize('NFC','가.txt'))"
# False 가 나오면 정규화 불일치다
프로젝트 적용
- 경계에서 정규화 형식을 고정한다. 네트워크로 나가고 들어오는 문자열, 저장소에 쓰는 키는 NFC로 통일하는 것이 무난하다. 웹과 대부분의 서버가 NFC를 기대한다.
- 해시·서명·중복 판정 전에는 반드시 정규화한다. 정규화하지 않은 해시는 같은 파일이 다른 해시를 갖게 만든다.
- 파일 시스템과 대조할 때는 양쪽을 같은 형식으로 맞춘다. 저장된 이름을 그대로 믿지 않는다.
- 사용자 입력을 그대로 키로 쓰지 않는다. 닉네임 중복 검사, 태그 비교 등은 정규화 후 비교한다.
- NFKC는 신중하게 쓴다. 검색 색인에는 유용하지만 ①과 1을 같게 만드는 등 의미를 바꾼다. 표시용 원본은 따로 보관한다.
"겉보기에 같은 문자면 UTF-8 바이트 수도 항상 같다"는 틀렸다 — count만 같다. "Swift의 ==는 바이트 단위 비교다"도 틀렸다 — 정준 등가 비교이며, 바이트 그대로 비교하려면 compare(_:options: .literal)로 명시해야 한다. "정규화는 다국어 앱에서만 신경 쓰면 된다"도 위험하다 — 한글이 정확히 같은 패턴을 갖는다. "NFC로 통일하면 모든 문제가 사라진다"도 과장이다 — 파일 시스템은 우리가 통제하지 못하므로 읽을 때도 정규화해야 한다.
"가"라는 글자를 쓰는 방법이 두 가지라고 해 보자.
하나는 도장처럼 "가"가 통째로 새겨진 걸 한 번 찍는 것이다. 다른 하나는 "ㄱ" 도장과 "ㅏ" 도장을 나란히 찍어서 붙여 놓는 것이다. 종이에 찍힌 결과는 눈으로 구분이 안 된다.
사람이 보기엔 둘 다 "가"다. 그래서 Swift도 "같다"고 답한다.
그런데 잉크가 몇 방울 들었는지 세는 기계에게 물으면 답이 다르다. 도장 하나는 세 방울, 도장 둘은 여섯 방울이다. 파일 이름을 다루는 시스템이나 지문(해시)을 뜨는 기계가 딱 이 기계다.
더 곤란한 건 맥의 파일 시스템은 도장 두 개 방식을 좋아하고, 인터넷은 도장 하나 방식을 좋아한다는 점이다. 그래서 서버에서 받은 이름으로 내 컴퓨터의 파일을 찾으면 "그런 파일 없다"는 답이 돌아올 수 있다.
해결책은 간단하다 — 문 앞에서 도장 방식을 하나로 통일하는 것이다. 들어올 때도 나갈 때도.
꼬리 질문
서버에 저장된 닉네임 중복 검사가 클라이언트와 다르게 판정된다. 어디를 봐야 하는가?
세 층을 순서대로 확인한다.
(1) 정규화 형식이 다른가. 클라이언트가 NFD로 보내고 서버가 NFC로 저장하면, 같은 닉네임이 중복이 아니라고 판정된다. 가장 흔한 원인이다.
(2) 데이터베이스 대조(collation) 규칙. MySQL의 utf8mb4_general_ci와 utf8mb4_bin은 판정이 다르다. 전자는 대소문자·악센트를 무시하고, 후자는 바이트 그대로 본다. 그래서 서버 안에서도 애플리케이션 코드와 DB 유니크 제약의 판정이 갈릴 수 있다.
(3) 보이지 않는 문자. 제로폭 공백(U+200B), 오른쪽에서 왼쪽 표시자 같은 문자는 화면에 안 보이지만 다른 문자열로 만든다. 닉네임 도용에 악용되기도 한다.
대응은 정규화 + 금지 문자 제거를 한 함수로 묶어 클라이언트와 서버가 같은 규칙을 쓰게 하는 것이다.
extension String {
/// 중복 판정·검색용 정규 형태 — 표시용 원본과 별도로 보관한다
var canonicalKey: String {
precomposedStringWithCanonicalMapping
.trimmingCharacters(in: .whitespacesAndNewlines)
.lowercased()
.filter { !$0.unicodeScalars.contains { s in
s.properties.isDefaultIgnorableCodePoint // 제로폭 등
} }
}
}정규화를 매번 하면 비용이 문제되지 않는가?
대부분의 경우 문제되지 않지만, 어디서 하느냐가 중요하다.
정규화는 문자열 길이에 비례하는 비용이고, 새 문자열을 할당한다. 짧은 닉네임 하나는 무시할 만하지만, 목록의 모든 항목을 화면 갱신마다 정규화하면 보인다.
실무 규칙 셋이다.
- 경계에서 한 번만 한다. 디코딩 직후·저장 직전에 정규화하고, 그 뒤로는 이미 정규화된 값을 다룬다. 매 비교마다 하지 않는다.
- 정규 형태를 함께 저장한다. 검색·중복 판정에 쓸
canonicalKey를 모델에 필드로 두면 조회마다 계산하지 않아도 된다. 인덱스도 걸 수 있다(P0 09장 Q3). - 대부분의 문자열은 이미 NFC다. Foundation은 변경이 필요 없으면 빠르게 반환하므로, 한국어·영어 위주 데이터에서는 실제 비용이 작다.
측정 없이 최적화하지 않는 것이 원칙이다 — 정규화를 빼서 생기는 버그가 아끼는 시간보다 훨씬 비싸다.
사용자가 입력한 이모지가 서버에서 "?"로 깨져서 돌아온다. 원인은?
거의 항상 어느 계층이 UTF-8 4바이트 문자를 못 받는 경우다. 정규화 문제가 아니다.
대표 원인은 MySQL의 utf8 문자셋이다. 이름과 달리 최대 3바이트만 저장한다. 이모지는 UTF-8로 4바이트라 잘리거나 ?로 치환된다. 해결은 utf8mb4로 바꾸는 것이고, 컬럼·테이블·커넥션 문자셋을 모두 바꿔야 한다.
진단 순서는 이렇다.
- 클라이언트가 보낸 바이트를 확인한다. 프록시나 로그로 요청 본문의 실제 바이트를 본다. 여기서 이미 깨졌으면 인코딩 설정 문제다.
- 서버가 받은 직후 값을 로그로 찍는다. 여기까지 정상이면 저장 계층이다.
- DB에 저장된 바이트를 본다.
SELECT HEX(nickname)으로 확인한다.
이 진단은 계층별로 잘라 들어가는 20장 Q7의 좁히기 절차와 같은 구조다 — 한 번에 가장 큰 구간을 배제하는 질문부터 던진다.
Q3. String.Index는 왜 정수가 아니며 언제 무효화되는가?
같은 위치의 정수 오프셋이 뷰마다 전부 다르기 때문이다. UTF-8·UTF-16·스칼라·Character 네 뷰가 같은 지점을 가리켜도 오프셋이 제각각이라, Swift는 넷이 동일한 불투명 String.Index 타입을 공유하게 하고 뷰 간 변환은 실패 가능한 이니셜라이저로만 허용한다(SE-0180). 그래서 idx + 1 같은 정수 산술은 아예 컴파일되지 않는다. 그리고 인덱스는 만들어질 당시 버퍼 구조에 묶여 있어, 문자열을 재할당하면 트랩이 나거나 — 더 나쁘게는 — 범위 안에 우연히 남아 조용히 엉뚱한 값을 낸다.
CS 원리
가변 길이 인코딩에서 "n번째 위치"는 정의되지 않은 개념이다. 무엇의 n번째인지를 말해야 의미가 생긴다.
고정 길이 배열이라면 base + n × size로 위치를 계산할 수 있다. 그런데 UTF-8은 문자마다 1~4바이트라, "5번째 글자"의 바이트 오프셋은 앞의 네 글자를 실제로 읽어봐야 안다. 이것이 문자열 인덱스가 정수일 수 없는 근본 이유다.
설계 선택지는 둘이었다.
- 뷰마다 다른 인덱스 타입 — 타입 안전하지만 뷰를 옮길 때마다 변환 코드가 폭증한다.
- 하나의 불투명 인덱스를 공유 — Swift의 선택. 인덱스는 "이 문자열의 이 지점"이라는 불투명한 표식이고, 뷰는 그것을 각자 해석한다.
두 번째 선택의 대가가 수명 문제다. 인덱스가 버퍼 안의 위치를 담고 있으므로, 버퍼가 바뀌면 그 표식의 의미도 바뀐다. 이는 C++ 이터레이터 무효화나 데이터베이스 커서 무효화와 정확히 같은 구조의 문제다 — 컨테이너를 수정하면 그 안을 가리키던 참조가 못 쓰게 된다.
iOS에서는
정수 산술이 정의돼 있지 않아 컴파일 단계에서 막힌다. 이동은 전용 메서드로만 한다.
let s = "가👍🏽나"
// ❌ 컴파일 에러 — String.Index 에는 + 가 없다
// let bad = s.startIndex + 1
// ✅ 이동은 index(after:) / index(_:offsetBy:)
let i1 = s.index(after: s.startIndex)
let i2 = s.index(s.startIndex, offsetBy: 2)
s[i2] // "나"
// 뷰 간 변환은 실패 가능하다 — 경계가 맞지 않으면 nil
let u8 = s.utf8.index(s.utf8.startIndex, offsetBy: 3)
let asChar = u8.samePosition(in: s) // Optional<String.Index>
무효화 규칙은 "인덱스가 가리키던 위치보다 앞쪽이 바뀌었는가"로 갈린다.
var text = "abcdef"
let idx = text.index(text.startIndex, offsetBy: 5) // "f" 위치
// (1) 뒤에만 덧붙이면 — 앞쪽이 그대로라 인덱스가 살아 있다
text.append("gh")
text[idx] // "f" ← 유효
// (2) 앞쪽을 건드리면 — 의미가 달라진다
text.insert("Z", at: text.startIndex)
text[idx] // "e" ← 조용히 밀렸다. 트랩도 안 난다
// (3) 재할당으로 범위를 넘으면 — 트랩
text = "ab"
// text[idx] // Fatal error: String index is out of bounds
// (4) 다른 문자열에 재사용 — 범위 안이면 트랩 없이 틀린 값
let other = "ZYXWVUT"
other[idx] // "U" ← 의미 없는 결과
(2)와 (4)가 위험하다. 크래시가 나지 않아 잘못된 값이 그대로 흘러간다.
실험 · 도구
무효화 네 가지 경우를 직접 재현해 보면 규칙이 몸에 남는다.
func probe(_ label: String, _ body: () -> String?) {
print("\(label): \(body().map { "\"\($0)\"" } ?? "nil")")
}
var t = "abcdef"
let idx = t.index(t.startIndex, offsetBy: 5)
t.append("gh"); probe("append 후", { String(t[idx]) }) // "f"
t.insert("Z", at: t.startIndex); probe("앞에 삽입", { String(t[idx]) }) // "e" ← 밀림
t = "ab"
// 여기서 t[idx] 를 부르면 트랩 — 방어하려면 범위를 먼저 확인한다
probe("재할당 후", { idx < t.endIndex ? String(t[idx]) : nil }) // nil
안전하게 쓰려면 인덱스를 오래 보관하지 않는 것이 원칙이고, 보관해야 한다면 오프셋으로 바꿔 둔다.
// 인덱스 대신 "시작에서 몇 번째"를 보관하면 재계산할 수 있다
let offset = s.distance(from: s.startIndex, to: i2) // Int
// 나중에 복원
let restored = s.index(s.startIndex, offsetBy: offset)
프로젝트 적용
- 인덱스를 프로퍼티나 컬렉션에 저장하지 않는다. 계산해서 바로 쓰고 버린다. 저장이 필요하면
distance(from:to:)로 오프셋을 보관한다. - 문자열을 수정한 뒤에는 인덱스를 다시 계산한다. 특히
insert·removeSubrange뒤. - 다른 문자열에서 얻은 인덱스를 재사용하지 않는다. 값이 맞아 보여도 의미가 없다.
NSRange와 섞을 때는 반드시 변환 API를 쓴다.Range(nsRange, in: string). UTF-16 좌표를 Character 감각으로 다루면 어긋난다(Q7).- 범위 검사를 습관화한다. 외부에서 받은 인덱스는
idx < endIndex를 먼저 확인한다.
"String.Index는 그냥 Int라 산술이 된다"는 틀렸다 — 정수 산술은 정의돼 있지 않아 컴파일이 안 된다. "한 문자열에서 얻은 인덱스를 다른 문자열에 써도 값이 범위 안이면 안전하다"도 틀렸다 — 트랩이 나거나 조용히 틀린 위치를 가리킨다. "문자열을 수정하면 항상 인덱스가 무효화된다"도 부정확하다 — append만으로는 앞쪽 인덱스가 그대로 유효하다. 이 "가끔 살아 있음"이 오히려 버그를 늦게 발견하게 만든다.
책에 책갈피를 끼워 뒀다고 하자.
책갈피는 "몇 쪽"이라고 적혀 있지 않다. 그냥 그 자리에 꽂혀 있을 뿐이다. 왜냐하면 이 책은 쪽마다 글자 수가 달라서, "다섯 번째 글자"가 몇 번째 쪽인지 미리 알 수 없기 때문이다. 세어봐야 안다.
이제 책을 고쳐 보자.
뒤에 새 장을 덧붙이면 책갈피는 여전히 원래 자리에 있다. 앞쪽이 안 바뀌었으니까.
그런데 앞에 한 장을 끼워 넣으면 책갈피가 가리키던 내용이 한 칸 밀린다. 책갈피는 그대로인데 내용이 옮겨간 것이다. 이때가 무섭다 — 책갈피를 펼치면 아무 문제 없어 보이는 다른 문장이 나오기 때문이다.
책을 통째로 새 책으로 바꾸면? 책갈피가 책 끝을 넘어가서 "그런 쪽 없음" 하고 멈춘다. 이건 차라리 낫다. 바로 알 수 있으니까.
꼬리 질문
텍스트 뷰에서 커서 위치를 저장했다가 복원하려면 무엇을 저장해야 하는가?
인덱스가 아니라 오프셋이고, 그 오프셋의 단위를 명시해야 한다.
선택지는 셋이다.
- Character 오프셋 — 사용자 감각과 일치한다. 복원 비용은 O(n).
- UTF-16 오프셋 —
UITextView·NSRange계열과 그대로 맞는다. UIKit과 섞을 때 변환이 없어 편하다. - UTF-8 오프셋 — 저장·전송에 유리하고 O(1)로 접근되지만, UI 계층과 맞추려면 변환이 필요하다.
UIKit 텍스트 뷰를 쓴다면 UTF-16 기준이 실용적이다. 다만 텍스트가 그 사이에 바뀌었다면 오프셋도 의미가 없다는 점은 같다 — 편집이 있었다면 복원이 아니라 재계산해야 한다.
// 저장
let utf16Offset = text.distance(from: text.startIndex, to: cursor) // Character 기준
// 또는 UIKit 좌표계로
let nsOffset = text.utf16.distance(from: text.utf16.startIndex,
to: cursor.samePosition(in: text.utf16)!)
// 복원 — 반드시 범위를 확인한다
let safe = min(savedOffset, text.count)
let restored = text.index(text.startIndex, offsetBy: safe)문자열에서 특정 부분을 반복해서 잘라내는 코드가 느리다. 무엇을 의심해야 하는가?
index(_:offsetBy:)를 루프 안에서 매번 처음부터 부르고 있는지를 본다. 이 메서드는 시작 지점부터 하나씩 세어 이동하므로 O(거리)다.
// ❌ 매 반복이 처음부터 센다 — 전체가 O(n²)
for i in 0..<text.count {
let idx = text.index(text.startIndex, offsetBy: i)
process(text[idx])
}
// ✅ 인덱스를 이어서 전진시킨다 — O(n)
var idx = text.startIndex
while idx < text.endIndex {
process(text[idx])
idx = text.index(after: idx)
}
// ✅ 더 낫게 — 그냥 순회한다
for ch in text { process(ch) }두 번째로 볼 것은 어느 뷰에서 세고 있는가다. 바이트 단위 작업이라면 utf8 뷰에서 다루는 편이 훨씬 싸다 — 그래프임 경계 계산이 없기 때문이다.
세 번째는 불필요한 String 생성이다. 잘라낸 조각을 바로 String(...)으로 바꾸면 매번 복사가 일어난다. 잠깐 쓰고 버릴 것이라면 Substring으로 두는 편이 낫다 — 다만 오래 들고 있으면 안 된다(Q5).
서버에서 받은 "잘라낼 위치" 숫자를 그대로 쓰면 왜 위험한가?
서버가 어느 단위로 센 숫자인지를 모르기 때문이다. 대부분의 백엔드는 바이트나 코드포인트로 세고, Swift는 그래프임으로 센다.
구체적으로 이런 일이 생긴다.
- 서버가 "20번째 문자에서 자르라"고 했는데 그게 코드포인트 20이면, 가족 이모지가 들어간 문자열에서 Swift의 20번째 Character와 전혀 다른 지점이다.
- 바이트 오프셋을 Character 오프셋으로 착각하면 그래프임 중간을 가리켜 이모지가 조각난다.
- 최악은 그 위치가 유효한 경계가 아닐 때다. UTF-8 뷰에서 인덱스를 만들어 Character 뷰로 변환하면
nil이 나온다.
대응은 단위를 프로토콜에 명시하는 것이다. 필드 이름에 단위를 넣으면 실수가 줄어든다 — offset이 아니라 utf8Offset, highlightRangeUTF16처럼.
// 서버가 UTF-8 바이트 오프셋을 준다는 계약이라면
guard let u8 = text.utf8.index(text.utf8.startIndex,
offsetBy: serverUTF8Offset,
limitedBy: text.utf8.endIndex),
let idx = u8.samePosition(in: text) else {
// 경계가 아니거나 범위를 벗어남 — 서버 계약 위반으로 처리
return
}Q4. 문자열은 메모리에 어떻게 담기는가?
String 값 자체는 항상 16바이트 구조체이고, 64비트 플랫폼에서 UTF-8 15바이트 이하면 힙 할당 없이 그 안에 인라인으로 담긴다(스몰 스트링 최적화). 넘으면 힙 버퍼를 할당하고 참조 카운트가 붙는다. 경계는 글자 수가 아니라 바이트 수라, 완성형 한글은 글자당 3바이트이므로 정확히 5글자가 경계다. 실측 벤치에서 15바이트에서 16바이트가 되는 순간 약 6배 느려진다(0.1166초 → 0.7192초). 그리고 Swift 5부터 네이티브 String의 저장 형식은 UTF-16이 아니라 UTF-8이다.
CS 원리
이것은 소형 객체 최적화(small object optimization)의 문자열 판본이다. 아이디어는 단순하다 — 포인터를 담을 자리에 값을 직접 담는다.
힙 할당은 비싸다. 할당자 호출, 캐시 미스, 그리고 해제 시점 관리(참조 카운팅)까지 붙는다. 그런데 실제 프로그램에서 다루는 문자열의 상당수는 아주 짧다 — 키 이름, 식별자, 짧은 레이블. 이 경우 포인터 8바이트를 저장하려고 힙을 쓰느니, 그 8바이트 자리에 문자를 직접 넣는 편이 낫다.
대가는 분기다. 모든 접근이 "지금 인라인인가 힙인가"를 판별해야 한다. 그래서 값 안에 판별 비트를 둔다. Swift는 _countAndFlagsBits와 포인터 워드의 여분 비트를 이 용도로 쓴다 — P0 05장 Q6에서 본 extra inhabitant를 태그로 재활용하는 것과 같은 기법이다.
여기서 불연속적 성능 절벽이 생긴다. 대부분의 자료구조는 크기가 커질수록 비용이 매끄럽게 늘지만, 이 최적화는 경계에서 한 번에 튄다. 14바이트와 15바이트는 거의 같고, 15바이트와 16바이트는 몇 배 차이가 난다.
iOS에서는
Swift 5부터 네이티브 String은 검증된 UTF-8 바이트를 단일 연속 버퍼에 담는다. 예전에는 UTF-16 기반이었고, 이 전환이 상당한 성능 변화를 만들었다.
MemoryLayout<String>.size // 16 ← 값 자체는 항상 16바이트
// 인라인 여부를 직접 볼 수는 없지만 성능으로 드러난다
let short = "abcdefghijklmno" // 15바이트 — 인라인
let long = "abcdefghijklmnop" // 16바이트 — 힙
// 한글은 글자당 3바이트라 경계가 훨씬 이르다
"가나다라마".utf8.count // 15 ← 인라인 경계 정확히
"가나다라마바".utf8.count // 18 ← 이미 힙
실무에 미치는 영향은 비용의 성격이 달라진다는 점이다.
- 인라인 문자열의 복사는 값 복사다. 참조 카운트 증가가 없다.
- 힙 문자열의 복사는 참조 카운트 증가이고, 수정하면 CoW로 실제 복사가 일어난다(P0 05장 Q3).
그래서 짧은 키를 대량으로 다루는 코드에서 차이가 난다 — 딕셔너리 키, 열거형 원시값, 로그 카테고리 같은 것들.
// 짧은 키는 인라인이라 사전 조회에서 ARC 트래픽이 없다
var counts: [String: Int] = [:]
for event in events { counts[event.name, default: 0] += 1 }
// event.name 이 15바이트 이하면 힙 접근 없이 비교·해싱된다
// 긴 키는 매 조회마다 힙 버퍼를 따라간다
// → 키를 짧게 유지하거나, 열거형·정수 ID로 바꾸는 것이 유리할 수 있다
실험 · 도구
경계를 직접 재 보면 절벽이 그대로 보인다.
import Foundation
func bench(_ label: String, _ seed: String, iterations: Int = 2_000_000) {
let clock = ContinuousClock()
let elapsed = clock.measure {
for _ in 0..<iterations {
var s = seed
s.append("!") // 수정 → 인라인이면 값 조작, 힙이면 CoW 확인
_ = s
}
}
print("\(label) (\(seed.utf8.count)바이트): \(elapsed)")
}
bench("14바이트", String(repeating: "a", count: 14))
bench("15바이트", String(repeating: "a", count: 15)) // 여기까지 인라인
bench("16바이트", String(repeating: "a", count: 16)) // 여기서 튄다
bench("한글 5자", "가나다라마") // 15바이트 — 인라인
bench("한글 6자", "가나다라마바") // 18바이트 — 힙
힙 할당 자체를 세고 싶다면 Instruments의 Allocations 계측기로 malloc 호출 수를 비교한다(P0 15장 Q6). 인라인 구간에서는 문자열당 할당이 0이다.
# 커맨드라인에서 대략 확인 — 힙 할당 수를 세어 비교한다
# (macOS 실행 파일 기준. iOS 는 Instruments Allocations 를 쓴다)
MallocStackLogging=1 ./bench-short # 15바이트 버전
MallocStackLogging=1 ./bench-long # 16바이트 버전
# leaks --outputGraph 로 스냅숏을 떠 할당 건수를 비교하면 차이가 드러난다
프로젝트 적용
- 뜨거운 경로의 키를 짧게 유지한다. 15바이트(한글 5자) 이하면 힙 할당이 사라진다. 로그 카테고리·이벤트 이름처럼 초당 수천 번 만들어지는 문자열에 특히 효과가 있다.
- 다만 가독성을 희생하지 않는다. 이름을 줄여 얻는 이득은 측정 가능한 뜨거운 경로에서만 의미가 있다. 대부분의 코드에서는 무시할 만하다.
- 식별자에는 문자열 대신 열거형을 고려한다. 인라인 여부를 신경 쓸 필요가 아예 없어지고, 오타도 컴파일 단계에서 잡힌다.
- "짧으면 공짜"라고 가정하지 않는다. 멀티바이트 문자가 섞이면 경계가 훨씬 이르다.
- 벤치마크는 릴리스 빌드로 한다. 디버그 빌드에서는 최적화가 없어 이 차이가 묻힌다(P0 12장 Q4).
"Swift String은 내부적으로 UTF-16이다"는 Swift 5부터 틀렸다 — 저장은 UTF-8이고 .utf16은 요청할 때 계산되는 뷰다. "모든 짧은 문자열은 힙 할당이 없다"도 부정확하다 — 정확히 15바이트 이하일 때만이고, 멀티바이트 문자가 섞이면 훨씬 적은 글자 수에서 넘어간다. "16바이트 구조체니까 문자열 하나가 16바이트다"도 틀렸다 — 힙으로 넘어가면 값 16바이트 + 힙 버퍼다. "경계를 넘으면 조금 느려진다"도 과소평가다 — 실측에서 약 6배였다.
주머니와 창고를 생각해 보자.
작은 물건은 주머니에 그냥 넣는다. 꺼낼 때 바로 손이 닿으니 빠르다. 그런데 주머니는 크기가 정해져 있다.
물건이 주머니보다 크면 창고에 맡기고, 주머니에는 보관증만 넣는다. 이제 물건을 꺼내려면 보관증을 들고 창고까지 가야 한다. 게다가 누가 같이 쓰고 있는지 장부에 적어 두는 일까지 생긴다.
여기서 재미있는 건 딱 한 칸 차이로 갈린다는 점이다. 주머니에 15칸이 있으면 15칸짜리 물건은 들어가고, 16칸짜리는 안 들어간다. 한 칸 늘었을 뿐인데 창고 왕복이 통째로 생긴다.
그리고 칸을 세는 기준이 물건 개수가 아니라 부피다. 한글 한 글자는 영어 한 글자보다 세 배 부피를 쓴다. 그래서 영어는 15글자까지 주머니에 들어가는데, 한글은 5글자면 벌써 꽉 찬다.
꼬리 질문
딕셔너리 키를 String 대신 enum으로 바꾸면 실제로 얼마나 유리한가?
세 가지가 동시에 좋아지지만, 측정 없이 바꿀 만큼 큰 차이는 아닌 경우가 많다.
(1) 해싱 비용. String 해싱은 바이트를 전부 훑어 SipHash를 돌린다. 원시값 없는 enum은 정수 태그 하나를 해싱한다.
(2) 비교 비용. 문자열 비교는 정준 등가 판정을 포함할 수 있다(Q2). 열거형은 태그 비교다.
(3) 오타 안전. 이게 사실 가장 큰 실익이다. "usename" 같은 오타가 컴파일 단계에서 잡힌다.
다만 주의할 점이 있다 — 원시값이 String인 열거형은 (1)(2)의 이득이 없다. rawValue로 딕셔너리 키를 만들면 결국 문자열 해싱이다.
// ❌ 이득이 없다 — 결국 String 키
enum Key: String { case userName, sessionID }
dict[Key.userName.rawValue] = v
// ✅ 열거형 자체를 키로 — Hashable 자동 합성, 태그 해싱
enum Key: Hashable { case userName, sessionID }
var dict: [Key: String] = [:]
dict[.userName] = v실무 판단: 오타 안전 때문에 바꾸는 것은 거의 항상 옳고, 성능 때문에 바꾸는 것은 프로파일로 그 경로가 뜨겁다고 확인된 뒤에 한다.
서버에서 받은 JSON의 문자열은 인라인 최적화를 받는가?
길이가 15바이트 이하라면 받는다. 출처와 무관하게 String 값이 만들어지는 시점의 바이트 수로 결정된다.
다만 디코딩 경로에서 더 큰 비용이 따로 있다.
- 중간 버퍼.
JSONDecoder는 원본Data에서 문자열을 잘라 새String을 만든다. 짧으면 인라인이라 할당이 없지만, 긴 문자열은 항목마다 힙 할당이다. - 키 문자열. 코딩 키는 대개 짧아 인라인에 들어간다. 그래서 키보다 값 쪽이 비용을 지배한다.
- UTF-8 검증. Swift
String은 검증된 UTF-8만 담으므로 디코딩 시 검사가 일어난다.
대량 목록을 디코딩하는 화면이 느리다면, 문자열 인라인 여부보다 불필요한 필드를 디코딩하고 있는지를 먼저 본다 — 안 쓰는 큰 텍스트 필드를 모델에서 빼는 것이 훨씬 크게 효과가 난다(31장에서 다룬다).
NSString과 Swift String을 섞어 쓰면 어떤 비용이 생기는가?
브리징 비용이 생기고, 방향에 따라 성격이 다르다.
Swift → NSString. 네이티브 UTF-8 버퍼를 NSString이 요구하는 형태로 넘겨야 한다. UTF-16 접근이 오면 변환이 필요할 수 있다.
NSString → Swift. 받은 객체가 실제로 어떤 구현인지에 따라 다르다. 그대로 감싸고 필요할 때만 변환하는 지연 브리징이 일어날 수 있어, 이후 접근마다 Objective-C 호출이 섞일 수 있다.
실무에서 이게 드러나는 전형이 NSRange를 쓰는 API다. NSRegularExpression·NSAttributedString은 UTF-16 좌표계를 쓰므로, Swift 문자열과 오갈 때마다 좌표 변환이 붙는다(Q7).
// 반복 루프 안에서 브리징하지 않는다
let ns = text as NSString // ✅ 한 번만 변환
for m in matches {
let piece = ns.substring(with: m.range) // 같은 NSString 재사용
}
// ❌ 매 반복 브리징
for m in matches {
let piece = (text as NSString).substring(with: m.range)
}이 브리징 계층 자체는 34장에서 자세히 다룬다.
Q5. Substring을 오래 들고 있으면 왜 메모리가 안 줄어드는가?
슬라이스는 "복사한 조각"이 아니라 "원본 버퍼에 대한 창(view)"이기 때문이다. Substring과 ArraySlice는 원본 스토리지 전체에 대한 참조를 그대로 들고 있어, 슬라이스가 살아 있는 한 원본 버퍼도 해제되지 않는다. Apple 문서도 이를 "메모리 누수처럼 보이게 만들 수 있다"고 명시적으로 경고한다. 실측으로 6천만 개(약 480MB) 배열에서 1개짜리 슬라이스만 남겨도 원본 지역 변수가 스코프를 벗어난 뒤에도 RSS가 480MB대에 머물렀다. 오래 보관할 값은 String(sub) / Array(slice)로 명시 변환해 떼어내야 한다.
CS 원리
슬라이스 설계는 복사 회피라는 명확한 목적에서 나온다. 문자열을 파싱할 때 토큰마다 새 문자열을 할당하면 비용이 크므로, 원본을 그대로 두고 범위만 가리키는 값을 만든다. 이것 자체는 훌륭한 설계다.
대가는 수명이 전염된다는 것이다. 슬라이스는 원본 버퍼를 강하게 참조하므로, 슬라이스의 수명이 곧 원본 버퍼의 수명이 된다. 여기서 비대칭이 생긴다.
| 슬라이스가 잡는 메모리 | 사용자가 기대하는 것 | |
|---|---|---|
| 큰 원본 · 큰 슬라이스 | 원본 전체 | 문제 없음 — 어차피 다 쓴다 |
| 큰 원본 · 작은 슬라이스 | 원본 전체 | 작은 조각만큼 ← 어긋난다 |
두 번째 행이 사고가 나는 자리다. 그리고 이 문제는 도구로 잡기 어렵다 — 메모리 누수 탐지기(Leaks)는 "도달할 수 없는데 해제되지 않은" 메모리를 찾는데, 여기서 원본 버퍼는 슬라이스를 통해 정당하게 도달 가능하다. 누수가 아니라 방치(abandoned)다.
두 번째 함정은 인덱스 기준점이다. 슬라이스의 인덱스는 0부터 시작한다는 보장이 없고, 원본에서의 오프셋을 그대로 물려받는다. array[2...4]의 startIndex는 2다.
Substring·ArraySlice·Data 슬라이스에 모두 적용된다.iOS에서는
Swift는 이 위험을 타입으로 알려준다. 슬라이스 타입이 원본과 다르다는 사실 자체가 "이건 오래 들고 있지 말라"는 신호다.
let huge = String(repeating: "x", count: 50_000_000) // 약 50MB
// ❌ Substring 을 그대로 보관 — 원본 50MB 가 계속 살아 있다
struct Cache { var token: Substring }
let cache = Cache(token: huge.prefix(3))
// huge 가 스코프를 벗어나도 50MB 가 해제되지 않는다
// ✅ 명시 변환으로 원본에서 떼어낸다
struct Cache2 { var token: String }
let cache2 = Cache2(token: String(huge.prefix(3)))
// 3바이트만 남고 원본은 해제된다
인덱스 기준점 문제도 실무에서 자주 터진다.
let arr = [10, 20, 30, 40, 50, 60]
let slice = arr[2...4]
slice.startIndex // 2 ← 0 이 아니다
slice.count // 3
// slice[0] // ❌ Fatal error: Index out of bounds
slice[slice.startIndex] // 30 ✅
slice.first // 30 ✅ (Optional)
Array(slice)[0] // 30 ✅ (복사 후에는 0 부터)
이 함정은 함수 경계에서 특히 위험하다. [Int]를 받는 함수에 ArraySlice를 Array(...)로 감싸 넘기면 안전하지만, 제네릭으로 Collection을 받으면 인덱스가 0부터라고 가정한 코드가 조용히 깨진다.
실험 · 도구
슬라이스가 원본을 붙잡는 것을 메모리로 직접 확인해 보자.
import Foundation
func rssMB() -> Double {
var info = mach_task_basic_info()
var count = mach_msg_type_number_t(MemoryLayout<mach_task_basic_info>.size) / 4
let kr = withUnsafeMutablePointer(to: &info) {
$0.withMemoryRebound(to: integer_t.self, capacity: Int(count)) {
task_info(mach_task_self_, task_flavor_t(MACH_TASK_BASIC_INFO), $0, &count)
}
}
return kr == KERN_SUCCESS ? Double(info.resident_size) / 1_048_576 : -1
}
print("시작: \(rssMB()) MB")
var kept: ArraySlice<Int>? = nil
do {
let big = Array(0..<60_000_000) // 약 480MB
print("할당 후: \(rssMB()) MB")
kept = big[0..<1] // 1개짜리 슬라이스만 남긴다
} // big 이 스코프를 벗어난다
print("스코프 밖: \(rssMB()) MB") // ← 여전히 480MB 대
kept = Array(kept!)[0..<1] // 복사로 떼어낸다
print("복사 후: \(rssMB()) MB") // ← 크게 떨어진다
Instruments로는 Allocations에서 "Persistent Bytes"를 본다. Leaks에는 안 잡힌다 — 도달 가능한 메모리이기 때문이다. 이것이 "누수"와 "방치"를 구분해야 하는 이유다.
프로젝트 적용
- 슬라이스를 저장 프로퍼티로 두지 않는다. 함수 안에서 잠깐 쓰고 버리는 용도로만 쓴다.
- 반환 타입을
Substring으로 노출하지 않는다. 호출자가 오래 보관할 수 있으므로, 공개 API는String으로 변환해 돌려준다. - 파싱 결과를 모델에 담을 때 반드시 변환한다. 큰 응답 본문을 파싱해 작은 필드만 모델에 넣는 경우가 정확히 이 패턴이다 — 응답 전체가 살아남는다.
- 슬라이스 인덱스를 정수 리터럴로 접근하지 않는다.
slice.first·slice[slice.startIndex]를 쓴다. - 메모리가 안 줄어드는 현상을 만나면 Leaks가 아니라 Allocations를 본다. 누수 탐지기에는 안 잡히는 종류다.
"Substring·ArraySlice는 필요한 부분만 복사해서 가볍다"는 정확히 반대다 — 원본 스토리지 전체를 붙잡는다. "메모리가 안 줄면 누수다"도 틀렸다 — 이건 도달 가능한 방치 메모리라 Leaks에 안 잡힌다. "슬라이스의 인덱스는 0부터 시작한다"도 틀렸다 — 원본 오프셋을 물려받는다. "prefix·dropFirst는 새 문자열을 만든다"도 틀렸다 — 슬라이스를 반환한다.
도서관에서 책 한 권을 빌려 왔다고 하자. 그 책에서 한 문장만 필요하다.
방법이 둘이다. 하나는 그 문장을 공책에 옮겨 적고 책을 반납하는 것. 다른 하나는 그 쪽에 포스트잇을 붙여 두고 책을 그대로 갖고 있는 것이다.
슬라이스는 포스트잇이다. 가볍고 빠르다 — 옮겨 적을 필요가 없으니까. 그런데 책을 반납할 수가 없다. 포스트잇이 그 책에 붙어 있으니까.
문장 하나 때문에 책 전체를 계속 갖고 있는 셈이다. 책이 백과사전이라면 방이 꽉 찬다.
더 헷갈리는 건 사서가 이걸 "분실"로 처리하지 않는다는 점이다. 책이 어디 있는지 분명히 알고 있으니까. 그냥 계속 대출 중일 뿐이다. 그래서 "없어진 책 찾기" 도구로는 이 문제가 안 잡힌다.
오래 쓸 거라면 공책에 옮겨 적고 책을 돌려주는 것이 맞다.
꼬리 질문
큰 JSON 응답에서 작은 필드만 모델에 담았는데 메모리가 계속 는다. 어디를 봐야 하는가?
파싱 과정에서 슬라이스가 모델로 새어 들어갔는지를 먼저 본다. 전형적인 경로가 셋이다.
(1) 직접 파싱하며 Substring을 모델에 넣은 경우. 응답 본문 전체가 모델 수만큼 살아남는다.
// ❌ 응답 전체가 항목마다 붙잡힌다
struct Item { let name: Substring }
let items = body.split(separator: "\n").map { Item(name: $0.prefix(20)) }
// ✅ 명시 변환
struct Item2 { let name: String }
let items2 = body.split(separator: "\n").map { Item2(name: String($0.prefix(20))) }(2) Data 슬라이스를 보관한 경우. Data도 같은 성질이라 data[0..<100]이 원본 전체를 붙잡는다. Data(data[0..<100])으로 떼어낸다.
(3) 클로저가 응답을 캡처한 경우. 완료 핸들러나 lazy 컬렉션이 원본을 캡처하면 같은 결과가 된다.
진단은 Allocations의 Persistent Bytes를 보고, 큰 버퍼의 참조 보유자를 역추적한다. JSONDecoder를 쓴다면 이 문제는 대개 안 생긴다 — 디코딩이 새 String을 만들기 때문이다.
그렇다면 Substring은 언제 쓰는 것이 맞는가?
원본보다 먼저 죽는 것이 확실한 지역 계산에 쓴다. 그게 설계 의도다.
적합한 경우는 이렇다.
- 파싱 중간 단계. 토큰을 잘라 검사하고 버린다. 여기서 매번
String을 만들면 할당이 폭증한다. - 함수 매개변수. 호출자가 소유한 문자열의 일부를 읽기만 할 때. 함수가 끝나면 슬라이스도 사라진다.
- 순회 중 임시 비교.
line.hasPrefix(...)같은 검사.
부적합한 경우는 저장이다 — 프로퍼티, 컬렉션 원소, 클로저 캡처, 반환값.
실용적인 규칙 하나: 함수 밖으로 나가는 순간 String으로 바꾼다. 안에서는 슬라이스로 싸게 다루고, 경계에서 복사한다. 이건 P0 17장의 "경계에서 변환한다"는 설계 원칙의 한 사례다.
// 안에서는 슬라이스로 싸게, 나갈 때 변환
func extractToken(from line: String) -> String {
let piece = line.drop(while: { $0 != "=" }).dropFirst() // Substring
return String(piece) // 경계에서 복사
}Leaks에 안 잡히는 메모리 증가는 어떻게 추적하는가?
"누수"와 "방치"를 구분하는 것이 첫 단계다.
| 누수(leak) | 방치(abandoned) | |
|---|---|---|
| 정의 | 도달할 수 없는데 해제 안 됨 | 도달 가능한데 더 안 씀 |
| 원인 | 순환 참조, 해제 누락 | 캐시 무한 증가, 슬라이스 보유, 전역 누적 |
| Leaks 계측기 | 잡힌다 | 안 잡힌다 |
| 도구 | Leaks, 메모리 그래프 디버거 | Allocations의 Generation 비교 |
방치 메모리 추적의 표준 기법이 세대 비교(generation analysis)다.
- 안정 상태에서 Mark Generation을 찍는다.
- 의심되는 동작(화면 진입 → 이탈)을 여러 번 반복한다.
- 같은 상태로 돌아온 뒤 다시 Mark.
- 세대 사이에 남아 있는 할당을 본다. 반복했는데 계속 쌓이면 그게 방치다.
메모리 그래프 디버거로는 "누가 이걸 붙잡고 있나"를 역추적한다. 큰 버퍼를 선택하면 들어오는 참조가 보이고, 슬라이스가 원인이면 슬라이스를 보유한 객체가 거기 나타난다.
이 구분과 추적은 32장에서 본격적으로 다룬다.
Q6. URL·쿼리에서 문자열은 어디서 깨지는가?
인코딩을 적용하는 계층을 잘못 잡을 때 깨진다. RFC 3986은 URI 문자를 reserved(구조를 만드는 문자 — :/?#[]@와 !$&'()*+,;=)와 unreserved(영숫자와 -._~)로 나누고, 나머지 바이트를 %+16진수로 인코딩한다. 함정은 .urlQueryAllowed를 값 하나에 그대로 쓰는 것이다 — 이 집합은 &와 =를 허용하므로 값 안에 든 &가 파라미터 구분자로 오해된다. 그리고 +는 RFC 3986상 유효한 문자라 인코딩되지 않는데, application/x-www-form-urlencoded 관례에서는 +가 공백이라 서버가 오독한다.
CS 원리
이 문제의 본질은 같은 문자가 계층마다 다른 의미를 갖는다는 것이다.
URL은 여러 층으로 이루어진 문법이다. 스킴, 호스트, 경로, 쿼리, 프래그먼트가 : / ? #로 구분되고, 쿼리 안은 다시 &와 =로 나뉜다. 그래서 "구분자로 쓰이는 문자"와 "데이터인 문자"를 구별해야 하는데, 둘의 생김새가 같다.
해결 방식이 이스케이프다. 데이터 안에 구분자와 같은 문자가 있으면 다른 형태로 바꿔 적는다. 이 구조는 SQL 인젝션, 셸 인젝션, HTML 인젝션과 정확히 같은 문제다 — 데이터와 코드가 같은 채널을 지나는 것.
여기서 계층 규칙이 나온다.
- 인코딩은 가장 안쪽 값에 먼저, 조립은 바깥에서. 값을 인코딩한 뒤
&로 이어 붙여야 한다. 이어 붙인 뒤 통째로 인코딩하면 구분자까지 인코딩된다. - 한 번만 인코딩한다. 이미 인코딩된 문자열을 다시 인코딩하면
%가%25가 되어 이중 인코딩이 된다. - 계층마다 허용 집합이 다르다. 경로에서 허용되는 문자와 쿼리 값에서 허용되는 문자가 다르다.
iOS에서는
URLComponents가 조립을 대신해 주므로, 직접 문자열을 이어 붙이지 않는 것이 첫 번째 원칙이다.
// ✅ 값을 그대로 넣으면 URLComponents 가 계층에 맞게 인코딩한다
var c = URLComponents(string: "https://api.example.com/search")!
c.queryItems = [
URLQueryItem(name: "q", value: "A&B=C"),
URLQueryItem(name: "page", value: "2"),
]
c.url?.absoluteString
// https://api.example.com/search?q=A%26B%3DC&page=2
// ❌ 직접 조립 + urlQueryAllowed — 값 안의 & 와 = 가 살아남는다
let bad = "A&B=C".addingPercentEncoding(withAllowedCharacters: .urlQueryAllowed)!
// "A&B=C" ← 아무것도 인코딩되지 않았다
+ 문제는 별도로 다뤄야 한다. RFC 3986상 +는 sub-delims라 구조적으로 유효해서 인코딩되지 않는다. 그런데 폼 인코딩 관례에서는 +가 공백이므로, 서버가 리터럴 +를 공백으로 오독할 수 있다.
extension CharacterSet {
/// 쿼리 "값 하나"에 안전한 집합 — 구분자와 + 를 제외한다
static let urlQueryValueAllowed: CharacterSet = {
var s = CharacterSet.urlQueryAllowed
s.remove(charactersIn: "+&=?")
return s
}()
}
"C++ 강좌".addingPercentEncoding(withAllowedCharacters: .urlQueryValueAllowed)!
// "C%2B%2B%20%EA%B0%95%EC%A2%8C" ← + 가 %2B 로 보존된다
그리고 최근 SDK의 동작 변화를 알아야 한다. URL(string:)은 공백·한글 같은 무효 문자를 만나면 예전처럼 nil을 돌려주는 대신 자동으로 퍼센트 인코딩한다(RFC 3986 파서로 통일, iOS 17 전후). 관대해진 것처럼 보이지만 부작용이 있다.
// 이미 인코딩된 %3A 와 무효 문자 [ 가 섞이면
URL(string: "https://x.com/a%3Ab[c]")?.absoluteString
// %3A 의 % 까지 다시 인코딩되어 %253A 가 되는 이중 인코딩이 실측으로 재현된다
// (2026-03 시점에도 추적 중인 미해결 이슈)
// ✅ 외부 입력은 엄격 검증으로 받는다
URL(string: rawInput, encodingInvalidCharacters: false) // 무효하면 nil
실험 · 도구
어떤 집합이 무엇을 통과시키는지 직접 찍어 보면 감이 잡힌다.
let sample = "A&B=C+D 한글/?#"
for (name, set) in [("urlQueryAllowed", CharacterSet.urlQueryAllowed),
("urlPathAllowed", .urlPathAllowed),
("urlHostAllowed", .urlHostAllowed),
("alphanumerics", .alphanumerics)] {
let enc = sample.addingPercentEncoding(withAllowedCharacters: set) ?? "-"
print(String(format: "%-18@ %@", name as NSString, enc as NSString))
}
// urlQueryAllowed A&B=C+D%20%ED%95%9C%EA%B8%80/?%23
// ← & = + / ? 가 전부 통과했다. 값 하나에 쓰면 안 되는 이유다
// alphanumerics A%26B%3DC%2BD%20%ED%95%9C%EA%B8%80%2F%3F%23
// ← 가장 안전하지만 - . _ ~ 까지 인코딩해 URL 이 길어진다
서버가 무엇을 받았는지 확인하는 것이 최종 검증이다.
# 실제로 나가는 요청을 그대로 본다
curl -v -G "https://httpbin.org/get" \
--data-urlencode "q=A&B=C+D" 2>&1 | grep -E "^> GET"
# > GET /get?q=A%26B%3DC%2BD HTTP/2 ← 값이 통째로 인코딩됐는지 확인
# 이중 인코딩 여부는 %25 를 찾으면 된다
echo "https://x.com/a%253Ab" | grep -o '%25'
# %25 가 보이면 이미 인코딩된 문자열을 또 인코딩한 것이다
프로젝트 적용
- URL을 문자열 보간으로 만들지 않는다.
"\(base)?q=\(query)"는 거의 항상 버그다.URLComponents로 조립한다. - 완성된 URL을 다시 파싱하지 않는다. 재파싱은 이중 인코딩의 주된 원인이다. 필요하면
URLComponents를 계속 들고 다니며 수정한다. - 외부 입력은
encodingInvalidCharacters: false로 엄격 검증한다. 자동 인코딩에 기대면 무엇이 들어왔는지 모른 채 통과시킨다. - 서버의 폼 인코딩 관례를 확인한다.
+를 공백으로 읽는 서버라면 값에서+를 반드시%2B로 보낸다. - URL을 로그에 남길 때는 쿼리를 스크러빙한다. 검색어·토큰이 그대로 남는다(P1 23장 Q1).
".urlQueryAllowed를 쓰면 쿼리는 안전하다"가 가장 위험한 오해다 — 이 집합은 쿼리 문자열 전체용이라 &·=를 허용한다. 값 하나에 쓰면 값 안의 &가 구분자가 된다. "URL(string:)이 관대해졌으니 검증을 덜 해도 된다"도 정반대다 — 자동 인코딩은 이중 인코딩 버그를 만들 수 있고, 무엇이 들어왔는지 모르게 만든다. "퍼센트 인코딩은 한 번 하면 끝"도 틀렸다 — 어느 계층에서 하느냐가 결과를 바꾼다. "+는 안전한 문자다"도 맥락에 따라 틀리다.
편지 봉투에 주소를 쓰는데, 쉼표로 칸을 나누는 규칙이 있다고 하자. "서울, 강남구, 1번지" 이런 식이다.
그런데 어떤 사람의 이름이 "김, 철수"라면? 그대로 적으면 우체국은 "김"과 "철수"를 다른 칸으로 읽는다. 칸이 하나 늘어난 것이다.
그래서 이름 안의 쉼표는 다른 표시로 바꿔 적기로 한다. 이게 인코딩이다.
여기서 순서가 중요하다. 이름을 먼저 고쳐 쓰고, 그다음에 쉼표로 칸을 나눠야 한다. 다 적어 놓고 나서 통째로 고치면 칸을 나누는 쉼표까지 바뀌어 버린다.
또 하나. 이미 고쳐 쓴 걸 한 번 더 고치면 표시 자체가 또 바뀌어서 원래 이름으로 되돌릴 수 없게 된다. "한 번만" 이 규칙이다.
마지막으로 헷갈리는 게 하나 있다. 어떤 우체국은 "+" 기호를 빈칸으로 읽는 관습이 있다. 그래서 "C++"라고 적으면 "C "가 되어 버린다. 이런 지역 관습은 미리 물어봐야 안다.
꼬리 질문
서버는 한글 검색어를 제대로 받는데 일부 기기에서만 깨진다. 무엇을 의심해야 하는가?
정규화 형식(Q2)과 인코딩 시점이 겹친 문제일 가능성이 높다.
같은 "한글"이라도 NFC로 만든 문자열과 NFD로 만든 문자열은 퍼센트 인코딩 결과가 다르다 — 바이트가 다르기 때문이다.
let nfc = "가".precomposedStringWithCanonicalMapping
let nfd = "가".decomposedStringWithCanonicalMapping
nfc.addingPercentEncoding(withAllowedCharacters: .alphanumerics)!
// %EA%B0%80 (3바이트)
nfd.addingPercentEncoding(withAllowedCharacters: .alphanumerics)!
// %E1%84%80%E1%85%A1 (6바이트) ← 완전히 다른 URL 이 된다기기마다 갈리는 이유는 문자열의 출처가 다르기 때문이다. 키보드 입력, 클립보드 붙여넣기, 파일 이름에서 가져온 값(macOS/iOS 파일 시스템은 NFD 경향), 다른 앱에서 공유받은 값이 각각 다른 형식일 수 있다.
대응은 URL을 만들기 직전에 NFC로 정규화하는 것이다.
URLQueryItem(name: "q", value: rawQuery.precomposedStringWithCanonicalMapping)딥링크로 받은 URL의 쿼리 값을 그대로 화면에 표시하면 무엇이 위험한가?
두 가지가 겹친다 — 디코딩 계층 혼동과 신뢰 경계 침범이다.
(1) 이중 디코딩. 값을 이미 디코딩해 주는 API(URLComponents.queryItems)에서 꺼낸 뒤 또 removingPercentEncoding을 부르면, 사용자가 의도적으로 넣은 %2F 같은 문자가 실제 /가 되어 경로 조작에 쓰일 수 있다.
(2) 표시 계층의 인젝션. 값을 웹뷰에 넣으면 HTML/JS 인젝션이 되고, 파일 경로로 쓰면 경로 탈출이 된다. URL 인코딩을 풀었다고 해서 그 값이 안전해진 것은 아니다 — 인코딩은 전송 계층의 문제이고, 표시·사용 계층은 각자 다시 이스케이프해야 한다.
이건 P1 23장에서 본 심층 방어와 같은 구조다. 한 계층의 처리가 다른 계층의 안전을 보장하지 않는다.
// 딥링크 처리 — 화이트리스트로 받는다
guard let c = URLComponents(url: incoming, resolvingAgainstBaseURL: false),
let id = c.queryItems?.first(where: { $0.name == "itemID" })?.value,
id.allSatisfy(\.isHexDigit), id.count == 32 else {
return // 형식이 맞지 않으면 버린다
}
// ← 값을 "해석"하지 말고 "검증"한다URLComponents로 만든 URL이 서버에서 404가 난다. 어디를 보는가?
경로(path)와 쿼리(query)의 허용 집합이 다르다는 점을 먼저 확인한다.
가장 흔한 원인은 경로에 슬래시가 든 값을 넣은 경우다.
var c = URLComponents(string: "https://api.example.com")!
c.path = "/files/report 2026/Q3.pdf"
c.url?.absoluteString
// /files/report%202026/Q3.pdf ← 공백은 인코딩됐지만 / 는 그대로다
// 경로 구분자로 해석되어 서버가 다른 자원을 찾는다경로 세그먼트 하나에 /가 들어가야 한다면 직접 인코딩해야 한다.
let segment = "report 2026/Q3.pdf"
.addingPercentEncoding(withAllowedCharacters: .alphanumerics.union(CharacterSet(charactersIn: "-._~")))!
c.percentEncodedPath = "/files/" + segment
// /files/report%202026%2FQ3.pdf ← 세그먼트 하나로 전달된다그다음 확인 순서는 이렇다.
- 실제로 나간 바이트를 본다. 프록시나
curl -v로 요청 라인을 확인한다. 코드에서 만든 값과 다르면 중간 계층이 손댄 것이다. - 서버가 디코딩 후 무엇을 받았는지 확인한다. 서버 로그에 디코딩된 경로를 찍어 대조한다.
- 이중 인코딩 여부를
%25로 확인한다.
이 계층별 좁히기는 20장 Q7의 절차와 같다 — 가장 큰 구간을 먼저 배제한다.
Q7. 정규식은 유니코드를 어떻게 다루는가?
어느 단위 위에서 매칭하느냐가 엔진마다 다르다. Swift의 Regex는 String의 Character(확장 그래프임 클러스터) 인덱스 위에서 동작해 타입 있는 캡처와 유니코드 정확성을 준다. 반면 NSRegularExpression은 NSString 기반이라 NSRange = UTF-16 코드 유닛 위에서 동작한다. 실측으로 "가족👨👩👧👦 이모지"는 String.count가 7인데 (as NSString).length는 17이다 — 가족 이모지 하나가 코드 유닛 11개다. 그래서 match.range를 Character 감각으로 다루면 다른 위치를 가리킨다. 변환은 반드시 Range(nsRange, in:)로 한다.
CS 원리
정규식 엔진은 기호의 열(sequence of symbols) 위에서 오토마톤을 돌린다. 그래서 "기호가 무엇인가"를 먼저 정해야 하고, 이 선택이 엔진의 의미론 전체를 결정한다.
| 기호 단위 | .이 매칭하는 것 | 대표 사례 |
|---|---|---|
| 바이트 | 바이트 하나 | C 계열 저수준 엔진. 멀티바이트 문자를 쪼갠다 |
| UTF-16 코드 유닛 | 코드 유닛 하나 | NSRegularExpression. 이모지가 반쪽으로 잡힐 수 있다 |
| 유니코드 스칼라 | 스칼라 하나 | 결합 문자가 분리된다 |
| 그래프임 클러스터 | 사람이 보는 글자 하나 | Swift Regex의 기본 |
맨 아래를 고르면 사용자 직관과 일치하지만, 다른 시스템과 좌표를 주고받을 때 변환이 필요하다. 위쪽을 고르면 좌표가 단순하지만 문자를 쪼갤 위험이 생긴다.
실무에서 이 차이가 드러나는 전형이 "세 글자 이상" 같은 수량 검사다. .{3,}이 그래프임 기준이면 이모지 3개가 통과하고, UTF-16 기준이면 이모지 2개만으로도 통과한다(하나당 2코드유닛).
iOS에서는
Swift Regex는 String의 Character 인덱스 위에서 동작하므로, 결과 범위가 그대로 String에 쓸 수 있는 인덱스다. 변환이 없다.
let text = "가족👨👩👧👦 이모지"
text.count // 7
(text as NSString).length // 17 ← UTF-16 코드 유닛
// Swift Regex — 결과가 String.Index 범위다
if let m = text.firstMatch(of: /이모지/) {
text[m.range] // "이모지" ← 바로 쓸 수 있다
}
// NSRegularExpression — NSRange 를 반드시 변환한다
let re = try NSRegularExpression(pattern: "이모지")
let ns = text as NSString
if let m = re.firstMatch(in: text, range: NSRange(location: 0, length: ns.length)) {
// ❌ 위치 숫자를 그대로 쓰면 안 된다
// ✅ 변환
if let r = Range(m.range, in: text) { text[r] } // "이모지"
}
타입 있는 캡처가 Swift Regex의 또 다른 이점이다.
let line = "2026-08-15 ERROR payment_failed"
let pattern = /(?<date>\d{4}-\d{2}-\d{2}) (?<level>\w+) (?<code>\w+)/
if let m = line.firstMatch(of: pattern) {
m.date // Substring — 이름으로 접근, 컴파일 타임 검사
m.level
m.code
}
// NSRegularExpression 이라면 range(at: 1) 처럼 번호로 접근하고
// 번호가 틀려도 컴파일 단계에서 잡히지 않는다
다만 성능 기대는 신중해야 한다. Swift Regex의 이득은 주로 인덱스 변환 비용 제거와 타입 안전이지, 매칭 엔진 자체가 항상 더 빠른 것은 아니다.
실험 · 도구
두 좌표계의 차이를 직접 확인해 보자.
import Foundation
let samples = ["hello", "가족👨👩👧👦 이모지", "🇰🇷🇰🇷🇰🇷"]
for s in samples {
let ns = s as NSString
print(String(format: "%-20@ count=%2d utf16=%2d 차이=%2d",
s as NSString, s.count, ns.length, ns.length - s.count))
}
// hello count= 5 utf16= 5 차이= 0
// 가족👨👩👧👦 이모지 count= 7 utf16=17 차이=10
// 🇰🇷🇰🇷🇰🇷 count= 3 utf16=12 차이= 9
"세 글자 이상" 검사가 엔진마다 다르게 판정되는 것도 재현된다.
let three = "🇰🇷🇰🇷" // 사람이 보기엔 2글자
// Swift Regex — 그래프임 기준이라 통과하지 않는다
three.wholeMatch(of: /.{3,}/) != nil // false ✅ 의도대로
// NSRegularExpression — UTF-16 기준이라 8코드유닛이므로 통과한다
let re = try NSRegularExpression(pattern: "^.{3,}$")
let ns = three as NSString
re.firstMatch(in: three, range: NSRange(location: 0, length: ns.length)) != nil
// true ← 2글자인데 "세 글자 이상"을 통과했다
기존 코드가 어느 쪽을 쓰는지 훑어보려면 다음으로 찾는다.
# NSRange 를 직접 다루는 곳 — 변환 없이 쓰고 있으면 위험하다
grep -rn "NSRange(location:" --include=*.swift Sources/ | head -20
# 변환 API 를 쓰는지 확인
grep -rn "Range(.*, in:" --include=*.swift Sources/ | wc -l
# NSString 캐스팅 — 브리징이 루프 안에 있으면 비용이다 (Q4 꼬리질문)
grep -rn "as NSString" --include=*.swift Sources/
프로젝트 적용
- 새 코드는 Swift
Regex를 기본으로 쓴다. 좌표 변환이 없어 실수할 자리가 사라지고, 캡처가 타입으로 잡힌다. NSRange를 만나면 즉시 변환한다.Range(nsRange, in: string). 위치 숫자를 직접 계산하지 않는다.- 수량 검사의 단위를 명시한다. "3자 이상"이 그래프임인지 코드 유닛인지 정하고, 서버와도 맞춘다(Q1).
- 사용자 입력 검증에 정규식을 쓸 때는 이모지·결합 문자를 테스트에 넣는다. ASCII만으로 테스트하면 이 계층 차이가 절대 드러나지 않는다.
- 성능을 이유로 무조건 바꾸지 않는다. 이득은 주로 변환 비용과 타입 안전이다. 매칭이 병목이면 정규식을 쓰지 않는 방법부터 검토한다.
"Swift Regex가 무조건 더 빠르다"는 과장이다 — 이득은 인덱스 변환 비용 제거와 타입 안전이지 매칭 엔진 자체의 속도 우위가 아니다. "NSRange의 location은 글자 번호다"도 틀렸다 — UTF-16 코드 유닛 번호다. ".은 항상 글자 하나를 뜻한다"도 엔진에 따라 다르다. "정규식으로 검증했으니 안전하다"도 위험하다 — 검증 단위가 서버와 다르면 양쪽 판정이 갈린다.
글자를 세는 자(ruler)가 두 종류 있다고 하자.
하나는 사람이 보는 글자에 눈금이 있다. 가족 이모지 👨👩👧👦 위에는 눈금이 하나다.
다른 하나는 기계가 저장하는 조각에 눈금이 있다. 같은 이모지 위에 눈금이 열한 개다.
문제는 두 자를 섞어 쓸 때 생긴다. 한 자로 "여기서 여기까지"라고 표시해 놓고 다른 자로 그 숫자를 읽으면 전혀 다른 자리를 가리킨다.
그래서 규칙이 하나다 — 자를 바꿀 때는 반드시 변환표를 거친다. 눈금 숫자를 그냥 옮겨 적으면 안 된다.
또 하나 재미있는 게 있다. "세 글자 이상 쓰세요"라는 규칙을 만들었는데, 사람 자로 재면 태극기 세 개가 필요하고 기계 자로 재면 두 개만 써도 통과한다. 어느 자로 재는지 안 정하면 규칙이 규칙이 아니게 된다.
꼬리 질문
이메일·전화번호 검증 정규식이 일부 사용자에게서만 실패한다. 어디를 보는가?
세 가지를 순서대로 본다.
(1) 비ASCII 문자. 국제화 도메인(IDN)은 사용자@한국.kr 같은 형태가 가능하고, 전화번호에는 아라비아 인도 숫자(٠١٢…)가 들어올 수 있다. [0-9]는 이것들을 매칭하지 않지만 \d는 유니코드 모드에서 매칭할 수도 있다 — 엔진 설정에 달렸다.
(2) 보이지 않는 문자. 붙여넣기로 들어온 제로폭 공백이나 방향 제어 문자가 섞이면 정규식이 실패한다. 사용자는 화면상 아무 이상을 못 느낀다.
(3) 정규화 형식. NFD로 들어온 문자열은 결합 문자가 분리돼 있어 \w 같은 클래스와 다르게 매칭될 수 있다(Q2).
실무 대응은 검증 전에 정규화·정제하는 것이다.
func sanitized(_ raw: String) -> String {
raw.precomposedStringWithCanonicalMapping // NFC 통일
.trimmingCharacters(in: .whitespacesAndNewlines)
.filter { !$0.unicodeScalars.contains { $0.properties.isDefaultIgnorableCodePoint } }
}그리고 이메일은 정규식으로 완전 검증하지 않는 것이 원칙이다. RFC의 실제 문법은 정규식으로 다루기에 지나치게 복잡하다. 대략 검사 + 실제 발송 확인이 실무의 답이다.
사용자 입력을 정규식으로 처리할 때 성능·안전 위험은 없는가?
있다. 대표적인 것이 재앙적 백트래킹(catastrophic backtracking)이다.
중첩된 수량자가 있는 패턴((a+)+b 같은)은 입력이 조금만 길어져도 매칭 시도가 지수적으로 늘어난다. 사용자가 특정 입력을 넣어 앱을 멈추게 만들 수 있고, 이건 서비스 거부가 된다. 메인 스레드에서 돌면 20장에서 본 행(hang)이 된다.
방어는 셋이다.
- 사용자가 패턴을 제공하게 하지 않는다. 검색 기능에 정규식을 그대로 노출하면 위험하다.
- 입력 길이를 먼저 제한한다. 매칭 전에 길이 상한을 두면 최악의 경우가 한정된다.
- 메인 스레드에서 돌리지 않는다. 긴 텍스트 매칭은 백그라운드로 옮긴다(20장 Q5).
패턴 자체도 점검한다 — 중첩 수량자를 피하고, 가능하면 정규식이 아닌 방법(hasPrefix, split, 파서)을 쓴다. 정규식은 편리하지만 비용이 입력에 따라 폭발할 수 있는 도구다.
기존 NSRegularExpression 코드를 Swift Regex로 옮길 때 무엇이 달라지는가?
좌표계·캡처 접근·일부 문법이 달라진다.
| NSRegularExpression | Swift Regex | |
|---|---|---|
| 좌표 | UTF-16 NSRange | String.Index 범위 |
| 캡처 | range(at: 1) — 번호 | 튜플·이름 — 타입 검사됨 |
| 패턴 작성 | 문자열(이스케이프 이중) | 리터럴 /…/ 또는 RegexBuilder |
| 실패 시점 | 런타임(생성 시 throw) | 리터럴은 컴파일 타임 |
이전 작업의 순서는 이렇다.
- 변환 지점부터 고친다.
Range(_:in:)없이location을 직접 쓰는 코드가 있으면 그게 이미 버그다. - 패턴을 리터럴로 옮긴다. 문자열 이스케이프가 한 겹 줄어 가독성이 크게 오른다.
- 복잡한 패턴은 RegexBuilder로. 정규식 문법 대신 코드로 조립해 읽기 쉬워진다.
- 테스트에 이모지·한글·결합 문자를 넣는다. 좌표계가 바뀌었으므로 여기서 회귀가 드러난다.
주의할 점은 의미론이 미묘하게 다를 수 있다는 것이다. 그래프임 기준 매칭으로 바뀌면서 .이나 수량자의 판정이 달라지므로, 기존 테스트를 그대로 통과하는지 반드시 확인한다.