Rust 문자열 시리즈

  1. 왜 Rust는 문자열 타입이 두 개인가요?
  2. 왜 Rust엔 문자열 타입이 이렇게 많은가요?

한국 러스트 사용자 그룹의 디스코드 채널에 있다 보면 종종 “Rust에는 왜 이렇게 많은 문자열 타입이 있나요?”라는 질문을 보게 됩니다. 이번 기회에 이 질문에 대한 답을 조금씩 정리해보려고 합니다.

정말 Rust의 문자열 타입은 두 개인가요?

결론부터 말하자면, Rust의 문자열은 str 단 하나뿐입니다. 그 밖의 다른 문자열 타입들은 엄밀히 말하면 CStr이나 OsStr처럼 외부 호환성을 위한 타입이거나, String처럼 문자열을 위한 유틸리티 타입에 해당하죠. 나머지 타입에 대해서는 조만간 더 이야기할 기회가 있을 테니 여기서는 일단 strString에 대해서만 다루도록 합시다.

str의 구조

Rust는 유니코드를 지원하는 프로그래밍 언어이며, 문자 타입인 char도 유니코드 문자1를 넉넉히 담을 수 있는 4바이트의 크기를 갖고 있습니다. 그러면 자연스럽게 str도 4바이트 단위의 문자 배열로 이루어진 타입이겠죠? 하지만 실제로는 그렇지 않습니다. str은 다음과 같이 생겼습니다.

block-beta
columns 17
  A0["H"] A1["e"] A2["l"] A3["l"] A4["o"]
  space:12
  B0["안"]:3
  B3["녕"]:3
  B6[" "]
  B7["세"]:3
  B10["상"]:3
  B13["아"]:3
  B16["!"]

str은 연속된 u8들로 이루어진 바이트열이며, 이 바이트열은 유니코드 문자열을 UTF-8로 인코딩한 것입니다. Go 문자열과 비슷한 방식이죠. 주의점을 먼저 언급하자면, UTF-8은 가변 길이 인코딩이기 때문에, 우리가 다른 언어에서 기대했던 성질이 str에 대해서는 성립하지 않습니다. 예를 들어, Rust에서는 문자열의 다섯 번째 문자에 접근하기 위해 a[4]라고 쓸 수 없습니다. 위의 예시를 보면, "Hello"라는 문자열에서는 기대한 대로 문자 'o'을 얻을 수 있겠지만, "안녕 세상아!"라는 문자열에서는 다섯 번째 바이트로 문자 '녕'의 한가운데인 엉뚱한 0x85를 얻거나, 또는 문자 '상'을 찾기 위해 문자열의 시작부터 O(n) 순차 탐색을 해야 하죠. 다행히 Rust에는 충분한 수의 문자열 처리 메서드들이 있기 때문에 직접 문자열 조작을 바닥부터 구현할 일은 별로 없습니다. 유니코드까지 고려해서 문자열 조작을 올바르게 구현하는 일은 꽤나 어렵기도 하고요.

이런 결점에도 불구하고 UTF-8 바이트열을 쓰는 데에는 중요한 이유가 있습니다. 세상은 인터넷이 점령했고, 인터넷은 웹이 점령했으며, 웹에서 떠다니는 텍스트는 대부분 UTF-8이 점령했기 때문이죠. 인터넷을 떠 다니는 대부분의 텍스트가 이미 UTF-8로 인코딩되어 있기 때문에, 아주 간단한 검증을 거치면 입력으로 온 대부분의 문자열을 그대로 올바른 Rust 문자열로 간주할 수 있게 됩니다.

그런데 왜 str 대신 &str을 쓰나요?

위의 예시에서 보듯이 str의 길이는 가변적입니다. 그리고 컴퓨터의 메모리 구조와 연관된 모종의 이유 때문에 길이가 정해지지 않은 타입은 Rust에서 직접적으로 다룰 수 없습니다. 따라서 str은 메모리 어딘가에 기록되어 있는 실제 문자열의 범위를 가리키는 참조 형태로만 쓸 수 있습니다. 이것이 바로 문자열 슬라이스 타입인 &str의 정체입니다.

&str을 원본 문자열에 대한 일종의 부분문자열substring로 생각할 수 있습니다. 잘라낸 결과가 올바른 UTF-8인지 검사하는 약간의 비용을 제외하면, 아주 간단히 &str의 일부를 잘라서 새로운 &str을 만들 수 있습니다.

그러면 String의 정체는 무엇인가요?

다른 참조나 슬라이스 타입처럼 &str도 메모리의 어딘가에 있는 다른 문자열을 가리키는 타입입니다. 결국 어딘가에는 원본 문자열이 있어야겠죠. &'static str이 한 방법이 될 수 있겠지만, 이 타입은 전역 상수처럼 프로그램이 실행될 때부터 끝날 때까지 변하지 않는 타입이라 파일이나 네트워크 입출력처럼 동적으로 생성되는 문자열을 다루기에는 적합하지 않습니다. String은 이런 상황을 위해 만들어진 타입입니다.

String은 다른 &str로 가리킬 수 있는 ‘원본 문자열’을 관리하는 능력을 가진 타입입니다. 필요한 만큼 문자열을 String에 덧붙이거나 아예 덮어쓸 수 있고, 미리 할당한 메모리 공간이 모자라면 Vec<T>처럼 스스로 더 큰 공간을 마련해 주기도 하죠.2 또한 잘라내기 이외의 수정이 불가능한 &str과 달리 String이 보관하는 문자열은 자유롭게 수정할 수 있습니다.

언제 String을 쓰고 언제 &str을 써야 하나요?

대체적으로 i32&i32, 또는 Vec<i32>&[i32]의 관계와 비슷하게 생각하시면 됩니다.

  • 만약 문자열을 읽기만 하거나, 부분문자열을 자르기만 할 것이라면 &str로 충분합니다.
  • 문자열을 다른 쓰레드로 전달해야 하는데 &str밖에 없다면 String으로 변환할 필요가 있습니다.
  • 새로운 문자열을 만들어야 한다면 대체로 String에서부터 시작해야 합니다.
  • &mut [i32]와 다르게, &mut str은 대체로 쓸모가 없습니다. &mut str은 길이가 고정되어 있는데, UTF-8이 가변 길이 인코딩이기 때문에 대부분의 수정이 문자열의 바이트 길이를 바꿀 수 있기 때문이죠. 이런 상황에서는 &mut String을 사용하면 됩니다.

  1. 엄밀히는 유니코드 코드 포인트를 말하는데, 이것은 유니코드에서 문자를 나타내는 가장 작은 단위이긴 하지만 항상 실제 문자와 1:1 대응이 되는 것은 아닙니다. 예를 들어 ée´라는 두 개의 코드 포인트로 이루어져 있으며, 👨‍👩‍👧‍👦와 같은 에모지는 가능한 모든 가족 구성원 조합을 모두 표현하기 위해 무려 7개의 코드 포인트로 구성된 ZWJ sequence로 이루어져 있습니다. 

  2. &str이 사실상 &[u8]과 똑같은 구조인 것처럼, String은 아예 Vec<u8>을 감싼 형태로 구현되어 있습니다. https://doc.rust-lang.org/stable/src/alloc/string.rs.html#365-367