MongoDB 및 Mongoose 환경에서 조회 속도가 느려지는 원인인 In-Memory Sort(메모리 정렬) 해결법과 .select(), populate 프로젝션을 통한 쿼리 성능 최적화 실무 사례를 소개합니다.
다른 페이지보다 5배 느린 조회 서비스
서비스를 운영하다 보면 유독 특정 페이지의 응답 속도가 눈에 띄게 떨어지는 순간이 있습니다. 최근 프로젝트에서 다른 도메인(Product, Brand) 목록에 비해 Journal 목록 및 상세 조회 속도가 최대 5배나 느린 현상이 발생했습니다.
단순히 "조회 요청이 많아서"라고 넘기기엔 단 한 건의 조회 비용 자체가 너무 무거웠던 상태였습니다. 무엇이 문제였고, 이를 어떻게 몇 줄의 코드로 해결했는지 MongoDB와 Mongoose 환경에서의 성능 개선 기록을 공유합니다.
증상 파악, 모니터링 툴이 보내온 위험 신호
문제를 인지하고 데이터베이스 모니터링을 확인했을 때, 명확한 이상 징후가 발견되었습니다.
-
Journal 목록/그리드 페이지의 API 응답 시간이 타 페이지 대비 비정상적으로 긺.
-
쿼리당 검사 문서 수 비율이 리턴되는 문서 수에 비해 월등히 높음. 즉, 10개의 데이터만 화면에 보여주는데도 데이터베이스 내부에서는 수백~수천 건의 문서를 일일이 헤집고 있었던 것입니다.
원인 분석, 성능을 갉아먹은 2가지 주범
병목의 원인을 심층 분석한 결과, 아래의 두 가지 악재가 곱해져 최악의 시너지를 내고 있었습니다.
① 인덱스 없는 필드로 정렬 → 메모리 정렬의 늪
모든 저널 목록 쿼리는 최신순 출력을 위해 .sort({ date: -1 })를 수행하고 있었습니다. 하지만 스키마 구조를 보니 date 필드는 String 타입인 데다 인덱스조차 없었습니다.
// src/lib/models/Journal.ts — 기존 인덱스 상황
JournalSchema.index({ "translations.ko.slug": 1 }, { unique: true, sparse: true });
JournalSchema.index({ "translations.en.slug": 1 }, { unique: true, sparse: true });
JournalSchema.index({ "translations.jp.slug": 1 }, { unique: true, sparse: true });
// ❌ date, category 관련 인덱스가 전혀 없음!
MongoDB는 인덱스가 없는 필드로 정렬 요청을 받으면 조건에 맞는 문서를 전부 메모리로 읽어 들여 정렬(In-Memory Sort)을 시도합니다. 이 때문에 examined 수가 폭증했던 것입니다. 반면, 속도가 빨랐던 Product 목록은 기본 인덱스인 _id를 활용해 정렬(.sort({ _id: -1 }))했기에 비용이 거의 제로에 가까웠습니다.
② .select() 없이 무거운 전체 본문(HTML) 페이로드 전송
목록 화면에서는 긴 본문 전체가 필요 없습니다. 160자 정도의 요약본(Excerpt)만 있으면 충분합니다. 하지만 기존 코드는 데이터의 크기를 고려하지 않고 전체를 긁어오고 있었습니다.
// 기존 코드: 전체 데이터를 다 가져온 뒤, 메모리에서 160자로 자르고 버림
const journals = await Journal.find(query).sort({ date: -1, _id: -1 }).limit(limit + 1).lean();
// ... 내부 로직
excerpt: buildExcerptFromHtml(translation?.content || "") // 이미 데이터베이스에서 무거운 HTML을 다 전송받은 상태
.lean()을 써서 JSON 객체로 가볍게 만들긴 했지만, .select()를 통한 프로젝션(Projection)이 누락되어 다국어(ko/en/jp)로 작성된 에디터(TinyMCE)의 거대한 HTML 본문 데이터가 네트워크 페이로드에 고스란히 실려 오고 있었습니다. 행마다 데이터 무게 자체가 무거우니 당연히 병목이 생길 수밖에 없었습니다.
해결 방안, 코드 최적화 적용하기
원인을 알았으니 해결은 명확합니다. 인덱스를 심어 탐색 비용을 줄이고, 프로젝션을 걸어 데이터 전송량을 최소화하는 것입니다.
A. 인덱스 추가 (정렬 및 필터 최적화)
가장 먼저 메모리 정렬을 없애기 위해 정렬 기준이 되는 복합 인덱스를 스키마에 추가했습니다. 카테고리 필터링과 집계 쿼리까지 고려하여 다각도로 인덱스를 설계했습니다.
// src/lib/models/Journal.ts
// 1. 목록/그리드의 최신순 date 정렬용 -> 메모리 정렬 제거
JournalSchema.index({ date: -1, _id: -1 });
// 2. 카테고리 필터링 + 목록 정렬 및 집계(getDistinctCategories) 최적화용
JournalSchema.index({ category: 1, date: -1, _id: -1 });
B. 목록 쿼리에 .select() 도입 (네트워크 페이로드 다이어트)
불필요한 타 언어 본문과 거대한 HTML 텍스트를 제거하고, 현재 사용자가 선택한 활성 locale 정보와 꼭 필요한 메타 데이터만 프로젝션하도록 수정했습니다. 본문이 아예 필요 없는 곳은 과감히 제외했습니다.
// 예시: src/app/(main)/[locale]/journal/actions.ts
// 전체 본문을 가져오던 것에서 현재 언어셋과 기본 정보만 선택적으로 push
const journals = await Journal.find(query)
.sort({ date: -1, _id: -1 })
.select("category image author date viewCount translations.<locale>") // 활성 locale만 정교하게 타겟팅!
.limit(limit + 1)
.lean();
이 조치 하나만으로도 다국어 본문 전송량이 기존 대비 약 3분의 1 이하로 줄어들었습니다.
C. 상세 페이지의 populate 필드 제한
상세 페이지 내부의 연관 상품(relatedProducts)을 불러오는 populate 구문도 기존에는 대용량 필드(description, howToUse 등)를 전부 가져오고 있었습니다. 이 역시 화면에 렌더링할 필드만 가져오도록 문자열 프로젝션을 추가했습니다.
// src/app/(main)/[locale]/journal/[slug]/page.tsx
const journal = await Journal.findOne(query)
.populate(
"relatedProducts",
`images translations.${locale}.name translations.${locale}.slug`, // 노출할 이미지와 이름, 슬러그만 제한
)
.lean();
결과 및 검증
코드 수정 후, 빌드 및 정적 타입 체크와 린트 과정을 거쳐 안정성을 검증했습니다.
-
테스트 통과 (타입 에러 없음)
-
npx eslint수정 파일 검사 통과
개선 후 결과는 정렬 인덱스가 잡히면서 examined docs 수가 리턴 문서 수 수준으로 대폭 락하했고, 페이로드 다이어트 덕분에 네트워크 전송 속도가 극적으로 개선되었습니다. 타 도메인 대비 5배나 느렸던 API 응답 속도가 정상 궤도(기타 도메인과 동등한 수준)로 회복되었습니다.
후기, 성능 최적화를 위해 기억할 핵심 요약
MongoDB 성능 최적화는 결국 "데이터베이스의 스캔량을 줄이고, 네트워크로 주고받는 가방의 무게를 줄이는 것"이 본질입니다.
-
정렬(
.sort())을 쓸 때는 반드시 해당 필드가 인덱스에 포함되어 있는지 확인하세요. 그렇지 않으면 무시무시한 In-Memory Sort 늪에 빠집니다. -
목록성 쿼리에는 필수적으로
.select()를 선언하세요. 유저에게 보여주지 않을 거대한 본문 데이터까지 백엔드 내부로 들고 오는 것은 엄청난 자원 낭비입니다. -
populate역시 필요한 필드만 지정해서 가져오세요. 관계형 데이터의 JOIN과 같으므로 쿼리 무게가 배로 늘어날 수 있습니다.
지금 운영 중인 서비스가 이유 없이 느리다면, 당장 쿼리의 인덱스 상태와 페이로드 크기부터 점검해 보시길 권장합니다.
자주 묻는 질문 (FAQ)
-
Q1. 정렬 필드에 인덱스가 없으면 왜 문제가 되나요?
-
A1. MongoDB는 인덱스가 없는 필드로 정렬할 때 검색된 모든 데이터를 메모리에 올린 후 정렬(In-Memory Sort)합니다. 이때 데이터가 많으면 대기 시간이 급증하고, 메모리 제한(32MB)을 초과하면 쿼리 에러가 발생합니다.
-
-
Q2.
.lean()을 이미 쓰고 있는데도.select()가 꼭 필요한가요?-
A2. 네, 필요합니다.
.lean()은 순수 자바스크립트 객체로 변환해 메모리 오버헤드를 줄여주지만, DB에서 네트워크를 타고 넘어오는 데이터 자체의 크기(페이로드)를 줄여주지는 못합니다. 페이로드를 줄이려면 반드시.select()로 필요한 필드만 명시해야 합니다.
-
-
Q3. 복합 인덱스를 설정할 때 필드의 순서가 중요한가요?
-
A3. 매우 중요합니다. 대개
동등 조건 필드(Equality) -> 정렬 필드(Sort) -> 범위 조건 필드(Range)순서로 인덱스를 생성해야 MongoDB가 효율적으로 인덱스를 탑니다. 이번 개선에서는 필터(category)와 정렬(date, _id) 순서에 맞춰 구성했습니다.
-