Rust 문자열 시리즈

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

지난 글에서도 언급했듯이, Rust가 인정하는 문자열 타입은 UTF-8로 인코드된 유니코드 바이트열 하나뿐입니다. UTF-8 인코딩은 주로 영미권에서 폭넓게 쓰이던 ASCII 텍스트와 하위호환되고, ASCII 호환성 덕에 유닉스와 리눅스 API를 뒤엎지 않고도 유니코드 텍스트를 다룰 수 있게 해 주죠. 그 덕에 인터넷을 떠돌아다니는 대다수의 텍스트를 UTF-8이 점령했기에, 별다른 변환을 거치지 않고도 대부분의 텍스트 데이타를 그대로 Rust 문자열로 다룰 수 있죠.

그러나 실제 세계에서 프로그래밍할 때는 때때로 UTF-8이 아닌 다양한 문자열을 만나게 됩니다. 예를 들어, 파일 경로는 종종 잘못 인코드된 UTF-8로 이루어질 수 있고, Java나 JavaScript 등이 만들어낸 UTF-16 문자열을 다뤄야 할 수도 있습니다. 아니면 EUC-KR이나 Shift_JIS 따위로 인코드된 낡은 파일을 읽어야 할 때도 있겠지요. Rust에서 제공되는 다양한 문자열 타입들은 이런 상황들을 다루기 위한 것입니다. 그러면 이제 어떤 타입들이 있는지 한 번 알아보겠습니다.

[u8]Vec<u8>

[u8]은 가장 기초적인 바이트열을 나타내는 타입입니다. 어떤 인코딩인지도 알 수 없고, 심지어 텍스트가 아닌 그저 8비트 정수의 나열이거나 바이너리 데이타일 수도 있습니다. 따지고 보면 그냥 슬라이스 타입인데 내용물이 8비트 정수인 u8일 뿐입니다. 때문에 [u8]::is_ascii처럼 약간의 문자열 관련 메서드가 있긴 하지만, &str처럼 다양한 문자열 연산이 제공되지는 않습니다.

내용물에 굳이 신경쓰지 않고 바이트열을 이곳에서 저곳으로 보내고 싶을 뿐이라면 이 타입만으로 충분할 수 있습니다. 하지만 만일 &[u8] 안에 든 텍스트에 관심이 있다면 &str로 변환하는 편이 좋겠죠. 다행히 &[u8] 안에 들어 있는 것이 올바른 UTF-8 문자열이라면, std::str::from_utf8 함수를 통해 추가적인 메모리 할당 없이 그대로 &str로 읽을 수 있습니다. 만일 EUC-KR 등의 다른 인코딩으로 된 텍스트라면, encoding_rs와 같은 별도의 크레이트를 사용할 수 있습니다.

String 타입이 str의 컨테이너 타입인 것처럼, Vec<u8> 타입을 [u8]의 컨테이너 타입으로 볼 수 있습니다. 뒤에 다른 바이트열을 덧붙이거나, 바이트열을 이리저리 잘라내거나 할 수 있는 것도 비슷합니다. 사실 Vec<u8>도 그저 일반적인 가변 길이 배열 타입이고 내용물이 u8일 뿐입니다. &[u8]과 마찬가지로, String::from_utf8 함수를 사용하면 추가 할당 없이 Vec<u8>String으로 변환할 수 있습니다.

소소한 팁으로, ""을 써서 문자열을 코드에 직접 쓸 수 있는 것처럼 b""을 써서 바이트열 리터럴을 만들 수도 있습니다.

let foo = "안녕하세요!";
let bar = b"Hello, world!";

다만 따로 타입을 &[u8]로 명시하지 않으면 슬라이스 대신 &[u8; 13]과 같이 고정된 길이의 배열 타입이 튀어나오는데, 이 때문에 종종 bar.as_slice() 또는 &bar[..]처럼 슬라이스로 변환해야 할 때가 있습니다.

CStrCString

&str은 스스로 문자열의 시작점과 길이를 둘 다 가지고 있습니다. Rust뿐만 아니라 대다수의 언어가 이렇게 문자열의 길이를 따로 보관하는 방식을 사용하죠. 반면 널 문자('\0')로 문자열의 끝을 표시하는 방식도 있는데, 길이를 따로 기록하는 방식에 비해 몇 바이트 정도의 메모리를 절약하지만 대신 문자열을 덧붙이거나 부분문자열을 구하는 등의 연산이 더 복잡해지고, 버퍼 오버플로 등의 보안 취약점을 유발하는 단점이 있어서 채택하는 언어가 별로 없습니다.

하지만 그 얼마 안 되는 언어 중 하필 C라는 언어가 유닉스 운영체제의 시스템 프로그래밍 언어로써 초대박을 치고 말았습니다. 그 탓에 리눅스와 윈도를 비롯한 다른 운영체제의 API도 C로 만들어버리고, OpenSSL과 같은 수많은 오픈소스 라이브러리들도 C로 작성되게 되었죠.1 그래서 이런 C 라이브러리를 호출하기 위해서 종종 C 방식의 문자열을 다룰 필요가 있습니다. 이럴 때 쓰이는 것이 CStrCString 타입입니다. 이름에서 쉽게 유추할 수 있듯이, &CStr&str에 대응되고 CStringString에 대응됩니다.

Rust에서 C 쪽으로 문자열을 보내려면, 문자열 안에 원래 \0이 들어 있던 게 아닌 이상에야 뒤에 널 문자를 하나 덧붙여야 하므로 &CStr로 바로 변환할 수는 없습니다. 대신 새 문자열을 할당하는 비용을 감수하고 CString::new를 쓸 필요가 있죠. 반대로 C에서 Rust로 UTF-8 문자열을 받을 때는 CStr::to_str로 추가 할당 없이 &CStr&str로 변환할 수 있습니다. 다만 C 문자열이 꼭 UTF-8로 인코드되어 있다는 법은 없으니 만일 다른 인코딩이 필요하다면 CStr::to_bytes로 바이트열로 바꾼 뒤 적절한 디코드 함수를 쓸 필요가 있겠습니다.

OsStrOsString

이 타입들은 주로 std::env처럼 Rust 표준 라이브러리를 통해 운영체제와 상호작용하는 과정에서 쓰입니다. str은 올바른 UTF-8 문자열이어야만 하는데, 리눅스 등의 운영체제에서 오는 값은 애초에 UTF-8이 아니거나, 혹은 UTF-8을 쓴다 하더라도 일부분이 깨진 텍스트를 포함할 수 있습니다. 이를 str의 규칙을 위반하거나 원본 값을 적당히 뭉개지 않고 원본 그대로 받아오기 위해 OsStr 타입을 사용하죠. 지금까지의 흐름대로, OsStringOsStr의 컨테이너 타입입니다. 그리고 이 타입 자체는 C 문자열이 아니므로, CStr과 다르게 뒤에 널 문자가 붙어있거나 하진 않습니다.

주의할 점이 하나 있는데, 윈도 API가 UTF-16으로 인코드된 와이드 문자열을 사용하기 때문에 OsStr도 윈도에서 내부 표현으로 u8이 아닌 u16을 사용할 것 같지만 실제로는 그렇지 않다는 것입니다. 대신 WTF-8이라고 하는 이상한 이름의 인코딩을 사용하는데, UTF-8의 슈퍼셋이라 추가 할당 없이 &str을 그대로 &OsStr로 바꿀 수 있게 해 주고, 윈도 API의 응답으로 나올 수 있는 망가진 UTF-16 표현을 손실 없이 상호 변환할 수 있는 특수한 능력이 있습니다.2

&OsStr이 올바른 UTF-8 문자열을 담고 있다면 OsStr::to_str 메서드를 써서 &str로 변환할 수 있습니다. 만일 리눅스와 같은 운영체제라면, OsStr::from_bytesOsStr::as_bytes 메서드를 써서 &[u8]과 상호 변환도 가능합니다. 윈도의 경우에는 추가 할당 없이 OsStr&[u16]으로 변환할 수는 없으나, 대신 OsStr::encode_wide 메서드를 써서 내용물을 u16의 나열로 순회할 수는 있습니다.

PathPathBuf

파일 경로도 운영체제와 연관된 문자열이기 때문에 OsStr로 다룰 수 있으나, 파일 경로를 위한 이런저런 연산을 하려면 별도의 타입으로 구분하는 편이 편리할 것입니다. 예를 들면 파일 경로에서 부모 디렉토리를 구하거나, 새로운 경로를 뒤에 덧붙이거나, .. 등이 포함된 경로를 정규화하는 등의 작업이 있겠죠.

PathPathBuf는 이런 필요를 위해 만들어진 타입으로, 파일 경로에 대한 다양한 메서드를 제공합니다. 리눅스와 윈도가 디렉토리 구분자를 각각 /\로 다르게 쓰는 문제 등도 이 타입이 알아서 처리해 줍니다.

안타깝게도 Path…Str 돌림자로 끝나지 않기 때문에, 대응되는 컨테이너 타입의 이름이 PathString 같은 게 아니라 PathBuf가 되었습니다. 어떻게 보면 String보다 좀 더 역할의 본질을 잘 드러내는 이름이 아닐까 싶네요.

요약

이제 필요에 따라 다음 중에서 용도에 알맞는 타입을 선택할 수 있겠습니다.

str String Rust 코드 내에서 통용되는, 올바르게 UTF-8로 인코드된 유니코드 문자열.
[u8] Vec<u8> 아무 바이트열, EUC-KR 등의 다른 인코딩으로 인코드되었을 수도, 혹은 아예 텍스트가 아닐 수도 있음.
CStr CString 끝이 '\0' 널 문자로 끝나는, C에서 통용되는 문자열. [u8]과 마찬가지로 무슨 인코딩인지는 알 수 없음.
OsStr OsString 운영체제에서 온 문자열. 대체로 유니코드이지만 일부가 깨져 있을 수도 있음.
Path PathBuf 파일 경로. 내용물은 OsStr과 같음.

🐄?

std::str::from_utf8처럼 Rust 바깥 세계의 문자열을 &str이나 String으로 변환하는 함수는 보통 이렇게 생겼습니다.

fn from_utf8(v: &[u8]) -> Result<&str, Utf8Error>

입력이 완전히 올바른 UTF-8 문자열이면 Ok(&str)을 주고, 조금이라도 깨진 부분이 있으면 모두 오류로 간주해서 Err(Utf8Error)를 던지죠. Utf8Error::valid_up_to와 같은 수단으로 일부 올바른 문자열을 건져낼 수도 있겠으나, 그냥 깨진 부분을 적당히 뭉개고 싶을 때도 있게 마련입니다.

다행히 String::from_utf8_lossy처럼 그런 방법이 있긴 합니다만, 이제 우리는 예상치 못한 이상한 타입과 마주하게 됩니다.

fn from_utf8_lossy(v: &[u8]) -> Cow<'_, str>

대체 Rust에서 뜬금없이 소cow가 왜 등장하는 걸까요? 다음 시간에 이어서 알아봅시다.


  1. 이 떡밥에 관심이 있으시다면 다음 트윗을 참조하세요. https://twitter.com/m11c_/status/1579444766445797377 

  2. 자세한 내용은 Rust RFC 517을 참고하세요.