Rust 기초 강좌 #7 컬렉션과 이터레이터: Vec·String·HashMap과 for문을 대체하는 체인

4 분 소요

2편에서 배열은 길이가 고정이라고 했고, 실무의 주인공은 따로 있다고 예고했습니다. 이번 편이 그 주인공들입니다. 가변 길이 목록 Vec, 문자열 String, 키-값 저장소 HashMap, 그리고 이 컬렉션들을 다루는 Rust다운 방식인 이터레이터입니다.

Vec: 실무의 기본 목록 #

src/main.rs
let mut scores: Vec<u32> = Vec::new();
scores.push(90);
scores.push(85);

let scores = vec![90, 85, 72]; // 리터럴 매크로

Vec<T>는 힙에 데이터를 두는 가변 길이 배열입니다. 힙을 소유하므로 3편의 소유권 규칙이 그대로 적용되고, 4편에서 본 “순회 중 push 금지"의 컴파일 에러도 Vec이 무대였습니다. 요소 접근은 두 가지인데, 차이가 중요합니다.

src/main.rs
let third = scores[2];        // 범위 밖이면 패닉
let third = scores.get(2);    // 범위 밖이면 None (Option<&u32>)

인덱스가 확실히 유효한 로직에서는 [2]를 쓰고, 인덱스가 입력이나 계산에서 오는 지점에서는 get으로 받아 Option을 처리합니다. 5편과 6편에서 세운 경계(“일어나면 안 되는 상태는 패닉, 예상 가능한 실패는 타입으로”)가 접근 방법 선택에도 그대로 이어지는 것입니다.

String: 인덱스가 안 되는 이유 #

Rust의 String은 UTF-8 바이트의 Vec입니다. 그래서 다른 언어에서 당연하던 코드가 컴파일되지 않습니다.

src/main.rs
let title = String::from("러스트 강좌");
let first = title[0]; // 컴파일 에러: String은 인덱스 접근 불가

2편에서 char는 4바이트 유니코드라고 했고, 4편에서는 슬라이스로 글자 중간을 자르면 패닉이 난다고 예고했습니다. 여기서 답이 모입니다. UTF-8에서 한글 한 글자는 3바이트라서 title[0]이 “첫 글자"라는 보장이 없습니다. 바이트 하나는 글자가 아니기 때문입니다. Rust는 “대충 첫 바이트를 돌려주는” 대신 이 연산 자체를 금지하고, 무엇을 원하는지 명시하게 합니다.

src/main.rs
let first_char = title.chars().next();       // Some('러') — 문자 단위
let byte_len = title.len();                  // 16 — 바이트 단위 길이
let char_count = title.chars().count();      // 5 — 문자 수

len()이 글자 수가 아니라 바이트 수라는 점은 입력 길이 제한 로직에서 흔한 함정이니 기억해 둘 가치가 있습니다. 문자열을 만들 때는 push_strformat! 매크로가 기본 도구입니다.

HashMap: 키-값과 entry API #

src/main.rs
use std::collections::HashMap;

let mut stock: HashMap<String, u32> = HashMap::new();
stock.insert(String::from("키보드"), 12);

let count = stock.get("키보드"); // Option<&u32>

조회가 Option인 것은 이제 놀랍지 않을 것입니다. 없는 키는 null이 아니라 None입니다. HashMap에서 알아 둘 관용구는 “없으면 넣고, 있으면 고치기"를 한 번의 조회로 처리하는 entry API입니다.

src/main.rs
let mut word_count: HashMap<&str, u32> = HashMap::new();
for word in text.split_whitespace() {
    *word_count.entry(word).or_insert(0) += 1;
}

“키가 있는지 확인 → 없으면 삽입 → 다시 조회해서 수정"이라는 3단계가 한 줄이 됩니다. 단어 빈도 세기 같은 집계 코드의 표준형입니다.

이터레이터: for를 대체하는 체인 #

세 컬렉션을 배웠으니, 이들을 다루는 Rust다운 방식을 보겠습니다. “80점 이상 점수의 합"을 두 가지로 써 봅니다.

src/main.rs
// for 방식
let mut total = 0;
for s in &scores {
    if *s >= 80 {
        total += s;
    }
}

// 이터레이터 체인 방식
let total: u32 = scores.iter().filter(|s| **s >= 80).sum();

filter에 넘긴 |s| ...는 클로저(익명 함수)입니다. 체인 방식이 관용구인 이유는 취향이 아니라 구조입니다. mut 변수가 사라지고, “걸러서 더한다"는 의도가 구현 순서가 아니라 이름으로 드러납니다. 변환에는 map, 결과를 새 컬렉션으로 모을 때는 collect를 씁니다.

src/main.rs
let names: Vec<String> = users.iter()
    .filter(|u| u.active)
    .map(|u| u.name.clone())
    .collect();

두 가지를 더 알아 두면 완성입니다. 첫째, 이터레이터는 지연 평가됩니다. mapfilter는 계획만 쌓고, collectsum 같은 소비 지점에서 한 번에 실행되므로 중간 컬렉션이 만들어지지 않습니다. 컴파일 후 성능은 손으로 쓴 반복문과 대등해서, 표현력을 위해 성능을 내주는 거래가 아닙니다. 둘째, 순회에도 소유권 구분이 있습니다. iter()는 참조로 빌려서 순회하고, into_iter()는 소유권을 가져가며 순회합니다. 순회 후에도 컬렉션을 계속 사용할 것이면 iter()가 기본값입니다.

정리 #

  • Vec이 실무의 기본 목록입니다. 확실한 인덱스는 [i], 밖에서 온 인덱스는 get으로 받아 Option을 처리합니다.
  • String은 UTF-8 바이트의 Vec이라서 인덱스 접근이 금지됩니다. 문자 단위는 chars(), len()은 바이트 수라는 것이 실무 함정입니다.
  • HashMap 조회는 Option이고, 집계는 entry(...).or_insert(...) 관용구가 표준형입니다.
  • 컬렉션 처리의 기본형은 for보다 iter().filter().map().collect() 체인입니다. 지연 평가라서 중간 비용 없이 손으로 쓴 반복문과 대등하게 컴파일됩니다.
  • 다음 편은 트레이트와 제네릭입니다. 지금까지 미뤄 온 derive(Debug), Copy 트레이트, Box<dyn Error>의 정체가 한 번에 풀립니다.
X