Rust 기초: HashMap으로 키-값 데이터 다루기

Rust 기초: HashMap으로 키-값 데이터 다루기

전화번호부를 떠올려보세요. 이름을 알면 전화번호를 바로 찾을 수 있죠. 프로그래밍에서도 이런 식으로 어떤 키를 가지고 그에 대응하는 값을 빠르게 찾고 싶을 때가 많습니다. 다른 언어에서는 딕셔너리(dictionary)나 맵(map)이라고 부르는 이 자료 구조를 Rust에서는 HashMap이라고 합니다.

이 글에서는 HashMap을 생성하고, 데이터를 넣고 꺼내고, 수정하고 삭제하는 기본 연산부터 Entry API와 소유권 이슈까지 정리해보겠습니다.

HashMap 생성

HashMap은 표준 라이브러리의 std::collections 모듈에 있어서 use로 가져와야 합니다. Vec처럼 프렐루드(prelude)에 포함되어 있지 않거든요.

가장 기본적인 방법은 HashMap::new()로 빈 맵을 만들고 insert()로 채우는 겁니다.

use std::collections::HashMap;

fn main() {
    let mut scores: HashMap<String, i32> = HashMap::new();
    scores.insert(String::from("Alice"), 95);
    scores.insert(String::from("Bob"), 87);

    println!("{:?}", scores);
}
결과
{"Alice": 95, "Bob": 87}

타입 표기를 보면 HashMap<String, i32>인데, 첫 번째가 키의 타입이고 두 번째가 값의 타입입니다. 컴파일러가 타입을 추론할 수 있으면 생략해도 됩니다.

초기값이 있다면 HashMap::from()으로 배열에서 바로 만들 수도 있습니다.

use std::collections::HashMap;

fn main() {
    let scores = HashMap::from([
        ("Alice", 95),
        ("Bob", 87),
        ("Carol", 92),
    ]);

    println!("{:?}", scores);
}
결과
{"Carol": 92, "Alice": 95, "Bob": 87}

출력 순서가 삽입 순서와 다른 게 보이시죠? HashMap은 내부적으로 해시 함수를 사용하기 때문에 데이터 순서를 보장하지 않습니다. 순서가 중요하다면 BTreeMap을 사용해야 합니다.

두 개의 Vec을 합쳐서 HashMap을 만들 수도 있습니다. zip()으로 쌍을 만들고 collect()로 모으면 됩니다.

use std::collections::HashMap;

fn main() {
    let names = vec!["Alice", "Bob", "Carol"];
    let grades = vec![95, 87, 92];

    let scores: HashMap<_, _> = names.into_iter().zip(grades).collect();
    println!("{:?}", scores);
}
결과
{"Alice": 95, "Carol": 92, "Bob": 87}

collect()가 어떤 컬렉션으로 모을지 알려면 타입 힌트가 필요합니다. HashMap<_, _>에서 _는 “키와 값의 타입은 컴파일러가 알아서 추론해달라”는 뜻이고, 외부 타입이 HashMap이라는 것만 명시하면 됩니다.

조회

HashMap에서 값을 가져오는 방법은 크게 세 가지입니다.

use std::collections::HashMap;

fn main() {
    let mut phone_book = HashMap::new();
    phone_book.insert("Alice", "010-1234-5678");
    phone_book.insert("Bob", "010-9876-5432");

    // get()은 Option<&V>를 반환
    if let Some(number) = phone_book.get("Alice") {
        println!("Alice: {}", number);
    }

    // contains_key()로 키 존재 여부 확인
    println!("Carol 있나요? {}", phone_book.contains_key("Carol"));

    // [] 인덱싱 — 키가 없으면 패닉
    let bob_number = phone_book["Bob"];
    println!("Bob: {}", bob_number);
}
결과
Alice: 010-1234-5678
Carol 있나요? false
Bob: 010-9876-5432

get()Option<&V>를 반환하기 때문에 키가 없어도 안전하게 처리할 수 있습니다. 반면 [] 인덱싱은 키가 없으면 프로그램이 패닉으로 종료되니까, 키가 확실히 존재하는 경우에만 사용해야 합니다. 실무에서는 대부분 get()을 쓰게 됩니다.

수정과 삭제

값을 덮어쓰려면 같은 키로 다시 insert()하면 됩니다. 삭제는 remove()를 사용합니다.

use std::collections::HashMap;

fn main() {
    let mut scores = HashMap::new();
    scores.insert("Alice", 95);
    scores.insert("Bob", 87);

    // 같은 키로 insert하면 값이 덮어씌워짐
    scores.insert("Alice", 100);
    println!("Alice: {}", scores["Alice"]);

    // remove()는 삭제된 값을 Option으로 반환
    let removed = scores.remove("Bob");
    println!("삭제됨: {:?}", removed);
    println!("Bob 있나요? {}", scores.contains_key("Bob"));
}
결과
Alice: 100
삭제됨: Some(87)
Bob 있나요? false

remove()는 삭제된 값을 Option<V>로 반환합니다. 키가 없었다면 None이 돌아오고요.

Entry API

HashMap에서 “키가 없으면 넣고, 있으면 기존 값을 유지하거나 수정하는” 패턴은 정말 자주 나옵니다. get()insert()를 조합해서 작성할 수도 있지만 코드가 번거로워지죠. Rust는 이런 패턴을 깔끔하게 처리할 수 있도록 Entry API를 제공합니다.

entry()는 해당 키의 “빈 자리”를 나타내는 Entry 열거형을 반환합니다. 여기에 체이닝 메서드를 붙여서 조건부 삽입과 수정을 한 줄로 할 수 있습니다.

or_insert()는 키가 없을 때만 값을 삽입합니다.

use std::collections::HashMap;

fn main() {
    let mut scores = HashMap::new();
    scores.insert("Alice", 95);

    scores.entry("Alice").or_insert(0); // 이미 있으니까 무시됨
    scores.entry("Bob").or_insert(87);  // 없으니까 삽입됨

    println!("Alice: {}", scores["Alice"]);
    println!("Bob: {}", scores["Bob"]);
}
결과
Alice: 95
Bob: 87

Alice는 이미 존재하니까 or_insert(0)이 무시되고, Bob은 없으니까 87이 삽입됩니다.

Entry API가 빛을 발하는 대표적인 예제가 단어 세기입니다.

use std::collections::HashMap;

fn main() {
    let text = "hello world hello rust hello world";
    let mut counts = HashMap::new();

    for word in text.split_whitespace() {
        let count = counts.entry(word).or_insert(0);
        *count += 1;
    }

    let mut result: Vec<_> = counts.iter().collect();
    result.sort_by_key(|(word, _)| word.to_string());
    for (word, count) in result {
        println!("{}: {}", word, count);
    }
}
결과
hello: 3
rust: 1
world: 2

or_insert(0)은 삽입된 값(또는 기존 값)의 가변 참조(&mut V)를 반환합니다. 그래서 *count += 1로 값을 바로 증가시킬 수 있죠. 이 패턴 없이 작성하려면 if let이나 match로 키 존재 여부를 분기해야 하는데, Entry API 덕분에 한 줄로 끝납니다.

or_default()는 값 타입의 기본값으로 초기화합니다. i32의 기본값은 0이니까 or_insert(0)과 같은 효과입니다.

use std::collections::HashMap;

fn main() {
    let mut counts: HashMap<&str, i32> = HashMap::new();

    *counts.entry("apple").or_default() += 1;
    *counts.entry("apple").or_default() += 1;
    *counts.entry("banana").or_default() += 1;

    println!("apple: {}", counts["apple"]);
    println!("banana: {}", counts["banana"]);
}
결과
apple: 2
banana: 1

and_modify()는 키가 있을 때 기존 값을 수정하고, or_insert()와 체이닝하면 없을 때의 초기값도 지정할 수 있습니다.

use std::collections::HashMap;

fn main() {
    let mut scores = HashMap::new();
    scores.insert("Alice", 95);

    // 있으면 5점 추가, 없으면 50으로 시작
    scores.entry("Alice").and_modify(|v| *v += 5).or_insert(50);
    scores.entry("Bob").and_modify(|v| *v += 5).or_insert(50);

    println!("Alice: {}", scores["Alice"]);
    println!("Bob: {}", scores["Bob"]);
}
결과
Alice: 100
Bob: 50

Alice는 기존 95에서 5가 더해져 100이 되고, Bob은 키가 없었으니까 and_modify()가 건너뛰어지고 or_insert(50)이 실행됩니다.

소유권

HashMap에 값을 넣을 때 소유권이 어떻게 되는지는 키와 값의 타입에 따라 다릅니다. i32처럼 Copy 트레이트를 구현한 타입은 값이 복사되지만, String처럼 힙에 할당되는 타입은 소유권HashMap으로 이동합니다.

use std::collections::HashMap;

fn main() {
    let name = String::from("Alice");
    let mut map = HashMap::new();
    map.insert(name, 95);

    // println!("{}", name); // 컴파일 에러! name의 소유권이 map으로 이동됨
    println!("{:?}", map);
}
결과
{"Alice": 95}

name의 소유권이 map.insert()에 의해 HashMap으로 이동했기 때문에 이후에는 name을 사용할 수 없습니다. 원래 변수를 계속 쓰고 싶다면 clone()으로 복사하거나, 처음부터 &str을 키로 사용하면 됩니다.

use std::collections::HashMap;

fn main() {
    let name = String::from("Alice");
    let mut map = HashMap::new();
    map.insert(name.as_str(), 95); // &str을 키로 사용

    println!("이름: {}", name); // name은 여전히 사용 가능
    println!("점수: {}", map["Alice"]);
}
결과
이름: Alice
점수: 95

그런데 String으로 키를 넣었는데 &str로 조회할 수 있다는 점이 좀 신기하죠?

use std::collections::HashMap;

fn main() {
    let mut map = HashMap::new();
    map.insert(String::from("Alice"), 95);
    map.insert(String::from("Bob"), 87);

    // String 키에 &str로 조회 가능!
    let score = map.get("Alice");
    println!("Alice: {:?}", score);
}
결과
Alice: Some(95)

이게 가능한 이유는 StringBorrow<str> 트레이트를 구현하고 있기 때문입니다. HashMapget() 메서드는 키 타입 K가 아니라 KBorrow할 수 있는 타입이면 뭐든 받도록 설계되어 있습니다. String&str은 같은 해시값을 생성하고 동등성도 보장되기 때문에, String으로 넣은 키를 &str로 안전하게 찾을 수 있는 거죠. 이 부분이 더 궁금하다면 AsRef와 Borrow 글을 참고해보세요.

순회

HashMap의 모든 항목을 돌면서 처리하는 방법을 알아보겠습니다.

for 루프에 &map을 넘기면 키-값 쌍을 차례로 받을 수 있습니다.

use std::collections::HashMap;

fn main() {
    let scores = HashMap::from([
        ("Alice", 95),
        ("Bob", 87),
        ("Carol", 92),
    ]);

    for (name, score) in &scores {
        println!("{}: {}", name, score);
    }
}
결과
Alice: 95
Bob: 87
Carol: 92

키만 필요하면 keys(), 값만 필요하면 values()를 사용합니다.

use std::collections::HashMap;

fn main() {
    let scores = HashMap::from([
        ("Alice", 95),
        ("Bob", 87),
        ("Carol", 92),
    ]);

    let mut names: Vec<_> = scores.keys().collect();
    names.sort();
    println!("이름: {:?}", names);

    let mut vals: Vec<_> = scores.values().collect();
    vals.sort();
    println!("점수: {:?}", vals);
}
결과
이름: ["Alice", "Bob", "Carol"]
점수: [87, 92, 95]

값을 순회하면서 수정하고 싶다면 iter_mut()을 사용합니다.

use std::collections::HashMap;

fn main() {
    let mut scores = HashMap::from([
        ("Alice", 95),
        ("Bob", 87),
        ("Carol", 92),
    ]);

    // 전원에게 보너스 5점 추가
    for (_, score) in scores.iter_mut() {
        *score += 5;
    }

    let mut result: Vec<_> = scores.iter().collect();
    result.sort_by_key(|(name, _)| name.to_string());
    for (name, score) in result {
        println!("{}: {}", name, score);
    }
}
결과
Alice: 100
Bob: 92
Carol: 97

iter_mut()은 값의 가변 참조를 제공하기 때문에 *score += 5로 직접 수정할 수 있습니다. 다만 키는 변경할 수 없습니다. 키를 바꾸면 해시값이 달라져서 내부 구조가 깨지기 때문이죠.

HashSet

HashSet은 값만 있는 HashMap이라고 생각하면 됩니다. 내부적으로 HashMap<T, ()>을 감싸고 있어서, 키만 저장하고 값은 빈 유닛 타입 ()입니다. 중복 없이 고유한 값들의 모음이 필요할 때 사용합니다.

use std::collections::HashSet;

fn main() {
    let mut fruits = HashSet::new();
    fruits.insert("apple");
    fruits.insert("banana");
    fruits.insert("apple"); // 중복이라 무시됨

    println!("개수: {}", fruits.len());
    println!("apple 있나요? {}", fruits.contains("apple"));
    println!("grape 있나요? {}", fruits.contains("grape"));
}
결과
개수: 2
apple 있나요? true
grape 있나요? false

"apple"을 두 번 넣었지만 HashSet은 중복을 허용하지 않으니까 하나만 저장됩니다.

HashSet은 수학의 집합 연산도 지원합니다.

use std::collections::HashSet;

fn main() {
    let a: HashSet<i32> = HashSet::from([1, 2, 3, 4]);
    let b: HashSet<i32> = HashSet::from([3, 4, 5, 6]);

    let mut union: Vec<_> = a.union(&b).collect();
    union.sort();
    println!("합집합: {:?}", union);

    let mut intersection: Vec<_> = a.intersection(&b).collect();
    intersection.sort();
    println!("교집합: {:?}", intersection);

    let mut difference: Vec<_> = a.difference(&b).collect();
    difference.sort();
    println!("차집합 (a - b): {:?}", difference);
}
결과
합집합: [1, 2, 3, 4, 5, 6]
교집합: [3, 4]
차집합 (a - b): [1, 2]

방문한 페이지 추적, 태그 목록 관리, 중복 제거 같은 상황에서 HashSet이 유용합니다.

함수 매개변수로 받기

함수에서 HashMap을 읽기만 할 때는 참조로 받는 것이 좋습니다. 소유권을 넘기지 않으니까 호출한 쪽에서 계속 사용할 수 있거든요.

use std::collections::HashMap;

fn average_score(scores: &HashMap<String, f64>) -> f64 {
    if scores.is_empty() {
        return 0.0;
    }
    let total: f64 = scores.values().sum();
    total / scores.len() as f64
}

fn main() {
    let mut scores = HashMap::new();
    scores.insert(String::from("Alice"), 95.0);
    scores.insert(String::from("Bob"), 87.0);
    scores.insert(String::from("Carol"), 92.0);

    let avg = average_score(&scores);
    println!("평균 점수: {:.1}", avg);

    // 함수 호출 후에도 scores를 계속 사용 가능
    println!("인원 수: {}", scores.len());
}
결과
평균 점수: 91.3
인원 수: 3

String과 &str의 관계와 같은 원칙입니다. 읽기만 할 때는 &HashMap<K, V>로 빌려 받고, 함수 안에서 새로 만들어 반환할 때는 HashMap<K, V>를 소유해서 돌려줍니다.

마치며

HashMap은 키-값 쌍을 저장하고 키로 빠르게 값을 찾아야 할 때 쓰는 Rust의 핵심 컬렉션입니다. insert(), get(), remove()로 기본 CRUD 연산을 하고, Entry API로 “없으면 넣고 있으면 수정하는” 패턴을 깔끔하게 처리할 수 있습니다.

String 같은 소유 타입을 키로 쓸 때는 소유권 이동에 주의해야 하고, Borrow 트레이트 덕분에 String 키를 &str로 조회할 수 있다는 점도 기억해두세요. 중복 없는 값의 모음이 필요하다면 HashSet도 좋은 선택입니다.

더 자세한 내용은 std::collections::HashMap - Rust 공식 문서를 참고하세요.

This work is licensed under CC BY 4.0 CC BY

개발자를 위한 뉴스레터

달레가 정리한 AI 개발 트렌드와 직접 만든 콘텐츠를 전해드립니다.

Discord