
Rust를 처음 배우다 보면
String과&str두 가지 문자열 타입 때문에 혼란스러워하는 경우가 많습니다. "왜 문자열 타입이 두 개나 있지?" "언제 어떤 것을 써야 하지?" 같은 의문이 드는 것은 자연스러운 일일겁니다.특히 C언어 경험이 있으신 분들이라면 더욱 헷갈릴 수 있습니다. C에서는
char*와char[]의 차이를 알고 있어도, 이 개념에 빗대어 이해하려고 하면 혼란을 은근히 가중시키기도 하는데, Rust의String과&str은 완전히 다른 철학을 가지고 있기 때문입니다.이 글에서는 Rust의 두 문자열 타입을 C언어와 비교하면서 깊이 있게 알아보겠습니다. 단순히 문법만 알아보는 것이 아니라, 왜 이렇게 설계되었는지, 언제 어떤 것을 사용해야 하는지, 그리고 C언어에서 흔히 발생하는 메모리 관련 실수들을 어떻게 방지할 수 있는지까지 살펴보겠습니다.
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: 수정 가능, 동적으로 크기 변경 가능하지만 이것만으로는 충분하지 않습니다. 더 깊이 들어가 보겠습니다.
두 타입의 진짜 차이를 이해하려면 메모리에서 어떻게 저장되는지 알아야 합니다.
&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바이트) │ │ (할당된 전체공간) │
└─────────────────┘ └──────────────────┘
이 차이가 바로 두 타입의 모든 특성을 결정합니다.
앞서 본 메모리 구조의 차이가 실제로 어떤 의미를 갖는지 자세히 살펴보겠습니다.
&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을 보장합니다.
&str은 실제 데이터를 소유하지 않습니다. 다른 곳에 있는 데이터를 가리키기만 해요.&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타입 문자열 데이터 저장 구조의 핵심적인 의미:
String은 힙의 데이터를 완전히 소유합니다.이런 차이점들이 왜
&str과String이 다르게 설계되었는지, 그리고 언제 무엇을 사용해야 하는지를 명확하게 보여줍니다.&str은 효율적인 참조와 슬라이싱을 위해,String은 소유권과 변경 가능성을 위해 각각 최적화된 설계를 가지고 있어요.
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;
}
이제 위의 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됨
}
| 특징 | C char* | C char[] | Rust &str | Rust String |
|---|---|---|---|---|
| 메모리 위치 | 정적/힙 | 스택 | 정적/스택/힙 | 힙 |
| 수정 가능성 | 위험 | 가능 | 불가능 | 가능 |
| 메모리 관리 | 수동 | 자동 | 자동 | 자동 |
| 크기 변경 | 불가능 | 불가능 | 불가능 | 가능 |
| 안전성 | 낮음 | 보통 | 높음 | 높음 |
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);
}
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);
}
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
}
String을 쓰고, 언제 &str을 써야 할까?실제 예제들을 통해 Rust에서 문자열을 다루고자 할 때 어떤 타입을 사용해야 하는지 알아보겠습니다.
함수 매개변수로 문자열을 받을 때
// 좋은 예: &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"
}
문자열을 수정해야 할 때
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();
}
}
&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);
}
메모리 할당 비용
// 비효율적: 불필요한 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());
}
}
이제 지금까지 배운 내용을 종합해서 실제 프로그램을 만들어 보겠습니다.
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);
}
이 예제에서 주목할 점들:
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);
}
// 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);
}
만약 C언어 경험이 있으시다면, 다음과 같이 생각해보시면 좋을 것 같습니다:
&str ≈ const char* (하지만 더 안전)String ≈ char* with malloc/free (하지만 자동 관리)Rust는 C언어의 성능과 제어력을 유지하면서도, 메모리 안전성을 컴파일 시점에 보장합니다. 처음에는 소유권 시스템이 번거로울 수 있지만, 익숙해지면 C에서 겪었던 수많은 버그들을 미리 방지할 수 있다는 것을 알게 될 것입니다.
이 글에서 다룬 내용은 Rust 문자열 처리의 기초입니다. 더 깊이 학습하고 싶다면:
Rust의 문자열 시스템은 처음에는 복잡해 보이지만, 이해하고 나면 안전하고 효율적인 프로그램을 작성하는 강력한 도구가 됩니다. C언어에서 겪었던 메모리 관련 고민들을 덜고, 더 안전하고 표현력 있는 코드를 작성할 수 있을 것입니다.
Happy Coding! 🦀