Vec·String·HashMap - 소유권과 함께 쓰는 컬렉션
이 장에서 배우는 것
앞 장에서는 값이 없을 수 있음과 실패할 수 있음을 Option 과 Result 로 표현했다. 이 장에서는 가계부 프로그램이 실제로 기록을 쌓고 분류별로 묶을 때 쓰는 컬렉션 세 가지를 다룬다. 기록을 담는 Vec, 글자를 담는 String 과 &str, 분류 이름으로 합계를 찾는 HashMap 이다. 세 타입 모두 힙에 데이터를 두기 때문에 소유권과 빌림 규칙이 코드 모양을 결정한다.
Vec의 원소를 빌리는 동안 벡터를 수정할 수 없는 이유를 메모리 배치로 설명할 수 있다.Vec에서 원소를 읽기, 고치기, 꺼내기 중 어느 방식이 소유권에 어떤 영향을 주는지 구분한다.String과&str의 차이를 알고, UTF-8 문자열을 정수 위치로 인덱싱할 수 없는 이유를 설명한다.HashMap의entryAPI 로 "없으면 넣고, 있으면 고친다" 를 한 번의 조회로 쓴다.- 해시맵을 결정적인 순서로 출력하도록 정렬한다.
문제 상황
가계부 도구는 텍스트 파일이나 입력에서 한 줄씩 기록을 읽는다. 한 줄은 분류, 금액, 메모 세 칸이다. 이 줄들을 읽어 다음 일을 해야 한다.
- 기록이 몇 건이 될지 모르므로 길이가 늘어나는 저장소가 필요하다.
- 메모가 비어 있으면 "(없음)" 을 채워 넣는다. 저장된 기록을 제자리에서 고치는 일이다.
- 메모는 한글이다. 긴 메모는 화면 폭에 맞춰 잘라 보여 주고 싶은데, 몇 바이트에서 자를지가 문제가 된다.
- 분류별 합계를 구한다. 분류 이름은 미리 정해지지 않았으므로 "처음 보는 분류면 0 에서 시작하고, 이미 있으면 더한다" 가 필요하다.
다른 언어에서는 이 코드를 별생각 없이 쓴다. 리스트에 append 하면서 첫 원소를 들고 있어도 되고, 문자열을 s[0:2] 로 자르기도 하고, 맵에서 값을 꺼내 고친 뒤 다시 넣기도 한다. Rust 에서는 이 세 가지가 각각 컴파일 오류, 실행 중 패닉, 불필요한 이중 조회로 이어진다. 이유를 알면 규칙이 자의적이지 않다는 것이 보인다.
Vec: 빌림과 수정
벡터의 배치
Vec<T> 는 스택에 포인터, 길이(len), 용량(capacity) 세 값을 두고, 실제 원소는 힙의 연속된 버퍼에 둔다. 용량이 가득 찬 상태에서 push 를 하면 더 큰 버퍼를 새로 잡아 원소를 옮기고 옛 버퍼를 반납한다. 이 재할당 때문에 원소를 빌린 참조가 남아 있는 채로 push 를 허용할 수 없다.
아래 코드는 이 상황을 그대로 옮긴 것이다.
fn main() {
let mut v = vec![1, 2, 3];
let first = &v[0];
v.push(4);
println!("{first}");
}
컴파일하면 error[E0502]: cannot borrow `v` as mutable because it is also borrowed as immutable 이 나온다. 요지는 first 가 v 를 공유해서 빌리고 있고 그 빌림이 println! 까지 이어지는데, 그 사이에 push 가 v 를 수정용으로 빌리려 한다는 것이다. 앞 장까지 다룬 규칙, 곧 읽기 참조는 여럿이어도 되지만 수정 참조가 있으면 다른 참조가 없어야 한다는 규칙이 그대로 적용된다. 벡터에서는 그 규칙이 실제 메모리 사고를 막는다. 옛 버퍼가 반납된 뒤에 first 를 읽는 일은 C 에서 흔한 버그인데, Rust 는 컴파일 단계에서 막는다.
println! 을 push 앞으로 옮기면 first 의 마지막 사용이 앞당겨져 빌림이 끝나므로 컴파일된다. 빌림은 변수의 유효 범위가 아니라 마지막으로 쓰이는 지점까지 이어진다.
읽기, 고치기, 꺼내기
벡터를 다루는 방식은 소유권 관점에서 세 가지로 나뉜다. 읽기는 공유 빌림, 고치기는 수정 빌림, 꺼내기는 소유권 이동이다.
| 메서드 | 필요한 빌림 | 결과 | 비고 |
|---|---|---|---|
&v[i] | 공유 | 원소 참조 | 범위를 벗어나면 패닉 |
v.get(i) | 공유 | Option<&T> | 범위를 벗어나면 None |
v.iter_mut() | 수정 | 원소별 &mut T | 제자리에서 고칠 때 사용 |
v.push(x) | 수정 | 없음 | x 의 소유권이 벡터로 이동 |
v.remove(i) | 수정 | 원소 T | 소유권을 돌려받고 뒤 원소를 당김 |
v.retain(f) | 수정 | 없음 | 조건이 참인 원소만 남김 |
원소가 String 처럼 힙 데이터를 가진 타입이면 인덱스로 값을 꺼내 변수에 옮길 수 없다. 벡터가 원소의 주인이므로 인덱스 문법은 참조만 준다. 이 내용은 뒤의 "실무에서 자주 틀리는 것" 에서 오류 메시지와 함께 다룬다.
가계부 코드에서 수정 빌림은 for e in entries.iter_mut() 형태로 나온다. 루프 변수 e 가 &mut Entry 이므로 e.memo.push_str(...) 처럼 원소 안의 필드를 직접 고칠 수 있다. 이 루프가 도는 동안에는 entries 를 다른 방식으로 쓸 수 없다.
함수 인자로 벡터를 넘길 때는 &Vec<Entry> 보다 &[Entry] 로 받는 편이 좋다. &[Entry] 는 슬라이스(slice)라 부르는, 연속된 원소 일부를 가리키는 참조다. 벡터 전체도, 배열도, 벡터의 일부 구간도 같은 함수에 넘길 수 있다.
String 과 &str, 그리고 UTF-8
두 타입의 역할
String 은 힙에 문자열 버퍼를 소유하고 늘리거나 고칠 수 있다. &str 은 어딘가에 있는 UTF-8 바이트열 일부를 빌려 보는 참조다. 소스 코드에 쓴 "식비" 같은 리터럴도 &str 이다. 벡터와 슬라이스의 관계와 같다고 보면 된다.
| 항목 | String | &str |
|---|---|---|
| 데이터의 주인 | 자신 | 다른 곳(빌림) |
| 내용 수정 | 가능(push_str 등) | 불가능 |
| 함수 인자로 받을 때 | 소유권이 필요할 때만 | 읽기만 하면 이쪽을 선호 |
| 만드는 법 | String::from, to_string | 리터럴, &s, s.as_str() |
읽기만 하는 함수는 &str 로 받으면 String 이든 리터럴이든 모두 받을 수 있다. 위 코드의 parse_line(line: &str) 이 그 예다.
UTF-8 과 인덱싱
Rust 문자열은 항상 유효한 UTF-8 이다. UTF-8 에서 영문자는 1바이트지만 한글 한 글자는 3바이트를 차지한다. 그래서 "식비".len() 은 글자 수 2 가 아니라 바이트 수 6 이다. s[0] 처럼 정수 하나로 인덱싱하는 문법이 없는 것도 이 때문이다. "0번째" 가 첫 바이트인지 첫 글자인지 모호하고, 글자 단위로 찾으려면 앞에서부터 세어야 해서 비용이 상수가 아니기 때문이다.
구간 슬라이싱 &s[0..3] 은 바이트 위치로 자른다. 위치가 글자 경계에 맞으면 "식" 을 주지만, &s[0..1] 처럼 글자 중간이면 컴파일은 되고 실행 중에 패닉이 난다. 메시지는 byte index 1 is not a char boundary 로 시작한다. 안전하게 시도하려면 s.get(0..1) 을 쓴다. 경계가 아니면 None 을 돌려준다.
글자 단위 작업은 chars() 반복자로 한다. 처음 N글자만 취하려면 s.chars().take(n).collect::<String>() 로 쓴다. 글자 수는 s.chars().count() 로 센다. 여기서 "글자" 는 유니코드 스칼라 값(char) 하나를 뜻한다. 이모지 조합이나 자모 결합처럼 화면에서 한 글자로 보이는 것이 여러 char 로 이루어지는 경우는 이 책 범위 밖이다.
HashMap 과 entry API
분류별 합계의 첫 시도
HashMap<K, V> 는 키로 값을 찾는 표다. 표준 라이브러리에 있지만 프렐류드(prelude, 별도 선언 없이 쓸 수 있는 이름 목록)에는 없어서 use std::collections::HashMap; 가 필요하다. 분류별 합계를 처음 떠올리는 방식은 이렇다.
let old = totals.get(category).copied().unwrap_or(0);
totals.insert(category, old + amount);
동작은 하지만 키를 두 번 해시하고 두 번 찾는다. 읽고 나서 쓰는 두 단계라서 코드 의도도 흐려진다.
entry 로 한 번에
entry(key) 는 그 키의 칸을 가리키는 Entry 값을 돌려준다. 칸이 비어 있을 수도 있고 차 있을 수도 있다. 여기에 or_insert(기본값) 을 이으면 비어 있을 때만 기본값을 넣고, 어느 쪽이든 값에 대한 &mut 참조를 돌려준다. 그 참조를 역참조해서 바로 더한다.
*totals.entry(category).or_insert(0) += amount;
한 줄로 "없으면 0 에서 시작, 있으면 그 값에 더한다" 가 된다. * 는 앞 장들에서 본 대로 참조가 가리키는 값에 접근하는 연산이다. 값 타입이 Vec 처럼 기본값이 명확하면 or_default() 를 쓴다. 그 타입의 기본값을 자동으로 넣는다.
groups.entry(category).or_default().push(memo);
이 코드는 "분류 칸이 없으면 빈 Vec 을 만들고, 거기에 메모를 추가한다" 를 뜻한다. or_insert 든 or_default 든 돌려주는 참조는 맵을 수정용으로 빌린 것이므로, 그 참조를 쓰는 문장이 끝나기 전에는 맵을 다시 만질 수 없다. 위처럼 한 문장 안에서 쓰고 끝내는 것이 자연스럽다. 더 많은 조합은 표준 라이브러리 문서의 Entry 항목에서 확인할 수 있다.
키의 소유권과 출력 순서
insert 와 entry 는 키를 값으로 받는다. 키가 String 이면 소유권이 맵으로 이동하고, &str 이면 참조가 복사되어 들어간다. 이 장의 예제는 Entry 의 category: String 을 as_str() 로 빌려서 HashMap<&str, i64> 를 만든다. 문자열을 복제하지 않는 대신 맵이 원본 기록을 빌려 쓰므로, 기록 벡터가 살아 있는 동안에만 맵을 쓸 수 있다. 이 관계를 컴파일러에게 명시하는 방법은 수명을 다루는 장에서 자세히 본다. 이 장의 함수들은 입력 참조가 하나뿐이라 생략된 수명으로 충분하다.
해시맵의 순회 순서는 정해져 있지 않고 실행할 때마다 달라질 수 있다. 출력이 항상 같아야 하는 가계부 요약이라면 (키, 값) 쌍을 벡터로 모아 정렬한 뒤 출력한다. (&str, i64) 튜플은 서로 비교할 수 있어서 sort() 를 바로 쓸 수 있다.
완성 코드
src/main.rs 한 파일이다. 기록 일곱 줄 중 두 줄은 일부러 잘못된 형식이다.
use std::collections::HashMap;
struct Entry {
category: String,
amount: i64,
memo: String,
}
fn parse_line(line: &str) -> Result<Entry, String> {
let parts: Vec<&str> = line.split(',').map(|p| p.trim()).collect();
if parts.len() != 3 {
return Err(format!("칸이 3개가 아니다: {line}"));
}
let amount: i64 = parts[1]
.parse()
.map_err(|_| format!("금액을 읽을 수 없다: {}", parts[1]))?;
Ok(Entry {
category: parts[0].to_string(),
amount,
memo: parts[2].to_string(),
})
}
fn total_by_category(entries: &[Entry]) -> HashMap<&str, i64> {
let mut totals = HashMap::new();
for e in entries {
*totals.entry(e.category.as_str()).or_insert(0) += e.amount;
}
totals
}
fn memos_by_category(entries: &[Entry]) -> HashMap<&str, Vec<&str>> {
let mut groups: HashMap<&str, Vec<&str>> = HashMap::new();
for e in entries {
groups
.entry(e.category.as_str())
.or_default()
.push(e.memo.as_str());
}
groups
}
fn largest_expense(entries: &[Entry]) -> Option<&Entry> {
entries
.iter()
.filter(|e| e.amount < 0)
.min_by_key(|e| e.amount)
}
fn shorten(text: &str, max_chars: usize) -> String {
if text.chars().count() <= max_chars {
return text.to_string();
}
let head: String = text.chars().take(max_chars - 1).collect();
format!("{head}…")
}
fn main() {
let raw_lines = [
"식비, -12000, 점심김밥",
"교통, -1450, 지하철",
"급여, 3000000, 9월 급여",
"식비, -8500, 친구와 저녁 약속",
"교통, -2800, ",
"식비, abc, 잘못된줄",
"카페, -4500",
];
let mut entries: Vec<Entry> = Vec::new();
for line in raw_lines {
match parse_line(line) {
Ok(entry) => entries.push(entry),
Err(msg) => println!("건너뜀: {msg}"),
}
}
for e in entries.iter_mut() {
if e.memo.is_empty() {
e.memo.push_str("(없음)");
}
}
println!("읽은 기록: {}건", entries.len());
let totals = total_by_category(&entries);
let mut rows: Vec<(&str, i64)> = totals.into_iter().collect();
rows.sort();
for (name, sum) in &rows {
println!("{name}: {sum}");
}
let memos = memos_by_category(&entries);
let mut names: Vec<&str> = memos.keys().copied().collect();
names.sort();
for name in names {
let list: Vec<String> = memos[name].iter().map(|m| shorten(m, 6)).collect();
println!("{name}: {}", list.join(" | "));
}
match largest_expense(&entries) {
Some(e) => println!("가장 큰 지출: {} {} ({})", e.category, e.amount, e.memo),
None => println!("지출 기록이 없다"),
}
let word = "식비";
println!("{word}: 바이트 {}, 글자 {}", word.len(), word.chars().count());
println!("{:?} {:?}", word.get(0..1), word.get(0..3));
}
줄별 해설
parse_line. split(',') 은 쉼표 기준 조각을 &str 으로 돌려주는 반복자이고, trim() 은 양쪽 공백을 제거한다. 조각이 원본 line 을 빌려 보므로 문자열 복사가 일어나지 않는다. 칸이 세 개가 아니면 Err 로 일찍 돌아간다. 금액은 parse() 가 Result 를 주고, map_err 로 오류 타입을 String 메시지로 바꾼 뒤 ? 로 넘긴다. 앞 장에서 다룬 흐름이다. 마지막에 to_string() 으로 각 조각을 String 으로 복제해 Entry 가 자기 데이터를 소유하게 한다. Entry 가 line 이 사라진 뒤에도 살아 있어야 하기 때문이다.
total_by_category. 인자가 &[Entry] 슬라이스라서 벡터를 그대로 넘겨도 된다. 루프의 e 는 &Entry 다. e.category.as_str() 로 String 을 &str 로 빌려 키로 쓰고, entry(..).or_insert(0) 이 돌려준 &mut i64 에 금액을 더한다. 맵의 타입은 반환 타입에서 추론된다.
memos_by_category. 값이 Vec<&str> 라서 or_default() 가 빈 벡터를 넣고, 곧바로 push 로 메모를 빌려 추가한다. 여기서도 문자열은 복제하지 않는다.
largest_expense. 음수 금액만 걸러 내고 min_by_key 로 가장 작은 값, 곧 절댓값이 가장 큰 지출을 찾는다. 기록이 없을 수 있으므로 Option<&Entry> 를 돌려준다. 원소를 꺼내지 않고 참조만 돌려주므로 벡터의 소유권에는 영향이 없다.
shorten. 글자 수를 chars().count() 로 세고, 초과하면 앞의 max_chars - 1 글자에 말줄임표를 붙인다. 바이트 슬라이싱이 아니라 chars() 를 쓰므로 한글 중간에서 잘리지 않는다. 여섯 글자 제한에 "친구와 저녁 약속" 은 아홉 글자라서 "친구와 저…" 가 된다.
main 의 읽기 루프. raw_lines 배열을 값으로 순회하면 각 원소는 &str 이다. match 로 성공한 기록만 push 하고, 실패는 메시지를 출력하고 건너뛴다. push 는 Entry 의 소유권을 벡터로 옮긴다.
수정 루프. iter_mut() 로 원소를 수정 빌림하고, 메모가 비어 있으면 push_str 로 "(없음)" 을 붙인다. e.memo 가 String 이기 때문에 가능한 일이다. 이 루프가 끝난 뒤에야 entries 를 읽기용으로 다시 빌린다.
합계 출력. totals.into_iter().collect() 로 맵을 소비해 (키, 값) 벡터로 바꾸고 sort() 한다. 튜플 정렬은 첫 원소인 문자열을 바이트 순서로 비교한다. 한글 음절은 유니코드 순서가 가나다 순서와 같으므로 "교통, 급여, 식비" 순이 된다. for (name, sum) in &rows 는 벡터를 빌려 순회한다.
메모 출력. memos.keys().copied() 는 키 참조 &&str 에서 &str 을 복사해 꺼낸다. 정렬한 키로 memos[name] 을 조회하면 그 분류의 Vec<&str> 이 나오고, shorten 을 거쳐 join 으로 이어 붙인다.
마지막 두 줄. "식비" 의 바이트 수는 6, 글자 수는 2 다. get(0..1) 은 글자 중간이라 None, get(0..3) 은 첫 글자 Some("식") 이다. {:?} 는 Option 을 출력할 때 쓰는 디버그 형식이다.
실행 결과
$ cargo run
건너뜀: 금액을 읽을 수 없다: abc
건너뜀: 칸이 3개가 아니다: 카페, -4500
읽은 기록: 5건
교통: -4250
급여: 3000000
식비: -20500
교통: 지하철 | (없음)
급여: 9월 급여
식비: 점심김밥 | 친구와 저…
가장 큰 지출: 식비 -12000 (점심김밥)
식비: 바이트 6, 글자 2
None Some("식")
실무에서 자주 틀리는 것
1. 순회하면서 같은 벡터에 추가한다
지출 하나마다 "세금 메모" 기록을 덧붙이려는 코드다.
for e in &entries {
if e.amount < 0 {
entries.push(Entry {
category: String::from("메모"),
amount: 0,
memo: e.memo.clone(),
});
}
}
error[E0502]: cannot borrow `entries` as mutable because it is also borrowed as immutable 이 나온다. 순회 중인 &entries 가 공유 빌림이라 push 가 끼어들 수 없다. 새 기록을 별도 벡터에 모은 뒤 순회가 끝나고 합친다.
let mut extra: Vec<Entry> = Vec::new();
for e in &entries {
if e.amount < 0 {
extra.push(Entry {
category: String::from("메모"),
amount: 0,
memo: e.memo.clone(),
});
}
}
entries.extend(extra);
2. 인덱스로 String 을 꺼내 가져가려 한다
let names = vec![String::from("식비"), String::from("교통")];
let first = names[0];
error[E0507]: cannot move out of index of `Vec<String>` 가 나온다. 벡터가 원소의 주인이므로 인덱스로 소유권을 빼앗을 수 없다는 뜻이다. 읽기만 하면 빌리고, 독립된 사본이 필요하면 복제한다. 벡터에서 완전히 빼내려면 remove 를 쓴다.
let first = &names[0]; // 빌림
let copy = names[0].clone(); // 복제
3. 한글 문자열을 바이트 위치로 자른다
let memo = String::from("점심김밥");
let head = &memo[0..4];
컴파일은 되지만 실행하면 byte index 4 is not a char boundary 패닉이 난다. 4 는 두 번째 글자 중간이다. 글자 수 기준으로 자르려면 chars().take(n) 을 쓴다. 바이트 위치가 입력에 따라 달라질 수 있으면 get(..) 으로 Option 을 받는다.
let head: String = memo.chars().take(2).collect(); // "점심"
let maybe = memo.get(0..4); // None
4. 해시맵을 그대로 순회해 출력한다
for (name, sum) in &totals {
println!("{name}: {sum}");
}
오류는 없지만 실행할 때마다 순서가 달라질 수 있다. 출력을 비교하는 테스트나 사용자에게 보여 주는 요약이 불안정해진다. 순회 전에 정렬한다.
let mut rows: Vec<(&str, i64)> = totals.into_iter().collect();
rows.sort();
for (name, sum) in &rows {
println!("{name}: {sum}");
}
한눈에 보기
| 주제 | 규칙 | 대표 코드 |
|---|---|---|
| Vec 빌림 | 원소 참조가 살아 있는 동안 push 불가 | &v[0] 뒤 v.push(4) 는 E0502 |
| Vec 수정 | 제자리 수정은 수정 빌림 | for e in v.iter_mut() |
| Vec 원소 이동 | 인덱스로는 소유권을 못 뺌 | &v[i], clone(), remove(i) |
| String / &str | 소유하면 String, 빌려 읽으면 &str | fn f(s: &str) |
| UTF-8 | 한글 한 글자는 3바이트, 정수 인덱싱 없음 | chars(), get(a..b) |
| HashMap 갱신 | entry 로 한 번에 조회와 삽입 | *m.entry(k).or_insert(0) += n |
| HashMap 출력 | 순서가 불특정이므로 정렬 | into_iter().collect() 후 sort() |
| 메서드 | 빈 칸일 때 | 돌려주는 것 | 쓰는 경우 |
|---|---|---|---|
or_insert(v) | v 를 넣음 | &mut V | 합계 0 에서 시작 |
or_default() | 기본값을 넣음 | &mut V | 빈 Vec 이나 0 으로 시작 |
or_insert_with(f) | 함수 결과를 넣음 | &mut V | 기본값 생성 비용이 클 때 |
연습 문제
- 아래 코드는 컴파일되지 않는다. 오류 코드와 원인을 말하고, 두 가지 방법으로 고쳐라.
let mut v = vec![10, 20, 30]; let last = v.last().unwrap(); v.clear(); println!("{last}"); "가계부"의len()과chars().count()는 각각 얼마인가.get(0..3)과get(1..4)의 결과는 무엇인가.- 문장을 공백으로 나눠 단어별 등장 횟수를 세는 함수
count_words(text: &str) -> HashMap<&str, usize>를entry로 작성하라. &[Entry]를 받아 분류별로 (건수, 합계) 를 담은HashMap<&str, (u32, i64)>를 돌려주는 함수를 작성하라. 이 장의Entry구조체를 쓴다.
정답과 해설
1. 오류는 E0502 다. last 가 v 의 원소를 공유 빌림하고 있는데 v.clear() 가 수정 빌림을 요구한다. clear 로 원소가 사라지면 last 가 가리킬 곳이 없어지므로 막는 것이 맞다. 고치는 방법 하나는 println! 을 clear 앞으로 옮겨 빌림을 먼저 끝내는 것이다. 다른 하나는 값을 복사하는 것이다.
let last = *v.last().unwrap(); // i64 는 복사되므로 빌림이 끝난다
v.clear();
println!("{last}");
2. 세 글자이고 글자당 3바이트이므로 len() 은 9, chars().count() 는 3 이다. get(0..3) 은 첫 글자 경계에 맞아 Some("가") 다. get(1..4) 는 시작 위치 1 이 글자 중간이라 None 이다. 같은 구간을 [1..4] 로 슬라이싱했다면 패닉이 났을 것이다.
3.
fn count_words(text: &str) -> HashMap<&str, usize> {
let mut counts = HashMap::new();
for w in text.split_whitespace() {
*counts.entry(w).or_insert(0) += 1;
}
counts
}
단어 조각이 text 를 빌려 보므로 반환 맵은 text 가 살아 있는 동안만 유효하다. 횟수는 반환 타입 덕분에 usize 로 추론된다.
4.
fn count_and_sum(entries: &[Entry]) -> HashMap<&str, (u32, i64)> {
let mut map = HashMap::new();
for e in entries {
let slot = map.entry(e.category.as_str()).or_insert((0, 0));
slot.0 += 1;
slot.1 += e.amount;
}
map
}
or_insert 가 돌려준 &mut (u32, i64) 를 변수 slot 에 받아 두 필드를 차례로 고친다. slot 이 살아 있는 동안 map 은 수정 빌림 상태이므로, 루프 한 바퀴 안에서만 쓰고 끝내야 한다. 이 장의 예제 데이터에 적용하면 식비는 (2, -20500) 이 된다.