8. 문자열, 벡터, 해시맵, 이터레이터
이제야 비로소 "데이터를 다룬다"
앞선 챕터에서 변수, 함수, 소유권, 참조, 에러 처리까지 배웠다면 이제 기본 공구는 손에 넣은 셈이다. 하지만 망치 하나만으로 집을 지을 수는 없다. 실제 프로그램은 여러 개의 값을 모아 저장하고, 텍스트를 다듬고, 반복해서 가공해야 한다.
Rust에서 그 일을 가장 자주 맡는 주인공이 바로 Vec<T>, String, HashMap<K, V>, 그리고 이들을 우아하게 처리하는 이터레이터다. 표준 라이브러리 관점에서도 대부분의 일반적인 데이터 저장 문제는 Vec와 HashMap으로 시작해도 충분하다고 볼 수 있다.1
이 장의 목표는 문법을 더 외우는 것이 아니다. "입력된 여러 값"을 "쓸 만한 결과"로 바꾸는 흐름을 익히는 것이다.
컬렉션이 왜 중요한가
배열과 튜플은 이미 배웠다. 하지만 배열은 길이가 고정이고, 튜플은 구조는 좋지만 값이 많아질수록 다루기 불편하다. 현실의 프로그램은 이런 상황을 자주 만난다.
사용자가 입력한 여러 개의 점수를 저장하고 싶다.
문장에서 단어를 하나씩 꺼내 개수를 세고 싶다.
상품 이름과 가격을 짝지어 보관하고 싶다.
여러 줄의 텍스트를 읽어 필요한 것만 걸러내고 싶다.
이때 필요한 것이 컬렉션이다. 컬렉션은 보통 힙에 데이터를 저장하며, 실행 중 크기가 늘어나거나 줄어들 수 있다.1
먼저 감각부터 잡아보자.
순서 있는 목록이 필요하면
Vec<T>텍스트를 소유하면서 수정하려면
String키로 값을 빠르게 찾으려면
HashMap<K, V>컬렉션을 읽고, 바꾸고, 걸러내고, 합치려면 이터레이터
이 네 가지가 익숙해지면 Rust 코드가 갑자기 "프로그램처럼" 보이기 시작한다.
벡터 Vec<T>: 가장 많이 쓰는 동적 배열
Vec<T>는 같은 타입의 값을 순서대로 저장하는 컬렉션이다. 크기가 실행 중 바뀔 수 있고, 인덱스로 접근할 수 있다.
벡터 만들기
가장 단순한 방법은 Vec::new()와 vec! 매크로다.
fn main() {
let mut scores: Vec<i32> = Vec::new();
scores.push(10);
scores.push(20);
scores.push(30);
let nums = vec![1, 2, 3, 4, 5];
println!("{:?}", scores);
println!("{:?}", nums);
}비어 있는 벡터는 타입을 추론할 단서가 없어서 Vec<i32>처럼 타입을 적어 주는 경우가 많다. 반면 vec![1, 2, 3]은 값이 이미 있으니 Rust가 알아서 타입을 짐작할 수 있다.
값 추가, 제거, 길이 확인
fn main() {
let mut tasks = vec!["공부", "산책"];
tasks.push("정리");
println!("길이: {}", tasks.len());
let last = tasks.pop();
println!("마지막 값: {:?}", last);
println!("현재 목록: {:?}", tasks);
}push는 뒤에 값을 추가하고, pop은 뒤에서 값을 꺼내며 Option<T>를 반환한다. 비어 있을 수도 있으니 Some 또는 None으로 처리하게 만드는 것이다.
요소 접근: []와 get()의 차이
여기서 초보자가 자주 부딪히는 갈림길이 나온다.
fn main() {
let v = vec![10, 20, 30];
let first = v[0];
println!("첫 번째: {}", first);
match v.get(10) {
Some(value) => println!("값: {}", value),
None => println!("그 인덱스는 없습니다."),
}
}v[0]은 간단하지만 범위를 벗어나면 패닉이 난다. 반면 get()은 Option<&T>를 돌려주므로 안전하다. 인덱스가 확실할 때는 [], 사용자 입력처럼 불확실할 때는 get()을 쓰는 습관이 좋다.
이 장에서 중요한 감각은 이것이다. Rust는 "없을 수도 있는 값"을 숨기지 않는다. 그냥 None으로 드러낸다. 그래서 코드가 조금 길어져도 실수가 줄어든다.
반복하기: 읽기, 수정하기, 소유권 가져가기
벡터를 반복할 때는 세 가지 방식이 특히 중요하다.1
fn main() {
let numbers = vec![1, 2, 3];
for n in numbers.iter() {
println!("읽기: {}", n);
}
let mut numbers = vec![1, 2, 3];
for n in numbers.iter_mut() {
*n *= 10;
}
println!("수정 후: {:?}", numbers);
let numbers = vec![1, 2, 3];
for n in numbers.into_iter() {
println!("소유: {}", n);
}
// numbers는 여기서 더 이상 사용할 수 없음
}iter()는&T를 준다. 읽기 전용이다.iter_mut()는&mut T를 준다. 값을 직접 바꿀 수 있다.into_iter()는T자체를 준다. 벡터를 소비한다.
처음엔 이름이 비슷해 헷갈리지만, 기준은 단 하나다. 읽기만 할 건가, 고칠 건가, 아예 가져갈 건가?
벡터와 빌림 규칙
다음 코드는 초보자가 한 번쯤 써 보고 에러를 만난다.
fn main() {
let mut v = vec![1, 2, 3];
let first = &v[0];
// v.push(4); // 컴파일 에러
println!("첫 값: {}", first);
}왜 막을까? push()가 내부 메모리 재배치를 일으킬 수 있기 때문이다. 그러면 first가 가리키던 위치가 더 이상 안전하지 않을 수 있다. Rust는 이런 가능성을 미리 차단한다.
즉, 벡터는 편하지만 소유권과 빌림 규칙을 절대 잊으면 안 된다. 이건 귀찮은 제약이 아니라, 런타임에서 터질 버그를 컴파일 타임으로 끌고 온 안전장치다.
서로 다른 타입을 한 벡터에 넣고 싶다면
벡터는 원칙적으로 같은 타입만 저장한다. 그런데 가끔 숫자, 문자열, 실수를 한 줄에 담고 싶을 수 있다. 그럴 때는 enum을 쓴다.
enum Cell {
Int(i32),
Float(f64),
Text(String),
}
fn main() {
let row = vec![
Cell::Int(3),
Cell::Text(String::from("blue")),
Cell::Float(10.5),
];
for cell in row {
match cell {
Cell::Int(v) => println!("정수: {}", v),
Cell::Float(v) => println!("실수: {}", v),
Cell::Text(v) => println!("문자열: {}", v),
}
}
}이 방식은 "아무거나 넣는 상자"가 아니라, "미리 정한 종류들만 넣는 상자"다. 그래서 안전하다.
문자열: String과 &str를 구분하자
Rust 문자열은 초보자에게 약간 까다롭다. 하지만 이유를 알면 오히려 납득이 잘 된다.
가장 먼저 기억할 것은 두 가지다.
String: 소유하는, 수정 가능한 문자열&str: 문자열 슬라이스, 빌려 쓰는 문자열
예를 들어 문자열 리터럴인 "hello"는 보통 &str이다. 반면 String::from("hello")는 내가 소유하는 문자열이다.
문자열 만들기와 붙이기
fn main() {
let mut s = String::new();
s.push_str("Rust");
s.push('!');
let hello = String::from("안녕");
let world = "세계".to_string();
let message = format!("{} {}", hello, world);
println!("{}", s);
println!("{}", message);
}문자열을 이어 붙이는 방법은 여러 가지가 있지만, 초보자에게는 format!이 가장 편하다. + 연산자는 왼쪽 String의 소유권을 가져가서 처음에는 헷갈리기 쉽다.2
예를 들어 이런 코드는 작동하지만,
fn main() {
let s1 = String::from("Hello, ");
let s2 = String::from("Rust");
let s3 = s1 + &s2;
println!("{}", s3);
// println!("{}", s1); // 에러: s1은 이동됨
}처음 배우는 단계에서는 "복잡하게 붙일 땐 format!"이라고 기억해도 충분하다.
왜 문자열은 인덱싱이 안 될까
다른 언어를 쓰다가 Rust에 오면 꼭 묻는다.
"왜 s[0]이 안 되죠?"
이유는 Rust 문자열이 UTF-8이기 때문이다. UTF-8에서는 한 글자가 1바이트일 수도 있고 여러 바이트일 수도 있다. 그래서 "0번째 글자"를 단순히 바이트 한 칸으로 바로 가리키는 것이 안전하지도 않고, 항상 빠르다고 말할 수도 없다.2
fn main() {
let s = String::from("안녕");
// let first = s[0]; // 컴파일되지 않음
}Rust는 애매한 편의 기능을 허용하기보다, 아예 막아 버린다. 대신 목적에 맞는 방법을 쓰게 한다.
글자 단위로 보고 싶다면 chars(), 바이트 단위면 bytes()
fn main() {
let text = "안녕";
for ch in text.chars() {
println!("문자: {}", ch);
}
for b in text.bytes() {
println!("바이트: {}", b);
}
}chars()는 유니코드 스칼라 값 기준으로 순회한다.bytes()는 UTF-8 바이트를 그대로 순회한다.
텍스트 처리에서는 대부분 chars()가 더 직관적이다. 반면 저수준 처리나 인코딩 분석이 필요하면 bytes()가 유용하다.2
문자열 슬라이스는 바이트 경계에 주의
문자열은 슬라이스할 수 있지만, UTF-8 경계를 어기면 패닉이 날 수 있다.2
fn main() {
let s = String::from("hello");
let part = &s[0..2];
println!("{}", part);
}영어처럼 한 글자가 1바이트인 경우엔 감각적으로 잘 맞는다. 하지만 한글, 이모지, 악센트 문자가 섞이면 조심해야 한다. 초보자 단계에서는 "문자열을 자를 때는 바이트가 아니라 의미 단위가 중요하다"는 감각만 먼저 잡아도 충분하다.
문자열도 결국 컬렉션처럼 다룬다
문자열은 텍스트지만, 다룰 때 느낌은 컬렉션과 비슷하다.
fn main() {
let text = " Rust is fun ";
let cleaned = text.trim().to_lowercase();
let words: Vec<&str> = cleaned.split_whitespace().collect();
println!("정리된 문자열: {}", cleaned);
println!("단어들: {:?}", words);
}여기서 벌써 중요한 흐름이 보인다.
문자열을 만들고, 정리하고, 나누고, 모은다.
이 패턴은 앞으로 파일 처리, CLI 입력, 로그 분석, 웹 응답 처리에서 계속 반복된다.
해시맵 HashMap<K, V>: 이름표로 값을 찾는 저장소
HashMap<K, V>는 키와 값을 짝지어 저장한다. 인덱스 대신 키로 찾는다는 점이 벡터와 가장 다르다.
예를 들어 학생 이름으로 점수를 찾고 싶다면 벡터보다 해시맵이 잘 맞는다.
해시맵 만들기와 값 넣기
use std::collections::HashMap;
fn main() {
let mut scores = HashMap::new();
scores.insert(String::from("철수"), 90);
scores.insert(String::from("영희"), 95);
println!("{:?}", scores);
}해시맵은 std::collections::HashMap을 가져와야 쓸 수 있다.
값 가져오기: get()은 Option을 반환한다
use std::collections::HashMap;
fn main() {
let mut scores = HashMap::new();
scores.insert(String::from("철수"), 90);
match scores.get("철수") {
Some(score) => println!("철수 점수: {}", score),
None => println!("해당 학생 없음"),
}
}여기서 get("철수")가 가능한 점이 재미있다. 맵 안에는 String을 저장했지만 조회할 때는 &str로도 찾을 수 있다.3
또 중요한 점 하나. 해시맵의 반복 순서는 보장되지 않는다. 넣은 순서대로 나오리라 기대하면 안 된다.3
같은 키를 다시 넣으면 덮어쓴다
use std::collections::HashMap;
fn main() {
let mut scores = HashMap::new();
scores.insert("Blue", 10);
scores.insert("Blue", 25);
println!("{:?}", scores);
}결과는 Blue: 25가 된다. 기존 값을 바꾸는 효과다.
없을 때만 넣고 싶다면 entry()
HashMap의 진짜 매력은 entry API에서 빛난다.3
use std::collections::HashMap;
fn main() {
let mut scores = HashMap::new();
scores.entry(String::from("Blue")).or_insert(10);
scores.entry(String::from("Blue")).or_insert(50);
scores.entry(String::from("Yellow")).or_insert(30);
println!("{:?}", scores);
}or_insert(10)은 키가 없으면 10을 넣고, 이미 있으면 기존 값을 그대로 둔다.
이 패턴은 "초기값 넣기"에 아주 자주 쓴다.
기존 값에 더해 업데이트하기
가장 대표적인 실전 예제는 단어 개수 세기다.
use std::collections::HashMap;
fn main() {
let text = "rust makes systems programming fun rust";
let mut counts = HashMap::new();
for word in text.split_whitespace() {
let count = counts.entry(word).or_insert(0);
*count += 1;
}
println!("{:?}", counts);
}or_insert(0)은 &mut i32 같은 가변 참조를 돌려준다. 그래서 *count += 1처럼 역참조해서 값을 올린다.
처음엔 *가 낯설지만 의미는 단순하다. "참조가 가리키는 진짜 값에 접근한다"는 뜻이다.
해시맵과 소유권
문자열 키와 값을 insert할 때는 대개 소유권이 해시맵으로 이동한다.3
use std::collections::HashMap;
fn main() {
let key = String::from("name");
let value = String::from("Rustacean");
let mut map = HashMap::new();
map.insert(key, value);
// println!("{} {}", key, value); // 둘 다 이동되어 사용 불가
}이 규칙은 벡터에서도 보였고, 앞으로도 계속 보인다. 컬렉션은 값을 담는 순간 그 값을 "소유"할 수 있다. 그래서 이후에 원래 변수를 계속 쓰고 싶다면 참조를 빌리거나 clone()이 필요한 상황이 생긴다.
값이 벡터인 해시맵도 자주 쓴다
실제 프로그램에서는 HashMap<String, Vec<T>> 형태도 자주 등장한다. 예를 들어 카테고리별로 값을 모을 수 있다.
use std::collections::HashMap;
fn main() {
let items = vec![
("fruit", "apple"),
("fruit", "banana"),
("drink", "water"),
];
let mut grouped: HashMap<&str, Vec<&str>> = HashMap::new();
for (kind, name) in items {
grouped.entry(kind).or_insert(Vec::new()).push(name);
}
println!("{:?}", grouped);
}이 패턴은 분류, 그룹화, 집계에서 매우 강력하다. "키 하나에 값 하나"만 있는 게 아니라 "키 하나에 여러 값 묶음"을 연결하는 셈이다.
이터레이터: 반복을 더 똑똑하게 만드는 방식
이제 이 장의 마지막 주인공이다. 이터레이터는 컬렉션을 한 항목씩 처리하는 표준적인 방법이다. for문 뒤에서 조용히 일하는 존재이기도 하고, map, filter, collect 같은 강력한 메서드를 가능하게 하는 엔진이기도 하다.1
핵심은 이거다. 이터레이터는 "하나씩 꺼내 처리하는 흐름"을 값처럼 다루게 해 준다.
가장 기본적인 이터레이터 메서드
fn main() {
let numbers = vec![1, 2, 3, 4, 5];
let sum: i32 = numbers.iter().sum();
println!("합계: {}", sum);
}iter()가 만든 이터레이터에 sum()을 붙였다. 직접 변수를 만들고 for문으로 더하지 않아도 된다.
코드가 짧아졌다는 점보다 더 중요한 것은, 의도가 더 선명해졌다는 점이다. "합계를 구한다"는 목적이 바로 보인다.
map: 각 요소를 바꾼다
fn main() {
let numbers = vec![1, 2, 3, 4];
let doubled: Vec<i32> = numbers.iter()
.map(|x| x * 2)
.collect();
println!("{:?}", doubled);
}각 요소에 함수를 적용해서 새 값으로 바꾼다. 여기서는 모든 값을 두 배로 만들었다.
filter: 필요한 것만 남긴다
fn main() {
let numbers = vec![1, 2, 3, 4, 5, 6];
let evens: Vec<i32> = numbers.iter()
.filter(|x| *x % 2 == 0)
.copied()
.collect();
println!("{:?}", evens);
}짝수만 골라 새 벡터를 만들었다. 여기서 copied()는 &i32를 i32로 복사해 준다. 숫자처럼 Copy 가능한 타입에서는 매우 자주 보게 된다.
collect: 흐름을 실제 컬렉션으로 만든다
이터레이터는 대체로 지연 평가된다. 즉, map과 filter를 붙여도 바로 결과가 만들어지지 않는다. 실제로 결과가 필요할 때 비로소 소비된다. collect()는 그 대표적인 소비 방법이다.4
fn main() {
let words = vec!["Rust", "is", "fun"];
let sentence = words.into_iter().collect::<String>();
println!("{}", sentence);
}collect()는 이터레이터를 여러 종류의 컬렉션으로 바꿀 수 있다. Vec, HashMap, String 등 FromIterator를 구현한 타입이라면 가능하다.4
예를 들어 튜플 쌍을 해시맵으로 모을 수도 있다.
use std::collections::HashMap;
fn main() {
let pairs = vec![("apple", 3), ("banana", 5)];
let map: HashMap<&str, i32> = pairs.into_iter().collect();
println!("{:?}", map);
}처음 보면 마술 같지만, 사실은 "(키, 값) 쌍들을 해시맵으로 모아라"라는 뜻이다.
iter(), iter_mut(), into_iter()를 다시 정리하면
이터레이터는 결국 소유권과 연결된다.
iter()는 빌려서 읽는다.iter_mut()는 가변으로 빌려서 수정한다.into_iter()는 소유권을 넘기며 소비한다.
이 세 개를 몸에 익히면 반복 처리에서 막히는 일이 크게 줄어든다.
enumerate()로 인덱스와 함께 보기
fn main() {
let fruits = vec!["apple", "banana", "orange"];
for (index, fruit) in fruits.iter().enumerate() {
println!("{}번: {}", index, fruit);
}
}굳이 for i in 0..fruits.len()처럼 인덱스를 직접 돌리는 것보다 더 안전하고 읽기 좋다.
문자열, 벡터, 해시맵, 이터레이터를 한 번에 써 보기
이제 네 가지를 한 흐름으로 엮어 보자. 사용자가 입력한 문장에서 단어 길이를 세고, 길이별로 그룹화하는 예제다.
use std::collections::HashMap;
fn main() {
let text = "rust makes safe systems programming fun and fast";
let cleaned = text.to_lowercase();
let words: Vec<&str> = cleaned.split_whitespace().collect();
let mut grouped: HashMap<usize, Vec<&str>> = HashMap::new();
for word in words {
grouped.entry(word.len()).or_insert(Vec::new()).push(word);
}
for (len, group) in &grouped {
println!("길이 {}: {:?}", len, group);
}
}이 짧은 코드 안에 지금까지 배운 것이 다 들어 있다.
String으로 소문자 변환split_whitespace()로 단어 분리collect()로Vec<&str>생성HashMap<usize, Vec<&str>>로 그룹화entry().or_insert()로 없으면 새 벡터 생성push()로 해당 그룹에 추가
이런 식으로 컬렉션은 서로 연결되어 쓴다. 실제 개발에서는 "벡터만", "문자열만" 따로 쓰기보다, 여러 컬렉션이 하나의 데이터 파이프라인처럼 이어진다.
실전 감각: 언제 무엇을 고를까
초보자에게 가장 흔한 고민은 이것이다. "그래서 뭘 써야 하죠?"
아주 단순하게 시작해 보자.
순서 있는 목록이면
Vec<T>수정 가능한 텍스트면
String이름표로 찾는 데이터면
HashMap<K, V>반복 가공이면 이터레이터
예를 들어 할 일 목록 앱을 만든다고 해 보자.
할 일 전체 목록:
Vec<Task>할 일 제목:
String우선순위별 개수:
HashMap<Priority, usize>완료되지 않은 항목만 추리기:
iter().filter(...)
이렇게 생각하면 된다. 자료구조를 외우기보다, 데이터의 모양을 먼저 떠올리는 습관이 중요하다.
성능을 너무 걱정하기 전에 알아둘 것
Rust 컬렉션은 꽤 효율적이다. Vec는 일반적인 목록 처리에 매우 좋은 기본 선택이고, HashMap은 평균적으로 빠른 조회를 제공한다.13
또한 데이터 개수를 대략 안다면 with_capacity로 미리 공간을 잡아 둘 수 있다. 그러면 재할당 횟수를 줄이는 데 도움이 된다.13
use std::collections::HashMap;
fn main() {
let mut numbers = Vec::with_capacity(100);
let mut counts: HashMap<String, i32> = HashMap::with_capacity(50);
numbers.push(1);
counts.insert("rust".to_string(), 1);
}하지만 초보자 단계에서는 무조건 최적화부터 고민할 필요는 없다. 먼저 맞는 컬렉션을 고르고, 소유권 규칙에 맞게, 읽기 쉬운 코드를 쓰는 것이 우선이다.
초보자가 자주 하는 실수
문자열을 배열처럼 다루려 한다
Rust 문자열은 UTF-8이다. 그래서 인덱싱이 금지되고, 바이트와 문자 개념을 구분해야 한다.2
"첫 글자"가 필요하면 chars().next()를 떠올리자.
fn main() {
let s = "안녕";
let first = s.chars().next();
println!("{:?}", first);
}벡터 원소를 참조한 상태에서 벡터를 변경한다
이미 본 것처럼 참조를 들고 있는 동안 push() 같은 변경은 막힐 수 있다. 읽기와 수정의 타이밍을 분리하는 습관이 중요하다.
해시맵 순서가 고정이라고 착각한다
해시맵은 순서 보장이 없다.3 순서가 중요하면 나중에 BTreeMap 같은 다른 선택지를 배우면 된다. 지금은 "해시맵은 빠른 조회용"이라고 기억하자.
이터레이터 결과가 왜 안 나오는지 당황한다
map()만 쓰고 끝내면 아무 일도 안 일어난 것처럼 보일 수 있다. 이터레이터는 지연 평가되기 때문이다. collect(), sum(), for_each(), count() 같은 소비 단계가 있어야 실제로 실행된다.4
마무리
이번 장에서 배운 네 가지는 Rust 실전의 중심축이다.
Vec는 여러 값을 순서대로 담고, String은 텍스트를 소유하며, HashMap은 이름표로 값을 찾게 해 주고, 이터레이터는 그 데이터를 우아하게 가공하게 해 준다.
이제부터 여러분의 Rust 코드는 단순히 "값 몇 개를 출력하는 코드"에서 벗어나, "데이터를 읽고, 분류하고, 변환하고, 집계하는 코드"로 넘어갈 수 있다.
다음 장에서는 이 데이터들을 더 유연하게 다루기 위해 트레이트, 제네릭, 패턴 매칭을 배운다. 지금까지는 도구를 하나씩 익혔다면, 다음부터는 도구를 조합해 더 멋진 구조를 만드는 단계로 들어간다.
참고
2Working with Collections in Rust: Vectors, HashMaps, and Strings
이 노트는 보도·비평·교육·연구 목적으로 작성되었습니다. 저작권 문의가 있으시면 에서 알려주세요.