해시맵(HashMap)

해시맵은 해시 함수를 사용하여 키와 값을 매핑하는 자료구조다. 러스트의 표준 라이브러리에는 std::collections 모듈 아래에 HashMap이 정의되어 있으며, 해시 가능한 키와 임의의 값 타입을 맵핑하기에 최적화되어 있다. 해시맵을 사용할 때는 키 타입이 해시 가능해야 하며, 이를 위해 표준 라이브러리에서 제공하는 Hash, Eq 등의 트레이트를 만족해야 한다. 대부분의 기본 타입과 표준 라이브러리 구조체는 이미 이 조건을 만족하지만, 사용자 정의 타입은 직접 구현해주어야 할 수 있다.

해시맵을 생성하고 사용하는 방법은 간단하다. 기본적인 생성과 삽입 예시 코드는 다음과 같다.

use std::collections::HashMap;

fn main() {
    let mut scores = HashMap::new();
    scores.insert("Blue", 10);
    scores.insert("Yellow", 50);

    let score_blue = scores.get("Blue");
    println!("블루 팀 점수: {:?}", score_blue);
}

위 예시에서 HashMap을 생성한 뒤, 문자열 슬라이스("Blue", "Yellow")를 키로 하여 정수를 값으로 삽입한다. get 메서드를 통해 특정 키에 해당하는 값을 Option 타입으로 반환받고, 이를 패턴 매칭이나 기타 로직으로 처리할 수 있다.

해시맵에서는 같은 키에 대해 여러 번 insert를 호출하면 가장 마지막에 삽입된 값으로 덮어쓴다. 이미 존재하는 키에 새 값을 삽입하기 전에 확인이나 다른 처리를 해야 한다면 entry 메서드를 활용하면 된다. entry는 매핑하려는 키가 해시맵에 존재하는지 여부를 검사한 뒤, 각 상황에 따라 다르게 동작하도록 로직을 구성할 수 있게 해준다. 예시는 다음과 같다.

use std::collections::HashMap;

fn main() {
    let mut books = HashMap::new();
    books.insert("Rust Book", 1);

    books.entry("Rust Book").and_modify(|count| *count += 1);
    books.entry("Go Book").or_insert(1);

    println!("{:?}", books);
}

위 예시에서 "Rust Book" 키가 이미 존재하면 해당 값에 1을 더하고, "Go Book" 키는 존재하지 않으면 새로 삽입한다. entry API 덕분에 중복 여부를 쉽게 제어할 수 있으며, 클로저(closure)를 이용해 좀 더 유연한 로직을 만들 수 있다.

해시맵을 순회(iteration)할 때의 순서는 보장되지 않는다. 삽입 순서 혹은 어떤 정해진 순서를 보장해야 한다면 BTreeMap을 고려해야 한다. 해시맵의 경우 내부적으로 메모리 할당을 통해 버킷(bucket)을 구성하고, 해시 충돌이 발생하면 체이닝(chaining) 등의 방법으로 이를 해소한다. 표준 라이브러리의 HashMap은 보안과 성능을 모두 고려해 자체적으로 난수 시드를 사용하며, 일반적인 사용 환경에서 해시 충돌로 인한 성능 저하를 크게 걱정하지 않아도 된다.

필요하다면 다른 해시 함수를 사용하도록 HashMap의 타입 파라미터로 지정할 수 있다. 기본 구현은 사용자에게 적절한 보안성과 성능을 제공하므로 대다수의 경우 그대로 사용하는 것이 좋다. 그래도 특정 상황에서 사용자 정의 해시 함수를 적용해야 하는 경우, 예를 들어 커스텀 구조체에 대한 해시 최적화나 별도의 해싱 전략이 필요하다면 해시 함수로 BuildHasher 트레이트를 구현한 타입을 지정해 사용하면 된다.

해시맵은 값 타입으로 어떤 것도 보관할 수 있으나, 수명(lifetime)이 있는 참조자를 키나 값으로 직접 저장할 때는 주의해야 한다. 일반적으로 해시맵은 키나 값을 자신의 스코프 안에 복사하거나 소유해야 하기 때문이다. 만약 참조자를 키로 쓰고자 한다면 해시맵의 스코프가 참조 대상의 수명보다 길어야 하며, 키로 사용되는 참조자 타입이 해시 가능하고 Eq를 만족해야 한다.

해시맵과 같은 컬렉션은 쓰레드 안전(thread-safe)을 별도로 제공하지 않는다. 단일 스레드 환경에서는 문제가 없지만, 멀티 스레드에서 데이터를 동시에 변경하려면 Mutex나 RwLock 같은 동기화 타입을 함께 사용해야 한다. 필요에 따라 더 고성능의 동시성 해시맵이 필요한 경우 다른 크레이트(예: dashmap)를 고려할 수 있다.

해시맵의 초기 용량(capacity)은 기본적으로 구현 세부 사항에 의해 결정되지만, 필요에 따라 with_capacity 메서드를 사용하여 미리 충분히 큰 용량을 확보할 수 있다. 대량의 데이터를 한꺼번에 삽입할 것으로 예상된다면 이 방법을 통해 재할당 횟수를 줄여 성능을 최적화할 수 있다. 이미 삽입된 항목이 많아 충분한 용량이 필요하거나, 반대로 삽입된 항목이 크게 줄어 더 이상 많은 버킷이 필요하지 않은 상황이라면 reserve와 shrink_to_fit 등의 메서드로 메모리 사용량을 관리할 수 있다.

원소가 많은 해시맵을 순회할 때는 키나 값의 순서가 삽입된 순서나 크기 순서로 나타난다고 기대해서는 안 된다. 해시 함수의 결과와 내부 구현에 의해 순서가 결정되며, 이는 구현 버전이나 환경에 따라 달라질 수 있다. 해시맵에서 키-값 쌍을 순회할 때는 다음과 같은 코드가 일반적이다.

이 코드는 키와 값 쌍에 대한 참조를 반환하며, 해시맵의 구조상 순회 순서는 보장되지 않는다. 따라서 결과가 매번 같으리라고 가정해서는 안 된다.

해시맵과 BTreeMap 사이의 선택은 정렬된 순서가 필요한지 여부에 크게 좌우된다. 해시맵은 평균적으로 O(1)에 가까운 탐색, 삽입, 삭제를 제공하지만, 키나 값이 특정 정렬을 가져야 한다면 BTreeMap을 사용해야 한다. BTreeMap은 내부적으로 트리를 유지하며 키를 정렬된 상태로 보관하기 때문에 O(log n)의 시간 복잡도를 갖지만 정렬된 순회와 범위 검색이 가능하다. 반면 해시맵은 빠른 평균 접근 성능을 제공하면서도 순서가 필요하지 않은 상황에 적합하다.

러스트의 해시맵은 기본적으로 안전한 메모리 모델과 소유권 시스템을 충실히 따른다. 값의 소유권이 해시맵으로 이동하면, 더 이상 원본 값으로 직접 접근할 수 없다. get과 get_mut, entry 등의 메서드는 소유권을 재정의하거나 필요에 따라 참조를 가져올 수 있는 여러 가지 접근 방식을 제공한다. 이러한 패턴을 이해하고 사용하면, 런타임에 예기치 않은 에러 없이 효율적이고 안전한 프로그램을 작성할 수 있다.

정리하자면, 해시맵은 러스트에서 가장 많이 사용되는 컬렉션 중 하나로서, 키와 값을 빠르게 매핑할 수 있는 편리한 API를 제공한다. 해시 가능한 키 타입과 삽입된 값의 소유권 규칙만 지킨다면 손쉽게 대용량의 데이터를 효율적으로 관리할 수 있다. 다만 해시맵은 순서가 보장되지 않으며, 멀티 스레드 환경에서 동시에 접근해야 할 때는 별도의 동기화 전략을 마련해야 한다는 점에 유의해야 한다. 이러한 특징들을 잘 이해하고 활용한다면, 해시맵은 대부분의 일반적인 데이터 매핑 문제에서 강력한 해결책이 될 것이다.

Last updated