Next.js와 MongoDB 환경에서 목록 페이지 조회 성능을 극대화하는 방법을 소개합니다. Mongoose의 .select() 최적화와 Excerpt 필드 도입을 통해 무거운 본문 데이터 파싱 부하와 DB 네트워크 비용을 드라마틱하게 절감한 실무 엔지니어링 경험을 공유합니다.
웹 서비스를 운영하면서 가장 자주 마주하는 화면 중 하나는 바로 포스트, 제품, 혹은 성분 리스트가 나열되는 '목록 페이지'입니다. 목록 페이지는 카드 형태의 UI 안에 제목, 대표 이미지, 그리고 본문의 핵심을 요약한 '발췌 한두 줄'을 보여주는 것이 일반적입니다.
최근 서비스 규모가 커짐에 따라 이 목록 페이지의 조회 속도와 DB 부하를 점검하던 중, 사소하지만 서버 비용과 성능에 치명적인 영향을 주던 레거시 구조를 발견하여 이를 최적화한 과정을 공유하고자 합니다.
기존 구조의 문제점, 요청마다 반복되는 본문 조회와 HTML 파싱
기존 시스템에서는 목록 페이지를 그릴 때, 각 글의 한두 줄 요약본(Excerpt)을 만들기 위해 DB에서 본문 전체(content)를 통째로 읽어오고 있었습니다.
// 변경 전 — 목록 쿼리가 본문(content)을 통째로 가져와 매 요청마다 발췌 생성
const journals = await Journal.find(query)
.select(`... translations.${locale}`) // ← 본문 content가 포함되어 있음
...
excerpt: buildExcerptFromHtml(translation?.content || ""); // 요청 시마다 HTML 파싱 및 절단
이 방식은 크게 두 가지 치명적인 낭비를 발생시키고 있었습니다.
-
불필요한 대용량 데이터 전송: 화면에는 단 한 줄의 요약만 보여주면 되는데, TinyMCE 에디터가 생성한 수십 KB 크기의 서식 마크업과 표, 텍스트가 담긴 무거운 HTML 전체를 DB에서 애플리케이션 서버로 매번 실어 날랐습니다.
-
CPU 연산 낭비: 사용자가 목록을 열 때마다 서버는
buildExcerptFromHtml()함수를 실행하여 거대한 HTML 문자열을 파싱하고 태그를 제거하는 연산을 반복했습니다.
어차피 본문의 앞부분은 글이 수정되지 않는 한 변하지 않는데, 저장 시점에 한 번만 계산해 두면 될 일을 목록을 조회할 때마다 수십, 수백 번씩 반복하고 있었던 것입니다.
excerpt 필드 도입과 Mongoose .select() 최적화
이 문제를 해결하기 위해 데이터베이스 스키마와 쿼리 방식을 완전히 전환했습니다. 각 번역 데이터(translation) 구조에 요약본을 미리 저장해 두는 excerpt 필드를 새로 추가했습니다.
그리고 목록을 조회하는 쿼리에서는 .select() 메서드를 사용해 무거운 본문(content)을 철저히 제외하고, 미리 생성된 excerpt 필드만 쏙 골라서 읽어오도록 변경했습니다.
// 변경 후 — 본문은 빼고 미리 만들어 둔 excerpt만 select
const journals = await Journal.find(query)
.select(`... ${localePath}.title ${localePath}.slug ${localePath}.excerpt`) // content 제외!
...
excerpt: translation?.excerpt || ""; // 저장된 값 그대로 즉시 반환
이렇게 쿼리를 한 줄 고치는 것만으로도 시스템 전체에 다음과 같은 비용 절감 메커니즘이 작동합니다.
-
DB 읽기 I/O 및 네트워크 전송량 급감: MongoDB Atlas에서 앱 서버로 오가는 문서의 페이로드 크기가 드라마틱하게 줄어듭니다.
-
직렬화/역직렬화 비용 최소화: 큰 문자열을 객체로 파싱하고, 이를 다시 Next.js의 React Server Component(RSC)에서 클라이언트로 보내기 위해 JSON으로 직렬화하는 CPU 부하가 사라집니다.
-
서버 가벼워짐: 요청당 무겁게 실행되던 HTML 파싱 로직이 완전히 제거되었습니다.
편집자를 위한 맞춤형 SEO 최적화 기능까지
단순한 성능 최적화로 시작한 작업이었지만, 비즈니스적으로도 큰 부수 효과를 얻었습니다. 발췌 데이터를 별도 필드로 분리하면서, 에디터 화면에 편집자가 검색 결과나 목록에 노출될 요약 문구를 직접 작성할 수 있는 입력란을 제공할 수 있게 되었습니다.
[발췌 생성 규칙]
- 입력란을 비워둘 경우: 저장 시 본문 앞부분에서 자동으로 텍스트를 추출하여 생성 (excerptManual=false)
- 직접 입력할 경우: 본문을 수정하더라도 편집자가 수동으로 작성한 고유의 요약본을 그대로 보존 (excerptManual=true)
이 덕분에 검색엔진(SEO)에 더 매력적으로 노출될 수 있는 메타 스니펫 작성이 수월해졌습니다.
적용 범위 및 앞으로의 측정
이 최적화 패치는 서비스 내 주요 목록을 사용하는 세 가지 도메인에 전부 완료되었습니다.
-
Journal: 메인 홈의 벤토 그리드 및 목록에서 본문 제외 완료
-
Product: 제품 카드가 더 이상 본문 스펙을 읽지 않도록
description,howToUse,fullIngredients제외 완료 -
Ingredient: 목록 상세 모달에서 전체 설명 대신 요약본(
excerpt)을 보여주고, 상세 내용은 단독 페이지로 위임하도록 변경 완료
앞으로 AWS 및 MongoDB Atlas의 쿼리 분석기(executionStats)를 통해 데이터 전송 바이트 변화량을 실측하고, 페르포망스 데이터를 지속적으로 트래킹하여 수치적인 성과도 데이터로 증명해 나가겠습니다.
작은 .select() 한 줄의 차이가 거대한 트래픽 앞에서는 엄청난 인프라 비용 절감으로 돌아온다는 점을 다시 한번 깨달은 프로젝트였습니다. Next.js와 MongoDB를 사용하는 다른 개발자분들에게도 좋은 힌트가 되었기를 바랍니다.
Q1. 본문을 제외하면 상세 페이지로 이동할 때 다시 불러와야 하므로 오히려 요청이 2번 발생해 손해 아닌가요?
A. 아닙니다. 목록 페이지에서는 다량의 카드(예: 20~30개)를 한 번에 보여주기 때문에 데이터 용량을 최소화하는 것이 절대적으로 유리합니다. 사용자가 그중 하나의 상세 페이지로 진입할 때만 단 하나의 본문 문서를 쿼리해 오므로, 전체적인 네트워크 용량과 DB 읽기 부하는 비교할 수 없을 정도로 절감됩니다.
Q2. Mongoose에서 본문을 제외하는 .select() 문법은 어떻게 사용하나요?
A. 쿼리 뒤에 .select('-content')와 같이 마이너스(-) 기호를 붙여 특정 필드를 제외하거나, 반대로 .select('title slug excerpt')와 같이 필요한 필드만 화이트리스트 형태로 명시해 주면 됩니다. 본 프로젝트에서는 필요한 필드만 선언하여 가독성과 안전성을 높였습니다.