Skip to content
Published on

Rust 문자열이 까다로운 이유, 그리고 한글에서 그 선택이 옳은 이유

공유하기
Authors

들어가며

Rust를 처음 만진 사람들이 가장 자주 하는 말이 "문자열이 어렵다"입니다. 구체적으로는 이런 것들입니다. s[0]이 안 됩니다. String&str이 따로 있습니다. 길이를 물으면 글자 수가 아니라 바이트 수를 줍니다.

다른 언어에서 오면 불필요한 까다로움으로 보입니다. 그런데 저는 한글을 검색하는 도구를 만들다가 생각이 바뀌었습니다. 그 제약들은 대부분 언어가 거짓말을 거절한 결과였습니다.

이 글은 그 이야기입니다. 앞 편에서 만든 kgrep이 문자 단위 컬럼을 보고하는데, 그렇게 만들지 않으면 어떤 버그가 나는지를 실제 실행 결과로 보겠습니다.

1. 숫자부터

설명 전에 실행 결과를 먼저 봅니다. 문장 하나를 놓고 네 가지를 물었습니다.

fn main() {
    let line = "커널 파라미터 튜닝";
    let byte_off = line.find("파라미터").unwrap();
    let char_col = line[..byte_off].chars().count() + 1;
    println!("byte offset : {byte_off}");
    println!("char column : {char_col}");
    println!("line.len()  : {} bytes", line.len());
    println!("chars       : {}", line.chars().count());
}
byte offset : 7
char column : 4
line.len()  : 26 bytes
chars       : 10

같은 문자열에 대해 10과 26이 동시에 참입니다. 그리고 같은 위치에 대해 4와 7이 동시에 참입니다.

grep으로 확인해도 같습니다.

grep -bo "파라미터" demo.txt
7:파라미터

grep -bo도 7이라고 답합니다. 즉 이건 Rust의 특이성이 아니라 UTF-8의 성질입니다.

2. 왜 26바이트인가

커널 파라미터 튜닝은 눈으로 세면 공백 둘을 포함해 10글자입니다. 바이트로는 26입니다.

UTF-8에서 한글 음절 하나는 3바이트입니다. ASCII 공백은 1바이트고요.

부분글자바이트
커널26
공백11
파라미터412
공백11
튜닝26
1026

파라미터 앞에는 커널(6바이트)과 공백(1바이트)이 있으므로 바이트 오프셋은 7입니다. 글자로 세면 , , 공백 다음이니 네 번째, 즉 컬럼 4입니다.

일본어도 같습니다. 히라가나·가타카나·한자 대부분이 3바이트입니다. 이모지는 4바이트이고, 그중에는 여러 코드포인트가 결합해 하나로 보이는 것도 있습니다.

3. Rust가 s[0]을 막는 이유

여기서 Rust의 설계가 이해됩니다. String에 정수 인덱싱을 하면 컴파일이 안 됩니다.

만약 허용했다면 s[0]은 무엇을 돌려줘야 할까요?

  • 첫 바이트를 준다면, 한글의 첫 바이트는 그 자체로는 아무 글자도 아닙니다. 3바이트 중 1바이트만 떼면 유효한 문자가 아닙니다.
  • 첫 글자를 준다면, 그건 O(1) 연산이 아닙니다. UTF-8은 가변 길이라 n번째 글자를 찾으려면 앞에서부터 세야 합니다.

두 답 모두 나쁩니다. 하나는 틀린 값을 주고, 하나는 인덱싱처럼 보이는데 O(n)입니다. 그래서 Rust는 셋 중 어느 것도 기본으로 주지 않고, 무엇을 원하는지 명시하게 만듭니다.

line.len()            // 26 — 바이트
line.chars().count()  // 10 — 코드포인트
line.bytes().nth(0)   // 첫 바이트를 원한다고 명시
line.chars().nth(0)   // 첫 글자를 원한다고 명시 (O(n)임이 드러남)

chars().nth(0)이 인덱싱보다 길고 번거로운 건 사실입니다. 그런데 그 번거로움이 이 연산은 공짜가 아니다라는 신호이기도 합니다.

4. 그래서 실제로 어떤 버그가 나는가

검색 도구는 "몇 번째 줄, 몇 번째 칸"을 알려줍니다. 그 칸 번호를 바이트로 세면 한글 줄에서 어긋납니다.

str::find는 바이트 오프셋을 줍니다. 그걸 그대로 컬럼으로 출력하면 커널 파라미터 튜닝에서 파라미터의 위치가 7이라고 나옵니다. 사람이 세면 4입니다. 세 칸이 어긋난 게 아니라, 애초에 다른 단위입니다.

에디터에서 "4번 칸으로 가라"는 말과 "7번 바이트로 가라"는 말은 다른 지시입니다. 영어 줄에서는 우연히 같아서 문제가 안 보이다가, 한글 줄에서 드러납니다.

kgrep에서는 변환 함수를 따로 두었습니다.

/// Byte offset -> 1-based character column.
///
/// This is the conversion that a naive implementation skips.
fn char_col(line: &str, byte_off: usize) -> usize {
    line[..byte_off].chars().count() + 1
}

하는 일은 단순합니다. 오프셋 앞부분을 슬라이스해서 글자를 세는 것뿐입니다. 중요한 건 이 한 줄이 없으면 조용히 틀린다는 점입니다. 컴파일도 되고 테스트도 통과하고, 영어로만 시험하면 정상으로 보입니다.

5. 테스트로 못 박기

그래서 이 동작을 테스트로 고정했습니다. 두 숫자를 모두 단언하는 게 요령입니다.

/// The bug this tool exists to avoid. "커널 파라미터" — the word
/// 파라미터 starts at character 4, but at BYTE 10, because each Hangul
/// syllable is 3 bytes. A byte offset here would be off by six.
#[test]
fn hangul_column_is_characters_not_bytes() {
    let line = "커널 파라미터 튜닝";
    let byte_off = line.find("파라미터").unwrap();
    assert_eq!(byte_off, 7, "byte offset is what str::find gives");

    let m = search(line, "파라미터", &Options::default());
    assert_eq!(m[0].col, 4, "character column is what a human counts");
}

byte_off가 7이라는 것까지 단언한 이유가 있습니다. 컬럼이 4라는 것만 확인하면, 나중에 누가 구현을 바꿔 우연히 4가 나와도 통과합니다. 7과 4가 다르다는 사실 자체를 테스트에 남겨두면, 이 테스트를 읽는 사람이 왜 이 함수가 존재하는지 알게 됩니다.

일본어도 같은 형태로 하나 더 두었습니다.

#[test]
fn japanese_column_is_characters_not_bytes() {
    let line = "これはカーネルです";
    let m = search(line, "カーネル", &Options::default());
    assert_eq!(m[0].col, 4);
}

6. 슬라이싱은 여전히 위험합니다

char_col에서 line[..byte_off]로 슬라이스했습니다. 이건 바이트 범위 슬라이싱이고, 글자 경계가 아닌 곳을 자르면 런타임에 패닉합니다.

byte index 1 is not a char boundary; it is inside '커' (bytes 0..3)

컴파일러가 막아주지 않는 자리입니다. 위 코드가 안전한 이유는 byte_offstr::find에서 나온 값이고, find는 항상 글자 경계를 돌려주기 때문입니다. 임의의 정수로 슬라이스하면 그 보장이 없습니다.

kgrep의 검색 루프에는 그래서 방어가 하나 더 들어 있습니다.

if line.is_char_boundary(byte_off) {
    // ...
}

대소문자 무시 검색에서 to_lowercase()가 문자열의 바이트 길이를 바꿀 수 있기 때문입니다. 소문자로 바꾼 문자열에서 찾은 오프셋이 원본에서는 글자 경간 안쪽일 수 있습니다. 흔한 경우는 아니지만, 패닉은 흔하지 않아도 패닉입니다.

7. 글자와 "사람이 보는 글자"는 또 다릅니다

한 단계 더 있습니다. chars()가 세는 것은 코드포인트이지 사람이 보는 글자가 아닙니다.

  • 결합 문자가 붙은 글자는 코드포인트 두 개인데 한 글자로 보입니다.
  • 이모지 중에는 여러 코드포인트가 결합해 하나로 렌더되는 것들이 있습니다(가족 이모지, 국기, 피부색 변형).
  • 한글은 완성형과 조합형이 둘 다 유효합니다. 같은 이 코드포인트 하나일 수도, 자모 셋일 수도 있습니다.

이걸 정확히 다루려면 자소 클러스터(grapheme cluster) 단위가 필요하고, 표준 라이브러리에는 없습니다. unicode-segmentation 크레이트를 써야 합니다.

kgrep은 코드포인트 단위에서 멈췄습니다. 이유를 정직하게 적자면, 대상 데이터가 완성형 한글과 일반적인 일본어 텍스트라서 코드포인트와 자소가 거의 일치하고, 의존성을 하나 더 늘릴 만큼의 이득이 없다고 판단했습니다. 이건 옳은 선택이 아니라 범위를 좁힌 선택입니다. 이모지가 많은 텍스트를 다룬다면 다시 봐야 합니다.

8. String&str

마지막으로 자주 헷갈리는 둘입니다.

  • String — 소유권이 있고, 크기가 변할 수 있는 문자열. 힙에 있습니다.
  • &str — 빌린 문자열 조각. 어딘가에 있는 UTF-8 바이트를 가리키는 뷰입니다.

Vec<T>&[T]의 관계와 같습니다. 함수 인자로는 대개 &str을 받는 게 맞습니다. String&str로 자동 변환되기 때문에, &str을 받으면 둘 다 받을 수 있습니다.

kgrep의 검색 함수가 그렇게 생겼습니다.

pub fn search(haystack: &str, pattern: &str, opts: &Options) -> Vec<Match>

반대로 MatchString으로 줄 내용을 들고 있습니다. 원본 텍스트보다 오래 살아야 하기 때문입니다. 빌린 조각으로 두면 원본이 사라질 때 같이 무효가 됩니다 — 그리고 그걸 컴파일러가 막습니다. 다음 편에서 다룰 소유권 이야기가 여기서 문자열 문제와 만납니다.

이 글이 다루지 않는 것

  • 정규식. kgrep은 리터럴 검색만 합니다. 정규식은 regex 크레이트의 영역이고, 유니코드 처리 규칙이 따로 있습니다.
  • 정규화(NFC/NFD). 같은 글자를 다르게 인코딩한 두 문자열을 같다고 판단하는 문제인데, 검색 도구에서는 실제로 중요합니다. 이 도구는 다루지 않습니다.
  • 자소 클러스터 단위 컬럼. 7절에서 적었듯 범위 밖입니다.

마치며

Rust에서 문자열이 까다롭다는 인상은 대체로 맞습니다. 다른 언어보다 물어야 할 것이 많습니다.

다만 한글을 다루면서는 그 까다로움의 성격이 달라 보였습니다. len()이 26을 줄 때, 그건 불친절이 아니라 26이 사실이라는 것이었습니다. 10을 원했으면 10을 원한다고 말해야 하고, 그 둘이 다르다는 것을 아는 것이 이 문제의 전부입니다.

영어만 다루면 이 차이는 평생 드러나지 않을 수도 있습니다. 한글을 다루면 첫날에 드러납니다.

이어서 읽기