Rust String vs &str: 소유권 차이와 함수 인자로 무엇을 받을지

이 글의 핵심

힙에 버퍼를 소유하는 String과 어딘가의 문자열을 빌려 보는 슬라이스 &str의 차이를 이해하면 대부분의 선택이 정해집니다. 빠른 비교표와 메모리 구조, 할당 비용 차이, 두 타입 사이의 변환 방법을 본 뒤, 인자로 &String 대신 &str을 받는 이유 같은 실전 선택 기준을 정리합니다.

들어가며

“String과 &str 중 무엇을 써야 할까요?” Rust를 배울 때 가장 헷갈리는 부분입니다. 이 글에서는 String과 &str의 차이를 명확히 이해하며, 상황에 맞는 타입을 선택하는 방법을 다룹니다. 비유로 말씀드리면, String은 내 책장에 꽂아 소유하는 책, &str은 도서관에서 잠시 빌려 읽는 구절에 가깝습니다. 수정이 필요하면 보통 String으로 만들고, 함수 인자로 읽기만 할 때는 &str이 자연스럽습니다.

언제 String을, 언제 &str을 쓰나요?

관점String&str
성능힙 할당·가변 버퍼참조만—가장 가벼운 읽기
사용성소유가 필요할 때리터럴·부분 문자열 뷰
적용 시나리오수집·누적·변경파싱·검색·함수 파라미터

두 타입이 헷갈리는 이유는 C++이나 Java처럼 “문자열 타입은 하나”인 언어에서 오면, 문자열 데이터를 누가 소유하고 누가 해제하는가를 타입으로 표현한다는 발상이 낯설기 때문입니다. String은 버퍼를 소유하고 스코프를 벗어나면 해제하는 타입이고, &str은 그 버퍼(또는 다른 어떤 문자열 데이터)의 일부를 가리키기만 하는 타입입니다. 이 구분 덕분에 Rust는 가비지 컬렉터 없이도 “해제된 문자열을 읽는” 버그를 컴파일 단계에서 막습니다. 아래에서는 메모리 구조부터 보고, 그 구조가 함수 시그니처와 구조체 설계에 어떤 결론을 주는지 따라갑니다.


String과 &str 한눈에 비교

특성String&str
타입소유 타입빌린 타입 (참조)
메모리힙 버퍼를 소유데이터를 소유하지 않음 (힙·정적 영역·스택 어디든 가리킬 수 있음)
가변성가변 (mut일 때)불변 (&mut str은 드묾)
크기스택에 24바이트(ptr·len·cap), 길이는 늘어날 수 있음스택에 16바이트(ptr·len), 가리키는 길이는 고정
수명소유자가 제어빌림 규칙 적용
용도소유권 필요읽기만 필요
함수 인자String (소유권 이동)&str (권장)
반환값String (소유권 반환)&str (수명 주의)

힙 버퍼 String과 슬라이스 &str의 메모리 구조

String: 힙 할당

let s = String::from("hello");
// 메모리 구조
// 스택:
//   ptr:  0x12345678 (힙 주소)
//   len:  5
//   cap:  5
//
// 힙:
//   [h][e][l][l][o]

String은 실제로는 Vec<u8>을 감싼 타입이고, 담긴 바이트가 항상 올바른 UTF-8이라는 것을 보장합니다. 스택에 놓이는 것은 포인터·길이·용량 세 개(64비트에서 24바이트)이며, 문자 데이터는 힙에 있습니다. len은 현재 쓰고 있는 바이트 수, cap은 다시 할당하지 않고 담을 수 있는 바이트 수입니다. push_str로 내용을 늘리다가 len이 cap을 넘으면 더 큰 버퍼를 새로 할당하고 내용을 옮기므로, 최종 길이를 대략 안다면 String::with_capacity(n)으로 재할당을 줄일 수 있습니다.

여기서 len이 문자 수가 아니라 바이트 수라는 점이 중요합니다. String::from("안녕").len()은 2가 아니라 6입니다. 한글 한 글자가 UTF-8에서 3바이트이기 때문입니다. 글자 수가 필요하면 s.chars().count()를 써야 하고, 이것은 전체를 순회하므로 O(n)입니다.

&str: 슬라이스 (참조)

let s: &str = "hello"; // 문자열 리터럴 (정적 메모리)
// 메모리 구조
// 스택:
//   ptr:  0x87654321 (정적 메모리 주소)
//   len:  5
//
// 정적 메모리 (.rodata):
//   [h][e][l][l][o]

슬라이스 생성

let s = String::from("hello world");
let hello: &str = &s[0..5];  // "hello"
let world: &str = &s[6..11]; // "world"
// 메모리 구조
// 스택:
//   s.ptr:     0x12345678
//   s.len:     11
//   s.cap:     11
//   hello.ptr: 0x12345678 (같은 힙 주소)
//   hello.len: 5
//   world.ptr: 0x1234567E (s.ptr + 6)
//   world.len: 5
//
// 힙:
//   [h][e][l][l][o][ ][w][o][r][l][d]
//    ^^^^^               ^^^^^
//    hello               world

&str은 포인터와 길이 두 개로 이루어진 팻 포인터(fat pointer)입니다. 가리키는 대상이 리터럴이면 실행 파일의 읽기 전용 영역, String의 일부면 힙, 스택 배열에서 만든 문자열이면 스택입니다. 즉 “&str은 스택에 있다”가 아니라 “&str이라는 참조 자체는 보통 스택에 있고, 데이터는 어디에나 있을 수 있다”가 정확합니다. 슬라이스를 만들 때 데이터가 복사되지 않는다는 것이 핵심이라, 큰 문자열을 파싱하면서 토큰마다 &str로 잘라 내면 할당이 한 번도 일어나지 않습니다.

슬라이스 범위 [0..5]는 바이트 인덱스입니다. ASCII만 있는 문자열에서는 문자 인덱스와 같지만, 한글이 섞이면 달라집니다. let s = String::from("안녕하세요"); &s[0..1]은 컴파일은 되지만 실행하면 byte index 1 is not a char boundary; it is inside '안' (bytes 0..3) 패닉이 납니다. 처음 Rust로 한국어 텍스트를 다룰 때 거의 반드시 한 번 겪는 문제입니다. 문자 단위로 자르려면 s.chars().take(2).collect::<String>()처럼 문자를 순회하거나, s.char_indices()로 바이트 경계를 찾은 뒤 슬라이스하고, 패닉 대신 Option을 받고 싶다면 s.get(0..1)을 씁니다.


소유하는 String, 빌리는 &str

String: 소유권

fn take_ownership(s: String) {
    println!("{}", s);
} // s가 여기서 drop됨
let s = String::from("hello");
take_ownership(s);
// println!("{}", s); // ❌ error[E0382]: borrow of moved value: `s`

take_ownership(s)는 s의 포인터·길이·용량을 함수로 옮기고, 원래 변수 s는 더 이상 쓸 수 없게 표시합니다(move). 힙 데이터는 복사되지 않으므로 move 자체는 싸지만, 함수가 끝날 때 버퍼가 해제되므로 호출자는 그 문자열을 다시 쓸 수 없습니다. 이 에러를 만났을 때 흔히 take_ownership(s.clone())으로 해결하는데, 컴파일은 되지만 매번 힙 복사가 일어납니다. 대부분은 함수가 소유권을 가져갈 필요가 없는 경우이므로 매개변수를 &str로 바꾸는 것이 올바른 수정입니다.

&str: 빌림

fn borrow(s: &str) {
    println!("{}", s);
} // 빌림만 하므로 drop 안 됨
let s = String::from("hello");
borrow(&s);
println!("{}", s); // ✅ 여전히 사용 가능

borrow(&s)에서 &s의 타입은 &String인데 함수는 &str을 받습니다. 그래도 컴파일되는 것은 String이 Deref<Target = str>을 구현하고 있어서 컴파일러가 &String을 &str로 자동 변환(deref coercion)해 주기 때문입니다. 그래서 &str을 받는 함수는 &String, 문자열 리터럴, 다른 슬라이스를 모두 받을 수 있지만, &String을 받는 함수는 리터럴을 받으려면 호출자가 &"hello".to_string()처럼 불필요한 할당을 해야 합니다. Clippy의 ptr_arg 린트가 &String 매개변수에 경고하는 이유입니다.

함수 시그니처 선택

// ❌ 나쁜 패턴: 소유권 가져가기
fn process(s: String) {
    println!("{}", s);
}
let s = String::from("hello");
process(s);
// s를 더 이상 사용할 수 없음
// ✅ 좋은 패턴: 빌림
fn process(s: &str) {
    println!("{}", s);
}
let s = String::from("hello");
process(&s);
// s를 계속 사용 가능

할당 비용과 벤치마크

할당 비용

// String: 힙 할당 (느림)
let s1 = String::from("hello");
let s2 = s1.clone(); // 힙 메모리 복사
// &str: 포인터 복사 (빠름)
let s1: &str = "hello";
let s2 = s1; // 포인터와 길이만 복사 (64비트에서 16 bytes)

벤치마크

use std::time::Instant;
// String 생성
let start = Instant::now();
for _ in 0..1_000_000 {
    let _ = String::from("hello");
}
println!("String: {:?}", start.elapsed()); // 호출마다 힙 할당 + 복사
// &str 복사
let start = Instant::now();
let s: &str = "hello";
for _ in 0..1_000_000 {
    let _ = s;
}
println!("&str: {:?}", start.elapsed()); // 16바이트 복사뿐 (최적화 시 루프 자체가 사라질 수 있음)

이 측정은 경향을 보여 주는 참고용입니다. 디버그 빌드(cargo run)에서는 최적화가 꺼져 있어 전체적으로 느리고, 릴리스 빌드(cargo run --release)에서는 컴파일러가 결과를 쓰지 않는 루프를 통째로 없애 두 쪽 모두 0에 가까운 시간이 나올 수 있습니다. 두 번째 루프는 사실상 아무 일도 하지 않으므로 비교 대상이 되기 어렵습니다. 의미 있는 측정을 하려면 criterion 크레이트와 std::hint::black_box로 최적화에 의한 제거를 막아야 합니다.

측정 방법과 상관없이 확실한 사실은 String::from이 호출마다 힙 할당 한 번과 바이트 복사를 한다는 것이고, &str 복사는 16바이트를 옮길 뿐이라는 것입니다. 할당 자체는 수십 나노초 수준이라 한두 번은 문제가 되지 않지만, 요청마다 수천 개의 문자열을 만드는 파서나 로그 처리 경로에서는 누적되어 눈에 띄는 비용이 됩니다. 그래서 “뜨거운 경로에서는 &str로 흘려보내고, 저장할 때만 String으로 만든다”가 일반적인 원칙입니다.


String과 &str 사이의 변환

String → &str

let s = String::from("hello");
// 방법 1: 참조
let slice: &str = &s;
// 방법 2: as_str()
let slice: &str = s.as_str();
// 방법 3: 슬라이스
let slice: &str = &s[..];

&str → String

let s: &str = "hello";
// 방법 1: to_string()
let owned: String = s.to_string();
// 방법 2: String::from()
let owned: String = String::from(s);
// 방법 3: to_owned()
let owned: String = s.to_owned();

세 방법은 결과가 같고, 예전에는 to_string()이 Display 트레이트를 거쳐 약간 느리다는 이야기가 있었지만 현재 표준 라이브러리는 str에 대해 특수화되어 있어 차이가 없습니다. 팀 안에서 하나로 통일하는 정도면 충분합니다. 제네릭 코드에서는 .into()도 자주 쓰는데, let owned: String = s.into();처럼 목표 타입이 명확할 때만 쓸 수 있습니다. 반대 방향인 String → &str에서 &s[..], s.as_str(), &s도 모두 같은 결과이며, 타입 추론이 모호한 곳(예: match에서 리터럴과 비교할 때 match s.as_str() { "a" => ... })에서는 as_str()이 명시적이라 편합니다.

언제 변환하나?

// ✅ 좋은 패턴: 필요할 때만 String 생성
fn process(s: &str) -> String {
    if s.is_empty() {
        return String::from("default");
    }
    
    // 수정이 필요하면 String으로 변환
    let mut result = s.to_string();
    result.push_str(" processed");
    result
}
// ❌ 나쁜 패턴: 불필요한 변환
fn process(s: &str) -> &str {
    let owned = s.to_string(); // 불필요한 할당
    &owned // ❌ error[E0515]: cannot return reference to local variable `owned`
}

첫 번째 process는 빈 입력이면 기본값을, 아니면 수정한 값을 돌려주는데, 두 경로 모두 새 String을 할당합니다. 입력을 그대로 돌려줄 수 있는 경우가 많다면 std::borrow::Cow<'_, str>을 반환 타입으로 쓰는 방법이 있습니다. Cow는 “빌린 &str 또는 소유한 String 중 하나”를 담는 열거형이라, 수정이 필요 없을 때는 Cow::Borrowed(s)로 할당 없이 돌려주고, 수정할 때만 Cow::Owned(result)를 만듭니다. HTML 이스케이프처럼 “대부분의 입력은 바꿀 게 없는” 함수에서 흔히 쓰는 패턴입니다.


매개변수·구조체 필드·반환값에서 타입 고르기

함수 매개변수

// ✅ 기본: &str (유연함)
fn print_message(msg: &str) {
    println!("{}", msg);
}
print_message("hello");           // 리터럴
print_message(&String::from("hello")); // String
print_message(&my_string[..]);    // 슬라이스
// ❌ String (소유권 필요 시만)
fn consume_message(msg: String) {
    // msg를 소비하거나 저장할 때만
}

“&str이 기본”이라는 규칙에는 예외가 있습니다. 함수가 받은 문자열을 구조체에 저장하거나 다른 스레드로 보낸다면 결국 String이 필요합니다. 이때 매개변수를 &str로 받고 안에서 to_string()하면, 호출자가 이미 String을 가지고 있어 넘겨줄 수 있는 경우에도 무조건 복사가 일어납니다. 매개변수를 String으로 받으면 호출자가 필요 없는 String을 복사 없이 move로 넘길 수 있습니다. 호출하는 쪽의 편의까지 챙기고 싶다면 fn new(name: impl Into<String>)으로 받아 안에서 name.into()를 호출하면, 리터럴과 String을 모두 받으면서 String은 복사 없이 옮길 수 있습니다.

구조체 필드

// 소유권 필요 → String
struct User {
    name: String,  // 구조체가 소유
    email: String,
}
// 빌림 → &str (수명 매개변수 필요)
struct UserRef<'a> {
    name: &'a str,  // 다른 곳에서 빌림
    email: &'a str,
}
// 실전: 대부분 String 사용
// &str은 수명 관리가 복잡함

UserRef<'a>의 'a는 “이 구조체는 name과 email이 가리키는 원본보다 오래 살 수 없다”는 제약입니다. 이 제약은 구조체를 쓰는 모든 곳으로 전파됩니다. UserRef를 필드로 가진 다른 구조체도 수명 매개변수가 필요해지고, 원본 문자열을 담은 버퍼를 함수에서 반환하면서 UserRef도 함께 반환하는 식의 설계(자기 참조 구조체)는 불가능합니다. 처음 Rust를 쓸 때 “할당을 아끼려고” 구조체 필드를 &str로 만들었다가 수명 에러를 연쇄적으로 만나고 결국 String으로 되돌리는 경험을 많이들 합니다. 저도 설정 파일 파서를 만들며 같은 길을 걸었는데, 파싱 결과가 원본 텍스트보다 오래 살아야 하는 순간 &str 필드는 유지할 수 없었습니다.

&str 필드가 제값을 하는 경우는 짧게 살고 원본이 분명한 구조체입니다. 큰 로그 파일을 한 줄씩 읽어 필드를 나눈 뒤 곧바로 집계하고 버리는 파서라면, 줄마다 String을 여러 개 할당하는 대신 원본 줄을 가리키는 &str로 필드를 담아 할당을 없앨 수 있습니다. serde의 #[serde(borrow)]로 역직렬화 결과가 입력 버퍼를 빌리게 하는 것도 같은 아이디어입니다.

반환값

// ✅ String 반환 (소유권 이동)
fn create_greeting(name: &str) -> String {
    format!("Hello, {}!", name)
}
// ❌ &str 반환 (수명 문제)
fn create_greeting(name: &str) -> &str {
    let greeting = format!("Hello, {}!", name);
    &greeting // ❌ error: returns reference to local variable
}
// ✅ &str 반환 (입력 수명과 연결)
fn first_word(s: &str) -> &str {
    s.split_whitespace().next().unwrap_or("")
}

first_word가 수명 표기 없이도 컴파일되는 것은 수명 생략 규칙 덕분입니다. 참조 매개변수가 하나뿐이면 반환 참조의 수명은 그 매개변수와 같다고 컴파일러가 추론하므로, 실제 시그니처는 fn first_word<'a>(s: &'a str) -> &'a str입니다. 이 시그니처는 “반환값은 입력 s의 일부”라는 약속이고, 호출자는 반환값을 쓰는 동안 원본 문자열을 수정하거나 버릴 수 없습니다. 참조 매개변수가 두 개 이상이면(fn longer(a: &str, b: &str) -> &str) 어느 쪽 수명인지 추론할 수 없어 error[E0106]: missing lifetime specifier가 나고, <'a>를 직접 적어야 합니다.


String 인자, 불필요한 to_string(), 수명 문제

실수 1: String을 함수 인자로

// ❌ 나쁜 패턴
fn print(s: String) {
    println!("{}", s);
}
let s = String::from("hello");
print(s);
// s를 더 이상 사용할 수 없음
// ✅ 좋은 패턴
fn print(s: &str) {
    println!("{}", s);
}
let s = String::from("hello");
print(&s);
// s를 계속 사용 가능

실수 2: 불필요한 to_string()

// ❌ 나쁜 패턴
fn process(s: &str) {
    let owned = s.to_string(); // 불필요한 할당
    println!("{}", owned);
}
// ✅ 좋은 패턴
fn process(s: &str) {
    println!("{}", s); // 그냥 사용
}

실수 3: &str 반환 시 수명 문제

// ❌ 컴파일 안 됨
fn get_name() -> &str {
    let name = String::from("Alice");
    &name // error: returns reference to local variable
}
// ✅ String 반환
fn get_name() -> String {
    String::from("Alice")
}
// ✅ 정적 수명
fn get_default_name() -> &'static str {
    "Guest"
}

get_name() -> &str은 참조 매개변수가 없어 빌려 올 곳이 없으므로, 컴파일러는 지역 변수 참조 문제보다 먼저 missing lifetime specifier 에러를 내고 'static을 쓰라고 제안합니다. 이 제안을 따라 &'static str로 바꿔도 지역 String의 참조는 여전히 반환할 수 없습니다. 'static은 “프로그램이 끝날 때까지 유효”하다는 뜻이라 리터럴이나 static 변수에만 해당하기 때문입니다. 새로 만든 문자열을 돌려줘야 한다면 답은 항상 String 반환입니다. Box::leak으로 String을 'static으로 만드는 방법도 있지만 메모리를 의도적으로 누수시키는 것이므로, 프로그램 수명 동안 한 번만 만드는 설정 값 정도에만 씁니다.


마무리

Rust 문자열 타입 선택의 핵심:

  1. 함수 인자는 &str (유연함)
  2. 소유권 필요 시 String (구조체 필드, 반환값)
  3. 읽기만 필요하면 &str (성능 좋음)
  4. 수명 관리 복잡하면 String (안전함) 핵심: 기본은 &str, 소유권이 필요할 때만 String을 사용하세요.

FAQ

Q1. String과 &str 중 뭐가 더 빠른가요? &str이 더 빠릅니다 (힙 할당 없음). 하지만 소유권이 필요하면 String을 써야 합니다.

Q2. 구조체 필드는 항상 String인가요? 대부분 String을 사용합니다. &str은 수명 매개변수가 필요하여 복잡해집니다.

Q3. format! 매크로는 String을 반환하나요? 네, format!은 항상 String을 반환합니다.


같이 보면 좋은 글