Mongo Atlas Search 성능 개선하기
Mongo Atlas Search 성능 개선 경험을 바탕으로, 쿼리 플랜 분석, 쿼리 구조 변경, storedSource 적용 과정을 상세히 설명드립니다.
Contents
이슈의 배경 설명을 스킵하고 본론부터 읽고싶으신 분들은 성능 개선파트를 바로 읽어주시면 감사하겠습니다.
들어가며
회사에서 운영하고 있는 서비스에 질문/답변 게시판 역할을 하고 있는 게시판이 있는데, 어느날부터 Slow API 경보가 자주 발생하였습니다. 해당 API는 최근에 자주 변경된 적이 없어서 왜 그런 일이 발생하는지 추적하기 시작했습니다.
AI의 크롤링
해당 API를 모니터링하며, 문제가 되는 API가 평소보다 자주 호출되고 있다는 사실을 알게 되었습니다. 처음에는 누군가가 크롤링 스크립트를 작성하여 많은 요청을 보내고 있는 것으로 추측했습니다. AWS Athena를 이용하여, 어떤 IP가 위의 API를 자주 호출하고 있는 것인지 조회하였습니다. IP별로 API호출량을 집계하여 조회해 보니, 특정 IP대역대가 대부분의 요청을 차지하는 것을 발견하였습니다.

아웃라이어 IP 검출
해당 IP의 정체를 밝히기 위해서, CloudFront에서 위의 IP에 대한 요청 정보를 더 쿼리해 보았습니다. 해당 IP의 정체는 쉽지 않게 밝혀낼 수 있었는데, User-Agent 헤더에 해당 클라이언트의 정체가 적혀있었기 때문입니다.
Mozilla/5.0 AppleWebKit/537.36 (KHTML, like Gecko; compatible; GPTBot/1.2; +https://openai.com/gptbot)
OpenAI의 GPTBot이 서비스를 크롤링하는 것은 사실 서비스 입장에서는 긍정적인 신호라고 생각합니다. SEO만큼 AEO의 중요도가 높아지는 시점에, 긍정적인 신호라는 생각이 들었습니다.
성능 개선
쿼리플랜 분석
이제 누가 많은 요청을 보내는지는 파악했으니, 다시 Slow API 해결에 집중해야 합니다. AI가 요청을 많이 보내긴 했지만, 사실 API 서버가 버벅거릴 정도의 요청량은 아니었습니다. 그렇다면 왜 Slow API가 발생했을까요?
현재 해당 서비스의 검색으로는 Mongo Atlas Search를 이용하고 있습니다. 해당 API에서는 Pagination 기능이 포함되어 있었고, Atlas Search 의 $skip, $limit연산자가 사용되고 있었습니다. 위의 Mongo Aggregation에 대해 쿼리 플랜을 조회해 보니, 다음과 같은 부분이 눈에 띄었습니다.
{
"$_internalSearchIdLookup": {
"limit": 420
}
}
internalSearchIdLookup은 mongot에서 검색된 결과의 id를 바탕으로, mongod에서 실제 도큐먼트를 조회하는 과정에서 필요합니다. 만약 pageSize를 20으로 설정하고 21번째 페이지를 조회한다면, Mongo Atlas Search는 internalSearchIdLookup을 420으로 시도하게 됩니다. 이 단계만 제거해도 API의 성능이 향상될 것이라고 생각했습니다.

Mongo Atlas의 구조
쿼리 구조 변경
internalSearchIdLookup을 제거하기 위해, Mongo Aggregation 쿼리를 변경하기로 결정하였습니다. 기존에는 MongoDB에서 _id필드 외에도 검색에 필요한 필드들을 모두 반환하고 있었는데, 검색엔진에서는 _id필드만 반환해 주기로 결정하였습니다. 즉, 검색 프로세스를 정리하면 다음과 같습니다.
1. 검색엔진(Mongo Atlas Search)에서 검색 쿼리 실행
2. 검색 후 _id필드만 Projection
3. 검색엔진에서 반환받은 _id값들을 바탕으로 RDB에서 구체적인 정보를 조회 후 반환
위와 같이 조치한다면, ID값만 조회하게 되므로 internalSearchIdLookup단계는 필요없어질 것이라 판단했던 것이죠. 하지만 _id필드만 조회하도록 쿼리를 변경해도, 쿼리플랜에서 $_internalSearchIdLookup은 사라지지 않았습니다. MongoDB Aggregation Pipeline에서는 BSON형식의 도큐먼트가 필요한데, mongot의 반환값의 ID값은 BSON형식이 아니었기 때문입니다. 즉, mongot는 _id값을 BSON이 아닌 형태로 반환해 주었기 때문에, 후속 Aggregation Pipeline을 위해 BSON 형태로 다시 도큐먼트에서 조회할 필요가 있었던 것이죠.
storedSource 적용
이 문제는 단순한 쿼리 수정으로 해결되지 않는다는 사실을 알게 되었습니다. 더 자료조사를 하는 과정에서, Atlas Search의 storedSource기능에 대해 알게 되었습니다. storedSource는 mongot 데이터 자체에 특정 필드 값을 인덱스와 함께 저장하여, mongot단계에서 바로 데이터를 반환할 수 있게끔 도와줍니다. 공식 문서에도 적혀있듯이, Mongo Atlas Search 인덱스에 다음과 같은 인덱스 정의가 필요합니다.
{
"storedSource": true | false | {
"include" | "exclude": [
"<field-name>",
...
]
}
}
인덱스를 설정한 후에는, Mongo Aggregation에서 returnStoredSource옵션을 추가하여 storedSource를 사용하게끔 설정할 수 있습니다. (관련 문서)
위의 조치를 한 후, 다시 쿼리플랜을 실행해 보면 다음과 같이 쿼리 플랜이 변경된 것을 발견할 수 있습니다.
{
"$replaceRoot": {
"newRoot": {
"$ifNull": [
"$storedSource",
"$$ROOT"
]
}
}
}
기존에는 mongot의 검색된 id값들을 바탕으로 도큐먼트에서 검색했다면, 이제는 검색된 인덱스의 storedSource를 사용하게 된 것입니다.
정리하며
Mongo Atlas Search를 사용하여 Pagination 검색 쿼리를 작성할 때, mongot의 반환값을 mongod에서 한 번 더 lookup하면서 발생하는 성능 이슈가 있었습니다. 이를 해결하기 위해 도큐먼트의 Projection 필드를 최소화하고, storedSource 기능을 활용하였습니다.
참고 문서
- Mongo Atlas Search
- Return Stored Source
- Stored Source Definition
- Inflab Tech
- MongoDB Atlas Search 정렬이슈 해결기
이것도 읽어보세요