문자소 클러스터
고친 사람 github-actions[bot]
문자소 클러스터는 사람이 글자 하나로 보는 덩이를 프로그램이 알아보게 해 줍니다. 화면의 글자 하나가 저장될 때는 번호 여러 개로 나뉘는 일이 흔합니다. 글자 수를 세거나 문자열을 자를 때 이 덩이를 한 단위로 다루면 글자가 중간에서 쪼개지지 않습니다.
쉽고 빠른 이해
화면에 보이는 글자 하나를 프로그램 쪽에서 부르는 이름입니다. 유니코드는 글자와 글자 부품마다 번호를 붙입니다. 프로그램은 이 번호를 저장합니다. 가족 이모지 하나는 화면에 한 글자지만 저장된 번호는 다섯 개입니다.
프로그램은 문자열을 번호의 줄로 다룹니다. 번호 단위로 세면 글자 수가 사람이 센 것보다 많게 나옵니다. 번호 단위로 자르면 글자 하나가 반으로 갈라져 깨진 모양이 남습니다.
덩이는 이렇게 찾습니다.
- 번호마다 붙은 성질을 읽습니다
- 이웃한 두 번호 사이마다 끊을지 말지를 정해진 규칙으로 정합니다
- 끊은 곳과 끊은 곳 사이가 글자 하나입니다
대가도 있습니다. 덩이를 찾으려면 앞에서부터 차례로 훑어야 해서 n 번째 글자로 바로 건너뛸 수 없습니다. 규칙은 유니코드 판이 오를 때마다 고쳐져서 프로그램마다 센 값이 갈리기도 합니다.
사람이 안 보는 값에는 쓰지 않습니다. 저장 크기나 전송 크기는 바이트로 잽니다.
상세
카페 영수증을 떠올려 봅니다. 「아메리카노」 밑에 「샷 추가」와 「시럽 추가」가 한 줄씩 찍혀 영수증에는 줄이 셋입니다. 그래도 손님이 받는 잔은 하나입니다.
영수증의 줄 하나가 저장된 번호 하나이고, 손님이 받는 잔 하나가 문자소 클러스터입니다. 프로그램은 영수증의 줄을 세고 사람은 잔을 셉니다.
번호 몇 개가 어떻게 글자 하나로 묶이는지를 é · 한 · 태극기 · 가족 이모지로 따라가 봅니다. 먼저 번호와 글자가 왜 어긋나는지 봅니다. 이어서 덩이의 경계를 가르는 규칙을 봅니다. 뒤의 소절은 같은 글자를 세는 단위 넷을 견줍니다. 마지막으로 백엔드 코드에서 이 덩이로 세야 하는 작업을 가립니다.
번호와 글자가 어긋나는 까닭
유니코드는 세상의 글자에 번호를 하나씩 붙이는 표준입니다. 이 번호를 코드 포인트라고 부르고 U+ 뒤에 십육진수로 적습니다. 프로그램은 문자열을 이 번호의 줄로 저장합니다.
대부분의 글자는 번호 하나로 끝납니다. A 는 U+0041 하나입니다. 그런데 유니코드는 글자마다 통째로 번호를 주지 않고 부품에 번호를 주기도 합니다. 글자와 부호의 조합이 끝없이 많아서 조합마다 번호를 만들 수 없기 때문입니다.
부품으로 적은 글자는 번호 여럿이 모여 화면에 글자 하나로 그려집니다. e 뒤에 강세 부호 U+0301 을 이으면 화면에는 é 한 글자가 나옵니다. 앞 글자에 얹히는 이런 부호 번호를 결합 문자라고 부릅니다.
번호 여럿이 글자 하나로 보일 때, 그 번호 묶음이 문자소 클러스터입니다. 문자소(grapheme)는 글자 체계에서 뜻을 가르는 가장 작은 글자 단위를 가리키는 언어학 낱말입니다. 클러스터는 덩이라는 뜻이라, 문자소 클러스터는 글자 하나를 이루는 번호 덩이가 됩니다. 소리 나는 대로 그래핌 클러스터라고도 부릅니다.
한 덩이로 묶이는 번호들
번호 여럿이 한 덩이가 되는 흔한 경우는 넷입니다. 결합 문자 · 한글 낱자 · 국기 · 이어 붙인 이모지 순서로 봅니다.
첫째는 앞에서 본 결합 문자입니다. 바탕 글자 뒤에 부호 번호가 하나 이상 붙습니다. 부호가 몇 개 붙든 바탕 글자와 한 덩이입니다.
둘째는 한글을 낱자로 나눠 적은 경우입니다. 한 은 번호 하나 U+D55C 로 적을 수 있습니다. 첫소리 ᄒ(U+1112) · 가운뎃소리 ᅡ(U+1161) · 끝소리 ᆫ(U+11AB) 세 번호로 적을 수도 있습니다. 음절을 이루는 이런 낱자 번호를 한글 자모라고 합니다. 이어진 낱자들은 한 음절로 합쳐져 한 덩이가 됩니다.
셋째는 국기입니다. 유니코드는 국기마다 번호를 따로 두지 않습니다. 대신 알파벳 A부터 Z에 하나씩 대응하는 번호 스물여섯 개를 둡니다. 이 번호 둘로 나라 코드를 적습니다. 이 번호들을 지역 표시 기호라고 부릅니다. K 에 대응하는 U+1F1F0 과 R 에 대응하는 U+1F1F7 을 이으면 태극기 하나가 그려집니다.
넷째는 이모지를 이어 붙인 경우입니다. 제로 폭 결합자(ZWJ, Zero Width Joiner) U+200D 는 화면에 아무것도 그리지 않는 번호입니다. 이모지 두 개 사이에 이 번호를 끼우면 두 그림을 하나로 합쳐 그리라는 뜻이 됩니다. 남자 U+1F468 · 여자 U+1F469 · 여자아이 U+1F467 사이에 ZWJ 를 하나씩 끼우면 번호 다섯이 가족 이모지 한 글자가 됩니다.
아래 그림은 é · 한 · 태극기를 이어 쓴 문자열 하나입니다. 저장된 번호는 일곱 개이고 덩이는 셋입니다.
flowchart TD
subgraph C1["덩이 1 · 화면의 é"]
A1["U+0065 e"]
A2["U+0301 강세 부호"]
end
subgraph C2["덩이 2 · 화면의 한"]
B1["U+1112 ᄒ"]
B2["U+1161 ᅡ"]
B3["U+11AB ᆫ"]
end
subgraph C3["덩이 3 · 화면의 태극기"]
D1["U+1F1F0 지역 표시 K"]
D2["U+1F1F7 지역 표시 R"]
end
C1 --> C2 --> C3
상자 하나가 사람이 세는 글자 하나입니다. 화살표는 문자열 안에서 덩이가 놓인 순서입니다. 번호 단위로 세면 7, 덩이 단위로 세면 3이 나옵니다.
경계를 가르는 규칙
덩이를 찾는 일은 경계를 찾는 일입니다. 이웃한 두 번호 사이에서 「여기서 끊는가」를 정하는 법을 봅니다.
판정에 쓰려고 유니코드는 번호마다 성질 값을 하나씩 붙여 둡니다. 결합 문자인가, ZWJ 인가, 지역 표시 기호인가 하는 갈래입니다. 대부분의 글자는 이 가운데 어디에도 들지 않습니다. 이 성질의 정식 이름은 문자소 클러스터 경계 속성(Grapheme_Cluster_Break)입니다.
표에 나오는 제어 문자를 먼저 풉니다. 제어 문자는 글자를 그리지 않고 기기에 동작을 시키는 번호입니다. 줄바꿈에 쓰는 CR(Carriage Return, 캐리지 리턴)과 LF(Line Feed, 라인 피드)가 대표입니다. 윈도우는 줄바꿈 하나를 CR 과 LF 두 번호로 적습니다.
판정은 두 번호 사이마다 한 번씩 합니다. 앞 번호와 뒤 번호의 성질 값을 보고 아래 규칙을 위에서부터 맞춰 봅니다. 처음 맞는 규칙이 답입니다. 마지막 10번은 무엇에나 맞으므로, 위에서 하나도 안 맞으면 끊습니다.
| 순서 | 앞 번호 | 뒤 번호 | 판정 |
|---|---|---|---|
| 1 | CR | LF | 안 끊음 |
| 2 | 제어 문자 · CR · LF | 무엇이든 | 끊음 |
| 3 | 무엇이든 | 제어 문자 · CR · LF | 끊음 |
| 4 | 한글 첫소리 | 첫소리 · 가운뎃소리 | 안 끊음 |
| 5 | 한글 가운뎃소리 | 가운뎃소리 · 끝소리 | 안 끊음 |
| 6 | 한글 끝소리 | 끝소리 | 안 끊음 |
| 7 | 무엇이든 | 결합 문자 · ZWJ | 안 끊음 |
| 8 | 이모지 뒤에 온 ZWJ | 이모지 | 안 끊음 |
| 9 | 짝이 아직 안 찬 지역 표시 기호 | 지역 표시 기호 | 안 끊음 |
| 10 | 무엇이든 | 무엇이든 | 끊음 |
표는 자주 만나는 규칙만 추렸습니다. 4~6번은 한글 낱자가 첫소리 → 가운뎃소리 → 끝소리 순서로 이어지는 동안 끊지 않는다는 뜻입니다. 끝소리 뒤에 첫소리가 오면 10번에 걸려 끊깁니다. 거기서 새 음절이 시작됩니다.
1번 규칙 때문에 줄바꿈 CR LF 는 번호 둘이지만 한 덩이입니다. 2·3번 규칙은 제어 문자를 늘 혼자 서게 합니다. 그래서 줄바꿈 바로 뒤에 결합 문자가 오면 그 부호는 얹힐 바탕 글자 없이 혼자 한 덩이가 됩니다.
9번 규칙은 지역 표시 기호를 앞에서부터 둘씩 짝짓습니다. 지역 표시 기호 넷이 이어지면 국기 둘이 됩니다. 셋이 이어지면 마지막 하나가 짝 없이 남아 국기가 아닌 다른 모양으로 그려집니다.
부호 가운데에는 앞 글자에 얹히지 않고 옆에 붙어 자기 폭을 따로 차지하는 것도 있습니다. 힌디어를 적는 데바나가리 문자의 모음 기호 일부가 그렇습니다. 화면에서는 바탕 글자와 붙어 한 글자로 읽힙니다.
이런 부호를 어떻게 다루느냐로 정의가 둘로 갈립니다. 예전 정의인 레거시 문자소 클러스터(legacy grapheme cluster)는 이 부호를 바탕 글자에서 떼어 냈습니다. 지금 기본으로 쓰는 확장 문자소 클러스터(extended grapheme cluster)는 이 부호까지 바탕 글자와 묶습니다. 새로 짜는 코드는 확장 쪽을 씁니다.
새 이모지 조합이 생기면 성질 값과 규칙도 함께 고쳐집니다. 그래서 유니코드 판이 다른 두 프로그램은 같은 문자열을 다르게 나눌 수 있습니다.
같은 글자를 세는 네 단위
문자열의 「길이」는 무엇을 세느냐에 따라 값이 넷으로 갈립니다. 가족 이모지 한 글자를 네 단위로 세어 봅니다.
번호를 메모리나 파일에 담으려면 바이트로 바꿔 적어야 합니다. 그 방식을 인코딩이라 합니다. 흔히 UTF-8 과 UTF-16 을 씁니다. UTF 는 Unicode Transformation Format(유니코드 변환 형식)의 줄임말입니다.
UTF-16 은 번호를 16비트 조각으로 적습니다. 이 조각을 코드 유닛이라 부릅니다. U+FFFF 까지의 번호는 조각 하나, 그보다 큰 번호는 조각 둘이 됩니다.
UTF-8 은 번호를 바이트 단위로 적습니다. 번호가 클수록 바이트가 늘어 하나에서 넷까지 씁니다. U+0800 부터 U+FFFF 까지는 3바이트, U+FFFF 를 넘는 번호는 4바이트입니다.
| 세는 단위 | 가족 이모지 하나 | 이 값을 쓰는 곳 |
|---|---|---|
| 문자소 클러스터 | 1 | 사람이 세는 글자 수 |
| 코드 포인트 | 5 | 파이썬의 len |
| UTF-16 코드 유닛 | 8 | 자바의 String.length() · 자바스크립트의 length |
| UTF-8 바이트 | 18 | 저장 크기 · 네트워크로 보내는 크기 |
8과 18은 이렇게 나옵니다. 이모지 셋은 번호가 U+FFFF 를 넘으므로 하나마다 코드 유닛 둘, 바이트 넷입니다. ZWJ 둘은 번호가 U+200D 라서 하나마다 코드 유닛 하나, 바이트 셋입니다. 그래서 코드 유닛은 3×2+2 = 8, 바이트는 3×4+2×3 = 18 입니다.
같은 값을 자바스크립트 코드로 보면 이렇습니다. \u{...} 는 번호로 글자를 적는 표기라 이모지를 그리지 않고도 적을 수 있습니다. 첫 줄의 \u{200D} 가 ZWJ 입니다. 오른쪽 주석이 그 줄이 내는 값입니다.
const zwj = "\u{200D}";
const s = "\u{1F468}" + zwj
+ "\u{1F469}" + zwj
+ "\u{1F467}";
s.length; // 8
[...s].length; // 5
new TextEncoder()
.encode(s).length; // 18
const seg = new Intl.Segmenter(
"ko", { granularity: "grapheme" });
[...seg.segment(s)].length; // 1
length 는 UTF-16 코드 유닛을 셉니다. [...s] 는 문자열을 코드 포인트 하나씩 펼치므로 5가 나옵니다. TextEncoder 는 문자열을 UTF-8 바이트로 바꿔 줍니다.
코드 끝의 const seg = … 와 [...seg.segment(s)].length 가 문자소 클러스터를 셉니다. Intl.Segmenter 는 자바스크립트 표준 라이브러리의 텍스트 분할 도구입니다. granularity 를 "grapheme" 으로 주면 앞 소절의 규칙대로 덩이를 나눠 줍니다.
문자소 클러스터로 세야 하는 작업
백엔드 코드가 문자열을 다루는 일은 둘로 갈립니다. 기준은 하나, 그 값을 사람이 보느냐입니다.
첫째는 글자 수 제한입니다. 닉네임을 열 자로 막을 때 코드 유닛으로 세면 가족 이모지 하나가 여덟 자를 먹습니다. 사용자는 한 글자를 넣고도 너무 길다는 답을 받습니다. 앱은 덩이로 세고 서버는 코드 유닛으로 세면, 앱이 통과시킨 값을 서버가 거절하는 일도 생깁니다.
둘째는 자르기입니다. 미리보기를 몇 글자에서 자르고 말줄임표를 붙일 때, 코드 포인트로 자르면 덩이 한가운데가 잘립니다. 가족 이모지를 첫 ZWJ 앞에서 자르면 남자 이모지 하나만 남습니다. 태극기를 반으로 자르면 짝 잃은 지역 표시 기호 하나가 남습니다. 오류 없이 다른 글자가 되므로 자를 때는 덩이 경계에서 자릅니다.
저장 크기는 다릅니다. 데이터베이스 칼럼 크기와 요청 본문 크기는 바이트로 잽니다. 결합 문자는 바탕 글자 하나에 개수 제한 없이 붙을 수 있어서 덩이 하나가 번호 수백 개일 수도 있습니다. 그래서 글자 수 제한을 덩이로 두더라도 바이트 상한을 함께 둡니다.
덩이로 세는 데는 대가가 있습니다. 경계는 앞뒤 번호를 봐야 정해지므로 n 번째 덩이를 찾으려면 앞에서부터 훑어야 합니다. 번호마다 성질 값을 찾을 표도 있어야 합니다. 서버 라이브러리와 앱이 쓰는 유니코드 판이 다르면 새 이모지 조합 하나를 한쪽은 한 글자, 다른 쪽은 여러 글자로 셀 수 있습니다.
문자소 클러스터와 헷갈리는 이웃
글리프는 글꼴이 화면에 그리는 모양 하나입니다. 덩이 하나가 글리프 하나로 그려지는 일이 많지만 늘 그렇지는 않습니다. f 와 i 를 모양 하나로 붙여 그리는 합자는 덩이 둘이 글리프 하나가 됩니다. 덩이는 번호를 묶는 규칙이 정하고 글리프는 글꼴이 정합니다.
유니코드 정규화와도 다릅니다. 정규화는 é 를 번호 하나로 적은 미리 합성된 문자와 번호 둘로 적은 꼴을 한 꼴로 맞춥니다. 번호 수는 바뀌어도 덩이 수는 바뀌지 않습니다.
이름에 「클러스터」가 들어가지만 비슷한 데이터끼리 무리 짓는 군집화와는 관계가 없습니다. 서버 여러 대를 하나로 묶는 클러스터와도 다른 말입니다.
관련 항목
문자소 클러스터를 이루는 번호
결합 문자 · 결합 문자 시퀀스 · 한글 자모 · 제로 폭 결합자 · 지역 표시 기호 · 변이 선택자 · 이모지 수정자 · 이모지 ZWJ 시퀀스 · 제어 문자
문자소 클러스터 경계를 정하는 표준과 속성
Unicode · 유니코드 텍스트 분할 · 유니코드 문자 데이터베이스 · 일반 범주 · 단어 경계 · 문장 경계
문자열 길이를 세는 다른 단위
코드 포인트 · 코드 유닛 · 바이트 · 옥텟 · 대리 쌍
문자소 클러스터를 바이트로 적는 인코딩
문자 인코딩 · UTF-8 · UTF-16 · UTF-32
문자소 클러스터를 나누는 라이브러리와 API
Intl.Segmenter · ICU · BreakIterator · JavaScript
문자소 클러스터와 헷갈리는 이웃
글리프 · 합자 · 미리 합성된 문자 · 유니코드 정규화 · 군집화 · 클러스터
덩이를 잘못 세서 나는 문자열 문제
문자열 자르기 · 문자열 길이 · 글자 수 제한 · 문자열 뒤집기 · Zalgo 텍스트
다른 이름: grapheme cluster · extended grapheme cluster · 그래핌 클러스터 · 확장 문자소 클러스터 · user-perceived character