How to parse String?
Exploring String Parsing
January 2, 2025
러스트로 문자열 파싱하기
러스트로 GraphQL 서버 개발 중 문자열 파싱을 방법을 찾아보니 chars
함수를 이용해 파싱할 수 있었습니다. 다음은 예시 코드입니다.
fn main() {
let text: String = String::from("Hello, 안녕하세요");
for (i, c) in text.chars().enumerate() {
println!("위치 {}: {}", i, c);
}
}
터미널에 문자열이 하나씩 출력됨을 확인 할 수 있습니다. 그러다 문득 chars
함수의 동작 원리가 궁금해졌습니다. 그래서 함수의 설명을 찾아보니 다음과 같이 설명되어 있습니다.
// Returns an iterator over the chars of a string slice.
// As a string slice consists of valid UTF-8, we can iterate through a string slice by char. This method returns such an iterator.
// It's important to remember that char represents a Unicode Scalar Value
// and might not match your idea of what a 'character' is. Iteration over grapheme clusters may be what you actually want.
// This functionality is not provided by Rust's standard library, check crates. io instead.
fn chars() {...}
마지막 문장을 주목해야 합니다. 반환되는 문자는 유니코드 스칼라 값이며, 일반적인 문자가 아닐 수 있다고 합니다. 이러한 예시를 살펴보겠습니다.
fn main() {
let text: String = String::from("မင်္ဂလာ");
for (i, c) in text.chars().enumerate() {
println!("위치 {}: {}", i, c);
}
}
/**
* 위치 0: မ
* 위치 1: င
* 위치 2: ်
* 위치 3: ္
* 위치 4: ဂ
* 위치 5: လ
* 위치 6: ာ
*/
시각적으로 보이는 문자의 길이는 5개, 파싱 결과는 7개로 개수가 상이합니다. 여기서 궁금증이 생겼습니다. 왜 시각적으로 보이는 문자로 파싱하지 못할까요?
그 이유를 알아보기 위해 chars
함수가 반환하는 유니코드에 대해 알아봅시다.
유니코드란?
전세계의 모든 문자를 일관되게 표현 할 수 있도록 설계된 국제 표준 문자 인코딩 체계입니다.
논리적 구조
유니코드는 16개 평면(Plane)으로 나뉘며 이는 전체 코드 공간을 16개의 동일한 크기로 나눈 구역을 의미합니다. 현재는 전체 크기 중에 13% 정도만 사용하고 있어 추후 더 늘어나는 문자에 대해서 보충이 가능합니다. 여기서 각 평면에 표현 가능한 값을 유니코드 코드 포인트이라 합니다. 각 평면 별로의 역할은 다음과 같습니다.
평면 | 명칭 | 범위 | 용도 |
---|---|---|---|
0 | 기본 다국어 평면 (Basic Multilingual Plane, BMP) | U+0000 ~ U+FFFF | ASCII, 한글, 한자, 라틴문자 등 가장 일반적인 문자들 |
1 | 보충 다국어 평면 (Supplementary Multilingual Plane, SMP) | U+10000 ~ U+1FFFF | 고대 문자, 음악 기호, 수학 기호 등 |
2 | 상형 문자 보충 평면 (Supplementary Ideographic Plane, SIP) | U+20000 ~ U+2FFFF | 희귀 한자, 추가 한자 |
3 | 제3 평면 (Tertiary Ideographic Plane, TIP) | U+30000 ~ U+3FFFF | 고대 한자 |
4-13 | 미할당 평면 (Unassigned Planes) | U+40000 ~ U+DFFFF | 미래를 위해 예약 |
14 | 보충 특수 용도 평면 (Supplementary Special-purpose Plane, SSP) | U+E0000 ~ U+EFFFF | 특수 기호, 제어 문자 |
15 | 사용자 정의 영역 평면 (Private Use Area, PUA) | U+F0000 ~ U+10FFFF | 개인이나 조직이 자유롭게 사용 |
인코딩
유니코드는 UTF-8과 UTF-16의 인코딩 방식을 가집니다. 8, 16이라는 숫자는 문자 인코딩의 최소 비트 수를 의미합니다. 다음은 인코딩 방식에 따른 비트 수입니다.
유니코드 범위 | 평면 | UTF-8 | UTF-16 |
---|---|---|---|
U+0000~U+007F | 평면 0 (ASCII) | 1바이트 | 2바이트 |
U+0080~U+07FF | 평면 0 (라틴확장 등) | 2바이트 | 2바이트 |
U+0800~U+FFFF | 평면 0 (BMP 나머지) | 3바이트 | 2바이트 |
U+10000~U+10FFFF | 평면 1~15 (보충 평면) | 4바이트 | 4바이트 |
러스트와 일반적인 웹은 UTF-8, 자바스크립트는 UTF-16을 사용합니다.
브라우저에서 자바스크립트로 문자열을 전달 할때에는 자동으로 UTF-16으로 변환합니다.
온전한 문자로 파싱
유니코드는 언어에 따라 한 글자의 크기가 가변적이며 특수문자가 경우 별도의 처리를 해야하기 때문에 각 언어에 맞는 완전한 글자로 파싱하는 것은 복잡한 기능입니다.
아래는 unicode-segmentation
라이브러리를 사용하여 문자열을 파싱한 예시입니다. 라이브러리를 사용하더라도 발음기호가 분리되서 출력됩니다.
use unicode_segmentation::UnicodeSegmentation;
fn main() {
let text = "မင်္ဂလာ";
for grapheme in text.graphemes(true) {
println!("{}", grapheme);
}
}
/**
* မ
* င်္
* ဂ
* လ
* ာ
*/
그렇다면 다른 언어를 파싱해봅시다. 아래는 프랑스어의 café
을 파싱한 결과입니다. 프랑스어에서 악센트가 문자와 결합되어 출력되고 있습니다.
use unicode_segmentation::UnicodeSegmentation;
fn main() {
let text = "café";
for grapheme in text.graphemes(true) {
println!("{}", grapheme);
}
}
/**
* c
* a
* f
* é
*/
왜 미얀마어와 프랑스어의 파싱 결과가 다른지 살펴봅시다.
파싱 방법
unicode-segmentation
라이브러리는 유니코드의 UAX #29 (Unicode Text Segmentation)
규칙을 따라 문자열을 파싱합니다. 이 규칙은 문자소 클러스터, 단어, 문장 단위로 분할 단위를 정하고 있습니다.
문자소 클러스터
문자소 클러스터란 유저가 인식하는 문자의 최소 단위로, 하나의 문자를 의미합니다. 한글의 경우 "가", 라틴 문자의 경우 "é" 등을 의미합니다.
단어
단어는 문자소 클러스터의 집합으로, 공백이나 구두점으로 구분됩니다. 단어의 경계는 공백이나 구두점으로 구분됩니다.
문장
문장은 단어의 집합으로, 마침표, 느낌표, 물음표로 구분됩니다. 문장의 경계는 마침표, 느낌표, 물음표로 구분됩니다.
문자 속성
문자소 클러스터는 여러가지 문자 속성을 가진 문자로 구성되어 있습니다. 주요 속성들을 아래와 같습니다.
- Extend: 앞 문자와 결합하는 문자 (예: 프랑스어 악센트)
- SpacingMark: 자신의 공간을 가지는 표시 (예: 미얀마어 일부 발음기호)
- Prepend: 뒤 문자와 결합하는 문자
- L/V/T: 한글 자모
- Control: 제어 문자
이 기준으로 언어별로 예시를 살펴봅시다.
- 가 = ᄀ(L) + ᅡ(V)
- é = e + ́ ́(Extend)
- င်္ဂ = င(Base) + ်(SpacingMark) + ္(Extend) + ဂ(Base)
예시를 보면 프랑스어의 악센트인 경우 Extend
로 분류되어 있고, 미얀마어의 발음기호는 SpacingMark
로 분류되어 있습니다. SpacingMark
는 자신의 공간을 가지는 표시이므로, 다른 문자와 분리되어 출력됩니다.
결국 문자의 속성에 따라 파싱 결과가 달라지게 됩니다. 만약 미안마어의 발음기호를 분리하지 않고 출력하고 싶다면 각 유니코드 코드 포인트를 분석하여 SpacingMark
를 결합하는 로직을 추가해야 합니다.
정리
- 문자열은 유니코드로 이루어져 있으며 각 문자는 유니코드 코드 포인트로 표현됩니다.
- 유니코드 코드 포인트의 크기는 가변적이며, 코드 포인트 별로 사전에 정의된 문자 속성을 가지고 있습니다.
- 유니코드 코드 포인트의 문자 속성에 따라 파싱 결과가 달라지며, 문자 속성을 분석하여 원하는 결과를 만들 수 있습니다.
- SpacingMark 속성을 가지는 유니코드 코드 포인트가 존재하는 언어인 경우 문자열을 다룰 때 주의가 필요합니다.
- 글로벌 서비스에서 언어를 다룰 때에는 언어의 유니코드적 특성을 이해하고 만약 별도의 예외처리가 필요하다면 해당 언어에 대한 별도의 파싱 라이브러리를 구축이 필요합니다.