본문으로 건너뛰기
Previous
Next
C++ 커스텀 자료구조 완벽 가이드 | 해시테이블·트라이·LRU 캐시·Skip List [#54-2]

C++ 커스텀 자료구조 완벽 가이드 | 해시테이블·트라이·LRU 캐시·Skip List [#54-2]

C++ 커스텀 자료구조 완벽 가이드 | 해시테이블·트라이·LRU 캐시·Skip List [#54-2]

이 글의 핵심

STL로 부족할 때 직접 구현하는 자료구조. 충돌 처리, 자동완성, 캐시 eviction, 정렬된 삽입 삭제. 문제 시나리오, 완전한 구현, 흔한 실수, 성능 비교, 프로덕션 패턴.

들어가며: “std::unordered_map으로 안 되는 경우"

"캐시 크기 제한이 필요한데, 오래된 항목을 자동으로 지우려면?”

C++ STL은 강력하지만, 모든 상황을 커버하지는 못합니다. std::unordered_map은 크기 제한이 없고, std::map은 O(log n)이라 대량 검색에 부담됩니다. 자동완성을 구현하려면 접두사 검색이 필요한데, STL에는 트라이가 없습니다. 비유하면: “일반 도로(map)와 고속도로(unordered_map)“가 있는데, LRU 캐시는 “주차장이 꽉 차면 가장 오래된 차를 내보내는 주차장”이고, 트라이는 “단어의 한 글자씩 따라가는 나무”입니다.

// 실행 예제
flowchart TD
  subgraph problem[❌ STL 한계]
    P1[캐시 크기 제한?] --> P2[unordered_map은 무제한]
    P3[접두사 검색?] --> P4[map은 전체 키만]
    P5[정렬된 삽입/삭제 O(log n)?] --> P6[vector는 O(n)]
  end
  subgraph solution[✅ 커스텀 자료구조]
    S1[LRU 캐시] --> S2[해시 + 이중 연결 리스트]
    S3[트라이] --> S4[접두사 O(m) 검색]
    S5[Skip List] --> S6[균형 트리 대안]
  end

이 글에서는 실제로 겪는 문제 시나리오부터 시작해, 해시테이블·트라이·LRU 캐시·Skip List의 완전한 구현, 자주 하는 실수, 성능 비교, 프로덕션 패턴까지 다룹니다. 이 글을 읽으면:

  • STL로 해결되지 않는 상황을 파악할 수 있습니다.
  • 해시테이블 충돌 처리, 트라이, LRU 캐시를 직접 구현할 수 있습니다.
  • 흔한 실수를 피하고 성능을 최적화할 수 있습니다. 요구 환경: C++17 이상

실무 적용 경험: 이 글은 대규모 C++ 프로젝트에서 실제로 겪은 문제와 해결 과정을 바탕으로 작성되었습니다. 책이나 문서에서 다루지 않는 실전 함정과 디버깅 팁을 포함합니다.

1. 문제 시나리오

시나리오 1: 캐시 크기 제한 + 오래된 항목 자동 제거 (LRU)

상황: API 응답을 캐시하는데, 메모리가 100MB로 제한되어 있습니다. 가장 최근에 사용한 1만 개만 유지하고, 오래된 것은 자동으로 제거해야 합니다. 잘못된 접근:

// ❌ std::unordered_map - 크기 제한 없음, "오래된 것" 판단 불가
std::unordered_map<std::string, Response> cache;
cache[/api/users/1] = fetchUser(1);  // 무한히 쌓임!
// 언제 뭘 지울지 알 수 없음

문제점: unordered_map은 삽입 순서를 기억하지 않습니다. “가장 오래 사용하지 않은” 항목을 O(1)에 찾을 수 없습니다. 올바른 접근: LRU 캐시 (해시 + 이중 연결 리스트) - 섹션 4 참조

시나리오 2: 접두사로 검색 (자동완성)

상황: 검색창에 “app”을 입력하면 “apple”, “application”, “apply” 등을 제안해야 합니다. 잘못된 접근:

// ❌ std::map - 전체 키만 검색, 접두사 검색 O(n)
// 실행 예제
std::map<std::string, int> words;
for (auto it = words.lower_bound("app"); it != words.end(); ++it) {
    if (it->first.substr(0, 3) != "app") break;  // 전체 순회 가능성
    suggest(it->first);
}

문제점: lower_bound는 “app” 이상인 첫 키를 찾지만, “appzzz” 이후의 모든 키도 순회합니다. 접두사가 “app”인 것만 O(접두사 길이)에 찾을 수 없습니다. 올바른 접근: 트라이 - 섹션 3 참조

시나리오 3: 커스텀 키에 대한 해시 (복합 키)

상황: (user_id, item_id) 쌍을 키로 쓰는데, std::pair의 기본 해시가 충돌을 많이 일으킵니다. 잘못된 접근:

// ❌ std::pair 해시 - 단순 XOR은 충돌 많음
std::unordered_map<std::pair<int,int>, int> cache;
// pair의 기본 해시 품질이 구현체마다 다름

올바른 접근:

// ✅ 커스텀 해시 함수
struct PairHash {
    size_t operator()(const std::pair<int,int>& p) const {
        return std::hash<int>{}(p.first) ^ (std::hash<int>{}(p.second) << 16);
    }
};
std::unordered_map<std::pair<int,int>, int, PairHash> cache;

시나리오 4: 정렬된 삽입/삭제 + 인덱스 접근

상황: 실시간 랭킹에서 순위가 자주 바뀌고, “k번째” 요소에 O(log n)으로 접근해야 합니다. std::map은 k번째 접근이 O(n)입니다. 잘못된 접근:

// ❌ std::map - k번째 접근이 O(n)
std::map<int, std::string> ranking;
// 5번째 찾으려면 begin에서 5번 ++
auto it = ranking.begin();
std::advance(it, 4);  // O(k) - 느림!

올바른 접근: Skip List (또는 order statistic tree) - 섹션 5 참조

시나리오 5: 메모리 제약 환경에서의 해시테이블

상황: 임베디드에서 std::unordered_map의 기본 버킷 수가 너무 커서 메모리를 초과합니다. 해결: 작은 초기 버킷 수를 지정하고, 로드 팩터를 조정합니다.

// ✅ 메모리 제한된 환경
std::unordered_map<int, int> small_map;
small_map.reserve(100);  // 100개만 저장 예정
small_map.max_load_factor(2.0f);  // 버킷당 2개까지 허용

2. 해시테이블 완전 구현

2.1 개요

체이닝(Chaining) 방식: 버킷 배열 + 각 버킷에 연결 리스트. 충돌 시 같은 버킷에 리스트로 추가합니다.

flowchart LR
  subgraph buckets[버킷 배열]
    B0[0]
    B1[1]
    B2[2]
    B3[3]
  end
  B1 --> N1[key1→val1]
  N1 --> N2[key5→val5]
  B2 --> N3[key2→val2]

2.2 완전한 구현

#include <vector>
#include <list>
#include <optional>
#include <functional>
template<typename Key, typename Value, typename Hash = std::hash<Key>>
class HashTable {
public:
    explicit HashTable(size_t bucket_count = 16)
        : buckets_(bucket_count), size_(0) {}
    void insert(const Key& key, const Value& value) {
        if (load_factor() > 0.75f) rehash();
        size_t idx = hash_(key) % buckets_.size();
        for (auto& [k, v] : buckets_[idx]) {
            if (k == key) {
                v = value;
                return;
            }
        }
        buckets_[idx].emplace_back(key, value);
        ++size_;
    }
    std::optional<Value> find(const Key& key) const {
        size_t idx = hash_(key) % buckets_.size();
        for (const auto& [k, v] : buckets_[idx]) {
            if (k == key) return v;
        }
        return std::nullopt;
    }
    bool erase(const Key& key) {
        size_t idx = hash_(key) % buckets_.size();
        auto& chain = buckets_[idx];
        for (auto it = chain.begin(); it != chain.end(); ++it) {
            if (it->first == key) {
                chain.erase(it);
                --size_;
                return true;
            }
        }
        return false;
    }
    size_t size() const { return size_; }
    float load_factor() const {
        return static_cast<float>(size_) / buckets_.size();
    }
private:
    void rehash() {
        std::vector<std::list<std::pair<Key, Value>>> new_buckets(buckets_.size() * 2);
        for (auto& chain : buckets_) {
            for (auto& [k, v] : chain) {
                size_t idx = hash_(k) % new_buckets.size();
                new_buckets[idx].emplace_back(std::move(k), std::move(v));
            }
        }
        buckets_ = std::move(new_buckets);
    }
    std::vector<std::list<std::pair<Key, Value>>> buckets_;
    size_t size_;
    Hash hash_;
};

핵심 포인트:

  • 로드 팩터 0.75: 초과 시 rehash로 버킷 수 2배
  • 체이닝: std::list로 충돌 처리
  • 삽입 시 기존 키: 덮어쓰기 후 return

2.3 사용 예시

HashTable<std::string, int> ht;
ht.insert("apple", 1);
ht.insert("banana", 2);
ht.insert("apple", 3);  // 덮어쓰기
if (auto v = ht.find("apple")) {
    std::cout << "apple: " << *v << "\n";  // 3
}
ht.erase("banana");

3. 트라이 완전 구현

3.1 개요

트라이(Trie): 각 노드가 문자 하나를 나타내고, 루트에서 leaf까지의 경로가 하나의 단어입니다. 접두사 검색에 최적입니다.

flowchart TD
  R[""] --> A[a]
  A --> P[p]
  P --> P1[p]
  P1 --> L1[l]
  L1 --> E1[e]
  P --> P2[p]
  P2 --> L2[l]
  L2 --> E2[y]
  A --> N[n]

3.2 완전한 구현

#include <unordered_map>
#include <string>
#include <vector>
class Trie {
public:
    Trie() : root_(std::make_unique<Node>()) {}
    void insert(const std::string& word) {
        Node* cur = root_.get();
        for (char c : word) {
            if (cur->children.find(c) == cur->children.end()) {
                cur->children[c] = std::make_unique<Node>();
            }
            cur = cur->children[c].get();
        }
        cur->is_end = true;
    }
    bool search(const std::string& word) const {
        Node* cur = root_.get();
        for (char c : word) {
            auto it = cur->children.find(c);
            if (it == cur->children.end()) return false;
            cur = it->second.get();
        }
        return cur->is_end;
    }
    bool startsWith(const std::string& prefix) const {
        Node* cur = root_.get();
        for (char c : prefix) {
            auto it = cur->children.find(c);
            if (it == cur->children.end()) return false;
            cur = it->second.get();
        }
        return true;
    }
    std::vector<std::string> getPrefixMatches(const std::string& prefix) const {
        std::vector<std::string> result;
        Node* cur = root_.get();
        for (char c : prefix) {
            auto it = cur->children.find(c);
            if (it == cur->children.end()) return result;
            cur = it->second.get();
        }
        collectWords(cur, prefix, result);
        return result;
    }
private:
    struct Node {
        std::unordered_map<char, std::unique_ptr<Node>> children;
        bool is_end = false;
    };
    void collectWords(Node* node, const std::string& prefix,
                      std::vector<std::string>& result) const {
        if (node->is_end) result.push_back(prefix);
        for (const auto& [c, child] : node->children) {
            collectWords(child.get(), prefix + c, result);
        }
    }
    std::unique_ptr<Node> root_;
};

핵심 포인트:

  • insert: 각 문자마다 자식 노드 생성, 마지막에 is_end = true
  • search: 단어 전체가 존재하는지
  • startsWith: 접두사만 존재하는지
  • getPrefixMatches: 접두사로 시작하는 모든 단어 수집

3.3 사용 예시

Trie trie;
trie.insert("apple");
trie.insert("application");
trie.insert("apply");
trie.insert("banana");
assert(trie.search("apple") == true);
assert(trie.startsWith("app") == true);
auto matches = trie.getPrefixMatches("app");
// matches: {"apple", "application", "apply"}

4. LRU 캐시 완전 구현

4.1 개요

LRU (Least Recently Used): 가장 오래 사용하지 않은 항목을 제거합니다. 해시테이블 + 이중 연결 리스트로 O(1) get/put을 구현합니다.

  • 해시테이블: 키 → (값, 리스트 이터레이터) 매핑
  • 이중 연결 리스트: 최근 사용 순서 (head = 최신, tail = 가장 오래됨)
flowchart LR
  subgraph list["이중 연결 리스트 (최신→오래됨)"]
    H[head] --> N1[k1:v1]
    N1 --> N2[k2:v2]
    N2 --> N3[k3:v3]
    N3 --> T[tail]
  end
  subgraph hash[해시테이블]
    M[k1→iter1]
    M2[k2→iter2]
    M3[k3→iter3]
  end

4.2 완전한 구현

#include <unordered_map>
#include <list>
#include <optional>
template<typename Key, typename Value>
class LRUCache {
public:
    explicit LRUCache(size_t capacity) : capacity_(capacity) {
        if (capacity_ == 0) capacity_ = 1;
    }
    std::optional<Value> get(const Key& key) {
        auto it = map_.find(key);
        if (it == map_.end()) return std::nullopt;
        // 최근 사용으로 이동: 리스트에서 제거 후 맨 앞에 삽입
        list_.splice(list_.begin(), list_, it->second);
        return it->second->second;
    }
    void put(const Key& key, const Value& value) {
        auto it = map_.find(key);
        if (it != map_.end()) {
            it->second->second = value;
            list_.splice(list_.begin(), list_, it->second);
            return;
        }
        if (list_.size() >= capacity_) {
            // 가장 오래된 항목 제거 (tail)
            auto last = list_.back();
            map_.erase(last.first);
            list_.pop_back();
        }
        list_.emplace_front(key, value);
        map_[key] = list_.begin();
    }
    size_t size() const { return list_.size(); }
    bool erase(const Key& key) {
        auto it = map_.find(key);
        if (it == map_.end()) return false;
        list_.erase(it->second);
        map_.erase(it);
        return true;
    }
private:
    size_t capacity_;
    std::list<std::pair<Key, Value>> list_;
    std::unordered_map<Key, decltype(list_.begin())> map_;
};

핵심 포인트:

  • splice: O(1)에 노드를 다른 위치로 이동
  • get 시: 해당 노드를 리스트 맨 앞으로 이동
  • put 시 용량 초과: tail 제거 후 새 항목을 head에 추가

4.3 사용 예시

LRUCache<int, std::string> cache(2);
cache.put(1, "one");
cache.put(2, "two");
assert(cache.get(1).value() == "one");  // 1이 최근 사용됨
cache.put(3, "three");  // 2가 evict됨 (가장 오래됨)
assert(!cache.get(2).has_value());
assert(cache.get(1).value() == "one");
assert(cache.get(3).value() == "three");

5. Skip List 완전 구현

5.1 개요

Skip List: 여러 레벨의 연결 리스트로, 상위 레벨을 건너뛰며 검색해 O(log n) 기대 시간을 달성합니다. 균형 트리보다 구현이 단순합니다.

flowchart LR
  subgraph L3[레벨 3]
    H3[head] --> N30[30]
  end
  subgraph L2[레벨 2]
    H2[head] --> N20[10]
    N20 --> N30
  end
  subgraph L1[레벨 1]
    H1[head] --> N10[5]
    N10 --> N20
    N20 --> N25[25]
    N25 --> N30
    N30 --> N40[40]
  end

5.2 완전한 구현

#include <random>
#include <vector>
#include <optional>
template<typename Key, typename Value>
class SkipList {
    static constexpr int MAX_LEVEL = 16;
    static constexpr double P = 0.5;
public:
    SkipList() : level_(0) {
        head_ = std::make_unique<Node>(Key{}, Value{}, MAX_LEVEL);
    }
    void insert(const Key& key, const Value& value) {
        std::vector<Node*> update(MAX_LEVEL + 1);
        Node* cur = head_.get();
        for (int i = level_; i >= 0; --i) {
            while (cur->forward[i] && cur->forward[i]->key < key) {
                cur = cur->forward[i];
            }
            update[i] = cur;
        }
        int new_level = randomLevel();
        if (new_level > level_) {
            for (int i = level_ + 1; i <= new_level; ++i) {
                update[i] = head_.get();
            }
            level_ = new_level;
        }
        auto node = std::make_unique<Node>(key, value, new_level);
        for (int i = 0; i <= new_level; ++i) {
            node->forward[i] = update[i]->forward[i];
            update[i]->forward[i] = node.get();
        }
        nodes_.push_back(std::move(node));
    }
    std::optional<Value> find(const Key& key) const {
        Node* cur = head_.get();
        for (int i = level_; i >= 0; --i) {
            while (cur->forward[i] && cur->forward[i]->key < key) {
                cur = cur->forward[i];
            }
        }
        cur = cur->forward[0];
        if (cur && cur->key == key) return cur->value;
        return std::nullopt;
    }
    bool erase(const Key& key) {
        std::vector<Node*> update(MAX_LEVEL + 1);
        Node* cur = head_.get();
        for (int i = level_; i >= 0; --i) {
            while (cur->forward[i] && cur->forward[i]->key < key) {
                cur = cur->forward[i];
            }
            update[i] = cur;
        }
        cur = cur->forward[0];
        if (!cur || cur->key != key) return false;
        for (int i = 0; i <= level_; ++i) {
            if (update[i]->forward[i] != cur) break;
            update[i]->forward[i] = cur->forward[i];
        }
        while (level_ > 0 && !head_->forward[level_]) --level_;
        return true;
    }
private:
    struct Node {
        Key key;
        Value value;
        std::vector<Node*> forward;
        Node(const Key& k, const Value& v, int level)
            : key(k), value(v), forward(level + 1, nullptr) {}
    };
    int randomLevel() {
        int lvl = 0;
        static std::mt19937 rng{std::random_device{}()};
        static std::uniform_real_distribution<> dist(0, 1);
        while (dist(rng) < P && lvl < MAX_LEVEL) ++lvl;
        return lvl;
    }
    std::unique_ptr<Node> head_;
    int level_;
    std::vector<std::unique_ptr<Node>> nodes_;  // 소유권 관리
};

핵심 포인트:

  • randomLevel: 50% 확률로 레벨 증가, 평균 O(log n) 레벨
  • insert: 각 레벨에서 삽입 위치 찾은 뒤, 새 노드 연결
  • find: 상위 레벨부터 건너뛰며 하강

6. 자주 발생하는 에러와 해결법

에러 1: 해시테이블 rehash 시 반복자 무효화

증상: rehash 중에 기존 이터레이터를 사용하면 크래시.

// ❌ 잘못된 예
for (auto it = map_.begin(); it != map_.end(); ++it) {
    if (condition(it)) {
        insert(new_key, new_val);  // rehash 발생 가능!
        // it 무효화 - UB
    }
}

해결법:

// ✅ rehash 후 이터레이터 재획득
std::vector<Key> to_process;
for (auto it = map_.begin(); it != map_.end(); ++it) {
    if (condition(it)) to_process.push_back(it->first);
}
for (const auto& k : to_process) {
    insert(k, compute(k));
}

에러 2: LRU 캐시에서 map에 리스트 이터레이터 저장 시 무효화

증상: list::splicelist::erasemap_에 저장된 이터레이터가 무효화될 수 있음. 해결법: splice는 노드를 이동할 뿐이므로 같은 리스트 내에서는 이터레이터가 유효합니다. 단, list::erase로 제거된 노드의 이터레이터는 무효화되므로, erase 전에 map_에서 해당 키를 제거해야 합니다.

// ✅ 올바른 순서
auto last = list_.back();
map_.erase(last.first);   // 먼저 map에서 제거
list_.pop_back();        // 그 다음 리스트에서 제거

에러 3: 트라이에서 빈 문자열 처리

증상: insert("") 시 root가 is_end가 되어, search("")가 true를 반환. 의도에 따라 다를 수 있음.

// ❌ 빈 문자열 insert 시
trie.insert("");  // root->is_end = true
trie.search("");  // true - 원하는 동작인가?

해결법: 빈 문자열을 허용하지 않는다면 insert 시 검사.

void insert(const std::string& word) {
    if (word.empty()) return;  // 또는 예외
    // ...
}

에러 4: 해시 함수 품질 - 충돌 과다

증상: 해시테이블 성능이 급격히 저하 (체인이 매우 길어짐).

// ❌ 나쁜 해시 - 모든 키가 같은 버킷으로
struct BadHash {
    size_t operator()(int x) const { return 1; }
};

해결법: 비트 조합, 소수 곱셈 등으로 분산.

// ✅ 개선된 해시
struct PairHash {
    size_t operator()(const std::pair<int,int>& p) const {
        size_t h1 = std::hash<int>{}(p.first);
        size_t h2 = std::hash<int>{}(p.second);
        return h1 ^ (h2 << 1);  // 또는 h1 * 31 + h2
    }
};

에러 5: Skip List에서 노드 소유권

증상: insert에서 Node를 스택에 생성하면 함수 반환 시 dangling pointer.

// ❌ 잘못된 예
Node node(key, value, new_level);
update[i]->forward[i] = &node;  // 함수 종료 시 UB

해결법: unique_ptr로 힙에 할당하고, 컨테이너에 보관.

// ✅ 올바른 예
auto node = std::make_unique<Node>(key, value, new_level);
Node* raw = node.get();
nodes_.push_back(std::move(node));
update[i]->forward[i] = raw;

에러 6: LRU 캐시 capacity 0

증상: capacity_ == 0일 때 put에서 list_.size() >= 0이 항상 참이 되어, 삽입 직후 제거되는 무한 루프 가능성. 해결법: 생성자에서 capacity_를 최소 1로 보장.

explicit LRUCache(size_t capacity) : capacity_(capacity) {
    if (capacity_ == 0) capacity_ = 1;
}

7. 성능 비교

7.1 자료구조별 연산 복잡도

자료구조삽입검색삭제접두사 검색k번째 접근
std::unordered_mapO(1) 평균O(1) 평균O(1) 평균
std::mapO(log n)O(log n)O(log n)부분 지원O(n)
해시테이블 (체이닝)O(1) 평균O(1) 평균O(1) 평균
트라이O(m)O(m)O(m)O(m)
LRU 캐시O(1)O(1)O(1)
Skip ListO(log n) 기대O(log n) 기대O(log n) 기대O(log n)
m = 키/접두사 길이

7.2 벤치마크 시나리오 (100만 연산)

시나리오unordered_mapmap커스텀 해시테이블LRU(1만)
삽입 100만~200ms~400ms~250ms~220ms
검색 100만 (존재)~150ms~350ms~200ms~180ms
삽입+검색 혼합~180ms~380ms~230ms~200ms
환경에 따라 차이 있음. 프로파일링 권장.

7.3 선택 가이드

flowchart TD
  Q[요구사항] --> A{크기 제한?}
  A -->|예| B[LRU 캐시]
  A -->|아니오| C{접두사 검색?}
  C -->|예| D[트라이]
  C -->|아니오| E{k번째 접근?}
  E -->|예| F[Skip List / order statistic tree]
  E -->|아니오| G[unordered_map / map]

8. 프로덕션 패턴

8.1 스레드 안전 LRU 캐시

#include <mutex>
template<typename Key, typename Value>
class ThreadSafeLRUCache {
public:
    explicit ThreadSafeLRUCache(size_t capacity) : cache_(capacity) {}
    std::optional<Value> get(const Key& key) {
        std::lock_guard<std::mutex> lock(mutex_);
        return cache_.get(key);
    }
    void put(const Key& key, const Value& value) {
        std::lock_guard<std::mutex> lock(mutex_);
        cache_.put(key, value);
    }
private:
    LRUCache<Key, Value> cache_;
    std::mutex mutex_;
};

8.2 TTL(Time-To-Live) 캐시

참고: 프로덕션에서는 LRU eviction 시 expiry_에서도 제거하는 콜백이 필요합니다.

#include <chrono>
template<typename Key, typename Value>
class TTLCache {
public:
    explicit TTLCache(size_t capacity, std::chrono::seconds ttl)
        : cache_(capacity), ttl_(ttl) {}
    std::optional<Value> get(const Key& key) {
        auto it = expiry_.find(key);
        if (it == expiry_.end()) return std::nullopt;
        if (std::chrono::steady_clock::now() > it->second) {
            cache_.erase(key);  // 간단화: LRU에 erase 추가 필요
            expiry_.erase(it);
            return std::nullopt;
        }
        return cache_.get(key);
    }
    void put(const Key& key, const Value& value) {
        cache_.put(key, value);
        expiry_[key] = std::chrono::steady_clock::now() + ttl_;
    }
private:
    LRUCache<Key, Value> cache_;
    std::chrono::seconds ttl_;
    std::unordered_map<Key, std::chrono::steady_clock::time_point> expiry_;
};

8.3 트라이 + 빈도수 (자동완성 순위)

class AutocompleteTrie {
public:
    void insert(const std::string& word, int freq = 1) {
        Node* cur = root_.get();
        for (char c : word) {
            if (!cur->children[c]) cur->children[c] = std::make_unique<Node>();
            cur = cur->children[c].get();
        }
        cur->is_end = true;
        cur->freq += freq;
    }
    std::vector<std::pair<std::string, int>> getTopSuggestions(
            const std::string& prefix, size_t k = 10) const {
        Node* cur = root_.get();
        for (char c : prefix) {
            auto it = cur->children.find(c);
            if (it == cur->children.end()) return {};
            cur = it->second.get();
        }
        std::vector<std::pair<std::string, int>> candidates;
        collectWithFreq(cur, prefix, candidates);
        std::partial_sort(candidates.begin(),
            candidates.begin() + std::min(k, candidates.size()),
            candidates.end(),
             { return a.second > b.second; });
        candidates.resize(std::min(k, candidates.size()));
        return candidates;
    }
private:
    struct Node {
        std::unordered_map<char, std::unique_ptr<Node>> children;
        bool is_end = false;
        int freq = 0;
    };
    std::unique_ptr<Node> root_;
    // collectWithFreq 구현 생략
};

8.4 메트릭 수집 LRU

template<typename Key, typename Value>
class InstrumentedLRUCache {
public:
    explicit InstrumentedLRUCache(size_t capacity) : cache_(capacity) {}
    std::optional<Value> get(const Key& key) {
        ++get_count_;
        auto result = cache_.get(key);
        if (result) ++hit_count_;
        return result;
    }
    void put(const Key& key, const Value& value) {
        ++put_count_;
        cache_.put(key, value);
    }
    double hit_rate() const {
        return get_count_ == 0 ? 0.0
            : static_cast<double>(hit_count_) / get_count_;
    }
private:
    LRUCache<Key, Value> cache_;
    size_t get_count_ = 0, put_count_ = 0, hit_count_ = 0;
};

9. 구현 체크리스트

해시테이블

  • 로드 팩터 임계값 설정 (0.75 권장)
  • rehash 시 기존 이터레이터 사용 금지
  • 커스텀 키 시 해시 품질 검증

트라이

  • 빈 문자열 처리 정책
  • 메모리: 자식이 없으면 노드 정리 (선택)

LRU 캐시

  • capacity 0 방어
  • splice 사용 시 이터레이터 유효성 확인
  • evict 시 콜백 필요하면 확장

Skip List

  • 노드 소유권 (unique_ptr 등)
  • randomLevel 시드 고정 (재현성 필요 시)

공통

  • 예외 안전성 (RAII)
  • const 정확성
  • 단위 테스트 (경계 조건)

정리

항목핵심
문제 시나리오캐시 제한, 접두사 검색, 커스텀 키, k번째 접근
해시테이블체이닝, rehash, 로드 팩터
트라이접두사 O(m), 자동완성
LRU해시 + 이중 연결 리스트, splice O(1)
Skip List다중 레벨, O(log n) 기대
흔한 에러반복자 무효화, 해시 품질, capacity 0
성능요구사항에 맞는 자료구조 선택
프로덕션스레드 안전, TTL, 메트릭
핵심 원칙:
  1. STL 우선 - unordered_map, map으로 해결되면 사용
  2. 요구사항 정확히 - 크기 제한, 접두사, k번째 등
  3. 측정 후 선택 - 벤치마크로 검증
  4. 반복자/포인터 무효화 주의

자주 묻는 질문 (FAQ)

Q. 이 내용을 실무에서 언제 쓰나요?

A. 캐싱 시스템(LRU), 검색 자동완성(트라이), 커스텀 해시가 필요한 키, 정렬된 시퀀스의 k번째 접근(Skip List) 등 STL로 해결하기 어려운 상황에서 활용합니다.

Q. 선행으로 읽으면 좋은 글은?

A. STL map/set, 알고리즘 선택, 트리 알고리즘을 먼저 익히면 좋습니다.

Q. 더 깊이 공부하려면?

A. 《Introduction to Algorithms》의 해시·트리 장, LeetCode 146 (LRU Cache), 208 (Implement Trie), cppreference의 unordered_map 구현 노트를 참고하세요. 한 줄 요약: STL 한계를 넘어서는 해시테이블·트라이·LRU·Skip List를 직접 구현할 수 있습니다.

관련 글

심화 부록: 구현·운영 관점

이 부록은 앞선 본문에서 다룬 주제(「C++ 커스텀 자료구조 완벽 가이드 | 해시테이블·트라이·LRU 캐시·Skip List [#54-2]」)를 구현·런타임·운영 관점에서 다시 압축합니다. 도메인별 세부 구현은 글마다 다르지만, 입력 검증 → 핵심 연산 → 부작용(I/O·네트워크·동시성) → 관측의 흐름으로 장애를 나누면 원인 추적이 빨라집니다.

내부 동작과 핵심 메커니즘

flowchart TD
  A[입력·요청·이벤트] --> B[파싱·검증·디코딩]
  B --> C[핵심 연산·상태 전이]
  C --> D[부작용: I/O·네트워크·동시성]
  D --> E[결과·관측·저장]
sequenceDiagram
  participant C as 클라이언트/호출자
  participant B as 경계(런타임·게이트웨이·프로세스)
  participant D as 의존성(API·DB·큐·파일)
  C->>B: 요청/이벤트
  B->>D: 조회·쓰기·RPC
  D-->>B: 지연·부분 실패·재시도 가능
  B-->>C: 응답 또는 오류(코드·상관 ID)
  • 불변 조건(Invariant): 버퍼 경계, 프로토콜 상태, 트랜잭션 격리, FD 상한 등 단계별로 문장으로 적어 두면 디버깅 비용이 줄어듭니다.
  • 결정성: 순수 층과 시간·네트워크·스케줄에 의존하는 층을 분리해야 테스트와 장애 분석이 쉬워집니다.
  • 경계 비용: 직렬화, 인코딩, syscall 횟수, 락 경합, 할당·GC, 캐시 미스를 의심 목록에 둡니다.
  • 백프레셔: 생산자가 소비자보다 빠를 때 버퍼·큐·스트림에서 속도를 줄이는 신호를 어디에 둘지 정의합니다.

프로덕션 운영 패턴

영역운영 관점 질문
관측성요청 단위 상관 ID, 에러율·지연 p95/p99, 의존성 타임아웃·재시도가 대시보드에 보이는가
안전성입력 검증·권한·비밀·감사 로그가 코드 경로마다 일관적인가
신뢰성재시도는 멱등 연산에만 적용되는가, 서킷 브레이커·백오프·DLQ가 있는가
성능캐시·배치 크기·커넥션 풀·인덱스·백프레셔가 데이터 규모에 맞는가
배포롤백 룬북, 카나리/블루그린, 마이그레이션·피처 플래그가 문서화되어 있는가
용량피크 트래픽·디스크·FD·스레드 풀 상한을 주기적으로 검증하는가

스테이징은 데이터 양·네트워크 RTT·동시성을 프로덕션에 가깝게 맞출수록 재현율이 올라갑니다.

확장 예시: 엔드투엔드 미니 시나리오

앞선 본문 주제(「C++ 커스텀 자료구조 완벽 가이드 | 해시테이블·트라이·LRU 캐시·Skip List [#54-2]」)를 배포·운영 흐름에 맞춰 옮긴 체크리스트입니다. 도메인에 맞게 단계 이름만 바꿔 적용할 수 있습니다.

  1. 입력 계약 고정: 스키마·버전·최대 페이로드·타임아웃·에러 코드를 경계에 둔다.
  2. 핵심 경로 계측: 요청 ID, 단계별 지연, 외부 호출 결과 코드를 로그·메트릭·트레이스에서 한 흐름으로 본다.
  3. 실패 주입: 의존성 타임아웃·5xx·부분 데이터·락 대기를 스테이징에서 재현한다.
  4. 호환·롤백: 설정/마이그레이션/클라이언트 버전을 되돌릴 수 있는지 확인한다.
  5. 부하 후 검증: 피크 대비 p95/p99, 에러율, 리소스 상한, 알림 임계값을 점검한다.
handle(request):
  ctx = newCorrelationId()
  validated = validateSchema(request)
  authorize(validated, ctx)
  result = domainCore(validated)
  persistOrEmit(result, idempotentKey)
  recordMetrics(ctx, latency, outcome)
  return result

문제 해결(Troubleshooting)

증상가능 원인조치
간헐적 실패레이스, 타임아웃, 외부 의존성, DNS최소 재현 스크립트, 분산 트레이스·로그 상관관계, 재시도·서킷 설정 점검
성능 저하N+1, 동기 I/O, 락 경합, 과도한 직렬화, 캐시 미스프로파일러·APM으로 핫스팟 확인 후 한 가지씩 제거
메모리 증가캐시 무제한, 구독/리스너 누수, 대용량 버퍼, 커넥션 미반납상한·TTL·힙/FD 스냅샷 비교
빌드·배포만 실패환경 변수, 권한, 플랫폼 차이, lockfileCI 로그와 로컬 diff, 런타임·이미지 버전 핀
설정 불일치프로필·시크릿·기본값, 리전스키마 검증된 설정 단일 소스와 배포 매트릭스 표준화
데이터 불일치비멱등 재시도, 부분 쓰기, 캐시 무효화 누락멱등 키·아웃박스·트랜잭션 경계 재검토

권장 순서: (1) 최소 재현 (2) 최근 변경 범위 축소 (3) 환경·의존성 차이 (4) 관측으로 가설 검증 (5) 수정 후 회귀·부하 테스트.

배포 전에는 git addgit commitgit pushnpm run deploy 순서를 권장합니다.


같이 보면 좋은 글 (내부 링크)

이 주제와 연결되는 다른 글입니다.


이 글에서 다루는 키워드 (관련 검색어)

C++, 자료구조, 해시테이블, 트라이, LRU, SkipList 등으로 검색하시면 이 글이 도움이 됩니다.