최근 MongoDB에서 특정 컬렉션의 응답 시간이 간헐적으로 튀는 현상을 확인했습니다.
평균 지연 시간만 보면 아주 심각한 수준은 아니었습니다. 대부분의 요청은 빠르게 처리되고 있었지만, 일부 요청에서 평균보다 훨씬 높은 지연 시간이 발생하고 있었습니다.
처음에는 단순히 “인덱스가 부족해서 느린 것 아닌가?”라고 생각했습니다. 하지만 실제 코드를 다시 확인하면서 병목 후보를 다시 분류했고, 처음 세웠던 가설 중 일부는 수정하게 되었습니다.
이번 글은 MongoDB 지표를 바탕으로 성능 문제를 분석하고, 실제로 적용한 개선 작업과 보류한 작업을 정리한 후기입니다.
관측된 문제
MongoDB Atlas에서 확인한 핵심 신호는 다음과 같았습니다.
평균 지연 시간은 크게 높지 않음
하지만 상위 일부 요청의 지연 시간이 평균보다 크게 높음
전체 트래픽은 낮은 편
이 상황에서 중요한 점은 “전체 요청이 느린 것”이 아니라는 점입니다.
대부분의 요청은 정상적으로 빠르게 처리되고 있었습니다. 하지만 일부 요청에서만 지연 시간이 크게 튀었고, 이로 인해 응답 시간이 나빠지는 구조였습니다.
대부분의 요청
→ 인덱스를 사용하는 단순 조회 또는 작은 범위의 find 쿼리
느린 일부 요청
→ 검색 쿼리, 무거운 fallback 경로, 서버리스 콜드스타트 연결 비용 등이 섞인 요청
트래픽이 높은 상황이라면 먼저 부하를 의심했을 것입니다. 하지만 이번 경우는 트래픽이 낮은 상태에서도 지연이 튀고 있었습니다.
그래서 단순한 부하 문제라기보다, 일부 요청 경로에서 발생하는 무거운 연산과 콜드스타트가 꼬리 지연을 만들고 있다고 판단했습니다.
초기 가설, 인덱스가 부족한 것 아닐까?
가장 먼저 의심한 부분은 인덱스였습니다.
문제가 된 컬렉션은 콘텐츠성 데이터를 담고 있는 컬렉션이었습니다. 예를 들어 쇼핑몰이라면 브랜드, 카테고리, 상품 메타데이터처럼 여러 페이지에서 반복적으로 참조되는 성격의 데이터입니다.
해당 컬렉션에는 기본적인 고유 식별자 기반 인덱스만 존재했습니다.
{ slug: 1 } // unique index
반면 검색에 사용되는 다국어 이름 필드나 키워드 필드에는 별도의 B-tree 인덱스가 없었습니다.
처음에는 이 구조 때문에 검색 요청이 전체 컬렉션 스캔으로 떨어지고, 그 결과 지연 시간이 튀는 것이라고 생각했습니다.
하지만 코드를 다시 확인하면서 이 판단은 일부 수정이 필요했습니다.
현재 쿼리 구조는 대략 다음과 같았습니다.
1. 필터 없는 전체 조회
2. slug 기반 단건 조회
3. Atlas Search를 사용하는 검색
4. Atlas Search 실패 시 실행되는 regex fallback
5. 관리자 페이지에서 사용하는 중복 검사
이 중 slug 기반 조회는 이미 unique 인덱스를 사용하고 있었습니다.
그리고 find({})처럼 필터가 없는 조회는 새 인덱스를 추가해도 직접적인 효과를 보기 어렵습니다. 인덱스는 조건이나 정렬을 최적화할 때 의미가 있는데, 필터 없이 전체 데이터를 가져오는 구조라면 인덱스 추가만으로 해결되지 않습니다.
또한 대소문자 무시 regex 검색은 일반적인 B-tree 인덱스를 효과적으로 사용하기 어렵습니다.
결론적으로 “인덱스가 부족하니 인덱스를 추가하자”는 접근은 너무 단순했습니다. 현재 구조에서는 인덱스 추가보다 실제로 어떤 쿼리가 느린 요청을 만드는지 확인하는 것이 먼저였습니다.
지연 시간을 만들 수 있는 주요 후보
코드를 기준으로 지연 시간을 만들 수 있는 후보는 크게 세 가지였습니다.
검색 fallback의 regex 쿼리
검색 기능에는 Atlas Search가 실패했을 때를 대비한 fallback 쿼리가 있었습니다.
구조는 대략 다음과 같았습니다.
ContentModel.find({
$or: [
{ slug: regex },
{ "translations.ko.name": regex },
{ "translations.en.name": regex },
{ "translations.ja.name": regex },
],
})
.sort({ _id: -1 })
.limit(limit)
.lean();
이 쿼리는 여러 다국어 이름 필드에 대해 regex 검색을 수행합니다.
문제는 이런 형태의 쿼리가 자주 실행되면 전체 컬렉션 스캔이 발생할 가능성이 높다는 점입니다. 특히 대소문자 무시 옵션이 들어간 regex 검색은 일반 B-tree 인덱스를 잘 활용하지 못합니다.
따라서 이 fallback 경로가 자주 실행된다면 지연 시간의 주요 원인이 될 수 있습니다.
하지만 코드 재확인 결과 중요한 사실이 있었습니다.
이 fallback은 평상시 검색 경로가 아니었습니다. Atlas Search가 실패했을 때만 실행되는 예외 경로였습니다.
즉, 검색 요청마다 항상 실행되는 쿼리가 아니라, 검색 엔진 경로에서 문제가 발생했을 때만 실행되는 예외 로직이었습니다.
처음에는 이 regex fallback이 검색 타이핑마다 실행될 수 있다고 생각했지만, 실제로는 그렇지 않았습니다.
그래서 이 경로는 “항상 지연을 만드는 주범”이 아니라, “검색 엔진 실패 시 지연 스파이크를 만들 수 있는 후보”로 재분류했습니다.
Atlas Search 집계 쿼리
실제 검색의 주요 경로는 Atlas Search였습니다.
ContentModel.aggregate([
{
$search: {
index: "content_search",
compound: {
should: [
{
autocomplete: {
query,
path: `translations.${locale}.name`,
},
},
{
text: {
query,
path: localizedSearchPaths,
},
},
],
},
},
},
{
$addFields: {
searchScore: { $meta: "searchScore" },
},
},
{
$sort: {
searchScore: -1,
},
},
{
$limit: limit,
},
{
$project: {
slug: 1,
translations: 1,
image: 1,
},
},
]);
Atlas Search는 일반적인 MongoDB B-tree 인덱스와 다르게 검색 전용 인덱스와 검색 노드를 사용합니다.
따라서 일반 find 쿼리보다 다음 요소의 영향을 더 크게 받을 수 있습니다.
1. 검색 인덱스 구조
2. autocomplete 설정
3. 검색어 길이
4. 다국어 필드 수
5. 클러스터 티어
6. 검색 노드의 cold latency
특히 autocomplete 인덱스를 사용할 때 minGrams 값이 낮으면 아주 짧은 검색어에도 결과를 반환할 수 있습니다.
예를 들어 minGrams: 1이면 한 글자 입력부터 자동완성이 동작합니다. 사용자 경험 측면에서는 장점이 있지만, 토큰 수가 많아지고 검색 비용이 커질 수 있습니다.
따라서 Atlas Search가 실제 지연의 주범이라면 다음과 같은 개선을 검토할 수 있습니다.
1. autocomplete minGrams 조정
2. 검색 디바운스 강화
3. 검색어 최소 길이 제한
4. 검색 projection 정리
5. 검색 인덱스 필드 축소
다만 이 작업은 바로 적용하지 않았습니다.
검색 품질에 영향을 줄 수 있기 때문입니다. 특히 다국어 검색에서는 짧은 검색어에 대한 반응성이 중요할 수 있습니다. 성능만 보고 검색 인덱스를 바꾸면 사용자 경험이 나빠질 수 있습니다.
그래서 Atlas Search 튜닝은 실제 profiler에서 검색 쿼리가 지연의 주범으로 확인된 뒤 진행하기로 했습니다.
서버리스 환경의 MongoDB 연결 비용
세 번째 후보는 DB 연결 비용이었습니다.
프로젝트에서는 Mongoose를 사용하고 있었고, 일반적인 글로벌 캐시 패턴으로 MongoDB 연결을 재사용하고 있었습니다.
하지만 연결 풀 관련 설정은 명시되어 있지 않았습니다.
await mongoose.connect(MONGODB_URI);
서버리스 환경에서는 컨테이너가 항상 살아 있는 것이 아닙니다. 트래픽이 낮으면 죽었다가, 다음 요청에서 다시 깨어날 수 있습니다.
이때 첫 요청에서 DB 연결을 새로 열게 되면 다음 비용이 첫 쿼리의 지연 시간에 포함될 수 있습니다.
1. TLS 핸드셰이크
2. 서버 선택
3. 소켓 생성
4. 인증
5. 초기 연결 준비
즉, 실제 쿼리 자체는 단순한 조회일 수 있지만, 첫 요청에서는 연결 비용까지 합쳐져 느리게 보일 수 있습니다.
이번 사례에서는 트래픽이 높지 않았기 때문에 오히려 서버리스 콜드스타트의 영향을 더 의심할 수 있었습니다.
그래서 특정 컬렉션에만 영향을 주는 인덱스 추가보다, 전체 DB 접근에 영향을 주는 연결 풀 설정을 먼저 적용하기로 했습니다.
적용한 개선 작업
이번에 실제로 적용한 작업은 크게 두 가지였습니다.
MongoDB 연결 풀 설정 추가
Mongoose 연결 설정에 maxPoolSize, minPoolSize를 명시했습니다.
await mongoose.connect(MONGODB_URI, {
maxPoolSize: Number(process.env.MONGODB_MAX_POOL_SIZE ?? 10),
minPoolSize: Number(process.env.MONGODB_MIN_POOL_SIZE ?? 1),
});
여기서 핵심은 minPoolSize입니다.
최소 연결 수를 유지하면 warm 상태의 서버리스 인스턴스에서 연결을 재사용할 가능성이 높아집니다. 그 결과 첫 쿼리에 연결 생성 비용이 모두 몰리는 현상을 줄일 수 있습니다.
물론 서버리스 환경에서 실행 환경이 완전히 종료되면 연결을 유지할 수 없습니다. 하지만 모든 요청이 완전한 cold start에서 시작하는 것은 아니기 때문에, warm 상태에서의 재사용성을 높이는 것만으로도 tail latency 완화에 도움이 됩니다.
이 개선은 특정 컬렉션에만 적용되는 것이 아니라, MongoDB를 사용하는 전체 API 경로에 영향을 줄 수 있다는 장점이 있었습니다.
반복 조회 경로에 캐시와 projection 적용
또 하나 확인한 비효율은 필터나 사이드바 구성을 위해 특정 컬렉션의 전체 데이터를 반복 조회하는 경로였습니다.
기존 구조는 대략 다음과 같았습니다.
ContentModel.find().lean();
이 방식은 컬렉션 크기가 작을 때는 문제가 잘 드러나지 않습니다.
하지만 데이터가 늘어나면 불필요한 필드까지 모두 가져오게 되고, 캐시가 없다면 반복 요청마다 같은 비용이 발생합니다.
그래서 해당 조회 경로에 캐시를 적용하고, 필요한 필드만 가져오도록 projection을 추가했습니다.
ContentModel.find({})
.select("slug translations image")
.lean();
이렇게 하면 다음과 같은 효과를 기대할 수 있습니다.
1. DB 조회 횟수 감소
2. 네트워크 전송량 감소
3. Node.js 메모리 사용량 감소
4. 불필요한 필드 로드 방지
특히 콘텐츠성 데이터나 필터 데이터처럼 자주 바뀌지 않는 데이터는 캐시 적용 효과가 좋습니다.
쿼리 인벤토리를 먼저 만든 이유
이번 분석에서 가장 도움이 된 작업은 전체 쿼리 인벤토리를 만든 것이었습니다.
특정 컬렉션을 조회하는 모든 경로를 나열하고, 각 쿼리를 다음 기준으로 분류했습니다.
1. 사용자 요청에서 자주 실행되는가?
2. 관리자 전용인가?
3. 캐시가 적용되어 있는가?
4. ISR 또는 정적 재검증 대상인가?
5. 인덱스를 사용하는가?
6. fallback 경로인가?
7. 검색 엔진을 사용하는가?
| 경로 | 쿼리 유형 | 판단 |
|---|---|---|
| 메인 페이지 | find().limit() |
재검증 주기 있음, 영향 제한적 |
| 콘텐츠 상세 | findOne({ slug }) |
인덱스 사용 |
| 필터 데이터 | find().lean() |
캐시 적용 대상 |
| 검색 | $search aggregate |
지연 후보 |
| 검색 fallback | regex $or |
예외 상황의 COLLSCAN 후보 |
| 관리자 중복 검사 | regex $or |
저빈도 |
| sitemap | find().select() |
실행 빈도 확인 필요 |
이렇게 정리하고 나니, 단순히 “어떤 쿼리가 느려 보인다”가 아니라 “실제로 사용자 요청에서 얼마나 중요한 경로인가”를 기준으로 우선순위를 정할 수 있었습니다.
검증
적용 후 기본 검증을 진행했습니다.
tsc --noEmit
eslint
next build
타입 체크, 린트, 빌드가 모두 통과하는 것을 확인한 뒤 반영했습니다.
이번 작업은 대규모 구조 변경보다는 연결 설정과 조회 경로 최적화에 가까웠습니다. 그래서 기능 변경 리스크는 크지 않았지만, DB 연결 설정은 전체 API에 영향을 줄 수 있기 때문에 빌드 검증은 반드시 진행했습니다.
이번 개선 작업에서 가장 크게 느낀 점은 MongoDB 성능 문제를 무조건 인덱스 문제로 보면 안 된다는 것입니다.
물론 인덱스는 중요합니다. 하지만 쿼리 구조가 인덱스를 사용할 수 없는 형태라면, 인덱스를 추가해도 효과가 없습니다.
특히 다음과 같은 경우에는 더 신중해야 합니다.
필터 없는 find({})
이미 인덱스가 있는 단건 조회
Atlas Search를 사용하는 검색 경로
대소문자 무시 regex 검색
fallback 또는 관리자 전용 저빈도 쿼리
이번 사례에서는 새 인덱스를 추가하는 것보다 먼저 연결 풀을 명시하고, 캐시 가능한 조회 경로를 정리하는 것이 더 현실적인 개선이었습니다.
또 하나의 교훈은 평균 지연 시간만 봐서는 문제를 놓칠 수 있다는 점입니다.
평균이 괜찮아 보여도 일부 요청이 크게 튀면 사용자는 느린 응답을 경험할 수 있습니다. 특히 서버리스 환경에서는 콜드스타트, DB 연결 비용, 검색 노드 지연이 섞이면서 tail latency가 나빠질 수 있습니다.
앞으로의 작업
다음 단계는 추측이 아니라 측정입니다.
MongoDB Atlas Query Profiler 또는 애플리케이션 로그를 통해 실제로 어떤 operation이 느린지 확인해야 합니다.
확인 기준은 다음과 같습니다.
aggregate + $search 비중이 크다
→ Atlas Search 인덱스, autocomplete 설정, debounce 조정 검토
find + COLLSCAN 비중이 크다
→ regex fallback, 무필터 조회, projection 정리
연결 직후 첫 쿼리 지연이 크다
→ pool 설정, 서버리스 warm 전략, 캐시 전략 재검토
그리고 앞으로 특정 컬렉션에 새 필드가 추가되거나, 필터와 정렬 조건이 명확해지면 그때 인덱스를 다시 설계할 예정입니다.
이번 작업은 “인덱스를 추가해서 해결했다”는 이야기가 아닙니다.
오히려 반대에 가깝습니다.
처음에는 인덱스 부족을 가장 큰 원인으로 봤지만, 코드를 다시 확인하면서 판단이 바뀌었습니다.
1. regex fallback은 평상시 경로가 아니었다.
2. 새 B-tree 인덱스는 현재 쿼리 구조에서 효과가 제한적이었다.
3. Atlas Search와 서버리스 콜드스타트가 더 현실적인 지연 후보였다.
4. 즉시 적용할 수 있는 개선은 연결 풀 설정과 캐시/projection 정리였다.
성능 개선에서 중요한 것은 무언가를 많이 바꾸는 것이 아니라, 실제 병목에 가까운 것부터 안전하게 줄여가는 것입니다.
이번 작업을 통해 평균 지연 시간보다 응답 시간을 더 주의 깊게 봐야 한다는 점, 그리고 코드 경로를 재확인하지 않은 성급한 인덱스 추가는 피해야 한다는 점을 다시 확인할 수 있었습니다.