Rust String vs &str 완벽 비교 | 문자열 타입 선택 가이드

Rust String vs &str 완벽 비교 | 문자열 타입 선택 가이드

이 글의 핵심

Rust String vs &str 비교 - 소유권, 메모리, 성능 차이와 선택 기준

들어가며

“String과 &str 중 무엇을 써야 할까요?” Rust를 배울 때 가장 헷갈리는 부분입니다. 이 글에서는 String과 &str의 차이를 명확히 이해하고, 상황에 맞는 타입을 선택하는 방법을 다룹니다.

비유로 말씀드리면, String내 책장에 꽂아 소유하는 책, &str도서관에서 잠시 빌려 읽는 구절에 가깝습니다. 수정이 필요하면 보통 String으로 만들고, 함수 인자로 읽기만 할 때는 **&str**이 자연스럽습니다.

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

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

이 글을 읽으면

  • String과 &str의 메모리 구조를 이해합니다
  • 소유권 관점에서 차이를 배웁니다
  • 성능과 메모리 사용량 차이를 익힙니다
  • 실전에서 어떤 것을 써야 하는지 판단할 수 있습니다

목차

  1. 빠른 비교표
  2. 메모리 구조
  3. 소유권과 빌림
  4. 성능 비교
  5. 변환 방법
  6. 실전 선택 가이드
  7. 흔한 실수
  8. 마무리

1. 빠른 비교표

특성String&str
타입소유 타입빌린 타입 (참조)
메모리힙 할당스택 또는 정적
가변성가변불변
크기동적고정
수명소유자가 제어빌림 규칙 적용
용도소유권 필요읽기만 필요
함수 인자String (소유권 이동)&str (권장)
반환값String (소유권 반환)&str (수명 주의)

2. 메모리 구조

String: 힙 할당

let s = String::from("hello");

// 메모리 구조
// 스택:
//   ptr:  0x12345678 (힙 주소)
//   len:  5
//   cap:  5
//
// 힙:
//   [h][e][l][l][o]

&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

3. 소유권과 빌림

String: 소유권

fn take_ownership(s: String) {
    println!("{}", s);
} // s가 여기서 drop됨

let s = String::from("hello");
take_ownership(s);
// println!("{}", s); // ❌ error: value borrowed after move

&str: 빌림

fn borrow(s: &str) {
    println!("{}", s);
} // 빌림만 하므로 drop 안 됨

let s = String::from("hello");
borrow(&s);
println!("{}", s); // ✅ 여전히 사용 가능

함수 시그니처 선택

// ❌ 나쁜 패턴: 소유권 가져가기
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를 계속 사용 가능

4. 성능 비교

할당 비용

// String: 힙 할당 (느림)
let s1 = String::from("hello");
let s2 = s1.clone(); // 힙 메모리 복사

// &str: 포인터 복사 (빠름)
let s1: &str = "hello";
let s2 = s1; // 포인터만 복사 (8 bytes)

벤치마크

use std::time::Instant;

// String 생성
let start = Instant::now();
for _ in 0..1_000_000 {
    let _ = String::from("hello");
}
println!("String: {:?}", start.elapsed()); // 약 50ms

// &str 복사
let start = Instant::now();
let s: &str = "hello";
for _ in 0..1_000_000 {
    let _ = s;
}
println!("&str: {:?}", start.elapsed()); // 약 0.1ms (500배 빠름)

5. 변환 방법

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();

언제 변환하나?

// ✅ 좋은 패턴: 필요할 때만 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: returns reference to local variable
}

6. 실전 선택 가이드

함수 매개변수

// ✅ 기본: &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를 소비하거나 저장할 때만
}

구조체 필드

// 소유권 필요 → String
struct User {
    name: String,  // 구조체가 소유
    email: String,
}

// 빌림 → &str (수명 매개변수 필요)
struct UserRef<'a> {
    name: &'a str,  // 다른 곳에서 빌림
    email: &'a str,
}

// 실전: 대부분 String 사용
// &str은 수명 관리가 복잡함

반환값

// ✅ 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("")
}

7. 흔한 실수

실수 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"
}

마무리

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을 반환합니다.


관련 글

  • Rust 소유권 완벽 가이드
  • Rust 빌림과 참조
  • Rust 수명

키워드

Rust, String, str, 문자열, 소유권, 빌림, 수명, 성능, 메모리, 비교, 선택 가이드