C++ map vs unordered_map: 레드-블랙 트리와 해시 테이블의 성능·메모리 비교

이 글의 핵심

정렬이 필요 없는데 습관적으로 map을 쓰던 코드를 unordered_map으로 바꾸면 항상 빨라질까요? 해시 충돌이 몰릴 때의 최악 성능, load_factor가 성능에 주는 영향, 좋은 해시 함수의 조건을 짚고, 게임 엔티티 조회·시계열 데이터·설정 파일 파싱 사례로 선택 기준을 확인합니다.

들어가며: “map이 느린데 unordered_map으로 바꾸면 빨라지나요?”

C++ STL은 map(정렬된 맵)과 unordered_map(해시 테이블) 두 가지 연관 컨테이너를 제공합니다. 시간 복잡도가 다르므로 상황에 맞는 선택이 중요합니다. 해시 테이블의 일반 이론·충돌 처리는 알고리즘 시리즈: 해시 테이블에서 먼저 잡아 두면 unordered_map 선택이 수월합니다.

// map: O(log n) 조회 — 키 순서가 있음
std::map<std::string, int> ordered;
ordered["Alice"] = 100;  // O(log n)
int x = ordered["Alice"];  // O(log n)

// unordered_map: O(1) 평균 조회 — 키 순서 없음
std::unordered_map<std::string, int> hashed;
hashed["Alice"] = 100;  // O(1) 평균
int y = hashed["Alice"];  // O(1) 평균

두 블록은 같은 operator[] 문법을 쓰지만, 내부 자료구조가 트리(정렬 유지)와 해시 테이블(평균 상수 시간)로 달라서 시간 보장과 사용할 수 있는 연산(lower_bound 등)이 달라집니다.


내부 구조 비교

map: 레드-블랙 트리 (Red-Black Tree)

        [5]
       /   \
     [3]   [7]
    /  \   /  \
  [1] [4][6] [9]

특징:
- 자가 균형 이진 탐색 트리
- 키가 자동 정렬
- 최악의 경우도 O(log n) 보장
std::map<int, std::string> m;
m[5] = "five";
m[3] = "three";
m[7] = "seven";

// 순회하면 정렬된 순서로 출력
for (const auto& [key, value] : m) {
    std::cout << key << ": " << value << '\n';
}
// 출력: 3: three, 5: five, 7: seven

unordered_map: 해시 테이블 (Hash Table)

버킷 배열:
[0] → [key=3, val="three"]
[1] → [key=7, val="seven"] → [key=17, val="seventeen"]  (충돌)
[2] → 
[3] → [key=5, val="five"]
...

특징:
- 해시 함수로 버킷 인덱스 계산
- 키가 정렬되지 않음
- 평균 O(1), 최악 O(n) (충돌 시)
std::unordered_map<int, std::string> um;
um[5] = "five";
um[3] = "three";
um[7] = "seven";

// 순회하면 임의 순서로 출력
for (const auto& [key, value] : um) {
    std::cout << key << ": " << value << '\n';
}
// 출력: 7: seven, 3: three, 5: five (순서 보장 안 됨)

시간 복잡도 비교

연산별 시간 복잡도

연산mapunordered_map
삽입O(log n)O(1) 평균, O(n) 최악
삭제O(log n)O(1) 평균, O(n) 최악
조회O(log n)O(1) 평균, O(n) 최악
순회O(n) 정렬됨O(n) 정렬 안 됨
최솟값/최댓값O(1)O(n)

최악의 경우 차이

// map: 항상 O(log n)
std::map<int, int> m;
for (int i = 0; i < 1000000; ++i) {
    m[i] = i;  // O(log n) 보장
}

// unordered_map: 해시 충돌 시 O(n)
std::unordered_map<int, int> um;
// 나쁜 해시 함수 사용 시 모든 키가 같은 버킷에 → O(n)

연산별 비용을 측정하는 법

아래 코드는 연산별 비용을 직접 재 볼 때 쓸 수 있는 최소 골격입니다. 결과는 CPU, 컴파일러, 표준 라이브러리 구현, 키 분포에 따라 크게 달라지므로, 여기서는 숫자 대신 각 연산에서 무슨 일이 일어나는지를 설명합니다. 측정할 때는 -O2 이상으로 빌드하고, 결과 값(sum 등)을 출력하거나 volatile에 저장해 컴파일러가 루프를 지우지 못하게 해야 합니다.

테스트 1: 삽입 (insert)

// 벤치마크 코드
template <typename Map>
void benchInsert() {
    Map m;
    for (int i = 0; i < 1000000; ++i) {
        m[i] = i;
    }
}

map은 삽입마다 트리를 O(log n) 단계 내려가며 비교하고 회전으로 균형을 맞추는 반면, unordered_map은 해시로 버킷을 바로 찾습니다. 그래서 정수 키 대량 삽입에서는 보통 unordered_map이 여러 배 빠르게 나오고, reserve로 재해시(rehash)를 없애면 차이가 더 벌어집니다. 다만 키가 0부터 연속된 정수이고 libstdc++의 std::hash<int>는 값을 그대로 쓰기 때문에, 충돌이 거의 없는 유리한 조건이라는 점을 감안해야 합니다.

테스트 2: 조회 (lookup)

template <typename Map>
void benchLookup(const Map& m) {
    long long sum = 0;
    for (int i = 0; i < 1000000; ++i) {
        sum += m.at(i);
    }
}

map 조회는 트리 노드를 20단계 가까이(log₂ 100만) 따라가며 매번 다른 메모리 위치를 읽어 캐시 미스가 쌓입니다. 문자열 키처럼 해시 계산이 비싸거나 충돌이 많은 키에서는 차이가 줄어들 수 있습니다.

테스트 3: 순회 (iteration)

template <typename Map>
void benchIteration(const Map& m) {
    long long sum = 0;
    for (const auto& [key, value] : m) {
        sum += value;
    }
}

순회는 두 컨테이너 모두 O(n)이고 실제 비용도 비슷한 편입니다. 둘 다 원소마다 노드를 따로 할당하므로, 노드가 힙에 흩어져 있으면 포인터를 따라갈 때마다 캐시 미스가 납니다. libstdc++의 unordered_map은 모든 노드를 하나의 단일 연결 리스트로 이어 두어 빈 버킷을 건너뛰는 비용이 없습니다.

테스트 4: 정렬된 순회

// map: 자동 정렬
for (const auto& [key, value] : m) {
    // 키가 정렬된 순서로 순회
}

// unordered_map: 수동 정렬 필요
std::vector<std::pair<Key, Value>> sorted(um.begin(), um.end());
std::sort(sorted.begin(), sorted.end());

for (const auto& [key, value] : sorted) {
    // 정렬된 순서로 순회
}

정렬된 순서가 필요하면 map이 유리합니다. unordered_map은 순회 결과를 복사하고 O(n log n) 정렬을 추가로 해야 하기 때문입니다.


메모리 사용량 비교

원소당 오버헤드

// map<int, int> (libstdc++, 64비트 기준)
// [색상 + 부모/왼쪽/오른쪽 포인터 = 32바이트][키 4 + 값 4 = 8바이트] = 노드당 약 40바이트
// + malloc 청크 헤더·정렬로 실제로는 더 큼

// unordered_map<int, int> (libstdc++, 64비트 기준)
// [next 포인터 8바이트][키 4 + 값 4 = 8바이트] = 노드당 약 16바이트
// + 버킷 배열(버킷당 포인터 8바이트, 버킷 수 ≥ 원소 수 / max_load_factor)

100만 개 저장 시 메모리

두 컨테이너 모두 원소마다 별도 노드를 힙에 할당하므로, int 두 개(8바이트)를 저장하는 데 그보다 몇 배 큰 메모리를 씁니다. map은 노드마다 포인터 3개와 색상 정보를 갖고, unordered_map은 노드가 더 작은 대신 원소 수 이상의 버킷 배열을 따로 둡니다. 정확한 크기는 표준 라이브러리 구현(libstdc++, libc++, MSVC)과 할당기에 따라 달라지므로, 메모리가 중요하다면 /proc/self/status의 RSS나 커스텀 할당기로 직접 재 보세요.

결론: 보통 unordered_map이 조금 더 작지만, 버킷 배열이 커지는 재해시 직후에는 차이가 줄어듭니다. 메모리가 정말 중요하다면 정렬된 std::vector<std::pair<K,V>>나 C++23 std::flat_map이 노드 할당이 없어 훨씬 작습니다.


상황별 선택 가이드

언제 map을, 언제 unordered_map을 쓰나요?

관점map을 쓰시면 좋은 경우unordered_map을 쓰시면 좋은 경우
성능최악의 경우에도 O(log n)으로 예측 가능해야 할 때평균 조회·삽입이 빈번하며, 해시가 안정적일 때
사용성lower_bound 등 정렬 기반 API를 그대로 쓰고 싶을 때키 순서가 전혀 필요 없고 조회 속도만 중요할 때
적용 시나리오순위표, 시계열 구간, 키 범위 스캔단어 빈도, ID→객체 캐시, 설정 키 조회

결정 트리

Q1. 정렬이 필요한가?
    Yes → map
    No → Q2

Q2. 최악의 경우 성능이 중요한가? (실시간 시스템)
    Yes → map (O(log n) 보장)
    No → Q3

Q3. 키가 복잡한가? (해시 함수 작성 어려움)
    Yes → map
    No → unordered_map

상황별 권장

상황권장이유
기본 선택unordered_map평균 O(1), 빠름
정렬 필요map자동 정렬
범위 조회maplower_bound, upper_bound
최악 성능 중요mapO(log n) 보장
메모리 절약unordered_map오버헤드 낮음
키가 복잡map해시 함수 불필요
빠른 조회unordered_mapO(1) 평균

실전 예제

예제 1: 단어 빈도 카운트

// 요구사항: 빠른 조회, 정렬 불필요
// 권장: unordered_map

std::unordered_map<std::string, int> wordCount;

for (const auto& word : words) {
    ++wordCount[word];  // O(1) 평균
}

// 가장 빈번한 단어 찾기
auto maxIt = std::max_element(wordCount.begin(), wordCount.end(),
    [](const auto& a, const auto& b) {
        return a.second < b.second;
    });

예제 2: 순위표 (Leaderboard)

// 요구사항: 점수순 정렬, 상위 10명 조회
// 권장: multimap (내림차순) — 점수가 같은 사람이 있을 수 있으므로
// map<int, string>을 쓰면 같은 점수의 이전 이름이 덮어써진다

std::multimap<int, std::string, std::greater<int>> leaderboard;  // 내림차순

leaderboard.emplace(100, "Alice");
leaderboard.emplace(95, "Bob");
leaderboard.emplace(98, "Charlie");
leaderboard.emplace(98, "Dave");  // 같은 점수도 유지됨

// 상위 10명 (자동 정렬)
int count = 0;
for (const auto& [score, name] : leaderboard) {
    std::cout << name << ": " << score << '\n';
    if (++count >= 10) break;
}

예제 3: 캐시 (빠른 조회)

// 요구사항: 빠른 조회, 정렬 불필요
// 권장: unordered_map

std::unordered_map<UserId, UserData> cache;
cache.reserve(10000);  // 재해싱 방지

// 조회 O(1)
auto it = cache.find(userId);
if (it != cache.end()) {
    return it->second;
}

예제 4: 구간 쿼리

// 요구사항: 특정 범위의 키 찾기
// 권장: map

std::map<int, std::string> events;
events[100] = "Event A";
events[200] = "Event B";
events[300] = "Event C";

// 150~250 범위의 이벤트 찾기
auto start = events.lower_bound(150);  // >= 150
auto end = events.upper_bound(250);    // > 250

for (auto it = start; it != end; ++it) {
    std::cout << it->first << ": " << it->second << '\n';
}
// 출력: 200: Event B

해시 충돌과 성능

해시 충돌 시나리오

// ❌ 나쁜 해시 함수
struct BadHash {
    size_t operator()(int key) const {
        return 0;  // 모든 키가 버킷 0으로 → O(n)
    }
};

std::unordered_map<int, int, BadHash> um;

for (int i = 0; i < 1000000; ++i) {
    um[i] = i;  // O(n) 삽입 → 매우 느림
}

좋은 해시 함수

// ✅ 비트를 고르게 섞는 해시 함수 (splitmix64의 마무리 단계)
struct GoodHash {
    size_t operator()(int key) const {
        uint64_t x = static_cast<uint64_t>(key) + 0x9e3779b97f4a7c15ULL;
        x = (x ^ (x >> 30)) * 0xbf58476d1ce4e5b9ULL;
        x = (x ^ (x >> 27)) * 0x94d049bb133111ebULL;
        return static_cast<size_t>(x ^ (x >> 31));
    }
};

std::unordered_map<int, int, GoodHash> um;

libstdc++는 버킷 수로 소수를 쓰기 때문에 연속된 정수 키라면 값을 그대로 돌려주는 기본 std::hash<int>로도 충돌이 거의 없습니다. 이런 혼합 함수가 필요한 경우는 키가 특정 배수에 몰려 있거나, 외부 입력이 키라서 공격자가 일부러 같은 버킷에 몰리는 키를 보낼 수 있을 때입니다.

load_factor와 성능

std::unordered_map<int, int> um;

// load_factor = size / bucket_count
std::cout << "Load factor: " << um.load_factor() << '\n';
std::cout << "Max load factor: " << um.max_load_factor() << '\n';  // 기본 1.0

// 삽입 후 load_factor가 max_load_factor를 넘게 되면 재해싱 (rehashing)
um.reserve(1000000);  // 미리 버킷 확보 → 재해싱 방지

커스텀 키 타입

map: operator< 필요

struct Person {
    std::string name;
    int age;
    
    // map을 위한 비교 연산자
    bool operator<(const Person& other) const {
        if (name != other.name) return name < other.name;
        return age < other.age;
    }
};

std::map<Person, std::string> m;
m[{"Alice", 30}] = "Engineer";

unordered_map: std::hash 특수화 + operator==

struct Person {
    std::string name;
    int age;
    
    // unordered_map을 위한 동등 비교
    bool operator==(const Person& other) const {
        return name == other.name && age == other.age;
    }
};

// std::hash 특수화
namespace std {
    template <>
    struct hash<Person> {
        size_t operator()(const Person& p) const {
            size_t h1 = std::hash<std::string>{}(p.name);
            size_t h2 = std::hash<int>{}(p.age);
            return h1 ^ (h2 << 1);  // 간단한 조합(실무에서는 boost::hash_combine 같은 혼합 방식이 더 안전)
        }
    };
}

std::unordered_map<Person, std::string> um;
um[{"Alice", 30}] = "Engineer";

실전 사례 분석

사례 1: 게임 엔티티 조회

요구사항:

  • 엔티티 ID로 빠른 조회 (매 프레임)
  • 정렬 불필요

선택: unordered_map

class EntityManager {
    std::unordered_map<EntityId, Entity> entities_;
    
public:
    EntityManager() {
        entities_.reserve(10000);  // 재해싱 방지
    }
    
    void add(EntityId id, Entity e) {
        entities_[id] = std::move(e);  // O(1)
    }
    
    Entity* get(EntityId id) {
        auto it = entities_.find(id);  // O(1)
        return it != entities_.end() ? &it->second : nullptr;
    }
};

이유: 매 프레임 조회하므로 O(1)이 중요.

사례 2: 시계열 데이터

요구사항:

  • 타임스탬프순 정렬
  • 특정 시간 범위 조회

선택: map

class TimeSeriesData {
    std::map<Timestamp, double> data_;
    
public:
    void add(Timestamp t, double value) {
        data_[t] = value;  // 자동 정렬
    }
    
    std::vector<double> getRange(Timestamp start, Timestamp end) {
        std::vector<double> result;
        
        auto it_start = data_.lower_bound(start);  // >= start
        auto it_end = data_.upper_bound(end);      // > end
        
        for (auto it = it_start; it != it_end; ++it) {
            result.push_back(it->second);
        }
        
        return result;
    }
};

이유: 정렬과 범위 조회가 필요.

사례 3: 설정 파일 파싱

요구사항:

  • 키-값 쌍 저장
  • 조회 빈번, 삽입 드묾

선택: unordered_map

class Config {
    std::unordered_map<std::string, std::string> settings_;
    
public:
    void load(const std::string& filename) {
        // 파일 읽기
        settings_.reserve(100);  // 예상 크기
        
        // 파싱
        settings_["server.port"] = "8080";
        settings_["server.host"] = "localhost";
        // ...
    }
    
    std::string get(const std::string& key) const {
        auto it = settings_.find(key);  // O(1)
        return it != settings_.end() ? it->second : "";
    }
};

이유: 조회가 빈번하고 정렬 불필요.


같이 보면 좋은 글