안녕하세요. 인프런의 백엔드 개발자 인트입니다.

최근 인프런 사이트의 커뮤니티, 강의, 로드맵 검색 품질을 높이기 위한 검색엔진 프로젝트를 진행하였는데요.

이번 포스트에서는 해당 프로젝트를 하면서 겪은 정렬 관련 이슈와 해결 과정을 공유하고자 합니다.

우선 검색엔진으로 사용한 기술에 대해 간략한 소개를 하겠습니다.

Atlas Search는 MongoDB full-managed 클라우드 서비스인 MongoDB Atlas에서 제공하는 전문 검색 솔루션입니다.

AWS에서 제공하는 OpenSearch와 같이 Apache Lucene 검색 라이브러리를 탑재하고 있으며 실시간 모니터링 및 로그 분석을 지원합니다.

검색엔진으로 주로 사용되는 ElasticSearch보다 부족한 부분은 있지만 mongoDB의 NoSQL 기능을 함께 사용할 수 있다는 장점이 있습니다.

요구사항

저희는 검색엔진을 통해 다음과 같은 문제를 해결하고자 했습니다.

인프런 커뮤니티 페이지를 보면 아래 사진처럼 검색창이 존재하고 동시에 다음 항목을 선택할 수 있습니다.

  • 최신순
  • 정확도순 (새로 추가된 정렬)
  • 답변많은순
  • 좋아요순

community

기존에는 검색어를 입력하면 단순히 RDB의 like 연산을 사용하기에 성능이 좋지 못해 슬로우 쿼리가 자주 발생하였습니다.

특히 동의어 검색을 위해 동의어 목록 조회 -> 목록 개수만큼 like연산이 이루어지기에 쿼리를 개선하기 쉽지 않은 상황이었습니다.
따라서 이번 프로젝트의 목표는 기존에 제공하는 기능을 그대로 유지하면서 검색 품질과 성능을 향상하는 것이었습니다.

문제 상황

프로젝트를 진행하면서 새로 추가된 정확도순을 제외한 나머지 항목으로 정렬하면 성능에 문제가 발생하였습니다.

정확도순은 Atlas Search가 기본으로 검색어와의 유사도(relevance)를 기반으로 정렬해주기 때문에 저희가 따로 작업할 사항이 없었습니다.
하지만 최신순, 답변많은순, 좋아요순으로 정렬할 때에는 RDB의 ORDER BY처럼 특별한 구문을 넣어주어야 합니다.

Atlas Search는 검색을 위해 mongodb의 aggregation pipeline을 사용하기에 정렬은 $sort stage를 사용하면 될 것으로 생각했습니다.
그래서 다음과 같은 구문을 실행해 검색과 정렬을 수행하였습니다.

db.question.aggregate([
  {
    $search: {
      text: {
        query: "강의", // 검색어
        path: ["title", "body", "writer.name"] // 검색할 필드 목록
      }
    }
  },
  { $sort: { commentCount: -1 } }, // 댓글 개수로 정렬하기
  { $skip: 0 },
  { $limit: 10 },
])

slow query

해당 결과만 보면 2초가량 걸리지만, 검색어에 매칭되는 모든 항목의 개수를 추가로 구해서 응답으로 내려줘야 하므로 실제로는 4~5초가량 소요됩니다.

원인 파악

원인을 파악하기 위해 공식문서를 살펴보니 정렬과 관련된 성능 이슈에 대한 내용을 찾을 수 있었습니다.

바로 저희가 적용한 $search stage 다음에 바로 $sort를 사용하면 결과가 매우 느려진다는 것이었습니다.

sort performance issue

위와 같은 성능 이슈가 발생하는 원인을 알기 위해서 먼저 Atlas Search의 내부구조를 살펴볼 필요가 있습니다.

Atlas Search Architecture

atlas search architecture

Atlas Search 클러스터의 각 노드에는 mongotmongod라 불리는 두 개의 프로세스가 존재하며 다음과 같은 역할을 담당합니다.

  • mongot
    • Apache Lucene 기반의 자바 웹 프로세스
    • Search Index (Inverted Index)
    • $search stage를 수행
  • mongod
    • MongoDB 시스템의 기본 데몬 프로세스
    • Document, B-Tree Index
    • 일반적인 컬렉션을 조회하는 쿼리 작업을 수행

따라서 $search$sort를 사용한 명령어는 다음과 같은 순서로 작업이 진행됩니다.

  • mongot에서 검색어에 매칭되는 항목의 id와 메타데이터 반환
  • mongod가 받은 id를 통해 컬렉션의 데이터를 조회
  • 지정한 필드에 대해 정렬 수행

만약 mongot가 반환한 id의 개수가 많아지면 mongod가 이를 모두 조회해서 정렬을 하다 보니 성능에 문제가 발생할 수밖에 없는 구조였습니다.

결론적으로, 데이터를 필터링하는 프로세스와 정렬을 하는 프로세스가 달라 발생하는 문제라고 볼 수 있습니다.

db.question.aggregate([
  {
    $search: {
      text: {
        query: "강의",
        path: ["title", "body", "writer.name"]
      }
    }
  }, // 전문검색 - mongot 에서 처리
  { $sort: { commentCount: -1 } }, // 정렬 - mongod 에서 처리
])

해결 방안

공식문서에는 위와 같은 쿼리대신 대안으로 두 가지 방법을 제안하는데 각 내용에 대해 소개하고자 합니다.

storedSource와 returnStoredSource

앞서 mongot프로세스는 검색 인덱스를 관리하며 이는 역인덱스(Inverted Index) 구조로 되어 있습니다.
검색용 analyzer가 분석한 term과 이에 해당하는 Document ID만 가지고 있으며 그 외의 필드는 가지고 있지 않습니다.

Term Document ID
node 1, 2
java 4, 5
spring 2, 3, 5

하지만 storedSource옵션을 사용하면 검색 인덱스에 위 항목 외에 추가로 원하는 필드를 저장할 수 있게 해줍니다.

해당 기능을 사용하려면 검색 인덱스 설정 파일에 정렬하고자 하는 필드명을 넣어주어야 합니다.

{
  "storedSource": {
    "include": ["commentCount"] // 댓글 개수 필드를 인덱스에 저장
  }
}

이후 storedSource옵션을 통해 저장된 필드를 반환하는 returnStoredSource옵션을 검색 쿼리에 넣어서 요청합니다.

db.question.aggregate([
  {
    $search: {
      text: {
        query: "강의",
        path: ["title", "body", "writer.name"]
      },
      returnStoredSource: true
    }
  },
  { $sort: { commentCount: -1 } },
  { $skip: 0 },
  { $limit: 10 },
])

returnStoredSource옵션을 사용하면 mongot는 id외에 storedSource에 지정한 필드의 값도 같이 전달합니다.
따라서 mongod가 추가로 컬렉션 조회할 필요 없이 주어진 데이터만으로 정렬할 수 있게 됩니다.

이 방식은 필터, 정렬 등에 필요한 모든 칼럼이 인덱스에 들어있는 커버링 인덱스 기법과 유사하다고 할 수 있습니다.

만약 정렬필드 외에 추가로 필요한 필드가 있다면 $lookup stage를 활용할 수 있습니다.

db.question.aggregate([
  {
    $search: {
      text: {
        query: "강의",
        path: ["title", "body", "writer.name"]
      },
      returnStoredSource: true
    }
  },
  { $sort: { commentCount: -1 } },
  { $skip: 0 },
  { $limit: 10 },
  {
    $lookup: {
      from: "questions", localField: "_id", foreignField: "_id", as: "document"
    }
  }
])

storedSource옵션은 인덱스의 크기가 기존보다 커지게 되므로 주의가 필요합니다.

공식문서에도 인덱싱과 쿼리 성능에 영향을 주기 때문에 최소한으로 가져가는 것을 권장합니다.

near operator

이번에는 정렬하고자 하는 필드의 타입이 number, date, geometry인 경우 사용할 수 있는 near operator에 대해 설명하고자 합니다.

이 방식은 이전 storedSource와 다르게 정렬을 수행하는 프로세스가 mongot가 되어 필터링과 정렬을 하나의 프로세스에서 관리할 수 있게합니다.

monogot프로세스는 기본적으로 검색 항목에 대해 score를 계산하며 내림차순으로 정렬된 결과를 내려줍니다.

$search aggretaion에서 사용할 수 있는 여러 operator들은 각자 score계산 공식을 가지고 있으며 각각의 총합이 최종점수가 됩니다.

앞서 보았던 text operator가 기본적인 전문 검색에 대한 유사도 점수를 계산합니다.

near operator가 그중 하나로 다음과 같은 score 공식을 가지고 있습니다.

near formula

여기서 각 항목의 의미는 다음과 같습니다.

  • pivot : 임의의 숫자
  • distance : 특정 필드와 origin 사이의 거리 (|origin - value|)
  • origin : 기준값

계산식을 잘 보면 분자와 분모에 모두 pivot이 존재하고 distance는 0 이상의 수로 score는 0보다 크고 1보다 같거나 작은 값을 가집니다.
즉 필드의 값이 origin과 같은 경우 1점이 되며 거리가 커질수록 0에 가까워지게 됩니다.

near operator는 이름 그대로 필드의 값이 특정한 기준값(origin)에 가까울수록 score가 높아진다는 것을 알 수 있습니다.

이제 사용자가 검색어를 입력하고 댓글 많은 순으로 정렬하는 경우를 생각해봅시다.

먼저 댓글 개수에 대한 필드를 검색 인덱스에 number타입으로 선언해야 합니다.

{
  "mappings": {
    "dynamic": false,
    "fields": {
      "commentCount": {
        "representation": "int64",
        "type": "number"
      }
    }
  }
}

검색어에 대한 필터링은 text를 이용하고 정렬을 위해 near를 사용하는데 만약 text로 계산되는 점수가 최종 점수에 반영하면 정렬에 문제가 발생합니다.
이에 따라 원하는 operator만 점수에 반영하기 위해 compound operator를 사용합니다.

db.question.aggregate([
  {
    $search: {
      compound: {
        // filter 내부에 있는 operator는 점수에 반영하지 않는다.
        filter: [{ text: { query: "강의", path: ["title", "body", "writer.name"] }}],
        // must, should 내부에 있는 operator는 점수에 반영된다.
        must: [
          // 댓글 개수 컬럼의 값이 100000에 가까울 수록 점수가 높아진다.
          { near: { origin: 100000, path: "commentCount", pivot: 1 }}
        ]
      }
    }
  },
  // mongot가 이미 정렬된 목록을 제공하기에 $sort stage가 필요없다.
  { $skip: 0 },
  { $limit: 10 },
])

여기서 알 수 있는 점은 정렬 값에 해당하는 필드 값이 origin보다 크지 않아야 한다는 점입니다. (위에 예제에서는 100000)
만약 기준값을 초과하게되는 필드 값이 있는 경우에는 distance가 커져서 큰 값이 작은 값보다 나중에 나오는 현상이 발생합니다.

반대로 origin값을 너무 큰 값으로 설정한다면 score의 자료형 범위에 값을 담을 수 없을 정도로 작아져 점수가 제대로 계산되지 않는 문제가 발생합니다.
위 문제는 pivot값도 같이 비례하게 큰 값으로 설정하여 해결할 수 있습니다.

near operator를 사용하면 2차 정렬을 구현하기 어렵다는 단점이 있습니다.
예를 들면 댓글 많은 순으로 정렬 시 같은 개수를 가진 댓글끼리는 최신순으로 정렬하는 상황을 말합니다.

이를 해결하기 위해 다음에 소개할 score function을 같이 활용하게 됩니다.

score function

score function은 $search의 operator들이 가진 score계산 결과를 가공하는 역할을 합니다.

function옵션을 통해 add, multiply와 같은 사칙연산이나 path, gaussian와 같은 표현 식으로 가공된 최종 점수를 설정합니다.

이번 포스트에서는 모든 함수에 대한 설명보다 정렬에 사용할 path를 살펴보겠습니다.

모든 함수에 대한 설명은 공식문서를 참고해주세요.

path함수는 숫자 타입을 가진 필드의 값을 score로 만들어주는 역할을 합니다.
예를 들어 댓글 개수 칼럼이 값이 3인 문서의 score는 3점이 되는 방식입니다.
이 방식은 숫자 타입만 가능하다는 단점이 있지만 가장 간단합니다.

db.question.aggregate([
  {
    $search: {
      text: {
        query: "강의",
        path: ["title", "body", "writer.name"],
        score: {
          function: {
            path: {
              // 댓글 개수를 score로 설정한다.
              value: "commentCount",
              // 만약 댓글 개수 필드의 값이 없으면 0으로 설정한다.
              undefined: 0,
            }
          }
        }
      }
    }
  },
  { $skip: 0 },
  { $limit: 10 },
])

문제 해결

앞서 언급한 해결방안들은 각각 장단점이 존재하기에 상황에 따라 적절한 방법을 선택해야 했습니다.

우선 각 방법의 조회 속도를 측정해보았을 때는 storedSource가 가장 좋았습니다.

near와 score function은 검색어에 매칭되는 모든 항목에 대해 점수를 계산하고 정렬을 수행하지만, storedSource는 점수계산 과정이 없고 인덱스에 존재하는 항목으로 정렬만 수행하기 때문이라고 생각합니다.

하지만 저희는 현재 nearscore function을 사용하고 있습니다.
그 이유는 다음에 이야기할 고급정렬이 필요했기 때문입니다.

고급 정렬

score function을 활용하면 RDB의 ORDER BY보다 더 다양한 조건을 만족하는 정렬이 가능합니다.

  • 특정 조건을 만족하면 가중치를 주기
  • 특정 조건을 만족하면 그렇지 않은 문서보다 상단에 위치하기

주로 가중치 값을 조절해서 추천 항목이 상단에 노출되도록 하는 데 많이 사용합니다.

저희가 적용한 작업을 예로 들어 설명하겠습니다.

작업 배경

강의실 안에 질문답변 게시글 목록 조회 시 다음과 같이 강의 영상에 대한 현재 수업(unit) 질문에 대한 게시글을 우선 노출하는 요구사항이 있었습니다.
또한 같은 수업 질문이나 전체질문끼리는 최신순으로 정렬해야 하는 상황이었습니다.

강의실 게시글 정렬
현재 수업질문에 대한 게시글이 상위 노출되는 정렬

구현 로직

먼저 2차 정렬에 해당하는 최신순 정렬은 near를 사용하였습니다.
그리고 수업 질문인 경우에는 constant함수를 사용해서 10점을 받을 수 있게 하였습니다.
near의 점수 최댓값이 1이기 때문에 전체질문은 항상 10보다 작게 되므로 수업 질문이 항상 상단으로 이동합니다.

따라서 수업 질문이면 10점이 아닌 2점만 주어도 정상적으로 동작합니다.

db.question.aggregate([
  {
    $search: {
      compound: {
        filter: [{ text: { query: "강의", path: ["title", "body", "writer.name"] }}],
        // unitId가 123이면 10점을 준다.
        should: [{ range: { gte: 123, lte: 123, path: "unitId", score: { constant: { value: 10 } } }}],
        // 최신 순으로 2차정렬하기 위해 사용한다.
        must: [{ near: { origin: 10000000, path: "_id", pivot: 10000000 }}]
      }
    }
  },
  { $skip: 0 },
  { $limit: 10 },
])

이번에는 compound에 should를 사용한 것을 확인할 수 있습니다.

should에 들어가는 operator는 조건에 매칭되지 않아도 결과에 포함될 수 있습니다. (필터링 기능이 없음)
대신 조건에 매칭된다면 score를 계산해 총합에 반영하고 싶을 때 사용합니다.

compound에서 사용할 수 있는 3개의 operator의 동작을 정리하면 다음과 같습니다.

필터링 점수 반영 여부
must O O
should X O
filter O X

마무리

지금까지 MongoDB Atlas Search를 통해 문제 해결 과정 및 방안을 살펴보았습니다.
score를 활용해 정렬한다는 개념이 처음에는 낯설었지만 익숙해지니 다양한 상황을 해결할 수 있는 도구라는 생각이 들었습니다.

이 글이 저희와 같이 MongoDB Atlas를 도입하려는 누군가에게 도움이 되기를 바랍니다.
마지막으로 긴 글 읽어주셔서 감사합니다.