왜 Rust는 문자열 타입이 두 개인가요?
Rust 문자열 시리즈
한국 러스트 사용자 그룹의 디스코드 채널에 있다 보면 종종 “Rust에는 왜 이렇게 많은 문자열 타입이 있나요?”라는 질문을 보게 됩니다. 이번 기회에 이 질문에 대한 답을 조금씩 정리해보려고 합니다.
정말 Rust의 문자열 타입은 두 개인가요?
결론부터 말하자면, Rust의 문자열은 str
단 하나뿐입니다. 그 밖의 다른 문자열 타입들은 엄밀히 말하면 CStr
이나 OsStr
처럼 외부 호환성을 위한 타입이거나, String
처럼 문자열을 위한 유틸리티 타입에 해당하죠. 나머지 타입에 대해서는 조만간 더 이야기할 기회가 있을 테니 여기서는 일단 str
과 String
에 대해서만 다루도록 합시다.
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 대응이 되는 것은 아닙니다. 예를 들어
é
는e
와´
라는 두 개의 코드 포인트로 이루어져 있으며, 👨👩👧👦와 같은 에모지는 가능한 모든 가족 구성원 조합을 모두 표현하기 위해 무려 7개의 코드 포인트로 구성된 ZWJ sequence로 이루어져 있습니다. ↩ -
&str
이 사실상&[u8]
과 똑같은 구조인 것처럼,String
은 아예Vec<u8>
을 감싼 형태로 구현되어 있습니다. https://doc.rust-lang.org/stable/src/alloc/string.rs.html#365-367 ↩