iOS & Swift

[Swift 기초 #6] Swift 문자열은 왜 text[0]이 안 될까? 그래핌 클러스터(Grapheme Cluster) 총정리

다른 언어를 쓰다 Swift로 넘어온 개발자가 가장 어리둥절해하는 지점 중 하나가 문자열입니다. text[0]이 안 되고, text[2..<5]도 안 되고, 인덱스라는 별도 타입을 만들어서 text.index(text.startIndex, offsetBy: 2)라고 써야 해요.…

이석우iOS Developer8분 읽기
[Swift 기초 #6] Swift 문자열은 왜 text[0]이 안 될까? 그래핌 클러스터(Grapheme Cluster) 총정리 대표 이미지

다른 언어를 쓰다 Swift로 넘어온 개발자가 가장 어리둥절해하는 지점 중 하나가 문자열입니다. text[0]이 안 되고, text[2..<5]도 안 되고, 인덱스라는 별도 타입을 만들어서 text.index(text.startIndex, offsetBy: 2)라고 써야 해요. 파이썬이면 한 글자로 끝날 일인데요.

Swift 팀이 API를 못 만들어서 이렇게 된 게 아닙니다. 오히려 반대예요. “문자열의 n번째 글자”라는 개념 자체가 사실은 간단하지 않다는 걸 정직하게 드러낸 결과입니다. 다른 언어들은 이 복잡성을 숨기고 가끔 틀린 답을 주는 쪽을 택했습니다. Swift는 불편하더라도 항상 맞는 답을 주는 쪽을 택했어요. Swift 기초 시리즈 6편이자 마지막 편, 문자열이 어려운 진짜 이유를 파봅니다.

한 글자처럼 보이는 이모지의 속은 코드 포인트 여러 개입니다
한 글자처럼 보이는 이모지의 속은 코드 포인트 여러 개입니다

“몇 글자예요?“가 어려운 이유 — 유니코드의 세계

출발점은 이 질문입니다. 문자열 “café”는 몇 글자일까요. 당연히 4글자 같지만, 컴퓨터 입장에서는 두 가지 답이 가능합니다. é를 저장하는 방법이 두 가지거든요. 완성형 코드 포인트 하나(U+00E9)로 저장할 수도 있고 e(U+0065)와 결합 악센트(U+0301) 두 개를 겹쳐서 저장할 수도 있습니다. 화면에는 똑같이 é로 보이지만 내부 코드 포인트 수는 1개와 2개로 다릅니다.

한글은 이 문제를 더 실감 나게 보여줍니다. “각”은 완성형 음절 하나(U+AC01)일 수도, ㄱ+ㅏ+ㄱ 자모 세 개의 조합일 수도 있어요. macOS 파일명을 다뤄본 분이라면 NFD(Normalization Form D — 글자를 자모 단위로 분해해 저장하는 유니코드 정규화 방식)로 저장된 한글 파일명이 다른 시스템에서 자모가 풀어져 보이던 경험이 있을 겁니다. 같은 “글자”가 코드 포인트 1개일 수도 3개일 수도 있는 세계인 거죠.

여기에 이모지가 기름을 붓습니다. 가족 이모지 👨‍👩‍👧‍👦는 사람 이모지 4개를 제로 폭 결합자(ZWJ, Zero Width Joiner — 이모지들을 한 글자처럼 묶어주는 보이지 않는 문자)로 이어붙인 것이고 코드 포인트가 7개입니다. UTF-16 코드 유닛으로는 11개고요. 하지만 사람 눈에는 한 글자입니다.

그래서 유니코드 표준에는 “사람이 인식하는 한 글자” 단위가 따로 정의되어 있습니다. 확장 그래핌 클러스터(extended grapheme cluster)라고 부릅니다. é도, 조합형 “각”도, 가족 이모지도 각각 그래핌 클러스터 하나예요.

언어들의 선택 — 틀린 답이 빠른가, 맞는 답이 느린가

이제 각 언어가 “n번째 글자”를 어떻게 처리하는지 비교해볼 수 있습니다.

자바스크립트의 "👨‍👩‍👧‍👦".length는 11입니다. UTF-16 코드 유닛 수를 세거든요. 자바도 같습니다. 파이썬3의 len은 7을 줍니다. 코드 포인트 수를 세니까요. 전부 “한 글자”라는 인간의 직관과 어긋납니다. 이 언어들에서 이모지가 섞인 문자열을 순진하게 자르면 이모지 한가운데가 잘려 깨진 문자가 나올 수 있어요.

Swift의 "👨‍👩‍👧‍👦".count는 1입니다. Swift의 Character 타입이 코드 포인트가 아니라 그래핌 클러스터를 의미하기 때문입니다. é가 어느 방식으로 저장됐든 count는 같고 비교(==)도 정규화를 고려해 참이 나옵니다. “사람이 보는 글자” 기준으로 항상 맞는 답을 주는 거예요.

그런데 이 정확함에는 비용이 있습니다. 그래핌 클러스터는 가변 길이라, n번째 글자를 찾으려면 앞에서부터 경계를 하나씩 판정하며 세어야 합니다. 그래서 Swift 문자열에는 정수 인덱스가 없는 겁니다. text[7]이 O(1)에 되는 것처럼 보이는 API를 제공하면, 실제로는 O(n)인 비용이 숨겨지니까요. text[i]를 루프 안에서 쓰는 순간 O(n²)이 되는데, 그걸 언어가 조장하지 않겠다는 결정입니다. String.Index라는 불투명한 타입은 “이 위치는 세어서 찾은 결과”라는 사실을 타입으로 드러낸 장치예요. 철학 1편에서 본 원칙, 비용을 숨기지 않는다가 문자열에서도 반복되는 겁니다.

같은 문자열을 자로 재도 언어마다 답이 다릅니다
같은 문자열을 자로 재도 언어마다 답이 다릅니다

실무 도구 상자 — 인덱스 없이 문자열 다루기

원리를 알았으니 실전입니다. 정수 인덱스가 없어도 대부분의 작업은 더 안전한 도구로 해결됩니다.

앞뒤 자르기는 prefix와 suffix. text.prefix(10)은 앞 10글자(그래핌 기준)를 안전하게 돌려줍니다. 범위를 넘어도 크래시 없이 있는 만큼만 줘요. 미리보기 텍스트 만들 때 substring 인덱스 계산할 필요가 없습니다.

검색은 range(of:)와 firstIndex(of:). 위치가 필요한 경우는 대부분 “특정 내용의 위치”입니다. 결과로 받은 Range나 Index를 그대로 서브스크립트에 넣으면 되니 정수 변환이 필요 없어요.

분리는 split. text.split(separator: ",")가 수동 인덱스 순회를 대체합니다.

진짜 n번째가 필요하면 Array로. 문자 단위 알고리즘 문제처럼 임의 접근이 정말 필요하면 Array(text)로 변환하는 게 정석입니다. 변환 비용 O(n)을 한 번 내면 이후 접근은 전부 O(1)이에요. 루프마다 index(offsetBy:)를 부르는 것보다 훨씬 낫습니다.

바이트가 필요하면 뷰를 명시. 네트워크 전송량이나 DB 컬럼 제한처럼 “글자 수”가 아니라 “바이트 수”가 필요한 순간이 있습니다. Swift는 text.utf8.count, text.utf16.count, text.unicodeScalars.count로 어떤 단위를 세는지 코드에 명시하게 합니다. 참고로 UserDefaults나 옛 API에서 만나는 NSString의 length는 UTF-16 기준이라 Swift의 count와 다를 수 있다는 것도 알아두면 디버깅이 빨라집니다.

한 가지 함정도 짚어둡니다. 서로 다른 문자열의 인덱스는 호환되지 않습니다. a에서 얻은 String.Index를 b에 쓰면 크래시하거나 엉뚱한 결과가 나와요. 인덱스는 “그 문자열의 그 시점” 전용입니다. 문자열을 수정했다면 이전 인덱스도 무효라고 생각하는 게 안전합니다.

정수 인덱스 없이도 문자열 작업 대부분이 해결됩니다
정수 인덱스 없이도 문자열 작업 대부분이 해결됩니다

한국어 개발자에게 특히 중요한 이유

한글 서비스를 만들다 보면 이 지식이 관념이 아니라 실무가 되는 순간들이 있습니다.

닉네임 글자 수 제한이 대표적입니다. “10자 이내”를 검증할 때 서버(다른 언어)와 클라이언트(Swift)가 다른 단위로 세면, 클라이언트에서 통과한 닉네임이 서버에서 거절되는 버그가 납니다. 서버가 UTF-8 바이트로 제한한다면 한글은 글자당 3바이트라 10글자가 30바이트인 것도 계산에 넣어야 하고요. “무엇을 세는가”를 팀 차원에서 합의하는 게 먼저고 Swift 쪽에서는 count(그래핌)인지 utf8.count(바이트)인지를 의도적으로 골라 쓰면 됩니다.

자모 분해 이슈도 있습니다. 위에서 언급한 NFD 문제로, 외부 시스템에서 온 한글 문자열은 눈으로 같아 보여도 코드 포인트가 다를 수 있습니다. Swift의 == 비교는 정규화를 고려하므로 대부분 그냥 동작하지만 해시 기반 딕셔너리 키로 외부 문자열을 쓰거나 다른 언어로 넘길 때는 NFC(Normalization Form C — 자모를 완성형 글자로 합쳐 저장하는 정규화 방식)로의 정규화(precomposedStringWithCanonicalMapping)를 명시적으로 해주는 게 안전합니다.

검색·자동완성에서 초성 검색 같은 기능을 만들 때는 반대로 그래핌을 자모 단위로 분해해야 하는데, 이때 unicodeScalars 뷰가 출발점이 됩니다. 뷰 개념을 알고 있으면 이런 요구사항이 “특별한 라이브러리가 필요한 일”이 아니라 “다른 뷰를 순회하는 일”로 정리돼요.

정리

  • “n번째 글자”가 어려운 건 유니코드에서 한 글자(그래핌 클러스터)가 가변 개수의 코드 포인트로 이뤄지기 때문입니다. é, 조합형 한글, ZWJ 이모지가 대표 사례입니다.
  • 다른 언어의 length는 코드 유닛이나 코드 포인트를 세서 직관과 어긋날 수 있고, Swift의 count는 그래핌을 세서 항상 사람 기준의 답을 줍니다.
  • 그 대가로 임의 접근이 O(n)이 됐고, Swift는 이 비용을 숨기지 않으려고 정수 인덱스를 제거했습니다.
  • 실무에서는 prefix/suffix, range(of:), split로 대부분 해결되고, 임의 접근은 Array 변환, 바이트 계산은 utf8 뷰를 명시적으로 씁니다.
  • 한글 서비스에서는 글자 수 검증 단위 합의와 NFC/NFD 정규화가 실전 포인트입니다.

이것으로 기초 시리즈 6편이 끝났습니다. 다음부터는 중급 시리즈입니다. 첫 주제는 Swift 메모리 관리의 심장, ARC(Automatic Reference Counting, 자동 참조 계수)와 weak·unowned 선택 기준이에요. 클로저 편에서 예고했던 순환 참조 이야기의 본편입니다.

함께 보면 좋은 글