Rust 기초: String과 &str, 문자열이 두 개인 이유

Rust 기초: String과 &str, 문자열이 두 개인 이유

Rust를 처음 배울 때 많은 분이 당황하는 지점이 있습니다. 문자열 타입이 두 개라는 겁니다. String도 있고 &str도 있는데, 대체 뭐가 다른 걸까요?

다른 언어에서는 문자열 하나면 충분했는데 Rust는 왜 이렇게 만들었을까요? 답은 Rust의 소유권 시스템에 있습니다. 누가 문자열 데이터를 소유하고 있느냐, 아니면 잠깐 빌려서 보고 있느냐를 타입으로 구분하는 거죠.

이 글에서는 String&str의 내부 구조부터 시작해서 언제 어떤 걸 쓰는지까지 정리해보겠습니다.

String의 내부 구조

String은 힙에 할당된 문자열 데이터를 소유하는 타입입니다. 내부적으로는 Vec<u8>을 감싸고 있어서 세 가지 정보를 스택에 저장합니다.

fn main() {
    let s = String::from("hello");

    println!("포인터: {:p}", s.as_ptr());
    println!("길이: {}", s.len());
    println!("용량: {}", s.capacity());
}
결과
포인터: 0x600000cf4040
길이: 5
용량: 5

스택에는 힙 데이터를 가리키는 포인터, 현재 문자열의 바이트 길이, 그리고 할당된 버퍼의 용량이 저장됩니다. 실제 문자열 데이터(hello의 UTF-8 바이트)는 힙에 있고요.

StringVec<u8> 기반이라는 건 표준 라이브러리 소스를 보면 확인할 수 있습니다.

pub struct String {
    vec: Vec<u8>,
}

Vec처럼 데이터를 소유하고 있기 때문에 크기를 늘리거나 줄이는 것도 자유롭습니다.

fn main() {
    let mut s = String::from("hello");
    println!("변경 전: {}, 용량: {}", s, s.capacity());

    s.push_str(", world!");
    println!("변경 후: {}, 용량: {}", s, s.capacity());
}
결과
변경 전: hello, 용량: 5
변경 후: hello, world!, 용량: 13

용량이 부족하면 힙에 더 큰 버퍼를 새로 할당하고 데이터를 옮깁니다. 소유권을 가지고 있으니까 이런 변경이 가능한 거죠.

&str은 빌려보는 창

&str은 이미 어딘가에 존재하는 문자열 데이터를 빌려서 보는 슬라이스입니다. 스택에 포인터와 길이만 저장하고, 데이터 자체를 소유하지 않습니다.

fn main() {
    // 문자열 리터럴은 바이너리에 포함된 데이터를 가리키는 &str
    let greeting: &str = "hello";

    // String에서 일부를 빌려볼 수도 있음
    let s = String::from("hello, world!");
    let slice: &str = &s[0..5];

    println!("{}", greeting);
    println!("{}", slice);
}
결과
hello
hello

문자열 리터럴 "hello"는 프로그램 바이너리에 포함된 데이터를 가리키는 &str입니다. 프로그램이 실행되는 동안 쭉 유효하기 때문에 수명이 'static이죠.

&s[0..5]처럼 String의 일부분을 빌려와서 &str로 만들 수도 있습니다. 어느 쪽이든 &str은 데이터를 소유하지 않고 참조만 하고 있어서 내용을 변경할 수 없습니다.

String과 &str의 비교

둘의 차이를 표로 비교하면 이렇습니다.

특성String&str
소유권데이터를 소유빌려서 참조만
저장 위치힙 (데이터) + 스택 (메타)스택 (포인터+길이만)
변경 가능mut이면 가능불가능
크기런타임에 변경 가능고정
메모리 구성포인터 + 길이 + 용량포인터 + 길이

&str에서 String으로

&str에서 String을 만드는 방법은 여러 가지입니다.

fn main() {
    let s1 = String::from("hello");
    let s2 = "hello".to_string();
    let s3 = "hello".to_owned();

    println!("{} {} {}", s1, s2, s3);
}
결과
hello hello hello

셋 다 결과는 같습니다. String::from()From 트레이트를 통한 변환이고, to_string()Display 트레이트를 통한 변환입니다. to_owned()는 빌린 데이터의 소유 버전을 만들어주는 ToOwned 트레이트의 메서드고요.

어떤 걸 쓰든 상관없지만 프로젝트 내에서 일관성을 유지하는 게 좋습니다.

String에서 &str로

반대로 String에서 &str을 얻는 것은 더 간단합니다.

fn main() {
    let s = String::from("hello");

    let r1: &str = s.as_str();
    let r2: &str = &s;
    let r3: &str = &s[..];

    println!("{} {} {}", r1, r2, r3);
}
결과
hello hello hello

as_str()은 명시적인 변환이고, &s만 써도 됩니다. &s가 되는 이유는 StringDeref<Target = str>을 구현하고 있어서 역참조 강제가 자동으로 &String&str로 바꿔주기 때문입니다.

이 변환은 새로운 데이터를 할당하지 않고 기존 힙 데이터를 그대로 가리키기만 합니다. &strString은 힙에 복사가 일어나지만, String&str은 복사 없이 참조만 만드는 거라 비용이 들지 않죠.

문자열 이어붙이기

String에 문자열을 추가하는 방법은 몇 가지가 있습니다.

push_str()&str을 뒤에 이어붙이고, push()는 문자 하나를 추가합니다.

fn main() {
    let mut s = String::from("hello");

    s.push_str(", world");
    s.push('!');

    println!("{}", s);
}
결과
hello, world!

+ 연산자도 사용할 수 있는데, 왼쪽의 String 소유권을 가져간다는 점에 주의해야 합니다.

fn main() {
    let s1 = String::from("hello");
    let s2 = String::from(", world!");

    let s3 = s1 + &s2;  // s1은 이동됨, s2는 빌림
    println!("{}", s3);
    // println!("{}", s1); // 컴파일 에러! s1은 이미 이동됨
    println!("{}", s2);    // s2는 여전히 사용 가능
}
결과
hello, world!
, world!

+ 연산자는 내부적으로 fn add(self, s: &str) -> String을 호출합니다. self로 소유권을 받으니까 s1은 이동되고, 오른쪽은 &str로 빌려오기만 합니다.

여러 문자열을 합칠 때는 format! 매크로가 더 편합니다. 소유권을 가져가지 않거든요.

fn main() {
    let s1 = String::from("Rust");
    let s2 = String::from("프로그래밍");
    let s3 = String::from("언어");

    let result = format!("{} {} {}", s1, s2, s3);
    println!("{}", result);
    println!("{} {} {}", s1, s2, s3); // 모두 여전히 사용 가능
}
결과
Rust 프로그래밍 언어
Rust 프로그래밍 언어

UTF-8과 인덱싱

Rust의 문자열은 항상 UTF-8로 인코딩됩니다. 그래서 s[0]처럼 인덱스로 접근하는 게 안 됩니다.

fn main() {
    let s = String::from("안녕하세요");
    // let ch = s[0]; // 컴파일 에러!
}

왜 안 될까요? UTF-8에서는 문자마다 차지하는 바이트 수가 다르기 때문입니다.

fn main() {
    let ascii = "hello";
    let korean = "안녕하세요";

    println!("hello: {} 글자, {} 바이트", ascii.chars().count(), ascii.len());
    println!("안녕하세요: {} 글자, {} 바이트", korean.chars().count(), korean.len());
}
결과
hello: 5 글자, 5 바이트
안녕하세요: 5 글자, 15 바이트

영문자는 1바이트지만 한글은 3바이트입니다. s[0]이 “첫 번째 바이트”를 의미하는지 “첫 번째 문자”를 의미하는지 모호하고, 바이트 단위로 접근하면 유효하지 않은 UTF-8이 나올 수 있어서 Rust는 아예 막아놓은 겁니다.

글자 단위로 순회하려면 chars()를, 바이트 단위로 보려면 bytes()를 사용합니다.

fn main() {
    let s = "Rust 🦀";

    print!("글자: ");
    for ch in s.chars() {
        print!("{} ", ch);
    }
    println!();

    print!("바이트: ");
    for b in s.bytes() {
        print!("{} ", b);
    }
    println!();
}
결과
글자: R u s t   🦀
바이트: 82 117 115 116 32 240 159 166 128

🦀 이모지가 4바이트를 차지하는 게 보이죠.

바이트 범위로 슬라이싱하는 것은 가능하지만, UTF-8 문자 경계를 벗어나면 패닉이 발생합니다.

fn main() {
    let s = "안녕하세요";

    let hello = &s[0..6]; // "안녕" (한글 2자 = 6바이트)
    println!("{}", hello);

    // let broken = &s[0..4]; // 패닉! 문자 경계를 벗어남
}
결과
안녕

함수 매개변수는 &str로

함수를 작성할 때 문자열 매개변수는 &str로 받는 것이 좋습니다.

fn count_words(text: &str) -> usize {
    text.split_whitespace().count()
}

fn main() {
    // &str 직접 전달
    println!("{}", count_words("hello world"));

    // String도 역참조 강제로 전달 가능
    let s = String::from("Rust is great");
    println!("{}", count_words(&s));
}
결과
2
3

&str로 받으면 &str&String 모두 넘길 수 있습니다. String으로 받으면 호출하는 쪽에서 소유권을 넘기거나 복사해야 하니까 불필요한 비용이 생기죠. 함수가 문자열을 읽기만 한다면 &str로 충분합니다.

반대로 함수 안에서 문자열을 만들어 반환해야 한다면 String을 써야 합니다. &str은 빌린 참조라 함수가 끝나면 원본이 사라질 수 있으니까요.

fn make_greeting(name: &str) -> String {
    format!("안녕하세요, {}님!", name)
}

fn main() {
    let greeting = make_greeting("Rust");
    println!("{}", greeting);
}
결과
안녕하세요, Rust님!

정리하면 “읽기만 하면 &str, 만들어서 돌려줄 때는 String”이 기본 원칙입니다.

마치며

Rust에서 문자열이 두 개인 이유는 결국 소유권 때문입니다. String은 힙에 할당된 문자열을 소유하는 타입이고, &str은 그 데이터를 빌려서 보는 슬라이스입니다. Vec과 슬라이스의 관계와 정확히 같은 구조죠.

&strString은 힙 할당과 복사가 필요하고, String&str은 참조만 만들면 되니까 거의 공짜입니다. 이 차이를 알고 나면 어디서 어떤 타입을 쓸지 자연스럽게 감이 옵니다.

나중에 Deref 트레이트를 배우면 String에서 &str로의 자동 변환이 어떻게 작동하는지 더 깊이 이해할 수 있고, AsRef 트레이트를 배우면 String&str을 모두 받는 유연한 함수를 설계하는 방법도 알게 될 겁니다.

더 자세한 내용은 std::string::String - Rust 공식 문서를 참고하세요.

This work is licensed under CC BY 4.0 CC BY

개발자를 위한 뉴스레터

달레가 정리한 AI 개발 트렌드와 직접 만든 콘텐츠를 전해드립니다.

Discord