뒤로가기

Rust에서 유니코드 문자열 파싱의 복잡성

rust

Rust 문자열 파싱의 함정

Rust에서 문자열을 파싱할 때 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.
// 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.

반환되는 값은 유니코드 스칼라 값이며, 일반적으로 인식하는 '문자'와 다를 수 있습니다. 다음 예시가 이를 명확히 보여줍니다:

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개입니다. 이러한 불일치가 발생하는 이유를 이해하려면 유니코드의 구조를 파악해야 합니다.

유니코드 구조

유니코드는 전세계의 모든 문자를 일관되게 표현할 수 있도록 설계된 국제 표준 문자 인코딩 체계입니다.

논리적 구조

유니코드는 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바이트

Rust와 일반적인 웹은 UTF-8을 사용하고, JavaScript는 UTF-16을 사용합니다. 브라우저에서 JavaScript로 문자열을 전달할 때 자동으로 UTF-16으로 변환됩니다.

Grapheme Cluster 기반 파싱

유니코드는 언어에 따라 한 글자의 크기가 가변적이며, 결합 문자와 발음 기호는 별도의 처리가 필요합니다. unicode-segmentation 라이브러리는 UAX #29 (Unicode Text Segmentation) 규칙에 따라 문자소 클러스터 단위로 파싱합니다:

use unicode_segmentation::UnicodeSegmentation;
 
fn main() {
    let text = "မင်္ဂလာ";
 
    for grapheme in text.graphemes(true) {
        println!("{}", grapheme);
    }
}
/**
 * မ
 * င်္
 * ဂ
 * လ
 * ာ
 */

발음기호가 여전히 분리되어 출력됩니다. 프랑스어의 경우를 비교해보겠습니다:

use unicode_segmentation::UnicodeSegmentation;
 
fn main() {
    let text = "café";
 
    for grapheme in text.graphemes(true) {
        println!("{}", grapheme);
    }
}
/**
 * c
 * a
 * f
 * é
 */

프랑스어에서는 악센트가 문자와 결합되어 출력됩니다. 이러한 차이는 유니코드 문자 속성에서 비롯됩니다.

문자소 클러스터 규칙

문자소 클러스터는 사용자가 인식하는 문자의 최소 단위로, 여러 유니코드 코드 포인트가 결합되어 하나의 문자를 형성할 수 있습니다. 주요 문자 속성은 다음과 같습니다:

  • Extend: 앞 문자와 결합하는 문자 (예: 프랑스어 악센트)
  • SpacingMark: 자신의 공간을 가지는 표시 (예: 미얀마어 일부 발음기호)
  • Prepend: 뒤 문자와 결합하는 문자
  • L/V/T: 한글 자모
  • Control: 제어 문자

언어별 예시:

  • 가 = ᄀ(L) + ᅡ(V)
  • é = e + ́(Extend)
  • င်္ဂ = င(Base) + ်(SpacingMark) + ္(Extend) + ဂ(Base)

프랑스어 악센트는 Extend 속성으로 앞 문자와 결합되지만, 미얀마어 발음기호는 SpacingMark 속성으로 자신의 공간을 가지므로 분리되어 출력됩니다. 미얀마어를 온전히 파싱하려면 SpacingMark를 결합하는 별도 로직이 필요합니다.

결론

  • 문자열은 유니코드 코드 포인트로 구성되며, 코드 포인트는 사전 정의된 문자 속성을 가집니다.
  • 문자 속성(Extend, SpacingMark 등)에 따라 파싱 결과가 달라집니다.
  • chars() 함수는 유니코드 스칼라 값을 반환하며, 시각적 문자와 다를 수 있습니다.
  • Grapheme cluster 기반 파싱도 언어의 문자 속성에 따라 완벽하지 않을 수 있습니다.
  • 글로벌 서비스에서는 언어별 유니코드 특성을 이해하고, 필요시 언어별 예외 처리를 구현해야 합니다.

문자열 파싱은 단순해 보이지만, 유니코드의 복잡성으로 인해 언어별로 다른 접근이 필요할 수 있습니다. 특히 SpacingMark 속성을 가지는 언어를 다룰 때는 세심한 주의가 필요합니다.

관련 아티클