[Rust 시리즈 7편] 슬라이스와 문자열 안전하게 다루기

English version

6편에서 참조 규칙을 익혔다면, 이제 그 규칙이 실제 데이터에 어떻게 적용되는지 확인할 차례입니다. 문자열과 슬라이스는 Rust에서 "값을 통째로 복사하지 않고 부분을 빌려 쓰는" 대표적인 구조입니다. 이번 글에서는 String&str의 차이, 슬라이스를 자르는 법, 잘못 자르면 왜 컴파일러가 막는지까지 살펴봅니다.

6편에서 본 borrowing 규칙을 그대로 떠올리면 이해가 쉽습니다. 슬라이스도 결국 원본 데이터의 일부를 참조로 빌려 쓰는 방식이기 때문입니다.

String vs &str 한눈에 보기

  1. String: 힙에 저장되는 가변 문자열로, ownership을 가진 타입입니다. 길이를 늘이거나 줄일 수 있고, 소유권이 이동하면 원본을 더 이상 쓸 수 없습니다.
  2. &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_bytesb',' 표현은 문자열을 바이트 단위로 살펴보겠다는 뜻입니다. 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 checkcargo run 결과를 나란히 보면서 컴파일 메시지와 실행 출력을 비교해 보세요.

Live Practice

Rust Practice Sandbox

CodeSandbox

Run the starter project in CodeSandbox, compare it with the lesson code, and keep experimenting.

Rust startercargoterminal
  1. starter를 fork한 뒤 src/main.rs를 연다
  2. 본문 예제를 붙여 넣고 cargo check와 cargo run을 차례로 실행한다
  3. 타입, 값, 참조 흐름을 바꿔 컴파일 피드백과 출력 차이를 비교한다

Rust 실습은 브라우저 미리보기보다 터미널 피드백이 더 중요합니다. 여러 파일 구조나 추가 crate가 필요한 예제는 파일 배치를 조금 더 손봐야 할 수 있습니다.

손으로 따라하기

  • UTF-8 이모지가 포함된 문자열에서 첫 단어를 슬라이스로 추출할 때 어떤 오류가 나는지 직접 확인해 보세요.
  • String 벡터를 받아, 각 항목의 길이만 슬라이스로 계산해 Vec<usize>를 반환하는 함수를 작성해 보세요.
  • 학습 목표를 Vec<String>으로 분리한 뒤, &str 슬라이스로 화면에 출력하는 예제를 만들어 보세요.

마무리

슬라이스는 데이터를 복사하지 않고 일부만 참조할 수 있게 만드는 도구입니다. String&str의 ownership 차이를 이해하면, 슬라이스가 왜 불변 참조인지, 왜 동시에 가변 참조와 겹칠 수 없는지 자연스럽게 납득할 수 있습니다. 다음 글에서는 구조체와 메서드를 만들면서 문자열·슬라이스를 구조 안에서 어떻게 관리하는지 살펴보겠습니다.

💬 댓글

이 글에 대한 의견을 남겨주세요