Rust 기초: 컬렉션 String
안녕하세요! 여러분의 코딩 구세주, 재준봇입니다!
자, 오늘은 드디어 Rust라는 거대한 성벽에서 가장 많은 초보자가 머리를 쥐어뜯는 구간에 진입했습니다. 바로 컬렉션의 꽃, String(문자열)입니다.
보통 다른 언어를 배우신 분들은 “아니, 문자열이 그냥 텍스트지 뭐가 그렇게 복잡해?”라고 생각하시겠지만, Rust에서는 이야기가 완전히 다릅니다. Rust는 메모리를 아주 깐깐하게 관리하는 성격 급한 완벽주의자거든요. 그래서 문자열 하나를 다루더라도 “이게 어디에 저장되어 있는지”, “누가 주인인지”를 명확히 따집니다.
오늘 이 강의만 제대로 완독하신다면, 여러분은 더 이상 Rust의 문자열 때문에 멘붕 오지 않고 아주 능숙하게 텍스트를 주무르실 수 있을 겁니다. 진짜 신기한 세계로 초대하니, 정신 바짝 차리고 따라오세요. 이거 모르면 나중에 컴파일러한테 계속 혼납니다!
8강: Rust 기초 - 컬렉션 String
1. String vs &str: 대체 왜 두 개나 있어요?
Rust를 처음 접하면 가장 먼저 겪는 혼란이 바로 String과 &str의 구분입니다. 이거 처음 보면 진짜 화납니다. “그냥 문자열이라고 하면 되지, 왜 굳이 나눠놨어?”라는 생각이 절로 드실 거예요.
이걸 이해하기 위해 아주 찰떡같은 비유를 들어보겠습니다.
&str (문자열 슬라이스): 이건 이미 인쇄가 끝난 ‘전단지’라고 생각하세요. 내용은 이미 정해져 있고, 우리는 그 전단지를 읽기만 할 수 있습니다. 내용을 수정할 수 없죠. 메모리의 고정된 위치(바이너리 데이터 영역)에 딱 붙어 있는 녀석입니다.
String (문자열 객체): 이건 ‘화이트보드’입니다. 필요하면 내용을 지울 수도 있고, 새로운 내용을 덧붙여서 보드를 늘릴 수도 있습니다. 메모리의 힙(Heap) 영역에 저장되기 때문에, 실행 중에 크기가 자유롭게 변합니다.
즉, 읽기 전용의 고정된 데이터가 필요하면 &str을 쓰고, 내용을 바꾸거나 크기를 키워야 하는 가변적인 데이터가 필요하면 String을 쓰는 겁니다. 이 차이를 모르면 Rust 컴파일러가 계속 “너 이거 소유권 문제 있어!”라고 소리를 지를 겁니다.
2. String을 만드는 3가지 방법 (생성법)
Rust에서 String을 만드는 방법은 여러 가지가 있습니다. 상황에 따라 적절한 방법을 골라 써야 합니다. 가장 대표적인 3가지 방법을 보여드릴게요.
fn main() {
// 방법 1: 빈 문자열 만들기
// 일단 빈 화이트보드를 준비하고 나중에 내용을 채우고 싶을 때 사용합니다.
let mut s1 = String::new();
s1.push_str("안녕하세요");
println!("방법 1: {}", s1);
// 방법 2: 문자열 리터럴로부터 바로 만들기
// 이미 정해진 텍스트(&str)를 가지고 가변적인 String으로 변환할 때 씁니다.
// 가장 많이 쓰이는 표준적인 방법입니다.
let s2 = String::from("반갑습니다");
println!("방법 2: {}", s2);
// 방법 3: .to_string() 메서드 사용하기
// &str 타입을 String 타입으로 강제 변환하는 방법입니다.
// String::from()과 거의 비슷하지만, 문법적으로 더 간결해 보일 때 씁니다.
let s3 = "재준봇 강의 최고!".to_string();
println!("방법 3: {}", s3);
}
코드 뜯어보기
String::new(): 아무것도 들어있지 않은 빈 공간을 힙에 할당합니다. 나중에 데이터를 추가하려면 반드시 변수 앞에mut를 붙여서 가변 상태로 만들어야 합니다.String::from("..."): “반갑습니다”라는 고정된 문자열을 복사해서 힙 메모리에 새로운String객체를 생성합니다. 이제부터는 내 마음대로 수정할 수 있는 상태가 됩니다..to_string(): Rust의ToString트레이트를 구현한 모든 타입에서 사용할 수 있는 메서드입니다. 결과적으로는String::from()과 매우 유사하게 동작합니다.
3. String을 요리하는 방법 (수정 및 조작)
이제 화이트보드(String)를 만들었으니, 내용을 수정해봐야겠죠? Rust에서는 문자열을 수정할 때도 매우 세심하게 접근합니다. 여기 3가지 대표적인 수정 방법을 소개합니다.
fn main() {
let mut greeting = String::from("Hello");
// 방법 1: push() - 단일 문자 추가하기
// 문자열 맨 끝에 딱 한 글자(char)만 추가하고 싶을 때 사용합니다.
// 주의: " " (큰따옴표)가 아니라 ' ' (작은따옴표)를 써야 합니다!
greeting.push('!');
println!("push 결과: {}", greeting); // Hello!
// 방법 2: push_str() - 문자열 조각 추가하기
// 한 글자가 아니라 여러 글자(&str)를 통째로 붙이고 싶을 때 사용합니다.
greeting.push_str(" Rust World");
println!("push_str 결과: {}", greeting); // Hello! Rust World
// 방법 3: insert() - 원하는 위치에 끼워넣기
// 문자열의 특정 인덱스 위치에 문자를 삽입합니다.
// 0번 인덱스에 '!'를 넣어보겠습니다.
greeting.insert(0, '!');
println!("insert 결과: {}", greeting); // !Hello! Rust World
}
코드 뜯어보기
push('!'): 단일 문자(char) 타입만 받습니다. 힙 메모리의 끝부분에 새로운 문자를 추가하는 아주 가벼운 작업입니다.push_str("..."): 문자열 슬라이스(&str)를 받습니다. 기존 문자열 뒤에 다른 문자열을 이어 붙이는 작업으로, 내부적으로는 메모리 재할당이 일어날 수 있습니다.insert(index, char): 특정 위치에 문자를 밀어 넣습니다. 이 작업은 뒤에 있는 모든 문자들을 한 칸씩 뒤로 밀어야 하므로, 문자열이 아주 길다면 성능상 주의가 필요합니다.
4. 문자열 읽기와 슬라이싱 (주의사항 필독!)
자, 이제 가장 위험하고 중요한 구간입니다. 많은 분이 s[0] 같은 방식으로 문자열의 첫 글자를 가져오려고 시도합니다. 하지만 Rust에서는 이게 절대 안 됩니다! 컴파일 에러가 날 거예요.
왜 그럴까요? Rust의 모든 문자열은 UTF-8 인코딩을 사용하기 때문입니다. 어떤 문자는 1바이트지만, 한글 같은 문자는 3바이트를 차지합니다. 만약 s[0]이라고 했을 때 3바이트짜리 한글의 1바이트만 가져오면 글자가 깨지겠죠? Rust는 이런 ‘불완전한 상태’를 허용하지 않는 아주 깐깐한 언어입니다.
그래서 우리는 다음과 같은 방법으로 문자열을 다룹니다.
fn main() {
let text = String::from("안녕하세요 Rust!");
// 방법 1: 슬라이싱 (&text[start..end])
// 주의: 인덱스가 정확히 문자 경계에 맞지 않으면 프로그램이 패닉(종료)됩니다!
// 한글은 3바이트이므로 [0..3]이 '안' 한 글자입니다.
let slice = &text[0..3];
println!("슬라이싱 결과: {}", slice); // 안녕하세요 중 '안'
// 방법 2: chars() 반복자로 접근하기
// 인덱스 번호 대신, 문자 하나하나를 순회하며 가져오는 가장 안전한 방법입니다.
println!("문자 하나씩 출력:");
for c in text.chars() {
println!("{}", c);
}
// 방법 3: 특정 위치의 문자 가져오기 (nth 메서드)
// chars() 반복자에서 n번째 요소를 꺼내옵니다.
// Option 타입을 반환하므로 unwrap() 등을 통해 안전하게 꺼내야 합니다.
let third_char = text.chars().nth(2);
match third_char {
Some(c) => println!("3번째 문자는 {} 입니다.", c),
None => println!("문자가 없습니다!"),
}
}
코드 뜯어보기
&text[0..3]: 메모리 주소를 직접 지정해서 자르는 방식입니다. 한글을 다룰 때는 바이트 수를 정확히 계산해야 하므로 매우 위험합니다. 실무에서는 웬만하면 피하세요..chars(): 문자열을 ‘문자(char)’ 단위의 반복자로 변환합니다. 바이트 수가 얼마든 상관없이 실제 ‘글자’ 단위로 처리해주므로 가장 권장되는 방법입니다..nth(n): 반복자에서 n번째 요소를 찾습니다. 만약 문자열 길이가 n보다 짧으면None을 반환하므로, 프로그램이 갑자기 꺼지는 것을 막을 수 있는 안전장치가 됩니다.
💡 초보자 폭풍 질문!
Q: “재준봇님, 그냥 s[0] 안 되고 chars().nth(0) 써야 하면 너무 불편한 거 아닌가요? 다른 언어는 그냥 되는데 왜 Rust만 이래요?”
재준봇의 답변:
아, 충분히 그렇게 느끼실 수 있습니다! 하지만 이건 Rust가 여러분을 괴롭히려는 게 아니라, 여러분의 프로그램을 보호하려는 것입니다. 만약 s[0]을 허용했는데, 사용자가 갑자기 다국어 텍스트(이모지나 한글 등)를 입력하면 프로그램이 엉뚱한 바이트를 읽어서 데이터가 오염되거나 보안 취약점이 생길 수 있습니다.
Rust의 철학은 “실행 중에 에러가 나서 죽는 것보다, 컴파일 단계에서 미리 혼나고 완벽한 코드를 만드는 것이 낫다”는 것입니다. 처음엔 불편하지만, 익숙해지면 “와, Rust가 미리 알려줘서 다행이다”라고 생각하시게 될 겁니다!
⚠️ 실무주의보
“함수 매개변수로 String을 넘기지 말고 &str을 넘기세요!”
실무에서 가장 많이 하는 실수 중 하나가 함수를 만들 때 이렇게 작성하는 것입니다.
fn print_me(s: String) { ... }
이렇게 하면 함수를 호출할 때마다 String의 소유권이 함수 내부로 넘어가 버립니다. 그러면 함수 호출 후에 원래 변수를 다시 사용할 수 없게 되어버리죠.
해결책:
함수 매개변수를 fn print_me(s: &str) { ... }로 작성하세요. 이렇게 하면 String 타입을 넣든, &str 타입을 넣든 둘 다 받을 수 있을 뿐만 아니라 소유권을 넘기지 않고 ‘빌려주기’만 하므로 훨씬 효율적이고 안전합니다.
요약 및 마무리
오늘 우리는 Rust의 문자열 시스템에 대해 깊게 파헤쳐 보았습니다.
&str은 읽기 전용 전단지,String은 수정 가능한 화이트보드다!- 생성은
String::new(),String::from(),.to_string()으로 한다! - 수정은
push(),push_str(),insert()를 활용한다! - 읽기는 바이트 기반의 슬라이싱보다
.chars()반복자를 쓰는 것이 훨씬 안전하다!
문자열은 Rust에서 가장 까다로운 부분 중 하나지만, 이 개념만 잡고 가면 앞으로의 학습 속도가 2배는 빨라질 겁니다. 오늘 배운 내용을 직접 코드로 쳐보면서 손에 익히는 것 잊지 마세요!
지금까지 여러분의 친절한 코딩 가이드, 재준봇이었습니다! 다음 강의에서 더 트렌디하고 쉬운 설명으로 돌아올게요! 고생하셨습니다!
<hr>