문자열을 사용하여 UTF-8 인코딩된 텍스트 저장

Beginner

This tutorial is from open-source community. Access the source code

소개

문자열로 UTF-8 인코딩 텍스트 저장하기에 오신 것을 환영합니다. 이 랩은 Rust Book의 일부입니다. LabEx 에서 Rust 기술을 연습할 수 있습니다.

이 랩에서는 Rust 에서 문자열의 복잡성, 특히 UTF-8 인코딩과 관련하여, 그리고 String 타입과 다른 컬렉션의 연산 및 차이점에 대해 논의할 것입니다.

문자열로 UTF-8 인코딩 텍스트 저장하기

4 장에서 문자열에 대해 이야기했지만, 이제 더 자세히 살펴보겠습니다. 새로운 Rust 사용자들은 세 가지 이유로 인해 문자열에서 자주 어려움을 겪습니다. Rust 가 가능한 오류를 드러내는 경향, 문자열이 많은 프로그래머들이 생각하는 것보다 더 복잡한 데이터 구조라는 점, 그리고 UTF-8 때문입니다. 이러한 요소들이 다른 프로그래밍 언어에서 온 사람들에게는 어려워 보일 수 있는 방식으로 결합됩니다.

문자열은 바이트의 컬렉션으로 구현되고, 해당 바이트가 텍스트로 해석될 때 유용한 기능을 제공하는 몇 가지 메서드를 포함하기 때문에 컬렉션의 맥락에서 문자열을 논의합니다. 이 섹션에서는 생성, 업데이트 및 읽기와 같이 모든 컬렉션 타입이 갖는 String의 연산에 대해 이야기할 것입니다. 또한 String이 다른 컬렉션과 어떻게 다른지, 즉, 사람과 컴퓨터가 String 데이터를 해석하는 방식의 차이로 인해 String의 인덱싱이 어떻게 복잡해지는지에 대해서도 논의할 것입니다.

문자열이란 무엇인가?

먼저 문자열이라는 용어가 의미하는 바를 정의하겠습니다. Rust 는 핵심 언어에 하나의 문자열 타입만 가지고 있으며, 이는 일반적으로 &str의 차용된 형태로 보이는 문자열 슬라이스 str입니다. 4 장에서 우리는 문자열 슬라이스에 대해 이야기했는데, 이는 다른 곳에 저장된 일부 UTF-8 인코딩된 문자열 데이터에 대한 참조입니다. 예를 들어, 문자열 리터럴은 프로그램의 바이너리에 저장되므로 문자열 슬라이스입니다.

Rust 의 표준 라이브러리에서 제공되며 핵심 언어에 코딩되지 않은 String 타입은 크기가 늘어나고, 변경 가능하며, 소유되고, UTF-8 인코딩된 문자열 타입입니다. Rust 사용자들은 Rust 에서 "문자열"을 언급할 때, 단지 하나의 타입만이 아닌 String 또는 문자열 슬라이스 &str 타입을 모두 지칭할 수 있습니다. 이 섹션은 주로 String에 대한 것이지만, 두 타입 모두 Rust 의 표준 라이브러리에서 널리 사용되며, String과 문자열 슬라이스 모두 UTF-8 로 인코딩됩니다.

새로운 문자열 생성하기

String은 실제로 몇 가지 추가적인 보장, 제한 및 기능을 갖춘 바이트 벡터의 래퍼로 구현되기 때문에, Vec<T>에서 사용할 수 있는 많은 동일한 연산이 String에서도 사용 가능합니다. Vec<T>String에서 동일하게 작동하는 함수의 예는 Listing 8-11 에 표시된 인스턴스를 생성하는 new 함수입니다.

let mut s = String::new();

Listing 8-11: 새로운 빈 String 생성하기

이 줄은 s라는 새로운 빈 문자열을 생성하며, 여기에 데이터를 로드할 수 있습니다. 종종, 문자열을 시작하려는 초기 데이터가 있을 것입니다. 이를 위해, 문자열 리터럴과 같이 Display 트레이트를 구현하는 모든 타입에서 사용할 수 있는 to_string 메서드를 사용합니다. Listing 8-12 는 두 가지 예를 보여줍니다.

let data = "initial contents";

let s = data.to_string();

// the method also works on a literal directly:
let s = "initial contents".to_string();

Listing 8-12: 문자열 리터럴로부터 String을 생성하기 위해 to_string 메서드 사용하기

이 코드는 initial contents를 포함하는 문자열을 생성합니다.

또한 String::from 함수를 사용하여 문자열 리터럴로부터 String을 생성할 수 있습니다. Listing 8-13 의 코드는 to_string을 사용하는 Listing 8-12 의 코드와 동일합니다.

let s = String::from("initial contents");

Listing 8-13: 문자열 리터럴로부터 String을 생성하기 위해 String::from 함수 사용하기

문자열은 매우 많은 용도로 사용되기 때문에, 우리는 문자열에 대해 다양한 제네릭 API 를 사용할 수 있으며, 이는 우리에게 많은 옵션을 제공합니다. 그 중 일부는 중복되어 보일 수 있지만, 모두 나름의 자리가 있습니다! 이 경우, String::fromto_string은 동일한 작업을 수행하므로, 어떤 것을 선택할지는 스타일과 가독성의 문제입니다.

문자열은 UTF-8 로 인코딩되므로, Listing 8-14 에 표시된 것처럼 적절하게 인코딩된 모든 데이터를 포함할 수 있습니다.

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");

Listing 8-14: 다양한 언어로 된 인사를 문자열에 저장하기

이 모든 것은 유효한 String 값입니다.

문자열 업데이트하기

String은 더 많은 데이터를 추가하면 Vec<T>의 내용과 마찬가지로 크기가 커지고 내용이 변경될 수 있습니다. 또한, + 연산자 또는 format! 매크로를 사용하여 String 값을 편리하게 연결할 수 있습니다.

push_strpush를 사용하여 문자열에 추가하기

Listing 8-15 에 표시된 것처럼 push_str 메서드를 사용하여 문자열 슬라이스를 추가하여 String을 늘릴 수 있습니다.

let mut s = String::from("foo");
s.push_str("bar");

Listing 8-15: push_str 메서드를 사용하여 문자열 슬라이스를 String에 추가하기

이 두 줄 이후, sfoobar를 포함하게 됩니다. push_str 메서드는 매개변수의 소유권을 가져갈 필요가 없기 때문에 문자열 슬라이스를 사용합니다. 예를 들어, Listing 8-16 의 코드에서 s2의 내용을 s1에 추가한 후에도 s2를 사용할 수 있기를 원합니다.

let mut s1 = String::from("foo");
let s2 = "bar";
s1.push_str(s2);
println!("s2 is {s2}");

Listing 8-16: String에 내용을 추가한 후 문자열 슬라이스 사용하기

push_str 메서드가 s2의 소유권을 가져갔다면, 마지막 줄에서 해당 값을 출력할 수 없을 것입니다. 하지만 이 코드는 우리가 예상한 대로 작동합니다!

push 메서드는 단일 문자를 매개변수로 받아 String에 추가합니다. Listing 8-17 은 push 메서드를 사용하여 문자 lString에 추가합니다.

let mut s = String::from("lo");
s.push('l');

Listing 8-17: push를 사용하여 String 값에 문자 하나 추가하기

결과적으로, slol을 포함하게 됩니다.

+ 연산자 또는 format! 매크로를 사용한 연결

종종 두 개의 기존 문자열을 결합해야 할 것입니다. 이를 수행하는 한 가지 방법은 Listing 8-18 에 표시된 것처럼 + 연산자를 사용하는 것입니다.

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

Listing 8-18: + 연산자를 사용하여 두 개의 String 값을 새로운 String 값으로 결합하기

문자열 s3Hello, world!를 포함하게 됩니다. 덧셈 후 s1이 더 이상 유효하지 않고, s2에 대한 참조를 사용한 이유는 + 연산자를 사용할 때 호출되는 메서드의 시그니처와 관련이 있습니다. + 연산자는 add 메서드를 사용하며, 해당 시그니처는 다음과 같습니다.

fn add(self, s: &str) -> String {

표준 라이브러리에서 제네릭 (generics) 과 연관된 타입 (associated types) 을 사용하여 정의된 add를 볼 수 있습니다. 여기서는 구체적인 타입을 대체했는데, 이는 String 값을 사용하여 이 메서드를 호출할 때 발생하는 일입니다. 제네릭에 대해서는 10 장에서 논의할 것입니다. 이 시그니처는 + 연산자의 까다로운 부분을 이해하는 데 필요한 단서를 제공합니다.

먼저, s2&를 가지고 있습니다. 이는 두 번째 문자열의 참조를 첫 번째 문자열에 추가한다는 의미입니다. 이는 add 함수의 s 매개변수 때문입니다. String&str만 추가할 수 있으며, 두 개의 String 값을 함께 추가할 수는 없습니다. 하지만 잠깐만요---&s2의 타입은 &str이 아니라 &String입니다. add의 두 번째 매개변수에 지정된 대로입니다. 그렇다면 Listing 8-18 이 컴파일되는 이유는 무엇일까요?

add 호출에서 &s2를 사용할 수 있는 이유는 컴파일러가 &String 인수를 &str로 *강제 변환 (coerce)*할 수 있기 때문입니다. add 메서드를 호출할 때 Rust 는 *역참조 강제 변환 (deref coercion)*을 사용하며, 여기서는 &s2&s2[..]로 변환합니다. 역참조 강제 변환에 대해서는 15 장에서 더 자세히 논의할 것입니다. adds 매개변수의 소유권을 가져가지 않기 때문에, 이 연산 후에도 s2는 여전히 유효한 String이 됩니다.

둘째, 시그니처에서 addself의 소유권을 가져가는 것을 볼 수 있습니다. 왜냐하면 self&가지고 있지 않기 때문입니다. 이는 Listing 8-18 의 s1add 호출로 이동하고 그 이후에는 더 이상 유효하지 않다는 것을 의미합니다. 따라서 let s3 = s1 + &s2;가 두 문자열을 모두 복사하여 새 문자열을 생성하는 것처럼 보이지만, 이 문은 실제로 s1의 소유권을 가져와 s2의 내용을 복사하여 추가한 다음 결과의 소유권을 반환합니다. 즉, 많은 복사를 하는 것처럼 보이지만 그렇지 않습니다. 구현은 복사보다 더 효율적입니다.

여러 문자열을 연결해야 하는 경우, + 연산자의 동작은 다루기 어려워집니다.

let s1 = String::from("tic");
let s2 = String::from("tac");
let s3 = String::from("toe");

let s = s1 + "-" + &s2 + "-" + &s3;

이 시점에서 stic-tac-toe가 됩니다. 모든 +" 문자로 인해 무슨 일이 일어나고 있는지 파악하기 어렵습니다. 더 복잡한 방식으로 문자열을 결합하려면 대신 format! 매크로를 사용할 수 있습니다.

let s1 = String::from("tic");
let s2 = String::from("tac");
let s3 = String::from("toe");

let s = format!("{s1}-{s2}-{s3}");

이 코드는 또한 stic-tac-toe로 설정합니다. format! 매크로는 println!과 유사하게 작동하지만, 출력을 화면에 인쇄하는 대신 내용을 포함하는 String을 반환합니다. format!을 사용하는 코드 버전은 훨씬 읽기 쉽고, format! 매크로에서 생성된 코드는 참조를 사용하므로 이 호출은 매개변수의 소유권을 가져가지 않습니다.

문자열 인덱싱 (Indexing)

다른 많은 프로그래밍 언어에서는 문자열 내의 개별 문자에 인덱스를 사용하여 참조하여 접근하는 것이 유효하고 일반적인 연산입니다. 그러나 Rust 에서 인덱싱 구문을 사용하여 String의 일부에 접근하려고 하면 오류가 발생합니다. Listing 8-19 의 잘못된 코드를 살펴보겠습니다.

let s1 = String::from("hello");
let h = s1[0];

Listing 8-19: String에 인덱싱 구문을 사용하려는 시도

이 코드는 다음과 같은 오류를 발생시킵니다.

error[E0277]: the type `String` cannot be indexed by `{integer}`
 --> src/main.rs:3:13
  |
3 |     let h = s1[0];
  |             ^^^^^ `String` cannot be indexed by `{integer}`
  |
  = help: the trait `Index<{integer}>` is not implemented for
`String`

오류와 노트는 그 이유를 설명합니다. Rust 문자열은 인덱싱을 지원하지 않습니다. 하지만 왜 안 될까요? 이 질문에 답하려면 Rust 가 메모리에 문자열을 저장하는 방식을 논의해야 합니다.

내부 표현 (Internal Representation)

StringVec<u8>을 감싸는 래퍼 (wrapper) 입니다. Listing 8-14 에서 올바르게 인코딩된 UTF-8 예제 문자열을 살펴보겠습니다. 먼저, 다음 문자열입니다.

let hello = String::from("Hola");

이 경우 len4가 되며, 이는 문자열 "Hola"를 저장하는 벡터가 4 바이트 길이임을 의미합니다. UTF-8 로 인코딩될 때 각 문자는 1 바이트를 차지합니다. 그러나 다음 줄은 예상 밖일 수 있습니다 (이 문자열은 아라비아 숫자 3 이 아닌 키릴 문자 대문자 Ze로 시작합니다).

let hello = String::from("Здравствуйте");

문자열의 길이가 얼마인지 묻는다면 12 라고 대답할 수 있습니다. 사실, Rust 의 대답은 24 입니다. 이는 "Здравствуйте"를 UTF-8 로 인코딩하는 데 필요한 바이트 수입니다. 이 문자열의 각 유니코드 스칼라 값은 2 바이트의 저장 공간을 차지하기 때문입니다. 따라서 문자열의 바이트에 대한 인덱스는 항상 유효한 유니코드 스칼라 값과 일치하지 않습니다. 이를 설명하기 위해 다음 잘못된 Rust 코드를 고려해 보겠습니다.

let hello = "Здравствуйте";
let answer = &hello[0];

이미 answer가 첫 번째 글자인 З가 아니라는 것을 알고 있습니다. UTF-8 로 인코딩될 때 З의 첫 번째 바이트는 208이고 두 번째 바이트는 151이므로, answer는 실제로 208이어야 하지만, 208은 그 자체로 유효한 문자가 아닙니다. 이 문자열의 첫 번째 글자를 요청한 경우 208을 반환하는 것은 사용자가 원하는 것이 아닐 것입니다. 그러나 이것이 Rust 가 바이트 인덱스 0 에서 가지고 있는 유일한 데이터입니다. 사용자는 문자열에 라틴 문자가 포함된 경우에도 바이트 값을 반환하는 것을 일반적으로 원하지 않습니다. 만약 &"hello"[0]이 바이트 값을 반환하는 유효한 코드였다면, h가 아닌 104를 반환했을 것입니다.

따라서 답은 예기치 않은 값을 반환하고 즉시 발견되지 않을 수 있는 버그를 방지하기 위해 Rust 는 이 코드를 전혀 컴파일하지 않고 개발 프로세스 초기에 오해를 방지한다는 것입니다.

바이트, 스칼라 값, 그리고 그래피미 클러스터 (Grapheme Clusters)! 세상에!

UTF-8 에 대한 또 다른 점은 Rust 의 관점에서 문자열을 볼 수 있는 세 가지 관련 방식이 있다는 것입니다: 바이트, 스칼라 값, 그리고 그래피미 클러스터 (우리가 글자라고 부르는 것에 가장 가까운 것).

Devanagari 스크립트로 작성된 힌디어 단어 "नमस्ते"를 살펴보면, 다음과 같은 u8 값의 벡터로 저장됩니다.

[224, 164, 168, 224, 164, 174, 224, 164, 184, 224, 165, 141, 224,
164, 164, 224, 165, 135]

이는 18 바이트이며, 컴퓨터가 궁극적으로 이 데이터를 저장하는 방식입니다. Rust 의 char 타입인 유니코드 스칼라 값으로 보면, 해당 바이트는 다음과 같습니다.

['न', 'म', 'स', '्', 'त', 'े']

여기에는 6 개의 char 값이 있지만, 네 번째와 여섯 번째는 글자가 아닙니다. 그 자체로는 의미가 없는 발음 구별 기호입니다. 마지막으로, 그래피미 클러스터로 보면, 사람이 힌디어 단어를 구성하는 4 개의 글자라고 부르는 것을 얻게 됩니다.

["न", "म", "स्", "ते"]

Rust 는 컴퓨터가 저장하는 원시 문자열 데이터를 해석하는 다양한 방법을 제공하여, 각 프로그램이 데이터가 어떤 언어로 되어 있든 필요한 해석을 선택할 수 있도록 합니다.

Rust 가 String을 인덱싱하여 문자를 가져오는 것을 허용하지 않는 마지막 이유는 인덱싱 연산이 항상 상수 시간 (O(1)) 을 갖도록 예상되기 때문입니다. 그러나 Rust 가 유효한 문자가 몇 개인지 결정하기 위해 처음부터 인덱스까지 내용을 살펴봐야 하므로, String으로 해당 성능을 보장하는 것은 불가능합니다.

문자열 슬라이싱 (Slicing Strings)

문자열을 인덱싱하는 것은 종종 좋지 않은 생각입니다. 문자열 인덱싱 연산의 반환 타입이 무엇이어야 하는지 명확하지 않기 때문입니다: 바이트 값, 문자, 그래피미 클러스터 또는 문자열 슬라이스 (string slice). 따라서 인덱스를 사용하여 문자열 슬라이스를 실제로 생성해야 하는 경우, Rust 는 더 구체적으로 지정하도록 요구합니다.

단일 숫자로 []를 사용하여 인덱싱하는 대신, 범위를 사용하여 특정 바이트를 포함하는 문자열 슬라이스를 생성할 수 있습니다.

let hello = "Здравствуйте";

let s = &hello[0..4];

여기서 s는 문자열의 처음 4 바이트를 포함하는 &str이 됩니다. 앞서 언급했듯이 각 문자는 2 바이트이므로 sЗд가 됩니다.

&hello[0..1]과 같이 문자의 바이트 일부만 슬라이싱하려고 하면, Rust 는 벡터에서 잘못된 인덱스에 접근한 경우와 마찬가지로 런타임에 패닉 (panic) 을 일으킵니다.

thread 'main' panicked at 'byte index 1 is not a char boundary;
it is inside 'З' (bytes 0..2) of `Здравствуйте`', src/main.rs:4:14

범위로 문자열 슬라이스를 생성할 때는 주의해야 합니다. 그렇게 하면 프로그램이 충돌할 수 있기 때문입니다.

문자열 반복 처리 메서드 (Methods for Iterating Over Strings)

문자열의 조각을 처리하는 가장 좋은 방법은 문자를 원하는지 바이트를 원하는지에 대해 명시적으로 지정하는 것입니다. 개별 유니코드 스칼라 값의 경우, chars 메서드를 사용하십시오. "Зд"에 대해 chars를 호출하면 두 개의 char 타입 값을 분리하여 반환하며, 각 요소에 접근하기 위해 결과를 반복 처리할 수 있습니다.

for c in "Зд".chars() {
    println!("{c}");
}

이 코드는 다음을 출력합니다.

З
д

또는, bytes 메서드는 각 원시 바이트를 반환하며, 이는 도메인에 적합할 수 있습니다.

for b in "Зд".bytes() {
    println!("{b}");
}

이 코드는 이 문자열을 구성하는 네 개의 바이트를 출력합니다.

208
151
208
180

하지만 유효한 유니코드 스칼라 값은 하나 이상의 바이트로 구성될 수 있다는 점을 기억하십시오.

Devanagari 스크립트와 같이 문자열에서 그래피미 클러스터를 가져오는 것은 복잡하므로, 이 기능은 표준 라이브러리에서 제공되지 않습니다. 이 기능이 필요한 경우 https://crates.io에서 크레이트 (crate) 를 사용할 수 있습니다.

문자열은 그렇게 단순하지 않습니다 (Strings Are Not So Simple)

요약하자면, 문자열은 복잡합니다. 다양한 프로그래밍 언어는 이 복잡성을 프로그래머에게 어떻게 제시할지에 대해 서로 다른 선택을 합니다. Rust 는 모든 Rust 프로그램에서 String 데이터의 올바른 처리를 기본 동작으로 선택했습니다. 이는 프로그래머가 UTF-8 데이터를 처리하는 데 더 많은 생각을 해야 함을 의미합니다. 이러한 트레이드오프는 다른 프로그래밍 언어에서 나타나는 것보다 문자열의 복잡성을 더 많이 드러내지만, 개발 수명 주기 후반에 비 ASCII 문자와 관련된 오류를 처리할 필요가 없도록 합니다.

다행스러운 점은 표준 라이브러리가 이러한 복잡한 상황을 올바르게 처리하는 데 도움이 되도록 String&str 타입을 기반으로 구축된 많은 기능을 제공한다는 것입니다. 문자열 내에서 검색하기 위한 contains 및 문자열의 일부를 다른 문자열로 대체하기 위한 replace와 같은 유용한 메서드에 대한 문서를 확인하십시오.

이제 조금 덜 복잡한 해시 맵 (hash map) 으로 전환해 보겠습니다!

요약

축하합니다! 문자열을 사용하여 UTF-8 인코딩된 텍스트 저장 (Storing UTF-8 Encoded Text With Strings) 랩을 완료했습니다. LabEx 에서 더 많은 랩을 연습하여 실력을 향상시킬 수 있습니다.