문자열 다루기 — UTF-8 인코딩 텍스트 저장하기
문자열 다루기 — UTF-8 인코딩 텍스트 저장하기
4장에서 문자열을 잠깐 다뤘는데요, 이번에는 좀 더 깊이 들어가 볼게요. 러스트를 처음 시작하는 분들이 문자열에서 자주 막히는 이유는 크게 세 가지로 나뉘어요. 러스트가 가능한 오류를 잘 드러내는 성향이 있다는 점, 문자열이 많은 프로그래머가 생각하는 것보다 더 복잡한 자료 구조라는 점, 그리고 UTF-8 때문이에요. 이 요소들이 겹치다 보니, 다른 언어에서 넘어온 입장에서는 꽤 어렵게 느껴질 수 있죠.
문자열을 컬렉션 맥락에서 다루는 이유는, 문자열이 바이트의 모음으로 구현되면서 그 바이트를 텍스트로 해석할 때 유용한 기능을 제공하는 메서드들이 붙어 있기 때문이에요. 이번 절에서는 모든 컬렉션 타입이 갖고 있는 String 연산 — 만들기, 갱신하기, 읽기 같은 것들 — 을 살펴볼게요. 또 String이 다른 컬렉션과 어떻게 다른지, 특히 사람과 컴퓨터가 String 데이터를 해석하는 방식의 차이 때문에 인덱싱이 왜 복잡해지는지도 이야기할 거예요.
출처: Rust 공식문서
본문
문자열 정의하기
먼저 우리가 '문자열'이라고 부르는 게 정확히 무엇인지부터 정리할게요. 러스트의 핵심 언어에는 문자열 타입이 딱 하나 있는데, 바로 문자열 슬라이스인 str이에요. 보통은 빌린 형태인 &str로 사용하죠. 4장에서 문자열 슬라이스는 다른 곳에 저장된 어떤 UTF-8 인코딩 문자열 데이터에 대한 참조라고 이야기했었는데요, 예를 들어 문자열 리터럴은 프로그램의 바이너리에 저장되기 때문에 바로 이 문자열 슬라이스에 해당해요.
한편 String 타입은 핵심 언어에 박혀 있는 게 아니라 러스트 표준 라이브러리가 제공하는 타입이에요. 늘어날 수 있고(growable), 가변적이며(mutable), 소유권이 있고(owned), UTF-8로 인코딩된 문자열 타입이죠. 러스트 개발자들이 러스트의 '문자열'이라고 말할 때는 이 String과 문자열 슬라이스 &str 중 하나만 가리키는 게 아니라 둘 중 어느 쪽일 수도 있어요. 이번 절이 주로 String에 집중하긴 하지만, 두 타입 모두 러스트 표준 라이브러리에서 아주 자주 쓰이고, 둘 다 UTF-8로 인코딩된다는 점 기억해 두세요.
새 문자열 만들기
String은 사실 바이트 벡터를 감싼 래퍼로 구현돼 있고, 거기에 몇 가지 추가 보장과 제약, 기능이 더해진 타입이에요. 그래서 Vec<T>에서 쓸 수 있는 연산 중 상당수가 String에서도 그대로 동작해요. Vec<T>와 String에서 똑같이 동작하는 함수의 예로는 인스턴스를 만들 때 쓰는 new 함수가 있는데, Listing 8-11에서 볼 수 있어요.
fn main() {
let mut s = String::new();
}
이 줄은 s라는 이름의 새 빈 문자열을 만들어요. 여기에 나중에 데이터를 채워 넣으면 되죠. 그런데 문자열을 시작할 때 이미 초깃값을 갖고 있는 경우가 많을 거예요. 그럴 때는 to_string 메서드를 쓰는데, 이 메서드는 Display 트레잇을 구현하는 모든 타입에서 사용할 수 있어요. 문자열 리터럴도 그중 하나예요. Listing 8-12에서 두 예시를 볼게요.
fn main() {
let data = "initial contents";
let s = data.to_string();
// The method also works on a literal directly:
let s = "initial contents".to_string();
}
이 코드는 initial contents라는 내용을 담은 문자열을 만들어요.
String::from 함수를 써서 문자열 리터럴로부터 String을 만들 수도 있어요. Listing 8-13의 코드는 to_string을 쓰는 Listing 8-12의 코드와 동일한 결과를 내요.
fn main() {
let s = String::from("initial contents");
}
문자열은 정말 많은 곳에서 쓰이기 때문에, 문자열을 위한 제네릭 API도 많아서 선택지가 넉넉해요. 어떤 건 중복돼 보일 수도 있는데, 모두 나름의 자리가 있어요! 이 경우 String::from과 to_string은 같은 일을 하니, 어느 쪽을 고르느냐는 취향과 가독성의 문제예요.
문자열은 UTF-8로 인코딩된다는 사실을 기억하세요. 그래서 제대로 인코딩된 데이터라면 무엇이든 담을 수 있어요. Listing 8-14에서 확인할 수 있죠.
fn main() {
let hello = String::from("السلام عليكم");
let hello = String::from("Dobrý den");
let hello = String::from("Hello");
let hello = String::from("שלום");
let hello = String::from("नमस्ते");
let hello = String::from("こんにちは");
let hello = String::from("안녕하세요");
let hello = String::from("你好");
let hello = String::from("Olá");
let hello = String::from("Здравствуйте");
let hello = String::from("Hola");
}
이 모든 것이 유효한 String 값이에요.
문자열 갱신하기
String은 Vec<T>처럼, 데이터를 더 밀어 넣으면(push) 크기가 커지고 내용이 바뀔 수 있어요. 거기에 더해 + 연산자나 format! 매크로로 String 값을 이어 붙이는 것도 편리하게 쓸 수 있고요.
push_str이나 push로 덧붙이기
push_str 메서드를 쓰면 문자열 슬라이스를 덧붙여서 String을 키울 수 있어요. Listing 8-15에서 볼게요.
fn main() {
let mut s = String::from("foo");
s.push_str("bar");
}
이 두 줄이 지나면 s에는 foobar가 들어 있어요. push_str 메서드가 문자열 슬라이스를 받는 이유는, 꼭 매개변수의 소유권을 가져가고 싶지 않을 때가 있기 때문이에요. 예를 들어 Listing 8-16의 코드에서는 s1에 s2의 내용을 덧붙인 뒤에도 s2를 계속 쓸 수 있기를 원해요.
fn main() {
let mut s1 = String::from("foo");
let s2 = "bar";
s1.push_str(s2);
println!("s2 is {s2}");
}
만약 push_str이 s2의 소유권을 가져갔다면, 마지막 줄에서 s2의 값을 출력할 수 없을 거예요. 하지만 이 코드는 우리가 기대한 대로 동작해요!
push 메서드는 매개변수로 문자 하나를 받아서 String에 추가해요. Listing 8-17에서는 push 메서드로 String에 글자 l을 추가하고 있죠.
fn main() {
let mut s = String::from("lo");
s.push('l');
}
그 결과 s에는 lol이 들어 있어요.
+나 format!로 이어 붙이기
기존 문자열 두 개를 합치고 싶은 경우가 자주 있을 거예요. 그 방법 중 하나가 + 연산자를 쓰는 건데, Listing 8-18에서 볼 수 있어요.
fn main() {
let s1 = String::from("Hello, ");
let s2 = String::from("world!");
let s3 = s1 + &s2; // note s1 has been moved here and can no longer be used
}
s3에는 Hello, world!가 들어 있어요. 덧셈 뒤에 s1이 더 이상 유효하지 않은 이유, 그리고 s2의 참조를 쓴 이유는 + 연산자를 쓸 때 호출되는 메서드의 시그니처와 관련이 있어요. + 연산자는 add 메서드를 사용하는데, 그 시그니처는 대략 이렇게 생겼어요.
fn add(self, s: &str) -> String {
표준 라이브러리에서는 add가 제네릭과 연관 타입을 써서 정의돼 있어서, 여기서는 구체 타입으로 바꿔서 보여드린 거예요. 이렇게 String 값으로 메서드를 호출할 때 실제로 벌어지는 일이죠. 제네릭은 10장에서 다룰게요. 이 시그니처가 + 연산자의 까다로운 부분을 이해할 단서를 줘요.
첫째, s2에 &가 붙어 있죠. 즉 두 번째 문자열의 참조를 첫 번째 문자열에 더하고 있다는 뜻이에요. 이건 add 함수의 s 매개변수 때문인데, String에는 문자열 슬라이스만 더할 수 있고 String 두 개를 더할 수는 없어요. 그런데 잠깐 — &s2의 타입은 &String이지 add의 두 번째 매개변수가 요구하는 &str이 아니잖아요. 그럼 Listing 8-18은 왜 컴파일될까요?
add 호출에서 &s2를 쓸 수 있는 이유는 컴파일러가 &String 인자를 &str로 강제 변환(coerce)할 수 있기 때문이에요. add 메서드를 호출할 때 러스트는 역참조 강제(deref coercion)를 사용하는데, 여기서는 &s2를 &s2[..]로 바꿔줘요. 역참조 강제에 대해서는 15장에서 더 자세히 다룰게요. add가 s 매개변수의 소유권을 가져가지 않기 때문에, 이 연산 이후에도 s2는 여전히 유효한 String이에요.
둘째, 시그니처에서 self에는 &이 없으므로 add가 self의 소유권을 가져간다는 걸 알 수 있어요. 즉 Listing 8-18의 s1은 add 호출로 이동(move) 되고, 그 뒤로는 더 이상 유효하지 않게 돼요. 그래서 let s3 = s1 + &s2;가 두 문자열을 복사해서 새 하나를 만드는 것처럼 보여도, 실제로는 s1의 소유권을 가져가고 s2의 내용을 복사해 덧붙인 다음 결과의 소유권을 돌려주는 거예요. 다시 말해 복사를 많이 하는 것처럼 보이지만 사실 그렇지 않아요. 구현이 복사보다 더 효율적이에요.
여러 문자열을 이어 붙여야 한다면 + 연산자는 다루기가 꽤 번거로워져요.
fn main() {
let s1 = String::from("tic");
let s2 = String::from("tac");
let s3 = String::from("toe");
let s = s1 + "-" + &s2 + "-" + &s3;
}
이 시점에서 s는 tic-tac-toe가 돼요. +와 " 문자가 이리저리 껴 있으니 뭐가 뭔지 알아보기 어렵죠. 더 복잡한 방식으로 문자열을 조합할 때는 그 대신 format! 매크로를 쓸 수 있어요.
fn main() {
let s1 = String::from("tic");
let s2 = String::from("tac");
let s3 = String::from("toe");
let s = format!("{s1}-{s2}-{s3}");
}
이 코드도 s를 tic-tac-toe로 만들어요. format! 매크로는 println!처럼 동작하지만, 화면에 출력하는 대신 그 내용을 담은 String을 돌려줘요. format!을 쓴 버전이 훨씬 읽기 쉽고, format! 매크로가 만들어 내는 코드는 참조를 사용해서 이 호출이 어떤 매개변수의 소유권도 가져가지 않아요.
문자열 인덱싱
다른 많은 프로그래밍 언어에서는 인덱스로 문자열의 개별 문자에 접근하는 게 유효하고 흔한 연산이에요. 하지만 러스트에서 인덱싱 문법으로 String의 일부에 접근하려 하면 오류가 나요. Listing 8-19의 잘못된 코드를 보세요.
fn main() {
let s1 = String::from("hi");
let h = s1[0];
}
이 코드는 다음 오류를 내요.
$ cargo run
Compiling collections v0.1.0 (file:///projects/collections)
error[E0277]: the type `str` cannot be indexed by `{integer}`
--> src/main.rs:3:16
|
3 | let h = s1[0];
| ^ string indices are ranges of `usize`
|
= help: the trait `SliceIndex<str>` is not implemented for `{integer}`
= note: you can use `.chars().nth()` or `.bytes().nth()`
for more information, see chapter 8 in The Book: <https://doc.rust-lang.org/book/ch08-02-strings.html#indexing-into-strings>
= help: the following other types implement trait `SliceIndex<T>`:
`usize` implements `SliceIndex<ByteStr>`
`usize` implements `SliceIndex<[T]>`
= note: required for `String` to implement `Index<{integer}>`
For more information about this error, try `rustc --explain E0277`.
error: could not compile `collections` (bin "collections") due to 1 previous error
오류 메시지가 이야기를 다 말해 주죠. 러스트 문자열은 인덱싱을 지원하지 않아요. 그런데 왜일까요? 이 질문에 답하려면 러스트가 문자열을 메모리에 어떻게 저장하는지를 살펴봐야 해요.
내부 표현
String은 Vec<u8>을 감싼 래퍼예요. Listing 8-14에서 본 제대로 인코딩된 UTF-8 예시 문자열들을 몇 개 들여다볼게요. 먼저 이 문자열이에요.
fn main() {
let hello = String::from("السلام عليكم");
let hello = String::from("Dobrý den");
let hello = String::from("Hello");
let hello = String::from("שלום");
let hello = String::from("नमस्ते");
let hello = String::from("こんにちは");
let hello = String::from("안녕하세요");
let hello = String::from("你好");
let hello = String::from("Olá");
let hello = String::from("Здравствуйте");
let hello = String::from("Hola");
}
이 경우 len은 4가 되는데, 즉 "Hola"를 저장하는 벡터가 4바이트 길이라는 뜻이에요. 이 글자들은 각각 UTF-8로 인코딩했을 때 1바이트를 차지하죠. 그런데 다음 줄은 좀 놀라실 수도 있어요 (이 문자열이 숫자 3이 아니라 대문자 키릴 문자 Ze로 시작한다는 점에 주의하세요):
fn main() {
let hello = String::from("السلام عليكم");
let hello = String::from("Dobrý den");
let hello = String::from("Hello");
let hello = String::from("שלום");
let hello = String::from("नमस्ते");
let hello = String::from("こんにちは");
let hello = String::from("안녕하세요");
let hello = String::from("你好");
let hello = String::from("Olá");
let hello = String::from("Здравствуйте");
let hello = String::from("Hola");
}
이 문자열의 길이가 얼마냐고 묻는다면 아마 12라고 말할 거예요. 그런데 사실 러스트의 답은 24예요. “Здравствуйте”를 UTF-8로 인코딩하는 데 필요한 바이트 수가 24이기 때문이죠. 문자열 안의 각 유니코드 스칼라 값이 저장 공간 2바이트를 차지하니까요. 따라서 문자열의 바이트에 대한 인덱스가 항상 유효한 유니코드 스칼라 값과 일치하는 건 아니에요. 그걸 보여주기 위해 이렇게 유효하지 않은 러스트 코드를 볼게요.
let hello = "Здравствуйте";
let answer = &hello[0];
answer가 첫 글자인 З가 아닐 거라는 건 이미 짐작하셨을 거예요. UTF-8로 인코딩하면 З의 첫 바이트는 208, 두 번째 바이트는 151이에요. 그래서 answer가 사실 208이어야 할 것처럼 보이는데, 208은 그 자체로는 유효한 문자가 아니에요. 사용자가 이 문자열의 첫 글자를 요청했을 때 208이 돌아오는 건 아마 원하는 결과가 아닐 거예요. 하지만 그게 바이트 인덱스 0에 러스트가 가진 유일한 데이터예요. 문자열이 라틴 알파벳만 담고 있더라도 사용자는 보통 바이트 값을 돌려받고 싶어 하지 않아요. &"hi"[0]이 유효한 코드라서 바이트 값을 돌려줬다면 h가 아니라 104를 돌려줬을 거예요.
그래서 답은 이거예요. 러스트는 예상치 못한 값을 돌려주고, 나중에 제때 발견되지 못할 버그를 만드는 걸 피하기 위해 이런 코드를 아예 컴파일하지 않아요. 개발 과정 초기에 오해를 미리 막아 주는 거죠.
바이트, 스칼라 값, 그리고 자소 클러스터
UTF-8에 관해 한 가지 더 짚어볼 점은, 러스트의 관점에서 문자열을 볼 수 있는 관련된 방식이 실제로 세 가지라는 거예요 — 바이트(byte), 스칼라 값(scalar value), 그리고 자소 클러스터(grapheme cluster, 우리가 '글자'라고 부르는 것에 가장 가까운 개념).
데바나가리 문자로 쓰인 힌디어 단어 “नमस्ते”를 보면, 이 단어는 다음과 같은 u8 값의 벡터로 저장돼요.
[224, 164, 168, 224, 164, 174, 224, 164, 184, 224, 165, 141, 224, 164, 164,
224, 165, 135]
이건 18바이트이고, 컴퓨터가 이 데이터를 궁극적으로 저장하는 방식이에요. 이걸 유니코드 스칼라 값으로 보면 — 러스트의 char 타입이 바로 이것인데 — 이 바이트들은 이렇게 보여요.
['न', 'म', 'स', '्', 'त', 'े']
여기에는 char 값이 여섯 개 있는데, 네 번째와 여섯 번째는 글자가 아니에요. 이들은 자체로는 의미가 없는 결합 부호(diacritics)예요. 마지막으로 이걸 자소 클러스터로 보면, 힌디어 단어를 이루는 네 글자로, 사람이 말하는 그 네 글자를 얻게 돼요.
["न", "म", "स्", "ते"]
러스트는 컴퓨터가 저장하는 원시 문자열 데이터를 해석하는 여러 방식을 제공해서, 데이터가 어떤 인간 언어로 쓰였는지와 상관없이 각 프로그램이 필요한 해석을 고를 수 있게 해요.
러스트가 String에 인덱싱으로 접근해 문자를 얻는 걸 허용하지 않는 마지막 이유는, 인덱싱 연산은 항상 상수 시간(O(1))이 걸릴 것이라고 기대되기 때문이에요. 그런데 String으로는 그 성능을 보장할 수 없어요. 러스트가 유효한 문자가 몇 개인지 알아내려면 내용을 처음부터 인덱스까지 훑어야 하거든요.
문자열 슬라이싱
문자열 인덱싱은 종종 나쁜 생각이에요. 문자열 인덱싱 연산의 반환 타입이 무엇이어야 하는지 — 바이트 값인지, 문자인지, 자소 클러스터인지, 문자열 슬라이스인지 — 분명하지 않기 때문이에요. 그래서 정말로 인덱스를 써서 문자열 슬라이스를 만들어야 한다면, 러스트는 좀 더 구체적으로 표현하길 요구해요.
인덱싱을 []에 숫자 하나를 쓰는 대신, []에 범위(range) 를 써서 특정 바이트를 담은 문자열 슬라이스를 만들 수 있어요.
#![allow(unused)]
fn main() {
let hello = "Здравствуйте";
let s = &hello[0..4];
}
여기서 s는 문자열의 첫 4바이트를 담은 &str이 돼요. 아까 이 글자들이 각각 2바이트라고 말했으니, s는 Зд가 되는 거예요.
만약 &hello[0..1]처럼 문자의 바이트 일부만 슬라이스하려 하면, 러스트는 벡터에서 잘못된 인덱스에 접근했을 때와 같은 방식으로 런타임에 패닉(panic)을 일으켜요.
$ cargo run
Compiling collections v0.1.0 (file:///projects/collections)
Finished `dev` profile [unoptimized + debuginfo] target(s) in 0.43s
Running `target/debug/collections`
thread 'main' panicked at src/main.rs:4:19:
byte index 1 is not a char boundary; it is inside 'З' (bytes 0..2) of `Здравствуйте`
note: run with `RUST_BACKTRACE=1` environment variable to display a backtrace
범위로 문자열 슬라이스를 만들 때는 조심해야 해요. 그렇게 하면 프로그램이 크래시할 수 있으니까요.
문자열 순회하기
문자열의 일부를 다루는 가장 좋은 방법은, 문자를 원하는지 바이트를 원하는지 명확히 밝히는 거예요. 개별 유니코드 스칼라 값이 필요하다면 chars 메서드를 써요. “Зд”에 chars를 호출하면 char 타입의 값 두 개로 나뉘어 돌아오고, 그 결과를 순회하면서 각 요소에 접근할 수 있어요.
#![allow(unused)]
fn main() {
for c in "Зд".chars() {
println!("{c}");
}
}
이 코드는 다음을 출력해요.
З
д
번갈아서, bytes 메서드는 각 원시 바이트를 돌려주는데, 이것이 여러분의 도메인에 더 적합할 수 있어요.
#![allow(unused)]
fn main() {
for b in "Зд".bytes() {
println!("{b}");
}
}
이 코드는 이 문자열을 이루는 4바이트를 출력해요.
208
151
208
180
다만 유효한 유니코드 스칼라 값이 1바이트보다 클 수도 있다는 점은 꼭 기억해 두세요.
데바나가리 문자에서처럼 문자열에서 자소 클러스터를 얻는 건 복잡하기 때문에, 이 기능은 표준 라이브러리가 제공하지 않아요. 이런 기능이 필요하면 crates.io에서 크레이트를 구할 수 있어요.
문자열의 복잡성 다루기
정리하자면, 문자열은 복잡해요. 프로그래밍 언어마다 이 복잡성을 프로그래머에게 어떻게 드러낼지 다른 선택을 해요. 러스트는 모든 러스트 프로그램에서 String 데이터를 올바르게 다루는 것을 기본 동작으로 삼기로 했는데, 그 덕에 프로그래머는 UTF-8 데이터를 다루는 데 처음부터 더 많은 생각을 쏟아야 해요. 이런 트레이드오프는 다른 언어에서보다 문자열의 복잡성을 더 많이 드러내지만, 그 대신 개발 주기 후반에 비-ASCII 문자가 얽힌 오류를 처리해야 하는 일은 막아 줘요.
다행인 점은 표준 라이브러리가 이런 복잡한 상황을 올바르게 다루도록 돕는 기능을 String과 &str 타입에 많이 얹어 놨다는 거예요. 문자열에서 검색할 때 쓰는 contains나, 문자열의 일부를 다른 문자열로 치환하는 replace 같은 유용한 메서드들의 문서를 꼭 확인해 보세요.
이제 좀 덜 복잡한 걸로 넘어가 볼게요 — 바로 해시 맵이에요!
더 알아보기
- 컬렉션 (Collections) 챕터 전체 보기
- 문자열 인덱싱 오류 자세히 보기 (E0277)
- crates.io — 자소 클러스터 처리 크레이트 탐색