- Authors

- Name
- Youngju Kim
- @fjvbn20031
들어가며
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바이트고요.
| 부분 | 글자 | 바이트 |
|---|---|---|
커널 | 2 | 6 |
| 공백 | 1 | 1 |
파라미터 | 4 | 12 |
| 공백 | 1 | 1 |
튜닝 | 2 | 6 |
| 합 | 10 | 26 |
파라미터 앞에는 커널(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_off가 str::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>
반대로 Match는 String으로 줄 내용을 들고 있습니다. 원본 텍스트보다 오래 살아야 하기 때문입니다. 빌린 조각으로 두면 원본이 사라질 때 같이 무효가 됩니다 — 그리고 그걸 컴파일러가 막습니다. 다음 편에서 다룰 소유권 이야기가 여기서 문자열 문제와 만납니다.
이 글이 다루지 않는 것
- 정규식.
kgrep은 리터럴 검색만 합니다. 정규식은regex크레이트의 영역이고, 유니코드 처리 규칙이 따로 있습니다. - 정규화(NFC/NFD). 같은 글자를 다르게 인코딩한 두 문자열을 같다고 판단하는 문제인데, 검색 도구에서는 실제로 중요합니다. 이 도구는 다루지 않습니다.
- 자소 클러스터 단위 컬럼. 7절에서 적었듯 범위 밖입니다.
마치며
Rust에서 문자열이 까다롭다는 인상은 대체로 맞습니다. 다른 언어보다 물어야 할 것이 많습니다.
다만 한글을 다루면서는 그 까다로움의 성격이 달라 보였습니다. len()이 26을 줄 때, 그건 불친절이 아니라 26이 사실이라는 것이었습니다. 10을 원했으면 10을 원한다고 말해야 하고, 그 둘이 다르다는 것을 아는 것이 이 문제의 전부입니다.
영어만 다루면 이 차이는 평생 드러나지 않을 수도 있습니다. 한글을 다루면 첫날에 드러납니다.