[2026] C++ 검색 엔진 구현 | 역색인·TF-IDF 랭킹·자동완성 [#50-9]

[2026] C++ 검색 엔진 구현 | 역색인·TF-IDF 랭킹·자동완성 [#50-9]

이 글의 핵심

사이트 내 검색, 로그 분석, 문서 검색을 구현할 때 단순 문자열 검색만으로는 부족합니다. 역색인(Inverted Index)으로 빠르게 문서를 찾고, TF-IDF로 관련도 순으로 정렬하며, 자동완성으로 사용자 경험을… 개념과 예제 코드를 단계적으로 다루며, 실무·학습에 참고할 수 있도록 구성했습니다.

들어가며: “검색 결과가 왜 이렇게 나오지?”

검색 엔진의 핵심

사이트 내 검색, 로그 분석, 문서 검색을 구현할 때 “단순 문자열 검색”만으로는 부족합니다. 역색인(Inverted Index)으로 빠르게 문서를 찾고, TF-IDF로 관련도 순으로 정렬하며, 자동완성으로 사용자 경험을 높여야 합니다. 이 글에서는 C++로 전문 검색 엔진의 핵심을 구현합니다. 목표:

  • 역색인 구조 설계 및 구현
  • TF-IDF 기반 랭킹
  • 토큰화 및 형태소 분석
  • Trie 기반 자동완성
  • 프로덕션 수준 최적화 요구 환경: C++17 이상 이 글을 읽으면:
  • 역색인·랭킹·자동완성 시스템을 구현할 수 있습니다.
  • 실전 문제를 해결할 수 있습니다.
  • 프로덕션 수준의 코드를 작성할 수 있습니다.

문제 시나리오: 검색 엔진이 필요한 상황

시나리오 1: 사이트 내 검색이 너무 느림
블로그나 문서 사이트에서 “검색”을 누르면 전체 문서를 순회하며 grep처럼 찾는 방식은 문서가 1만 개만 넘어도 수 초가 걸립니다. 역색인을 쓰면 단어 → 문서 목록으로 O(1)에 접근해 밀리초 단위로 응답할 수 있습니다. 시나리오 2: 검색 결과 순서가 이상함
”C++ 메모리 관리”를 검색했는데, “메모리”만 한 번 나오는 문서가 맨 위에 나옵니다. TF-IDF로 단어 빈도와 문서 내 중요도를 반영하면, 실제로 관련 있는 문서가 상위에 노출됩니다. 시나리오 3: 한글 검색이 제대로 안 됨
”검색엔진”을 검색했는데 “검색”, “엔진”으로 분리되지 않아 결과가 없습니다. 토큰화(Tokenization)형태소 분석으로 단어 단위 인덱싱이 필요합니다. 시나리오 4: 자동완성이 없음
사용자가 “C++“를 입력하는 동안 “C++ 메모리”, “C++ 스마트 포인터” 같은 제안이 없어 불편합니다. Trie 기반 자동완성으로 입력 중 실시간 제안을 제공할 수 있습니다. 시나리오 5: 대용량 인덱스 메모리 부족
수백만 문서를 인덱싱하면 역색인이 수 GB를 차지합니다. 메모리 매핑, 압축, 분할 인덱스로 제한된 리소스에서도 동작하도록 설계해야 합니다.

개념을 잡는 비유

이 글의 주제는 여러 부품이 맞물리는 시스템으로 보시면 이해가 빠릅니다. 한 레이어(저장·네트워크·관측)의 선택이 옆 레이어에도 영향을 주므로, 본문에서는 트레이드오프를 숫자와 패턴으로 정리합니다.

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

목차

  1. 시스템 아키텍처
  2. 역색인 구현
  3. TF-IDF 랭킹
  4. 토큰화 및 형태소 분석
  5. 자동완성 (Trie)
  6. 완전한 검색 엔진 예제
  7. 자주 발생하는 에러와 해결법
  8. 성능 최적화 팁
  9. 프로덕션 패턴
  10. 구현 체크리스트

1. 시스템 아키텍처

전체 구조

검색 엔진은 인덱싱 파이프라인검색 파이프라인으로 나뉩니다. 다음은 mermaid를 활용한 상세한 구현 코드입니다. 각 부분의 역할을 이해하면서 코드를 살펴보시기 바랍니다.

flowchart TB
    subgraph Indexing[인덱싱 파이프라인]
        D1[문서 입력]
        D1 --> T1[토큰화]
        T1 --> T2[정규화]
        T2 --> I1[역색인 구축]
        I1 --> I2[TF-IDF 가중치 계산]
    end
    subgraph Search[검색 파이프라인]
        Q1[쿼리 입력]
        Q1 --> Q2[쿼리 토큰화]
        Q2 --> Q3[역색인 조회]
        Q3 --> Q4[TF-IDF 스코어링]
        Q4 --> Q5[랭킹 정렬]
        Q5 --> Q6[결과 반환]
    end
    I2 --> Index[(역색인)]
    Index --> Q3
    subgraph Autocomplete[자동완성]
        A1[Trie]
        Q1 -.->|입력 중| A1
    end

시퀀스 다이어그램

다음은 mermaid를 활용한 상세한 구현 코드입니다. 각 부분의 역할을 이해하면서 코드를 살펴보시기 바랍니다.

sequenceDiagram
    participant User as 사용자
    participant API as 검색 API
    participant Index as 역색인
    participant Trie as 자동완성 Trie
    User->>API: "C++ 메모리" 검색
    API->>API: 쿼리 토큰화 ["C++", "메모리"]
    API->>Index: 각 토큰별 문서 ID 조회
    Index-->>API: Posting List
    API->>API: TF-IDF 스코어 계산
    API->>API: 상위 N개 정렬
    API-->>User: 검색 결과
    User->>API: "C++" 입력 중
    API->>Trie: prefix "C++" 조회
    Trie-->>API: ["C++ 메모리", "C++ 스마트 포인터", ...]
    API-->>User: 자동완성 제안

핵심 포인트:

  • 역색인: 단어 → (문서 ID, TF) 리스트. 검색 시 단어로 바로 문서 집합을 찾습니다.
  • TF-IDF: Term Frequency × Inverse Document Frequency. 흔한 단어보다 특정 문서에만 자주 나오는 단어에 높은 가중치.
  • Trie: 접두사 검색에 최적화된 트리. 자동완성에 사용합니다.

2. 역색인 구현

Posting 구조

역색인에서 각 단어(term)는 Posting List를 가집니다. 각 Posting은 (문서 ID, TF) 쌍입니다. 다음은 cpp를 활용한 상세한 구현 코드입니다. 필요한 모듈을 import하고, 클래스를 정의하여 데이터와 기능을 캡슐화하며. 각 부분의 역할을 이해하면서 코드를 살펴보시기 바랍니다.

// posting.hpp
#pragma once
#include <cstdint>
#include <vector>
namespace search {
// 단일 문서 내에서의 term 등장 정보
struct Posting {
    uint32_t doc_id;
    uint32_t term_freq;  // 해당 문서에서 term 등장 횟수
    Posting(uint32_t d, uint32_t tf) : doc_id(d), term_freq(tf) {}
};
// 역색인: term -> Posting List
// TF-IDF 계산을 위해 term_freq 저장
using PostingList = std::vector<Posting>;
}  // namespace search

역색인 인덱서

다음은 cpp를 활용한 상세한 구현 코드입니다. 필요한 모듈을 import하고, 클래스를 정의하여 데이터와 기능을 캡슐화하며, 반복문으로 데이터를 처리합니다, 조건문으로 분기 처리를 수행합니다. 각 부분의 역할을 이해하면서 코드를 살펴보시기 바랍니다.

// inverted_index.hpp
#pragma once
#include <string>
#include <unordered_map>
#include <vector>
#include <cstdint>
namespace search {
class InvertedIndex {
public:
    using DocId = uint32_t;
    // 문서 추가: doc_id와 토큰화된 term 목록
    void add_document(DocId doc_id, const std::vector<std::string>& terms) {
        // term별 빈도 계산
        std::unordered_map<std::string, uint32_t> term_freq;
        for (const auto& term : terms) {
            if (!term.empty()) {
                term_freq[term]++;
            }
        }
        // Posting 추가
        for (const auto& [term, freq] : term_freq) {
            index_[term].emplace_back(doc_id, freq);
        }
        // 문서 수 갱신 (IDF 계산용)
        num_docs_ = std::max(num_docs_, doc_id + 1);
    }
    // term의 Posting List 조회
    const std::vector<Posting>* get_postings(const std::string& term) const {
        auto it = index_.find(term);
        if (it == index_.end()) return nullptr;
        return &it->second;
    }
    // 문서 수 (IDF 계산용)
    uint32_t num_documents() const { return num_docs_; }
    // term이 등장하는 문서 수
    uint32_t document_frequency(const std::string& term) const {
        auto* postings = get_postings(term);
        return postings ? postings->size() : 0;
    }
private:
    std::unordered_map<std::string, std::vector<Posting>> index_;
    uint32_t num_docs_ = 0;
};
}  // namespace search

주의점: Posting List는 보통 doc_id 순 정렬되어 있어야 merge 연산(AND 검색)이 효율적입니다. 위 예제는 단순화했으며, 프로덕션에서는 인덱싱 시 정렬하거나 정렬된 구조(Vector, Skip List)를 사용합니다.

3. TF-IDF 랭킹

TF-IDF 공식

  • TF (Term Frequency): 문서 내 term 등장 빈도. 많이 나올수록 중요.
  • IDF (Inverse Document Frequency): log(N / df) — N은 전체 문서 수, df는 term이 등장하는 문서 수. 흔한 단어일수록 가중치 감소.
  • 스코어: TF × IDF (또는 정규화된 변형) 다음은 cpp를 활용한 상세한 구현 코드입니다. 필요한 모듈을 import하고, 클래스를 정의하여 데이터와 기능을 캡슐화하며, 반복문으로 데이터를 처리합니다, 조건문으로 분기 처리를 수행합니다. 각 부분의 역할을 이해하면서 코드를 살펴보시기 바랍니다.
// tfidf.hpp
#pragma once
#include "inverted_index.hpp"
#include <cmath>
#include <string>
#include <vector>
// 패키지 선언
namespace search {
class TFIDFScorer {
public:
    explicit TFIDFScorer(const InvertedIndex& index) : index_(index) {}
    // 단일 term에 대한 문서 스코어 계산
    double score_term(const std::string& term, uint32_t doc_id, uint32_t tf) const {
        uint32_t N = index_.num_documents();
        uint32_t df = index_.document_frequency(term);
        if (N == 0 || df == 0) return 0.0;
        // IDF: log((N + 1) / (df + 1)) + 1 (스무딩)
        double idf = std::log(static_cast<double>(N + 1) / (df + 1)) + 1.0;
        // TF: 1 + log(tf) (sublinear scaling — 과도한 반복 억제)
        double tf_score = 1.0 + std::log(1.0 + tf);
        return tf_score * idf;
    }
    // 쿼리 [t1, t2, ...]에 대한 문서 총 스코어
    double score_document(
        const std::vector<std::string>& query_terms,
        uint32_t doc_id
    ) const {
        double total = 0.0;
        for (const auto& term : query_terms) {
            auto* postings = index_.get_postings(term);
            if (!postings) continue;
            for (const auto& p : *postings) {
                if (p.doc_id == doc_id) {
                    total += score_term(term, doc_id, p.term_freq);
                    break;
                }
            }
        }
        return total;
    }
private:
    const InvertedIndex& index_;
};
}  // namespace search

BM25 변형: 실무에서는 TF-IDF 대신 BM25를 많이 씁니다. TF에 상한을 두어 한 문서에서 극단적으로 많이 나오는 term의 영향력을 줄입니다. 위 score_term에서 1 + log(1 + tf)가 그 역할을 일부 수행합니다.

4. 토큰화 및 형태소 분석

간단한 토큰화 (공백 분리)

다음은 cpp를 활용한 상세한 구현 코드입니다. 필요한 모듈을 import하고, 반복문으로 데이터를 처리합니다, 조건문으로 분기 처리를 수행합니다. 각 부분의 역할을 이해하면서 코드를 살펴보시기 바랍니다.

// tokenizer.hpp
#pragma once
#include <string>
#include <vector>
#include <cctype>
#include <algorithm>
namespace search {
// 공백 기준 분리 + 소문자화 (영문)
std::vector<std::string> tokenize_simple(const std::string& text) {
    std::vector<std::string> tokens;
    std::string current;
    for (char c : text) {
        if (std::isspace(static_cast<unsigned char>(c)) || c == ',' || c == '.') {
            if (!current.empty()) {
                std::transform(current.begin(), current.end(), current.begin(), ::tolower);
                tokens.push_back(std::move(current));
                current.clear();
            }
        } else {
            current += c;
        }
    }
    if (!current.empty()) {
        std::transform(current.begin(), current.end(), current.begin(), ::tolower);
        tokens.push_back(std::move(current));
    }
    return tokens;
}

한글/영문 혼합 토큰화 (자소 분리 없이)

한글은 형태소 분석기(MeCab, Kiwi 등)를 붙이는 것이 정석이지만, C++ 단독으로는 n-gram 또는 공백+특수문자 분리로 대체할 수 있습니다. 다음은 cpp를 활용한 상세한 구현 코드입니다. 반복문으로 데이터를 처리합니다, 조건문으로 분기 처리를 수행합니다. 각 부분의 역할을 이해하면서 코드를 살펴보시기 바랍니다.

// 한글 음절 단위 분리 (초성/중성/종성 분리 없이)
// "검색엔진" -> ["검색", "엔진"] 또는 [검색엔진] (사전 기반 필요)
// 실행 예제
std::vector<std::string> tokenize_korean_simple(const std::string& text) {
    std::vector<std::string> tokens;
    std::string current;
    for (unsigned char c : text) {
        if (std::isspace(c) || !std::isalnum(c)) {
            if (!current.empty()) {
                tokens.push_back(std::move(current));
                current.clear();
            }
        } else {
            current += c;
        }
    }
    if (!current.empty()) {
        tokens.push_back(std::move(current));
    }
    return tokens;
}
// 통합 토큰화: 영문은 소문자, 한글은 그대로
std::vector<std::string> tokenize(const std::string& text) {
    auto tokens = tokenize_korean_simple(text);
    for (auto& t : tokens) {
        // 영문만 소문자화
        bool all_ascii = std::all_of(t.begin(), t.end(),  {
            return static_cast<unsigned char>(c) < 128;
        });
        if (all_ascii) {
            std::transform(t.begin(), t.end(), t.begin(), ::tolower);
        }
    }
    return tokens;
}
}  // namespace search

실무 팁: 한글 품질을 높이려면 외부 형태소 분석기를 C++에서 호출하거나, 사전 기반 최장 일치를 구현합니다. 이 글에서는 단순 분리만 다룹니다.

5. 자동완성 (Trie)

Trie 노드

다음은 cpp를 활용한 상세한 구현 코드입니다. 필요한 모듈을 import하고, 클래스를 정의하여 데이터와 기능을 캡슐화하며, 반복문으로 데이터를 처리합니다, 조건문으로 분기 처리를 수행합니다. 각 부분의 역할을 이해하면서 코드를 살펴보시기 바랍니다.

// trie.hpp
#pragma once
#include <string>
#include <unordered_map>
#include <vector>
#include <memory>
namespace search {
class Trie {
public:
    void insert(const std::string& word) {
        Node* node = &root_;
        for (char c : word) {
            if (node->children.find(c) == node->children.end()) {
                node->children[c] = std::make_unique<Node>();
            }
            node = node->children[c].get();
        }
        node->is_end = true;
        node->word = word;  // 완성된 단어 저장 (선택)
    }
    // prefix로 시작하는 단어들 (최대 max_results개)
    std::vector<std::string> search_prefix(const std::string& prefix,
                                           size_t max_results = 10) const {
        const Node* node = &root_;
        for (char c : prefix) {
            auto it = node->children.find(c);
            if (it == node->children.end()) {
                return {};
            }
            node = it->second.get();
        }
        std::vector<std::string> results;
        collect_words(node, results, max_results);
        return results;
    }
private:
    struct Node {
        std::unordered_map<char, std::unique_ptr<Node>> children;
        bool is_end = false;
        std::string word;  // 리프에서만 사용
    };
    void collect_words(const Node* node,
                      std::vector<std::string>& results,
                      size_t max_results) const {
        if (results.size() >= max_results) return;
        if (node->is_end && !node->word.empty()) {
            results.push_back(node->word);
        }
        for (const auto& [c, child] : node->children) {
            collect_words(child.get(), results, max_results);
            if (results.size() >= max_results) break;
        }
    }
    Node root_;
};
}  // namespace search

개선: 자동완성 품질을 높이려면 빈도 기반 정렬을 넣습니다. 각 노드에 (word, freq)를 저장하고, collect_words 시 빈도 순으로 정렬해 반환합니다.

6. 완전한 검색 엔진 예제

통합 검색 엔진 클래스

다음은 cpp를 활용한 상세한 구현 코드입니다. 필요한 모듈을 import하고, 클래스를 정의하여 데이터와 기능을 캡슐화하며, 반복문으로 데이터를 처리합니다, 조건문으로 분기 처리를 수행합니다. 각 부분의 역할을 이해하면서 코드를 살펴보시기 바랍니다.

// search_engine.hpp
#pragma once
#include "inverted_index.hpp"
#include "tfidf.hpp"
#include "tokenizer.hpp"
#include "trie.hpp"
#include <string>
#include <vector>
#include <algorithm>
#include <unordered_map>
namespace search {
struct SearchResult {
    uint32_t doc_id;
    double score;
    std::string snippet;  // 미리보기 (선택)
};
class SearchEngine {
public:
    // 문서 추가
    void add_document(uint32_t doc_id, const std::string& title, const std::string& content) {
        auto terms = tokenize(title + " " + content);
        // 제목에 가중치 (제목 term 2회 반영)
        for (const auto& t : tokenize(title)) {
            terms.push_back(t);
        }
        index_.add_document(doc_id, terms);
        // 자동완성용: 제목/인기 검색어 등
        for (const auto& t : tokenize(title)) {
            if (t.size() >= 2) {
                trie_.insert(t);
            }
        }
    }
    // 검색
    std::vector<SearchResult> search(const std::string& query, size_t top_k = 10) {
        auto query_terms = tokenize(query);
        if (query_terms.empty()) return {};
        TFIDFScorer scorer(index_);
        // 문서별 스코어 집계
        std::unordered_map<uint32_t, double> doc_scores;
        for (const auto& term : query_terms) {
            auto* postings = index_.get_postings(term);
            if (!postings) continue;
            for (const auto& p : *postings) {
                double s = scorer.score_term(term, p.doc_id, p.term_freq);
                doc_scores[p.doc_id] += s;
            }
        }
        // 스코어 순 정렬
        std::vector<SearchResult> results;
        for (const auto& [doc_id, score] : doc_scores) {
            if (score > 0) {
                results.push_back({doc_id, score, ""});
            }
        }
        std::sort(results.begin(), results.end(),
             {
                return a.score > b.score;
            });
        if (results.size() > top_k) {
            results.resize(top_k);
        }
        return results;
    }
    // 자동완성
    std::vector<std::string> autocomplete(const std::string& prefix, size_t max = 10) {
        return trie_.search_prefix(prefix, max);
    }
private:
    InvertedIndex index_;
    Trie trie_;
};
}  // namespace search

사용 예시

다음은 cpp를 활용한 상세한 구현 코드입니다. 필요한 모듈을 import하고, 반복문으로 데이터를 처리합니다. 각 부분의 역할을 이해하면서 코드를 살펴보시기 바랍니다.

// main.cpp
#include "search_engine.hpp"
#include <iostream>
int main() {
    search::SearchEngine engine;
    // 문서 추가
    engine.add_document(0, "C++ 메모리 관리", "C++에서 스마트 포인터를 사용한 메모리 관리 방법.");
    engine.add_document(1, "C++ 스마트 포인터", "unique_ptr, shared_ptr, weak_ptr 설명.");
    engine.add_document(2, "검색 엔진 구현", "역색인과 TF-IDF를 이용한 검색 엔진 구현.");
    // 검색
    auto results = engine.search("C++ 메모리", 5);
    for (const auto& r : results) {
        std::cout << "doc_id=" << r.doc_id << " score=" << r.score << "\n";
    }
    // 자동완성
    auto suggestions = engine.autocomplete("C++", 5);
    for (const auto& s : suggestions) {
        std::cout << "  " << s << "\n";
    }
    return 0;
}

CMakeLists.txt

아래 코드는 cmake를 사용한 구현 예제입니다. 각 부분의 역할을 이해하면서 코드를 살펴보시기 바랍니다.

cmake_minimum_required(VERSION 3.14)
project(search_engine LANGUAGES CXX)
set(CMAKE_CXX_STANDARD 17)
add_executable(search_engine
    main.cpp
)
target_include_directories(search_engine PRIVATE ${CMAKE_CURRENT_SOURCE_DIR})

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

문제 1: 검색 결과가 비어 있음

증상: 쿼리를 넣었는데 항상 빈 결과 원인:

  1. 토큰화 방식 불일치 (인덱싱 시와 검색 시 다른 정규화)
  2. 대소문자 불일치 (영문)
  3. 인덱스가 비어 있음 해결법: 아래 코드는 cpp를 사용한 구현 예제입니다. 코드를 직접 실행해보면서 동작을 확인해보세요.
// ❌ 잘못된 예: 인덱싱은 소문자, 검색은 원문
index_.add_document(0, tokenize_lower(doc));  // "C++" -> "c++"
auto terms = tokenize(query);                 // "C++" -> "C++" (그대로)
// ✅ 올바른 예: 동일한 tokenize 함수 사용
index_.add_document(0, tokenize(doc));
auto terms = tokenize(query);

체크리스트:

  • tokenize()가 인덱싱과 검색 모두에서 동일하게 호출되는지 확인
  • index_.num_documents() > 0 확인
  • get_postings(term)이 nullptr이 아닌지 로그로 확인

문제 2: 메모리 부족 (OOM)

증상: 대량 문서 인덱싱 시 std::bad_alloc 원인: 역색인이 전부 메모리에 상주. 수백만 문서 × 평균 100 term이면 수 GB. 해결법: 아래 코드는 cpp를 사용한 구현 예제입니다. 조건문으로 분기 처리를 수행합니다. 각 부분의 역할을 이해하면서 코드를 살펴보시기 바랍니다.

// 1. Posting List 압축 (VByte, Simple9 등)
// 2. 메모리 매핑 파일로 디스크 오프로드
// 3. 배치 인덱싱 후 디스크에 저장, 검색 시 mmap
// ✅ 예: 문서 수 제한 또는 배치 플러시
constexpr size_t MAX_IN_MEMORY_DOCS = 100000;
if (index_.num_documents() >= MAX_IN_MEMORY_DOCS) {
    flush_index_to_disk();
    index_.clear();
}

문제 3: 검색 속도가 느림

증상: 쿼리당 수백 ms 이상 원인:

  1. Posting List가 정렬되지 않아 선형 스캔
  2. 스코어 계산 시 불필요한 반복
  3. 결과 정렬 비용 해결법: 아래 코드는 cpp를 사용한 구현 예제입니다. 반복문으로 데이터를 처리합니다. 각 부분의 역할을 이해하면서 코드를 살펴보시기 바랍니다.
// ✅ Posting List를 doc_id 순 정렬 (인덱싱 시 1회)
void add_document(DocId doc_id, const std::vector<std::string>& terms) {
    // ....term_freq 계산 ...
    for (const auto& [term, freq] : term_freq) {
        index_[term].emplace_back(doc_id, freq);
    }
}
// 인덱싱 완료 후:
for (auto& [term, list] : index_) {
    std::sort(list.begin(), list.end(),
         { return a.doc_id < b.doc_id; });
}

문제 4: 한글 검색이 안 됨

증상: “검색엔진” 검색 시 결과 없음 원인: “검색엔진”이 하나의 토큰으로만 저장되어 있고, “검색”, “엔진”으로 분리되지 않음 해결법: 아래 코드는 cpp를 사용한 구현 예제입니다. 반복문으로 데이터를 처리합니다. 각 부분의 역할을 이해하면서 코드를 살펴보시기 바랍니다.

// n-gram 인덱싱: "검색엔진" -> "검색", "색엔", "엔진" (2-gram)
std::vector<std::string> ngram_tokenize(const std::string& text, int n = 2) {
    std::vector<std::string> tokens;
    for (size_t i = 0; i + n <= text.size(); ++i) {
        tokens.push_back(text.substr(i, n));
    }
    return tokens;
}
// 또는 외부 형태소 분석기 연동 (MeCab, Kiwi 등)

문제 5: Trie 메모리 폭증

증상: 자동완성 Trie가 수 GB 사용 원인: 모든 고유 단어를 Trie에 넣고, 단어 수가 수백만 개 해결법:

// ✅ 인기 검색어만 Trie에 저장 (상위 10만 개 등)
// ✅ 또는 DAWG/MA-FSA 같은 압축 Trie 사용
// ✅ 인덱스와 별도로 "인기 쿼리"만 Trie에 유지

문제 6: 스레드 안전성

증상: 멀티스레드에서 검색 시 크래시 또는 잘못된 결과 원인: InvertedIndex, Trie가 동시 읽기/쓰기에 안전하지 않음 해결법: 다음은 cpp를 활용한 상세한 구현 코드입니다. 필요한 모듈을 import하고, 클래스를 정의하여 데이터와 기능을 캡슐화하며. 각 부분의 역할을 이해하면서 코드를 살펴보시기 바랍니다.

// ✅ 검색은 읽기만 하므로, 인덱스 구축 완료 후 std::shared_mutex
// ✅ 쓰기(인덱싱) 시점에는 검색 차단
#include <shared_mutex>
class InvertedIndex {
    mutable std::shared_mutex mtx_;
public:
    void add_document(...) {
        std::unique_lock lock(mtx_);
        // ...
    }
    const PostingList* get_postings(...) const {
        std::shared_lock lock(mtx_);
        // ...
    }
};

8. 성능 최적화 팁

1. Posting List 정렬 및 Skip List

AND 검색 시 두 Posting List를 merge할 때, 정렬된 리스트면 투 포인터로 O(n+m)에 가능합니다. 리스트가 길면 Skip List로 일부만 읽어 성능을 높일 수 있습니다. 다음은 cpp를 활용한 상세한 구현 코드입니다. 반복문으로 데이터를 처리합니다, 조건문으로 분기 처리를 수행합니다. 각 부분의 역할을 이해하면서 코드를 살펴보시기 바랍니다.

// 두 정렬된 Posting List의 교집합 (AND)
std::vector<Posting> merge_and(
    const std::vector<Posting>& a,
    const std::vector<Posting>& b)
{
    std::vector<Posting> result;
    size_t i = 0, j = 0;
    while (i < a.size() && j < b.size()) {
        if (a[i].doc_id == b[j].doc_id) {
            result.push_back(a[i]);
            ++i; ++j;
        } else if (a[i].doc_id < b[j].doc_id) {
            ++i;
        } else {
            ++j;
        }
    }
    return result;
}

2. 스코어 캐싱

동일 쿼리가 반복되면 LRU 캐시로 (query → top_k 결과)를 저장합니다. 아래 코드는 cpp를 사용한 구현 예제입니다. 필요한 모듈을 import하고. 코드를 직접 실행해보면서 동작을 확인해보세요.

#include <lru_cache.hpp>  // 또는 직접 구현
std::optional<std::vector<SearchResult>> cache_lookup(const std::string& query) {
    static LRUCache<std::string, std::vector<SearchResult>> cache(1000);
    return cache.get(query);
}

3. Early Termination

상위 K개만 필요할 때, 을 사용해 모든 문서를 정렬하지 않고 K개만 유지합니다. 아래 코드는 cpp를 사용한 구현 예제입니다. 반복문으로 데이터를 처리합니다, 조건문으로 분기 처리를 수행합니다. 각 부분의 역할을 이해하면서 코드를 살펴보시기 바랍니다.

// 상위 K개만 유지하는 최소 힙
auto cmp =  {
    return a.score > b.score;  // 최소 힙: 점수 낮은 것이 top
};
std::priority_queue<SearchResult, std::vector<SearchResult>, decltype(cmp)> heap(cmp);
for (const auto& [doc_id, score] : doc_scores) {
    if (heap.size() < top_k) {
        heap.push({doc_id, score, ""});
    } else if (score > heap.top().score) {
        heap.pop();
        heap.push({doc_id, score, ""});
    }
}

4. 메모리 풋프린트

구성요소문서 100만 개 기준 (추정)
역색인 (압축 없음)~2–5 GB
역색인 (VByte 압축)~500 MB–1 GB
Trie (10만 단어)~50–100 MB

9. 프로덕션 패턴

1. 인덱스 분할 (Sharding)

문서를 여러 인덱스 샤드로 나누고, 쿼리 시 모든 샤드에 병렬 요청 후 결과를 merge합니다. 다음은 cpp를 활용한 상세한 구현 코드입니다. 비동기 처리를 통해 효율적으로 작업을 수행합니다, 반복문으로 데이터를 처리합니다. 각 부분의 역할을 이해하면서 코드를 살펴보시기 바랍니다.

std::vector<SearchResult> search_sharded(const std::string& query, size_t top_k) {
    std::vector<std::future<std::vector<SearchResult>>> futures;
    for (auto& shard : shards_) {
        futures.push_back(std::async(std::launch::async, [&shard, &query, top_k]() {
            return shard.search(query, top_k * 2);  // 각 샤드에서 더 가져와 merge
        }));
    }
    std::vector<SearchResult> all;
    for (auto& f : futures) {
        auto r = f.get();
        all.insert(all.end(), r.begin(), r.end());
    }
    std::sort(all.begin(), all.end(), ...);
    all.resize(top_k);
    return all;
}

2. 인덱스 업데이트 전략

  • 전체 재구축: 주기적으로 전체 인덱스 재생성 (구현 단순)
  • 증분 인덱스: 새 문서만 추가, 삭제는 비트맵으로 마스킹
  • Double Buffer: 새 인덱스 구축 후 원자적으로 스왑

3. Docker Compose 예시

다음은 yaml를 활용한 상세한 구현 코드입니다. 각 부분의 역할을 이해하면서 코드를 살펴보시기 바랍니다.

# docker-compose.search.yml
version: '3.8'
services:
  search-api:
    build: .
    ports:
      - "8080:8080"
    environment:
      - INDEX_PATH=/data/index
      - CACHE_SIZE_MB=512
    volumes:
      - index-data:/data
    deploy:
      resources:
        limits:
          memory: 2G
  # 선택: Redis로 쿼리 캐시
  redis:
    image: redis:7-alpine
    ports:
      - "6379:6379"
volumes:
  index-data:

4. 헬스 체크

다음은 간단한 cpp 코드 예제입니다. 코드를 직접 실행해보면서 동작을 확인해보세요.

// /health 엔드포인트
bool is_healthy() const {
    return index_.num_documents() > 0 && !index_corrupt_;
}

5. 메트릭 수집

다음은 간단한 cpp 코드 예제입니다. 코드를 직접 실행해보면서 동작을 확인해보세요.

// Prometheus 메트릭 예시
// search_duration_seconds - 검색 지연
// search_queries_total - 쿼리 수
// index_documents_total - 인덱스된 문서 수

10. 구현 체크리스트

인덱싱

  • 토큰화 함수가 인덱싱/검색에서 동일하게 사용됨
  • Posting List가 doc_id 순 정렬됨
  • 대용량 시 배치 플러시 또는 디스크 오프로드 고려

검색

  • TF-IDF (또는 BM25) 스코어링 적용
  • 상위 K개 Early Termination 적용
  • 쿼리 캐시 (선택)

자동완성

  • Trie에 넣는 단어 수 제한 (인기 쿼리만)
  • prefix 길이 최소값 (1–2자 이상)

운영

  • 스레드 안전성 (shared_mutex 등)
  • 헬스 체크 엔드포인트
  • 메트릭 수집 (지연, QPS)
  • 인덱스 백업/복구 절차

정리

항목설명
역색인term → Posting List. O(1) 조회
TF-IDF관련도 랭킹. TF × IDF
토큰화공백/형태소 분리. 인덱싱·검색 일치 필수
자동완성Trie 기반 prefix 검색
최적화정렬, 캐시, Early Termination, Sharding
핵심 원칙:
  1. 인덱싱과 검색의 토큰화를 동일하게 유지
  2. Posting List 정렬로 merge 효율화
  3. 대용량 시 메모리 제한 및 디스크 오프로드
  4. 프로덕션에서는 스레드 안전성·헬스 체크·메트릭 필수

자주 묻는 질문 (FAQ)

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

A. 사이트 내 검색, 로그 분석, 문서 검색, 추천 시스템 등에 활용합니다. 실무에서는 위 본문의 예제와 선택 가이드를 참고해 적용하면 됩니다.

Q. Elasticsearch 대신 직접 구현하는 이유는?

A. 의존성 최소화, 임베디드/엣지 환경, 학습 목적에 적합합니다. 대규모 분산 검색이 필요하면 Elasticsearch/OpenSearch를 사용하는 것이 좋습니다.

Q. 한글 검색 품질을 높이려면?

A. 형태소 분석기(MeCab, Kiwi)를 C++에서 호출하거나, n-gram 인덱싱을 적용합니다. 상용 검색 엔진은 보통 전문 형태소 분석기를 사용합니다. 한 줄 요약: 역색인·TF-IDF·자동완성을 구현하여 실전 검색 엔진 개발 경험을 쌓을 수 있습니다. 다음 글: [C++ 실전 가이드 #51-1] 프로파일링 도구 마스터 이전 글: [C++ 실전 가이드 #50-8] 이전 글

관련 글

... 996 lines not shown ... Token usage: 63706/1000000; 936294 remaining Start-Sleep -Seconds 3