Tech Wiki

TOPICSSERIES

[Rust 실전 로드맵 09] Rust 슬라이스와 문자열: `&str`와 `String` 선택 기준

읽기만 하는 함수가 String을 요구하면 호출자는 텍스트의 소유권을 넘기거나 불필요한 복사를 해야 합니다. 반대로 요청 본문에서 잘라 낸 &str을 데이터베이스나 작업 큐에 그대로 보관할 수는 없습니다. Rust 문자열 API의 선택 기준은 단순합니다. 잠깐 읽거나 파싱할 때는 빌려 씁니다. 입력보다 오래 보관할 때는 소유합니다.

이 글에서는 이름 | URL | 태그 형식의 엔드포인트 한 줄을 파싱합니다. 파서는 입력을 복사하지 않고 &str 필드를 반환합니다. 저장 경계에서만 String으로 바꿉니다. 한글 URL과 이름을 사용해 UTF-8 경계도 함께 확인합니다. 완성된 예제는 Rust 2024 edition의 독립 Cargo 프로젝트입니다.

cd examples/article-09-slices-strings
cargo run --quiet -- '서울 API | https://example.com/상태 | critical, internal'
name=서울 API
url=https://example.com/상태
tags=critical, internal
preview=서울

1. 텍스트 보관 기간부터 정하기

String은 소유되고 크기를 늘릴 수 있는 UTF-8 문자열입니다. 버퍼를 직접 소유하므로 함수 밖으로 옮겨 저장할 수 있고 내용을 수정할 수도 있습니다. &str은 다른 곳에 저장된 유효한 UTF-8 텍스트를 빌린 뷰입니다. 문자열 리터럴도 &str이며 String의 일부를 빌려도 &str입니다.

공개 API에서는 다음 기준이 잘 맞습니다.

  • 함수가 텍스트를 읽기만 하면 &str을 받습니다. 문자열 리터럴과 String 참조를 모두 받을 수 있습니다.
  • 함수가 텍스트를 보관하거나 수정한 결과를 반환하면 String을 씁니다.
  • 호출자가 이미 가진 소유 문자열을 함수가 보관해야 한다면 String을 값으로 받으면 됩니다. 복사가 필요 없습니다.
  • 여러 원소를 읽기만 한다면 &[T]를 받습니다. 배열과 Vec<T>를 같은 함수에 전달할 수 있습니다.

&String이 틀린 타입은 아닙니다. 다만 읽기 전용 매개변수로는 &str보다 받을 수 있는 입력이 좁습니다. 문자열을 소유할 필요가 없는 함수의 매개변수를 &String에서 &str로 바꾸면 String과 문자열 슬라이스를 모두 받을 수 있습니다.

2. 파싱 중 입력 빌리기

엔드포인트 파서는 원본 한 줄 안의 조각을 가리키는 구조체를 만듭니다.

#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct BorrowedEndpoint<'a> {
    pub name: &'a str,
    pub url: &'a str,
    tags: &'a str,
}

impl BorrowedEndpoint<'_> {
    pub fn tags(&self) -> impl Iterator<Item = &str> {
        self.tags
            .split(',')
            .map(str::trim)
            .filter(|tag| !tag.is_empty())
    }
}

pub fn parse_endpoint_line(line: &str) -> Result<BorrowedEndpoint<'_>, ParseError> {
    let mut fields = line.split('|').map(str::trim);
    let name = fields
        .next()
        .filter(|value| !value.is_empty())
        .ok_or(ParseError::MissingName)?;
    let url = fields
        .next()
        .filter(|value| !value.is_empty())
        .ok_or(ParseError::MissingUrl)?;
    let tags = fields.next().ok_or(ParseError::MissingTags)?;

    if fields.next().is_some() {
        return Err(ParseError::ExtraField);
    }
    if !(url.starts_with("http://") || url.starts_with("https://")) {
        return Err(ParseError::UnsupportedScheme);
    }

    Ok(BorrowedEndpoint { name, url, tags })
}

splittrim은 새 String을 만들지 않습니다. name, url, tags는 모두 line 안을 가리킵니다. 반환 타입의 '_는 반환된 구조체가 입력보다 오래 살 수 없다는 관계를 컴파일러가 추론하게 합니다. 호출자가 원본 String을 지우거나 수정하려 하면 빌린 값의 사용이 끝날 때까지 컴파일러가 막습니다.

태그도 즉시 Vec<String>으로 만들지 않았습니다. tags()를 순회할 때 쉼표로 나누므로 검증 단계에서 필요한 태그만 읽을 수 있습니다. 입력 한 줄의 수명이 요청 처리 전체를 덮는다면 이 구조만으로 충분합니다.

3. 저장 경계의 소유권

파싱 결과가 요청 버퍼보다 오래 살아야 하는 순간에는 복사가 필요합니다. 캐시, 작업 큐, 데이터베이스 레코드가 그 경계입니다. 예제는 변환 위치를 From 구현에 모았습니다.

#[derive(Debug, Clone, PartialEq, Eq)]
pub struct OwnedEndpoint {
    pub name: String,
    pub url: String,
    pub tags: Vec<String>,
}

impl From<BorrowedEndpoint<'_>> for OwnedEndpoint {
    fn from(endpoint: BorrowedEndpoint<'_>) -> Self {
        Self {
            name: endpoint.name.to_owned(),
            url: endpoint.url.to_owned(),
            tags: endpoint.tags().map(str::to_owned).collect(),
        }
    }
}

여기서는 to_owned()가 실제 할당 지점입니다. 파서 안에서 무조건 복사하는 대신 보관하기로 결정한 호출자만 비용을 냅니다. 반대로 함수가 소유권을 넘겨받은 String을 그대로 구조체에 넣을 수 있다면 다시 to_owned()를 호출할 이유가 없습니다.

여러 줄을 받는 함수는 구체적인 컨테이너 대신 슬라이스를 사용합니다.

pub fn parse_endpoint_lines(lines: &[&str]) -> Result<Vec<OwnedEndpoint>, ParseError> {
    lines
        .iter()
        .map(|line| parse_endpoint_line(line).map(OwnedEndpoint::from))
        .collect()
}

&[&str]은 연속된 &str 원소를 빌립니다. 호출자는 배열에 &lines를 쓸 수 있습니다. Vec<&str>에는 &lines 또는 lines.as_slice()를 쓸 수 있습니다. 함수는 컨테이너를 소유하지 않으며 원소 수도 타입에 고정하지 않습니다.

4. 문자열 인덱싱이 없는 이유

Rust의 Stringstr은 UTF-8입니다. 그런데 text.len()은 문자의 개수가 아니라 바이트 수를 반환합니다. 한글 "상태"는 Unicode scalar value 두 개지만 UTF-8에서는 6바이트입니다. 단일 숫자 0이 첫 바이트, 첫 Unicode scalar value, 사용자가 보는 첫 글자 중 무엇을 뜻하는지 정할 수 없습니다.

그래서 다음 코드는 컴파일되지 않습니다. 예제 저장소에서 rustc --edition 2024 compile_fail/string_index.rs를 실행해 E0277이 출력되어야 합니다. 아래 텍스트 블록은 전체 표준 오류 중 첫 줄만 옮긴 축약 발췌입니다.

fn main() {
    let first = "상태"[0];
    println!("{first}");
}
error[E0277]: the type `str` cannot be indexed by `{integer}`

문자열 인덱싱에는 성능 제약도 있습니다. N번째 문자를 찾으려면 UTF-8을 앞에서부터 해석해야 하므로 문자 기준 임의 접근을 O(1)로 보장할 수 없습니다. 필요한 단위를 API에서 직접 고르는 편이 낫습니다.

  • 원시 데이터가 필요하면 bytes() 또는 as_bytes()를 씁니다.
  • Unicode scalar value 단위면 chars()char_indices()를 씁니다.
  • 사람이 보는 문자 단위인 grapheme cluster가 필요하면 표준 라이브러리 밖의 Unicode 분할 기능이 필요합니다. 하나의 char가 화면의 한 글자와 항상 같지는 않습니다.

5. UTF-8 경계에서 자르기

범위 슬라이스는 가능하지만 숫자는 바이트 오프셋입니다. &text[..1]처럼 한글 인코딩 중간을 자르면 런타임 패닉이 납니다. 외부에서 받은 바이트 범위라면 get을 사용해 Option<&str>을 받는 편이 안전합니다. 테스트는 "상태".get(..1)None, 첫 글자의 끝인 get(..3)Some("상")인지 확인합니다.

문자 수로 미리보기를 만들려면 먼저 char_indices()에서 유효한 바이트 경계를 얻을 수 있습니다.

#[must_use]
pub fn preview_chars(text: &str, limit: usize) -> &str {
    let end = text
        .char_indices()
        .nth(limit)
        .map_or(text.len(), |(index, _)| index);
    text.get(..end).unwrap_or(text)
}

char_indices()가 주는 인덱스는 각 char가 시작하는 바이트 위치입니다. 따라서 get(..end)는 UTF-8 중간을 가르지 않습니다. limit이 문자 수보다 크면 전체 문자열을 반환합니다. 이 함수의 기준은 Unicode scalar value이며 grapheme cluster가 아닙니다. 결합 문자나 가족 이모지를 화면 단위로 줄여야 하는 UI에는 별도의 grapheme 처리가 필요합니다.

6. 한글과 소유권 테스트

예제의 여섯 테스트는 파싱 오류뿐 아니라 Unicode와 수명 선택도 고정합니다. 다음 테스트는 파싱 결과가 원본을 빌린다는 점, 소유 형태로 바꾸면 원본 버퍼가 사라진 뒤에도 값이 남는다는 점을 확인합니다.

#[test]
fn parser_borrows_korean_and_unicode_fields() {
    let line = "서울 API | https://example.com/상태 | 중요, 내부";
    let endpoint = parse_endpoint_line(line).expect("valid endpoint line");

    assert_eq!(endpoint.name, "서울 API");
    assert_eq!(endpoint.url, "https://example.com/상태");
    assert_eq!(endpoint.tags().collect::<Vec<_>>(), ["중요", "내부"]);
}

#[test]
fn owned_endpoint_outlives_the_input_buffer() {
    let endpoint = {
        let line = String::from("검색 | https://example.com/검색 | public");
        OwnedEndpoint::from(parse_endpoint_line(&line).expect("valid endpoint line"))
    };

    assert_eq!(endpoint.name, "검색");
    assert_eq!(endpoint.tags, ["public"]);
}

포맷, 빌드 검사, Clippy 경고 차단, 테스트, 실행은 아래 명령으로 수행합니다. 텍스트 블록은 cargo test --quiet --all-features 출력에서 6개 단위 테스트의 결과만 옮긴 축약 발췌입니다. 전체 출력에는 바이너리와 문서 테스트의 0개 테스트 결과도 이어집니다.

cargo fmt --check
cargo check --all-targets --all-features
cargo clippy --all-targets --all-features -- -D warnings
cargo test --quiet --all-features
cargo run --quiet -- '서울 API | https://example.com/상태 | critical, internal'
running 6 tests
......
test result: ok. 6 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.00s

7. API 경계 선택 규칙

파서, 검사기, 포매터처럼 호출 중에만 텍스트를 보는 함수는 &str부터 고려합니다. 새 문자열을 조립해 반환한다면 String이 맞습니다. 비동기 작업이나 장기 저장소로 값을 넘긴다면 경계에서 소유 형태로 바꿉니다. 원소 묶음도 같은 방식으로 읽기 전용이면 &[T], 소유하고 늘려야 하면 Vec<T>를 선택하면 됩니다.

바이트 오프셋은 프로토콜이 바이트를 정의할 때만 직접 다루는 편이 좋습니다. 사용자에게 보여 줄 글자를 자르는 코드라면 chars()와 grapheme cluster의 차이를 먼저 정해야 합니다. Rust가 문자열 단일 인덱싱을 허용하지 않는 덕분에 그 결정을 코드에 남기게 됩니다.

전체 소스 코드

이 글의 전체 실행 가능한 소스는 GitHub의 Chapter 09 프로젝트에서 확인할 수 있습니다.

출처


답글 남기기

이메일 주소는 공개되지 않습니다. 필수 필드는 *로 표시됩니다

Tech Wiki

Built with WordPress · Learn in public.