Rust의 문자열: String vs &str

Rust

목록 보기
4/5
post-thumbnail

Rust를 처음 배우다 보면 String과 &str 두 가지 문자열 타입 때문에 혼란스러워하는 경우가 많습니다. "왜 문자열 타입이 두 개나 있지?" "언제 어떤 것을 써야 하지?" 같은 의문이 드는 것은 자연스러운 일일겁니다.

특히 C언어 경험이 있으신 분들이라면 더욱 헷갈릴 수 있습니다. C에서는 char*와 char[]의 차이를 알고 있어도, 이 개념에 빗대어 이해하려고 하면 혼란을 은근히 가중시키기도 하는데, Rust의 String과 &str은 완전히 다른 철학을 가지고 있기 때문입니다.

이 글에서는 Rust의 두 문자열 타입을 C언어와 비교하면서 깊이 있게 알아보겠습니다. 단순히 문법만 알아보는 것이 아니라, 왜 이렇게 설계되었는지언제 어떤 것을 사용해야 하는지, 그리고 C언어에서 흔히 발생하는 메모리 관련 실수들을 어떻게 방지할 수 있는지까지 살펴보겠습니다.


1. 기본 개념 이해하기

1.1 String과 &str, 무엇이 다를까?

먼저 간단한 아래 예제를 보겠습니다.

fn main() {
    // &str (문자열 슬라이스)
    let greeting: &str = "안녕하세요";
    
    // String (소유된 문자열)
    let mut message: String = String::from("안녕하세요");
    
    println!("&str: {}", greeting);
    println!("String: {}", message);
    
    // String은 수정 가능
    message.push_str(", 반갑습니다!");
    println!("수정된 String: {}", message);
    
    // &str은 수정 불가능
    // greeting.push_str("!"); // 컴파일 에러!
}

이 예제만 봐도 두 타입의 핵심적인 차이를 느낄 수 있습니다:

  • &str: 읽기 전용, 수정 불가능
  • String: 수정 가능, 동적으로 크기 변경 가능

하지만 이것만으로는 충분하지 않습니다. 더 깊이 들어가 보겠습니다.

1.2 메모리 관점에서 보는 차이점

두 타입의 진짜 차이를 이해하려면 메모리에서 어떻게 저장되는지 알아야 합니다.

&str의 메모리 구조

let text: &str = "Hello, World!";

&str은 사실 Fat Pointer(뚱뚱한 포인터)입니다. 일반적인 포인터보다 더 많은 정보를 담고 있어요:

스택 메모리:
┌─────────────────┐
│ 포인터 (8바이트) │ ──────┐
├─────────────────┤       │
│ 길이 (8바이트)   │       │
└─────────────────┘       │
                          │
                          ▼
                    프로그램 바이너리:
                    ┌──────────────────┐
                    │ "Hello, World!\0" │
                    └──────────────────┘

String의 메모리 구조

let text: String = String::from("Hello, World!");

심지어 String은 더 복잡한 구조를 가지고 있습니다:

스택 메모리:                    힙 메모리:
┌─────────────────┐            ┌──────────────────┐
│ 포인터 (8바이트) │ ──────────▶│ "Hello, World!"  │
├─────────────────┤            │                  │
│ 길이 (8바이트)   │            │ (사용된 공간)     │
├─────────────────┤            │                  │
│ 용량 (8바이트)   │            │ (할당된 전체공간) │
└─────────────────┘            └──────────────────┘

이 차이가 바로 두 타입의 모든 특성을 결정합니다.

1.3 메모리 구조가 만들어내는 실제 차이점

앞서 본 메모리 구조의 차이가 실제로 어떤 의미를 갖는지 자세히 살펴보겠습니다.

&str빌린 데이터의 창문 역할을 한다고 생각하면 쉽습니다:

fn analyze_str_behavior() {
    let text: &str = "Hello, World!";
    
    // &str의 실제 구조 확인
    println!("&str 크기: {} bytes", std::mem::size_of_val(&text));
    println!("문자열 길이: {} chars", text.len());
    println!("메모리 주소: {:p}", text.as_ptr());
    
    // &str은 항상 유효한 UTF-8을 가리킴 (보장됨)
    // &str은 항상 불변 (immutable)
    // &str은 다른 곳의 데이터를 "빌려서" 보여주는 창문
}

&str의 Fat Pointer 구조를 다시 보면…

&str의 메모리 레이아웃:
┌─────────────────┐
│ ptr: *const u8  │ ──────┐ (데이터의 시작 주소)
├─────────────────┤       │
│ len: usize      │       │ (UTF-8 바이트 길이)
└─────────────────┘       │
                          ▼
                    실제 문자열 데이터:
                    ┌──────────────────┐
                    │ H│e│l│l│o│,│ │W│o│r│l│d│!│
                    └──────────────────┘

&str의 3가지 핵심 특징:

데이터를 빌려 쓰고, 길이 정보를 포함하며, UTF-8을 보장합니다.

  1. 빌린 데이터: &str은 실제 데이터를 소유하지 않습니다. 다른 곳에 있는 데이터를 가리키기만 해요.
  2. 크기 정보: 포인터만으로는 문자열의 끝을 알 수 없으니까, 길이 정보를 함께 저장합니다.
  3. UTF-8 보장: Rust는 모든 &str이 유효한 UTF-8임을 컴파일 시점에 보장합니다.

반면에, String 타입 문자열은, 데이터를 소유하는 동적인 컨테이너입니다:

fn analyze_string_behavior() {
    let mut text: String = String::from("Hello");
    
    println!("String 구조체 크기: {} bytes", std::mem::size_of_val(&text));
    println!("현재 길이: {} bytes", text.len());
    println!("할당된 용량: {} bytes", text.capacity());
    println!("힙 데이터 주소: {:p}", text.as_ptr());
    
    // 용량보다 적게 사용 중일 때 데이터 추가
    text.push_str(", World!");
    println!("추가 후 길이: {} bytes", text.len());
    println!("추가 후 용량: {} bytes", text.capacity());
    println!("추가 후 힙 주소: {:p}", text.as_ptr()); // 주소가 같을 수도 있음
    
    // 용량을 초과하는 데이터 추가
    text.push_str(" This is a much longer string that will definitely exceed the current capacity!");
    println!("재할당 후 길이: {} bytes", text.len());
    println!("재할당 후 용량: {} bytes", text.capacity());
    println!("재할당 후 힙 주소: {:p}", text.as_ptr()); // 주소가 바뀔 것
}

String의 구조도 다시 살펴봅시다.

String의 메모리 레이아웃:
스택의 String 구조체:            힙의 실제 데이터:
┌─────────────────┐           ┌──────────────────┐
│ ptr: *mut u8    │ ─────────▶│ H│e│l│l│o│ │ │ │ │  (실제 문자열)
├─────────────────┤           ├──────────────────┤
│ len: usize      │           │ │ │ │ │ │ │ │ │ │  (사용되지 않은 공간)
├─────────────────┤           └──────────────────┘
│ capacity: usize │              ↑
└─────────────────┘              전체 할당된 공간
      ↑
   스택에 저장된 메타데이터

String타입 문자열 데이터 저장 구조의 핵심적인 의미:

  1. 소유권String은 힙의 데이터를 완전히 소유합니다.
  2. 가변성: 데이터를 자유롭게 수정, 추가, 삭제할 수 있습니다.
  3. 동적 크기: 필요에 따라 자동으로 메모리를 재할당합니다.
  4. 효율성: 미리 여분의 공간을 할당해서 작은 추가 작업 시 재할당을 피합니다.

이런 차이점들이 왜 &strString이 다르게 설계되었는지, 그리고 언제 무엇을 사용해야 하는지를 명확하게 보여줍니다. &str은 효율적인 참조와 슬라이싱을 위해, String은 소유권과 변경 가능성을 위해 각각 최적화된 설계를 가지고 있어요.


2. C언어와의 상세 비교

2.1 C언어의 문자열 처리 방식

C언어에서 문자열을 다루는 방법들을 먼저 살펴보겠습니다:

#include <stdio.h>
#include <stdlib.h>
#include <string.h>

int main() {
    // 방법 1: 문자열 리터럴 (읽기 전용)
    char* str1 = "Hello";           // 정적 영역에 저장
    
    // 방법 2: 문자 배열 (수정 가능)
    char str2[] = "Hello";          // 스택에 복사본 저장
    
    // 방법 3: 동적 할당 (수정 가능)
    char* str3 = malloc(10);        // 힙에 할당
    strcpy(str3, "Hello");
    
    // 사용 및 수정
    printf("str1: %s\n", str1);
    
    str2[0] = 'h';                  // OK: 스택 메모리 수정
    printf("str2: %s\n", str2);     // "hello"
    
    str3[0] = 'h';                  // OK: 힙 메모리 수정
    printf("str3: %s\n", str3);     // "hello"
    
    // str1[0] = 'h';               // 위험! 정적 영역 수정 시도
    
    free(str3);                     // 수동 메모리 해제 필요!
    return 0;
}

2.2 C언어 방식을 Rust로 표현하면?

이제 위의 C 코드와 동일한 동작을 하는 Rust 코드를 보겠습니다:

fn main() {
    // C의 char* str1 = "Hello"와 비슷 (읽기 전용)
    let str1: &str = "Hello";
    
    // C의 char str2[] = "Hello"와 비슷하지만 더 안전
    let mut str2: String = String::from("Hello");
    
    // C의 malloc + strcpy와 비슷하지만 자동 관리
    let mut str3: String = String::with_capacity(10);
    str3.push_str("Hello");
    
    println!("str1: {}", str1);
    
    // str2 수정 (안전하게)
    str2.replace_range(0..1, "h");
    println!("str2: {}", str2);     // "hello"
    
    // str3 수정 (안전하게)
    str3.replace_range(0..1, "h");
    println!("str3: {}", str3);     // "hello"
    
    // str1은 수정 불가능
    // str1.replace_range(0..1, "h"); // 컴파일 에러!
    
    // 메모리 해제는 자동!
    // 함수 끝에서 str2, str3가 자동으로 drop됨
}

2.3 핵심 차이점 정리

특징C char*C char[]Rust &strRust String
메모리 위치정적/힙스택정적/스택/힙
수정 가능성위험가능불가능가능
메모리 관리수동자동자동자동
크기 변경불가능불가능불가능가능
안전성낮음보통높음높음

3. C언어의 흔한 실수들과 Rust의 해결책

3.1 버퍼 오버플로우 (Buffer Overflow)

C에서 흔한 실수:

#include <string.h>
#include <stdio.h>

int main() {
    char buffer[5];
    strcpy(buffer, "Hello, World!");  // 💥 버퍼 오버플로우!
    printf("%s\n", buffer);
    return 0;
}

이 코드는 5바이트 배열에 13바이트 문자열을 복사하려고 시도합니다. 결과는 예측할 수 없는 메모리 손상입니다.

Rust의 안전한 접근:

fn main() {
    let buffer = String::with_capacity(5);
    
    // 이런 실수는 컴파일 시점에 방지됨
    let long_text = "Hello, World!";
    
    // 안전한 방법들
    let safe_buffer = String::from(&long_text[0..5]); // "Hello"
    println!("{}", safe_buffer);
    
    // 또는 용량을 늘려서 안전하게
    let mut expandable_buffer = String::new();
    expandable_buffer.push_str(long_text); // 자동으로 용량 확장
    println!("{}", expandable_buffer);
}

3.2 댕글링 포인터 (Dangling Pointer)

C에서 위험한 코드:

#include <stdlib.h>
#include <string.h>

char* create_greeting() {
    char local_buffer[20];              // 지역 변수
    strcpy(local_buffer, "Hello!");
    return local_buffer;                // 💥 댕글링 포인터 반환!
}

int main() {
    char* greeting = create_greeting();
    printf("%s\n", greeting);           // 이미 해제된 메모리 접근!
    return 0;
}

함수가 끝나면 local_buffer는 스택에서 해제되지만, 포인터는 여전히 그 주소를 가리키고 있습니다.

Rust는 이런 실수를 컴파일 시점에 방지:

fn create_greeting() -> String {        // String 반환 (소유권 이동)
    let greeting = String::from("Hello!");
    greeting                            // 소유권을 호출자에게 이동
}

// 또는 &str을 반환하고 싶다면
fn create_greeting_ref() -> &'static str {
    "Hello!"                            // 정적 문자열 리터럴 반환
}

fn main() {
    let greeting1 = create_greeting();  // 안전한 소유권 이동
    let greeting2 = create_greeting_ref(); // 안전한 정적 참조
    
    println!("{}", greeting1);
    println!("{}", greeting2);
}

3.3 메모리 누수 (Memory Leak)

C에서 자주 발생하는 실수:

#include <stdlib.h>
#include <string.h>

void process_data() {
    char* data = malloc(100);
    strcpy(data, "Important data");
    
    if (some_condition()) {
	      return;                         // 💥 free() 호출 없이 반환!
    }
    
    free(data);                         // 조건에 따라 실행되지 않을 수 있음
}

Rust는 자동 메모리 관리:

fn process_data() {
	  let data = String::from("Important data");
    
    if some_condition() {
        return;                         // ✅ 자동으로 data가 drop됨
    }
    
    // 여기서도 함수 끝에 자동으로 drop됨
}

fn some_condition() -> bool {
    true
}

4. 언제 String을 쓰고, 언제 &str을 써야 할까?

실제 예제들을 통해 Rust에서 문자열을 다루고자 할 때 어떤 타입을 사용해야 하는지 알아보겠습니다.

4.1 &str을 사용해야 하는 경우

함수 매개변수로 문자열을 받을 때

// 좋은 예: &str로 받기
fn print_message(msg: &str) {
    println!("{}", msg);
}

// 나쁜 예: String으로 받기
fn print_message_bad(msg: String) {
    println!("{}", msg);
}

fn main() {
    let string_data = String::from("Hello");
    let str_data = "World";
    
    // &str 매개변수는 둘 다 받을 수 있음
    print_message(&string_data);        // String을 &str로 변환
    print_message(str_data);            // &str 그대로
    
    // String 매개변수는 소유권을 가져감
    print_message_bad(string_data);     // 소유권 이동
    // print_message_bad(string_data);  // 💥 에러! 이미 이동됨
}

문자열 상수나 설정값

const DEFAULT_GREETING: &str = "안녕하세요";
const ERROR_MESSAGE: &str = "파일을 찾을 수 없습니다";

fn get_config_value() -> &'static str {
    "production"
}

문자열 슬라이싱

fn get_first_word(text: &str) -> &str {
    match text.find(' ') {
        Some(index) => &text[0..index],
        None => text,
    }
}

fn main() {
    let sentence = "Hello beautiful world";
    let first_word = get_first_word(sentence);
    println!("첫 번째 단어: {}", first_word); // "Hello"
}

4.2 String을 사용해야 하는 경우

문자열을 수정해야 할 때

fn build_message(name: &str, age: u32) -> String {
    let mut message = String::from("안녕하세요, ");
    message.push_str(name);
    message.push_str("님! 나이가 ");
    message.push_str(&age.to_string());
    message.push_str("세이시군요.");
    message
}

fn main() {
    let greeting = build_message("김철수", 25);
    println!("{}", greeting);
}

동적으로 문자열을 생성할 때

fn read_user_input() -> String {
    use std::io;
    
    println!("이름을 입력하세요:");
    let mut input = String::new();
    io::stdin()
        .read_line(&mut input)
        .expect("입력을 읽을 수 없습니다");
    
    input.trim().to_string()
}

컬렉션에 문자열을 저장할 때

fn main() {
    let mut names: Vec<String> = Vec::new();
    
    // String을 벡터에 저장
    names.push(String::from("Alice"));
    names.push(String::from("Bob"));
    names.push(String::from("Charlie"));
    
    // &str을 벡터에 저장하려면 라이프타임 고려 필요
    // let mut str_names: Vec<&str> = Vec::new(); // 복잡함
    
    for name in &names {
        println!("이름: {}", name);
    }
}

소유권이 필요한 경우

struct Person {
    name: String,           // 소유권 필요
    // name: &str,          // 라이프타임 지정이 복잡함
}

impl Person {
    fn new(name: &str) -> Person {
        Person {
            name: name.to_string(),  // &str을 String으로 변환
        }
    }
    
    fn set_name(&mut self, new_name: &str) {
        self.name = new_name.to_string();
    }
}

5. 변환과 성능 고려사항

5.1 String ↔ &str 변환 방법

&str에서 String으로

fn main() {
    let str_data: &str = "Hello";
    
    // 방법 1: to_string()
    let string1: String = str_data.to_string();
    
    // 방법 2: String::from()
    let string2: String = String::from(str_data);
    
    // 방법 3: to_owned()
    let string3: String = str_data.to_owned();
    
    println!("{}, {}, {}", string1, string2, string3);
}

String에서 &str로

fn main() {
    let string_data: String = String::from("Hello");
    
    // 방법 1: & 연산자 (자동 역참조)
    let str1: &str = &string_data;
    
    // 방법 2: as_str() 메소드
    let str2: &str = string_data.as_str();
    
    // 방법 3: 슬라이싱
    let str3: &str = &string_data[..];
    
    println!("{}, {}, {}", str1, str2, str3);
}

5.2 성능 고려사항

메모리 할당 비용

// 비효율적: 불필요한 String 생성
fn process_text_bad(text: &str) -> String {
    let owned_text = text.to_string();  // 힙 할당 발생
    // 단순 처리 후
    owned_text
}

// 효율적: &str 그대로 사용
fn process_text_good(text: &str) -> &str {
    // 처리 로직
    text
}

문자열 연결 성능

fn main() {
    // 비효율적: 매번 새로운 String 생성
    let mut result = String::new();
    for i in 0..1000 {
        result = result + &i.to_string(); // 매번 새로운 할당!
    }
    
    // 효율적: push_str() 사용
    let mut result = String::new();
    for i in 0..1000 {
        result.push_str(&i.to_string()); // 기존 버퍼 재사용
    }
    
    // 더 효율적: with_capacity()로 미리 할당
    let mut result = String::with_capacity(4000); // 예상 크기 미리 할당
    for i in 0..1000 {
        result.push_str(&i.to_string());
    }
}

6. 실전 예제: 간단한 텍스트 처리기

이제 지금까지 배운 내용을 종합해서 실제 프로그램을 만들어 보겠습니다.

use std::collections::HashMap;

pub struct TextProcessor {
    text: String,
    word_count: HashMap<String, usize>,
}

impl TextProcessor {
    // String을 받아서 소유권 가져오기
    pub fn new(text: String) -> Self {
        TextProcessor {
            text,
            word_count: HashMap::new(),
        }
    }
    
    // &str을 받아서 새로운 인스턴스 생성
    pub fn from_str(text: &str) -> Self {
        Self::new(text.to_string())
    }
    
    // 읽기 전용으로 텍스트 반환
    pub fn get_text(&self) -> &str {
        &self.text
    }
    
    // 텍스트 수정 (String 필요)
    pub fn append_text(&mut self, additional: &str) {
        self.text.push_str(additional);
        self.word_count.clear(); // 캐시 무효화
    }
    
    // 단어 개수 세기 (&str로 처리)
    pub fn count_words(&mut self) -> &HashMap<String, usize> {
        if self.word_count.is_empty() {
            for word in self.text.split_whitespace() {
                let word_lower = word.to_lowercase();
                *self.word_count.entry(word_lower).or_insert(0) += 1;
            }
        }
        &self.word_count
    }
    
    // 특정 단어 찾기 (&str 매개변수)
    pub fn find_word(&self, target: &str) -> Vec<usize> {
        let target_lower = target.to_lowercase();
        self.text
            .split_whitespace()
            .enumerate()
            .filter(|(_, word)| word.to_lowercase() == target_lower)
            .map(|(index, _)| index)
            .collect()
    }
    
    // 텍스트 정리 (String 반환)
    pub fn clean_text(&self) -> String {
        self.text
            .lines()
            .map(|line| line.trim())
            .filter(|line| !line.is_empty())
            .collect::<Vec<&str>>()
            .join("\n")
    }
}

fn main() {
    // String으로 생성
    let text = String::from("Hello world! This is a test. Hello again!");
    let mut processor = TextProcessor::new(text);
    
    // &str로 생성
    let mut processor2 = TextProcessor::from_str("Another text to process.");
    
    // 텍스트 추가
    processor.append_text(" More text here.");
    
    // 단어 개수 세기
    let word_counts = processor.count_words();
    for (word, count) in word_counts {
        println!("{}: {}", word, count);
    }
    
    // 단어 찾기
    let positions = processor.find_word("hello");
    println!("'hello' found at positions: {:?}", positions);
    
    // 텍스트 정리
    let cleaned = processor.clean_text();
    println!("Cleaned text: {}", cleaned);
}

이 예제에서 주목할 점들:

  1. 생성자: String을 받는 것과 &str을 받는 것을 모두 제공
  2. 읽기 메소드: &str을 반환하여 불필요한 복사 방지
  3. 수정 메소드: String의 가변성 활용
  4. 매개변수: 대부분 &str로 받아 유연성 제공
  5. 반환값: 필요에 따라 String 또는 &str 선택

7. 고급 주제: 라이프타임과 문자열

7.1 &str의 라이프타임 이해하기

fn main() {
    let long_lived = String::from("Long lived string");
    let str_ref: &str;
    
    {
        let short_lived = String::from("Short lived");
        str_ref = &short_lived;  // 💥 컴파일 에러!
        // short_lived는 여기서 drop됨
    }
    
    // println!("{}", str_ref);  // 댕글링 참조!
}

안전한 방법:

fn get_greeting(name: &str) -> String {  // String 반환으로 소유권 이동
    format!("Hello, {}!", name)
}

fn get_static_greeting() -> &'static str {  // 정적 라이프타임
    "Hello, World!"
}

fn main() {
    let name = "Alice";
    let greeting1 = get_greeting(name);      // 안전: 소유된 String
    let greeting2 = get_static_greeting();   // 안전: 정적 참조
    
    println!("{}", greeting1);
    println!("{}", greeting2);
}

7.2 구조체에서의 문자열 필드

// String 사용 (권장)
struct Person {
    name: String,
    email: String,
}

impl Person {
    fn new(name: &str, email: &str) -> Self {
        Person {
            name: name.to_string(),
            email: email.to_string(),
        }
    }
}

// &str 사용 (라이프타임 필요)
struct PersonRef<'a> {
    name: &'a str,
    email: &'a str,
}

impl<'a> PersonRef<'a> {
    fn new(name: &'a str, email: &'a str) -> Self {
        PersonRef { name, email }
    }
}

fn main() {
    // String 버전 (간단)
    let person1 = Person::new("Alice", "alice@example.com");
    
    // &str 버전 (복잡하지만 메모리 효율적)
    let name = "Bob";
    let email = "bob@example.com";
    let person2 = PersonRef::new(name, email);
}

8. 마무리하며

8.1 핵심 원칙 정리

  1. 기본적으로 &str 사용: 함수 매개변수, 읽기 전용 작업
  2. 소유권이 필요하면 String: 구조체 필드, 수정 작업, 반환값
  3. 성능 고려: 불필요한 to_string() 호출 피하기
  4. 안전성 우선: C언어의 메모리 실수들을 컴파일 시점에 방지

8.2 C언어 배경이 있으신 분들은…

만약 C언어 경험이 있으시다면, 다음과 같이 생각해보시면 좋을 것 같습니다:

  • &str ≈ const char* (하지만 더 안전)
  • String ≈ char* with malloc/free (하지만 자동 관리)

Rust는 C언어의 성능과 제어력을 유지하면서도, 메모리 안전성을 컴파일 시점에 보장합니다. 처음에는 소유권 시스템이 번거로울 수 있지만, 익숙해지면 C에서 겪었던 수많은 버그들을 미리 방지할 수 있다는 것을 알게 될 것입니다.

8.3 실무 개발 팁

  1. API 설계시: 매개변수는 &str, 반환값은 필요에 따라 String 또는 &str
  2. 성능 최적화: String::with_capacity()로 미리 메모리 할당
  3. 에러 메시지: &'static str 사용으로 메모리 절약
  4. 설정 관리: 상수는 &str, 사용자 입력은 String

8.4 더 나아가기

이 글에서 다룬 내용은 Rust 문자열 처리의 기초입니다. 더 깊이 학습하고 싶다면:

  • 고급 라이프타임: 복잡한 참조 관계 다루기
  • UTF-8 처리: 유니코드 문자열의 특별한 고려사항
  • 성능 최적화: Cow 타입과 지연 평가
  • 파싱과 정규표현식: 실제 텍스트 처리 도구들

Rust의 문자열 시스템은 처음에는 복잡해 보이지만, 이해하고 나면 안전하고 효율적인 프로그램을 작성하는 강력한 도구가 됩니다. C언어에서 겪었던 메모리 관련 고민들을 덜고, 더 안전하고 표현력 있는 코드를 작성할 수 있을 것입니다.


Happy Coding! 🦀

0개의 댓글