[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을 사용합니다.

손으로 따라하기

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

마무리

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

💬 댓글

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