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

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

이 글의 핵심

C++ 검색 엔진 구현에 대한 실전 가이드입니다. 역색인·TF-IDF 랭킹·자동완성 [#50-9] 등을 예제와 함께 설명합니다.

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

검색 엔진의 핵심

사이트 내 검색, 로그 분석, 문서 검색을 구현할 때 “단순 문자열 검색”만으로는 부족합니다. 역색인(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를 차지합니다. 메모리 매핑, 압축, 분할 인덱스로 제한된 리소스에서도 동작하도록 설계해야 합니다.

개념을 잡는 비유

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


목차

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

1. 시스템 아키텍처

전체 구조

검색 엔진은 인덱싱 파이프라인검색 파이프라인으로 나뉩니다.

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

시퀀스 다이어그램

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) 쌍입니다.

// 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

역색인 인덱서

// 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 (또는 정규화된 변형)
// 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. 토큰화 및 형태소 분석

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

// 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 또는 공백+특수문자 분리로 대체할 수 있습니다.

// 한글 음절 단위 분리 (초성/중성/종성 분리 없이)
// "검색엔진" -> ["검색", "엔진"] 또는 ["검색엔진"] (사전 기반 필요)
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 노드

// 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. 완전한 검색 엔진 예제

통합 검색 엔진 클래스

// 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

사용 예시

// 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_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. 인덱스가 비어 있음

해결법:

// ❌ 잘못된 예: 인덱싱은 소문자, 검색은 원문
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.

해결법:

// 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. 결과 정렬 비용

해결법:

// ✅ 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: 한글 검색이 안 됨

증상: “검색엔진” 검색 시 결과 없음

원인: “검색엔진”이 하나의 토큰으로만 저장되어 있고, “검색”, “엔진”으로 분리되지 않음

해결법:

// 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가 동시 읽기/쓰기에 안전하지 않음

해결법:

// ✅ 검색은 읽기만 하므로, 인덱스 구축 완료 후 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로 일부만 읽어 성능을 높일 수 있습니다.

// 두 정렬된 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 결과)를 저장합니다.

#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개만 유지합니다.

// 상위 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합니다.

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 예시

# 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. 헬스 체크

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

5. 메트릭 수집

// 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] 이전 글


관련 글

  • C++ 시리즈 전체 보기
  • C++ Adapter Pattern 완벽 가이드 | 인터페이스 변환과 호환성
  • C++ ADL |
  • C++ Aggregate Initialization |