Rust String vs &str 완벽 비교 | 문자열 타입 선택 가이드
이 글의 핵심
Rust String vs &str 비교 - 소유권, 메모리, 성능 차이와 선택 기준
들어가며
“String과 &str 중 무엇을 써야 할까요?” Rust를 배울 때 가장 헷갈리는 부분입니다. 이 글에서는 String과 &str의 차이를 명확히 이해하고, 상황에 맞는 타입을 선택하는 방법을 다룹니다.
비유로 말씀드리면, String은 내 책장에 꽂아 소유하는 책, &str은 도서관에서 잠시 빌려 읽는 구절에 가깝습니다. 수정이 필요하면 보통 String으로 만들고, 함수 인자로 읽기만 할 때는 **&str**이 자연스럽습니다.
언제 String을, 언제 &str을 쓰나요?
| 관점 | String | &str |
|---|---|---|
| 성능 | 힙 할당·가변 버퍼 | 참조만—가장 가벼운 읽기 |
| 사용성 | 소유가 필요할 때 | 리터럴·부분 문자열 뷰 |
| 적용 시나리오 | 수집·누적·변경 | 파싱·검색·함수 파라미터 |
이 글을 읽으면
- String과 &str의 메모리 구조를 이해합니다
- 소유권 관점에서 차이를 배웁니다
- 성능과 메모리 사용량 차이를 익힙니다
- 실전에서 어떤 것을 써야 하는지 판단할 수 있습니다
목차
1. 빠른 비교표
| 특성 | String | &str |
|---|---|---|
| 타입 | 소유 타입 | 빌린 타입 (참조) |
| 메모리 | 힙 할당 | 스택 또는 정적 |
| 가변성 | 가변 | 불변 |
| 크기 | 동적 | 고정 |
| 수명 | 소유자가 제어 | 빌림 규칙 적용 |
| 용도 | 소유권 필요 | 읽기만 필요 |
| 함수 인자 | String (소유권 이동) | &str (권장) |
| 반환값 | String (소유권 반환) | &str (수명 주의) |
2. 메모리 구조
String: 힙 할당
let s = String::from("hello");
// 메모리 구조
// 스택:
// ptr: 0x12345678 (힙 주소)
// len: 5
// cap: 5
//
// 힙:
// [h][e][l][l][o]
&str: 슬라이스 (참조)
let s: &str = "hello"; // 문자열 리터럴 (정적 메모리)
// 메모리 구조
// 스택:
// ptr: 0x87654321 (정적 메모리 주소)
// len: 5
//
// 정적 메모리 (.rodata):
// [h][e][l][l][o]
슬라이스 생성
let s = String::from("hello world");
let hello: &str = &s[0..5]; // "hello"
let world: &str = &s[6..11]; // "world"
// 메모리 구조
// 스택:
// s.ptr: 0x12345678
// s.len: 11
// s.cap: 11
// hello.ptr: 0x12345678 (같은 힙 주소)
// hello.len: 5
// world.ptr: 0x1234567E (s.ptr + 6)
// world.len: 5
//
// 힙:
// [h][e][l][l][o][ ][w][o][r][l][d]
// ^^^^^ ^^^^^
// hello world
3. 소유권과 빌림
String: 소유권
fn take_ownership(s: String) {
println!("{}", s);
} // s가 여기서 drop됨
let s = String::from("hello");
take_ownership(s);
// println!("{}", s); // ❌ error: value borrowed after move
&str: 빌림
fn borrow(s: &str) {
println!("{}", s);
} // 빌림만 하므로 drop 안 됨
let s = String::from("hello");
borrow(&s);
println!("{}", s); // ✅ 여전히 사용 가능
함수 시그니처 선택
// ❌ 나쁜 패턴: 소유권 가져가기
fn process(s: String) {
println!("{}", s);
}
let s = String::from("hello");
process(s);
// s를 더 이상 사용할 수 없음
// ✅ 좋은 패턴: 빌림
fn process(s: &str) {
println!("{}", s);
}
let s = String::from("hello");
process(&s);
// s를 계속 사용 가능
4. 성능 비교
할당 비용
// String: 힙 할당 (느림)
let s1 = String::from("hello");
let s2 = s1.clone(); // 힙 메모리 복사
// &str: 포인터 복사 (빠름)
let s1: &str = "hello";
let s2 = s1; // 포인터만 복사 (8 bytes)
벤치마크
use std::time::Instant;
// String 생성
let start = Instant::now();
for _ in 0..1_000_000 {
let _ = String::from("hello");
}
println!("String: {:?}", start.elapsed()); // 약 50ms
// &str 복사
let start = Instant::now();
let s: &str = "hello";
for _ in 0..1_000_000 {
let _ = s;
}
println!("&str: {:?}", start.elapsed()); // 약 0.1ms (500배 빠름)
5. 변환 방법
String → &str
let s = String::from("hello");
// 방법 1: 참조
let slice: &str = &s;
// 방법 2: as_str()
let slice: &str = s.as_str();
// 방법 3: 슬라이스
let slice: &str = &s[..];
&str → String
let s: &str = "hello";
// 방법 1: to_string()
let owned: String = s.to_string();
// 방법 2: String::from()
let owned: String = String::from(s);
// 방법 3: to_owned()
let owned: String = s.to_owned();
언제 변환하나?
// ✅ 좋은 패턴: 필요할 때만 String 생성
fn process(s: &str) -> String {
if s.is_empty() {
return String::from("default");
}
// 수정이 필요하면 String으로 변환
let mut result = s.to_string();
result.push_str(" processed");
result
}
// ❌ 나쁜 패턴: 불필요한 변환
fn process(s: &str) -> &str {
let owned = s.to_string(); // 불필요한 할당
&owned // ❌ error: returns reference to local variable
}
6. 실전 선택 가이드
함수 매개변수
// ✅ 기본: &str (유연함)
fn print_message(msg: &str) {
println!("{}", msg);
}
print_message("hello"); // 리터럴
print_message(&String::from("hello")); // String
print_message(&my_string[..]); // 슬라이스
// ❌ String (소유권 필요 시만)
fn consume_message(msg: String) {
// msg를 소비하거나 저장할 때만
}
구조체 필드
// 소유권 필요 → String
struct User {
name: String, // 구조체가 소유
email: String,
}
// 빌림 → &str (수명 매개변수 필요)
struct UserRef<'a> {
name: &'a str, // 다른 곳에서 빌림
email: &'a str,
}
// 실전: 대부분 String 사용
// &str은 수명 관리가 복잡함
반환값
// ✅ String 반환 (소유권 이동)
fn create_greeting(name: &str) -> String {
format!("Hello, {}!", name)
}
// ❌ &str 반환 (수명 문제)
fn create_greeting(name: &str) -> &str {
let greeting = format!("Hello, {}!", name);
&greeting // ❌ error: returns reference to local variable
}
// ✅ &str 반환 (입력 수명과 연결)
fn first_word(s: &str) -> &str {
s.split_whitespace().next().unwrap_or("")
}
7. 흔한 실수
실수 1: String을 함수 인자로
// ❌ 나쁜 패턴
fn print(s: String) {
println!("{}", s);
}
let s = String::from("hello");
print(s);
// s를 더 이상 사용할 수 없음
// ✅ 좋은 패턴
fn print(s: &str) {
println!("{}", s);
}
let s = String::from("hello");
print(&s);
// s를 계속 사용 가능
실수 2: 불필요한 to_string()
// ❌ 나쁜 패턴
fn process(s: &str) {
let owned = s.to_string(); // 불필요한 할당
println!("{}", owned);
}
// ✅ 좋은 패턴
fn process(s: &str) {
println!("{}", s); // 그냥 사용
}
실수 3: &str 반환 시 수명 문제
// ❌ 컴파일 안 됨
fn get_name() -> &str {
let name = String::from("Alice");
&name // error: returns reference to local variable
}
// ✅ String 반환
fn get_name() -> String {
String::from("Alice")
}
// ✅ 정적 수명
fn get_default_name() -> &'static str {
"Guest"
}
마무리
Rust 문자열 타입 선택의 핵심:
- 함수 인자는 &str (유연함)
- 소유권 필요 시 String (구조체 필드, 반환값)
- 읽기만 필요하면 &str (성능 좋음)
- 수명 관리 복잡하면 String (안전함)
핵심: 기본은 &str, 소유권이 필요할 때만 String을 사용하세요.
FAQ
Q1. String과 &str 중 뭐가 더 빠른가요?
&str이 더 빠릅니다 (힙 할당 없음). 하지만 소유권이 필요하면 String을 써야 합니다.
Q2. 구조체 필드는 항상 String인가요?
대부분 String을 사용합니다. &str은 수명 매개변수가 필요하여 복잡해집니다.
Q3. format! 매크로는 String을 반환하나요?
네, format!은 항상 String을 반환합니다.
관련 글
- Rust 소유권 완벽 가이드
- Rust 빌림과 참조
- Rust 수명
키워드
Rust, String, str, 문자열, 소유권, 빌림, 수명, 성능, 메모리, 비교, 선택 가이드