검색 성능을 개선해보자

캡스톤 프로젝트에서 약 288만 건, 약 20GB 규모의 arXiv 메타데이터를 대상으로 논문 검색 기능을 구현했습니다. 제목, 초록, 저자에서 키워드를 검색하고 카테고리 조건을 함께 적용하는 방식입니다.

초기에는 MongoDB의 $regex를 사용했습니다. $regex는 문자열 필드에 정규표현식을 적용해서 패턴이 맞는 문서를 찾는 검색 연산자입니다. 세 개의 필드에 대한 조건을 $or로 묶어 검색어가 포함된 문서를 찾았습니다.


먼저 explain("executionStats")로 실제 실행 계획을 확인했습니다.

db.papers.find({
  "$or": [
    {"title": {"$regex": "machine learning", "$options": "i"}},
    {"abstract": {"$regex": "machine learning", "$options": "i"}},
    {"authors": {"$regex": "machine learning", "$options": "i"}}
  ]
}).explain("executionStats")
검색 유형검색 조합실행 시간스캔한 키반환 문서스캔:반환 비율
키워드 검색"machine learning"452초8,634,93971,294121:1
짧은 키워드 검색"AI"2,172초8,634,9392,014,6784.3:1
카테고리 검색cs.AI88초149,866149,8661:1
키워드 + 카테고리 검색"transformer" + cs.AI, cs.LG260초412,85041,6169.9:1

"AI" 검색에서는 200만 건이 넘는 문서가 매칭됐습니다. $regex가 단어 단위가 아니라 부분 문자열을 기준으로 검색하기 때문에 "gain", "wait"처럼 검색 의도와 무관한 문자열까지 결과에 포함됐기 때문입니다. 그 결과 "AI" 검색은 2,014,678건이 매칭됐고, 실행 시간도 2,172초(약 36분) 까지 늘어났습니다.

// 'AI'를 검색하는 경우

AI research // 매칭
Explainable AI //매칭
gain // 매칭
wait // 매칭

실행 계획을 확인해보니 검색 과정에서 읽는 인덱스 키 수도 매우 많았습니다. title, abstract, authors 각 필드에는 인덱스가 존재했지만, 검색 조건이 문자열의 어느 위치에서든 검색어를 찾는 형태라 인덱스를 사용하더라도 탐색 범위를 충분히 줄이지 못했습니다.

여기에 세 필드를 $or로 묶으면서 비용이 더 커졌습니다.

OR
├─ IXSCAN title
├─ IXSCAN abstract
└─ IXSCAN authors

MongoDB는 각 필드의 인덱스를 탐색한 뒤 OR 스테이지에서 결과를 합치고 중복 문서를 제거했습니다. 실제로 "machine learning" 검색에서는 keysExamined8,634,939개로, 당시 전체 문서 수인 약 288만 건의 세 배에 가까웠습니다.

부분 문자열 검색 특성상 불필요한 문서까지 대량으로 매칭됐고, 인덱스가 있어도 검색 범위를 충분히 좁히기 어려운 구조였습니다. 288만 건 규모에서는 일반적인 키워드 검색도 수 분이 걸렸기 때문에, $regex를 유지한 채 쿼리만 수정하는 방식으로는 해결하기 어렵다고 판단했습니다.

$text

먼저 $regex를 MongoDB의 full-text search인 $text로 바꿨습니다. $text는 Text Index를 기반으로 단어 단위로 검색하기 때문에 불필요한 부분 문자열 매칭을 줄이면서 검색 범위도 더 효율적으로 좁힐 수 있을 것이라고 예상했습니다.

제목, 초록, 저자의 중요도가 서로 다르기 때문에 text index에는 가중치를 설정했습니다.

db.papers.createIndex(
  {
    title: "text",
    abstract: "text",
    authors: "text"
  },
  {
    weights: {
      title: 10,
      abstract: 5,
      authors: 3
    },
    name: "papers_fulltext_search"
  }
)

검색 쿼리도 $text를 사용하도록 변경하고 textScore를 기준으로 결과를 정렬했습니다.

db.papers.find(
  {
    $text: {
      $search: "machine learning"
    }
  },
  {
    score: {
      $meta: "textScore"
    }
  }
).sort({
  score: {
    $meta: "textScore"
  }
})

변경 후 검색 시간은 크게 감소했습니다.

검색 유형변경 전$text 적용 후감소율
키워드 단일452.8초91.6초80%
카테고리 단일277.7초31.2초89%
키워드 + 카테고리259.9초112.3초57%

특히 "machine learning" 검색은 452.8초에서 91.6초로 약 80% 감소했습니다.

keysExamined도 8,634,939개에서 476,279개로 약 95% 감소했습니다.

수 분이 걸리던 검색을 1~2분대로 줄였지만 API 응답 시간으로 사용하기에는 여전히 길었습니다. 특히 키워드와 카테고리를 함께 검색할 때 112초가 필요했습니다.

MongoDB 내 최적화의 한계

$text와 text index를 적용하면서 검색 성능은 크게 개선됐지만, 자주 등장하는 검색어에서는 여전히 수십 초가 걸렸습니다.

예를 들어 "transformer"를 검색하면 15만 건 이상의 문서가 매칭됐고, 카테고리 조건을 적용한 뒤에도 3만 건 이상이 남았습니다.

클라이언트에 필요한 결과는 그중 textScore가 높은 10건뿐이었습니다. 하지만 상위 10건을 결정하려면 검색어에 매칭된 많은 문서의 점수를 평가하고 정렬해야 했습니다.

실제 "transformer" 단일 검색의 explain() 결과에서도 152,957건의 문서를 확인한 뒤 10건을 반환했고, 약 35초가 소요됐습니다.

nReturned          10
totalKeysExamined  152,957
totalDocsExamined  152,957
executionTime      약 35초

MongoDB의 text index는 $text 검색에는 사용할 수 있지만, textScore 기준의 상위 결과를 인덱스 순서만으로 바로 가져올 수는 없었습니다. 검색어에 매칭되는 문서가 많아질수록 관련도 점수를 계산하고 상위 결과를 선별하는 비용도 함께 증가했습니다.

결국 $regex$text로 변경하면서 수분 단위였던 검색을 수십 초까지 줄였지만, 실제 API 응답 시간으로 사용하기에는 여전히 길었습니다. MongoDB 쿼리를 계속 조정하는 대신 검색과 relevance 정렬을 전담할 별도의 검색 시스템으로 분리하기로 했습니다.

Elasticsearch 도입

MongoDB에서 여러 차례 검색 성능을 개선했지만, 자주 등장하는 검색어에서는 많은 문서를 평가하고 textScore로 정렬하는 문제가 남았습니다. 이를 MongoDB 쿼리만으로 더 줄이기보다는 검색을 전담하는 시스템을 분리하기로 했습니다.

MongoDB는 논문 원본 데이터를 저장하는 역할로 유지하고, 사용자의 검색 요청은 Elasticsearch에서 처리하도록 구조를 변경했습니다.

image.png

Elasticsearch에서는 제목, 초록, 저자를 검색 필드로 구성하고 필드별 중요도에 따라 가중치를 적용했습니다. 카테고리는 검색 점수에 영향을 주지 않는 조건이므로 full-text 검색과 분리해 filter로 처리했습니다.

전환 이후에는 MongoDB에서 약 35.4초가 걸리던 검색을 Elasticsearch에서 약 300ms까지 줄일 수 있었습니다.