Rust 기초: String과 &str, 문자열이 두 개인 이유
Rust를 처음 배울 때 많은 분이 당황하는 지점이 있습니다.
문자열 타입이 두 개라는 겁니다. String도 있고 &str도 있는데, 대체 뭐가 다른 걸까요?
다른 언어에서는 문자열 하나면 충분했는데 Rust는 왜 이렇게 만들었을까요? 답은 Rust의 소유권 시스템에 있습니다. 누가 문자열 데이터를 소유하고 있느냐, 아니면 잠깐 빌려서 보고 있느냐를 타입으로 구분하는 거죠.
이 글에서는 String과 &str의 내부 구조부터 시작해서 언제 어떤 걸 쓰는지까지 정리해보겠습니다.
String의 내부 구조
String은 힙에 할당된 문자열 데이터를 소유하는 타입입니다.
내부적으로는 Vec<u8>을 감싸고 있어서 세 가지 정보를 스택에 저장합니다.
fn main() {
let s = String::from("hello");
println!("포인터: {:p}", s.as_ptr());
println!("길이: {}", s.len());
println!("용량: {}", s.capacity());
}
포인터: 0x600000cf4040
길이: 5
용량: 5
스택에는 힙 데이터를 가리키는 포인터, 현재 문자열의 바이트 길이, 그리고 할당된 버퍼의 용량이 저장됩니다.
실제 문자열 데이터(hello의 UTF-8 바이트)는 힙에 있고요.
String이 Vec<u8> 기반이라는 건 표준 라이브러리 소스를 보면 확인할 수 있습니다.
pub struct String {
vec: Vec<u8>,
}
Vec처럼 데이터를 소유하고 있기 때문에 크기를 늘리거나 줄이는 것도 자유롭습니다.
fn main() {
let mut s = String::from("hello");
println!("변경 전: {}, 용량: {}", s, s.capacity());
s.push_str(", world!");
println!("변경 후: {}, 용량: {}", s, s.capacity());
}
변경 전: hello, 용량: 5
변경 후: hello, world!, 용량: 13
용량이 부족하면 힙에 더 큰 버퍼를 새로 할당하고 데이터를 옮깁니다. 소유권을 가지고 있으니까 이런 변경이 가능한 거죠.
&str은 빌려보는 창
&str은 이미 어딘가에 존재하는 문자열 데이터를 빌려서 보는 슬라이스입니다.
스택에 포인터와 길이만 저장하고, 데이터 자체를 소유하지 않습니다.
fn main() {
// 문자열 리터럴은 바이너리에 포함된 데이터를 가리키는 &str
let greeting: &str = "hello";
// String에서 일부를 빌려볼 수도 있음
let s = String::from("hello, world!");
let slice: &str = &s[0..5];
println!("{}", greeting);
println!("{}", slice);
}
hello
hello
문자열 리터럴 "hello"는 프로그램 바이너리에 포함된 데이터를 가리키는 &str입니다.
프로그램이 실행되는 동안 쭉 유효하기 때문에 수명이 'static이죠.
&s[0..5]처럼 String의 일부분을 빌려와서 &str로 만들 수도 있습니다.
어느 쪽이든 &str은 데이터를 소유하지 않고 참조만 하고 있어서 내용을 변경할 수 없습니다.
String과 &str의 비교
둘의 차이를 표로 비교하면 이렇습니다.
| 특성 | String | &str |
|---|---|---|
| 소유권 | 데이터를 소유 | 빌려서 참조만 |
| 저장 위치 | 힙 (데이터) + 스택 (메타) | 스택 (포인터+길이만) |
| 변경 가능 | mut이면 가능 | 불가능 |
| 크기 | 런타임에 변경 가능 | 고정 |
| 메모리 구성 | 포인터 + 길이 + 용량 | 포인터 + 길이 |
&str에서 String으로
&str에서 String을 만드는 방법은 여러 가지입니다.
fn main() {
let s1 = String::from("hello");
let s2 = "hello".to_string();
let s3 = "hello".to_owned();
println!("{} {} {}", s1, s2, s3);
}
hello hello hello
셋 다 결과는 같습니다.
String::from()은 From 트레이트를 통한 변환이고, to_string()은 Display 트레이트를 통한 변환입니다.
to_owned()는 빌린 데이터의 소유 버전을 만들어주는 ToOwned 트레이트의 메서드고요.
어떤 걸 쓰든 상관없지만 프로젝트 내에서 일관성을 유지하는 게 좋습니다.
String에서 &str로
반대로 String에서 &str을 얻는 것은 더 간단합니다.
fn main() {
let s = String::from("hello");
let r1: &str = s.as_str();
let r2: &str = &s;
let r3: &str = &s[..];
println!("{} {} {}", r1, r2, r3);
}
hello hello hello
as_str()은 명시적인 변환이고, &s만 써도 됩니다.
&s가 되는 이유는 String이 Deref<Target = str>을 구현하고 있어서 역참조 강제가 자동으로 &String을 &str로 바꿔주기 때문입니다.
이 변환은 새로운 데이터를 할당하지 않고 기존 힙 데이터를 그대로 가리키기만 합니다.
&str → String은 힙에 복사가 일어나지만, String → &str은 복사 없이 참조만 만드는 거라 비용이 들지 않죠.
문자열 이어붙이기
String에 문자열을 추가하는 방법은 몇 가지가 있습니다.
push_str()은 &str을 뒤에 이어붙이고, push()는 문자 하나를 추가합니다.
fn main() {
let mut s = String::from("hello");
s.push_str(", world");
s.push('!');
println!("{}", s);
}
hello, world!
+ 연산자도 사용할 수 있는데, 왼쪽의 String 소유권을 가져간다는 점에 주의해야 합니다.
fn main() {
let s1 = String::from("hello");
let s2 = String::from(", world!");
let s3 = s1 + &s2; // s1은 이동됨, s2는 빌림
println!("{}", s3);
// println!("{}", s1); // 컴파일 에러! s1은 이미 이동됨
println!("{}", s2); // s2는 여전히 사용 가능
}
hello, world!
, world!
+ 연산자는 내부적으로 fn add(self, s: &str) -> String을 호출합니다.
self로 소유권을 받으니까 s1은 이동되고, 오른쪽은 &str로 빌려오기만 합니다.
여러 문자열을 합칠 때는 format! 매크로가 더 편합니다.
소유권을 가져가지 않거든요.
fn main() {
let s1 = String::from("Rust");
let s2 = String::from("프로그래밍");
let s3 = String::from("언어");
let result = format!("{} {} {}", s1, s2, s3);
println!("{}", result);
println!("{} {} {}", s1, s2, s3); // 모두 여전히 사용 가능
}
Rust 프로그래밍 언어
Rust 프로그래밍 언어
UTF-8과 인덱싱
Rust의 문자열은 항상 UTF-8로 인코딩됩니다.
그래서 s[0]처럼 인덱스로 접근하는 게 안 됩니다.
fn main() {
let s = String::from("안녕하세요");
// let ch = s[0]; // 컴파일 에러!
}
왜 안 될까요? UTF-8에서는 문자마다 차지하는 바이트 수가 다르기 때문입니다.
fn main() {
let ascii = "hello";
let korean = "안녕하세요";
println!("hello: {} 글자, {} 바이트", ascii.chars().count(), ascii.len());
println!("안녕하세요: {} 글자, {} 바이트", korean.chars().count(), korean.len());
}
hello: 5 글자, 5 바이트
안녕하세요: 5 글자, 15 바이트
영문자는 1바이트지만 한글은 3바이트입니다.
s[0]이 “첫 번째 바이트”를 의미하는지 “첫 번째 문자”를 의미하는지 모호하고, 바이트 단위로 접근하면 유효하지 않은 UTF-8이 나올 수 있어서 Rust는 아예 막아놓은 겁니다.
글자 단위로 순회하려면 chars()를, 바이트 단위로 보려면 bytes()를 사용합니다.
fn main() {
let s = "Rust 🦀";
print!("글자: ");
for ch in s.chars() {
print!("{} ", ch);
}
println!();
print!("바이트: ");
for b in s.bytes() {
print!("{} ", b);
}
println!();
}
글자: R u s t 🦀
바이트: 82 117 115 116 32 240 159 166 128
🦀 이모지가 4바이트를 차지하는 게 보이죠.
바이트 범위로 슬라이싱하는 것은 가능하지만, UTF-8 문자 경계를 벗어나면 패닉이 발생합니다.
fn main() {
let s = "안녕하세요";
let hello = &s[0..6]; // "안녕" (한글 2자 = 6바이트)
println!("{}", hello);
// let broken = &s[0..4]; // 패닉! 문자 경계를 벗어남
}
안녕
함수 매개변수는 &str로
함수를 작성할 때 문자열 매개변수는 &str로 받는 것이 좋습니다.
fn count_words(text: &str) -> usize {
text.split_whitespace().count()
}
fn main() {
// &str 직접 전달
println!("{}", count_words("hello world"));
// String도 역참조 강제로 전달 가능
let s = String::from("Rust is great");
println!("{}", count_words(&s));
}
2
3
&str로 받으면 &str과 &String 모두 넘길 수 있습니다.
String으로 받으면 호출하는 쪽에서 소유권을 넘기거나 복사해야 하니까 불필요한 비용이 생기죠.
함수가 문자열을 읽기만 한다면 &str로 충분합니다.
반대로 함수 안에서 문자열을 만들어 반환해야 한다면 String을 써야 합니다.
&str은 빌린 참조라 함수가 끝나면 원본이 사라질 수 있으니까요.
fn make_greeting(name: &str) -> String {
format!("안녕하세요, {}님!", name)
}
fn main() {
let greeting = make_greeting("Rust");
println!("{}", greeting);
}
안녕하세요, Rust님!
정리하면 “읽기만 하면 &str, 만들어서 돌려줄 때는 String”이 기본 원칙입니다.
마치며
Rust에서 문자열이 두 개인 이유는 결국 소유권 때문입니다.
String은 힙에 할당된 문자열을 소유하는 타입이고, &str은 그 데이터를 빌려서 보는 슬라이스입니다.
Vec과 슬라이스의 관계와 정확히 같은 구조죠.
&str → String은 힙 할당과 복사가 필요하고, String → &str은 참조만 만들면 되니까 거의 공짜입니다.
이 차이를 알고 나면 어디서 어떤 타입을 쓸지 자연스럽게 감이 옵니다.
나중에 Deref 트레이트를 배우면 String에서 &str로의 자동 변환이 어떻게 작동하는지 더 깊이 이해할 수 있고, AsRef 트레이트를 배우면 String과 &str을 모두 받는 유연한 함수를 설계하는 방법도 알게 될 겁니다.
더 자세한 내용은 std::string::String - Rust 공식 문서를 참고하세요.
This work is licensed under
CC BY 4.0