6편에서 참조 규칙을 익혔다면, 이제 그 규칙이 실제 데이터에 어떻게 적용되는지 확인할 차례입니다. 문자열과 슬라이스는 Rust에서 "값을 통째로 복사하지 않고 부분을 빌려 쓰는" 대표적인 구조입니다. 이번 글에서는 String과 &str의 차이, 슬라이스를 자르는 법, 잘못 자르면 왜 컴파일러가 막는지까지 살펴봅니다.
6편에서 본 borrowing 규칙을 그대로 떠올리면 이해가 쉽습니다. 슬라이스도 결국 원본 데이터의 일부를 참조로 빌려 쓰는 방식이기 때문입니다.
String vs &str 한눈에 보기
- String: 힙에 저장되는 가변 문자열로, ownership을 가진 타입입니다. 길이를 늘이거나 줄일 수 있고, 소유권이 이동하면 원본을 더 이상 쓸 수 없습니다.
- &str: 문자열 슬라이스로, 실제 데이터의 일부분을 가리키는 불변 참조입니다. 리터럴(
"hello")도&'static str타입으로 표현됩니다.
둘 사이의 차이는 "누가 데이터를 소유하는가"입니다. String은 데이터를 직접 가지고 있고, &str은 그 데이터를 잠시 빌립니다. 이 감각을 잃지 않으면 슬라이스 규칙이 자연스럽게 이어집니다.
학습 상태를 문자열로 관리해 보기
6편에서 만든 학습 상태 예제를 확장해 보겠습니다. 이번에는 목표 목록을 문자열로 모아 두고, 첫 번째 목표만 슬라이스로 빼 옵니다.
fn main() {
let mut goals = String::from("ownership,borrowing,strings");
let first = first_goal(&goals);
println!("첫 번째 목표: {}", first);
goals.push_str(",structs");
println!("업데이트된 목표: {}", goals);
}
fn first_goal(goal_list: &String) -> &str {
let bytes = goal_list.as_bytes();
for (i, &item) in bytes.iter().enumerate() {
if item == b',' {
return &goal_list[0..i];
}
}
&goal_list[..]
}
슬라이스 문법 &goal_list[start..end]는 Rust가 제공하는 부분 문자열 기법입니다. as_bytes와 b',' 표현은 문자열을 바이트 단위로 살펴보겠다는 뜻입니다. Rust는 UTF-8을 기본으로 하기 때문에, 슬라이스 범위를 정할 때 반드시 문자 경계를 지켜야 합니다. 위 예제는 ASCII 문자만 등장하므로 안전합니다.
다만 모든 문자열이 이렇게 단순하지는 않습니다. 한 글자가 여러 바이트로 표현될 수도 있기 때문에, 문자 단위로 다루고 싶을 때는 chars()를 함께 떠올려야 합니다.
fn main() {
let text = "가";
println!("문자 수: {}", text.chars().count());
}
슬라이스 규칙과 안전 장치
슬라이스는 참조의 한 종류이므로, borrowing 규칙을 그대로 따릅니다.
- 슬라이스는 데이터를 복사하지 않습니다.
- 슬라이스를 만드는 순간, 해당 범위의 데이터를 불변 참조로 빌립니다.
- 슬라이스가 살아 있는 동안 원본을 가변 참조로 빌릴 수 없습니다.
fn main() {
let mut label = String::from("Rust");
let part = &label[0..2];
label.push_str("ace"); // error: part가 살아 있는 동안 label을 변경하려 함
println!("{}", part);
}
이 코드가 막히는 이유는 label.push_str가 문자열의 내부 버퍼를 재할당할 수도 있기 때문입니다. 슬라이스가 가리키고 있는 메모리가 더 이상 안전하지 않을 수 있으므로, Rust는 미리 차단합니다. 필요하다면 슬라이스를 사용한 뒤 scope을 좁혀 drop시키거나, 새 문자열에 복사해 둔 뒤 원본을 수정하세요.
참고로, 잘못된 문자 경계로 슬라이스를 자르려 하면 컴파일이 아니라 실행 중 panic이 날 수 있습니다. 그래서 UTF-8 문자열을 다룰 때는 바이트 경계와 문자 경계를 구분하는 습관이 중요합니다.
문자열과 슬라이스를 함수에 전달하기
문자열 관련 함수는 인자로 &str을 받도록 설계하는 경우가 많습니다. 그러면 String이든 문자열 리터럴이든 모두 넘겨줄 수 있기 때문입니다.
fn summarize(target: &str) {
println!("요약: {}", target);
}
fn main() {
let owned = String::from("borrowing 진행 중");
summarize(&owned); // String -> &str 자동 강제(coercion)
summarize("enum 예습"); // 문자열 리터럴도 &str
}
이 패턴은 API를 설계할 때 매우 흔합니다. 가능한 한 &str을 받고, 필요할 때만 String을 반환하거나 수정 권한이 필요하면 &mut String을 사용합니다.
CodeSandbox로 이어서 실습하기
아래 샌드박스는 CodeSandbox의 Rust starter입니다. 이번 글의 핵심 코드를 src/main.rs에 옮기고, cargo check와 cargo run 결과를 나란히 보면서 컴파일 메시지와 실행 출력을 비교해 보세요.
💬 댓글
이 글에 대한 의견을 남겨주세요