MongoDB Atlas 완전 정복 Part 4 — 성능 최적화: 인덱싱, 쿼리 튜닝, Auto-scaling
Atlas 성능 최적화는 추측이 아니라 진단에서 시작한다. Performance Advisor·Query Profiler·explain()으로 병목을 찾고, Compound·Partial·TTL·Wildcard 인덱스를 ESR 법칙으로 설계하며, Aggregation Pipeline을 최적화한다. Working Set·Connection Pool·Reactive/Predictive Auto-scaling까지 쿼리부터 클러스터 티어까지 하나의 워크플로우로 정리한다.
시리즈 구성
목차
- 성능 문제를 보는 눈 — 진단 도구 3종 세트
- 인덱스 완전 정복 — 종류와 선택 기준
- 인덱싱 황금 규칙 — ESR 법칙
- Aggregation Pipeline 최적화
- Working Set과 RAM 크기 결정 원리
- Connection Pooling — 놓치기 쉬운 병목
- Auto-scaling 완전 해부 — Reactive & Predictive
- 스키마 설계가 성능을 결정한다
- 성능 최적화 실전 워크플로우
1. 성능 문제를 보는 눈 — 진단 도구 3종 세트
성능 문제를 해결하기 전에, 어디서 문제가 발생하는지 먼저 정확히 파악해야 한다. Atlas는 이를 위한 3가지 핵심 도구를 제공한다.
1-1. Performance Advisor — 자동 인덱스 추천의 핵심
Performance Advisor는 100ms 이상 걸리는 슬로우 쿼리를 자동 수집하고, 추가하면 도움이 될 인덱스를 Impact(영향도) 순으로 랭킹하여 제안한다.
핵심 지표 이해:
| 지표 | 의미 | 위험 신호 |
|---|---|---|
| Docs Examined | 쿼리 실행 중 스캔한 문서 수 | Docs Returned보다 10배 이상이면 위험 |
| Docs Returned | 실제 반환된 문서 수 | 기준값 |
| Keys Examined | 스캔한 인덱스 키 수 | 0이면 인덱스 미사용 (컬렉션 풀스캔) |
| Avg Query Targeting | Docs Examined / Docs Returned 비율 | 1에 가까울수록 최적, 1000 이상이면 심각 |
| Impact | 인덱스 추가 시 예상 성능 개선도 | High → Medium → Low 순으로 우선 처리 |
// Performance Advisor 감지 예시
// Namespace: shop.orders
// 평균 실행시간: 1,243ms | 하루 실행 횟수: 8,742회 | Impact: HIGH
// 현재 느린 쿼리들
db.orders.find({ status: "pending" }).sort({ createdAt: -1 })
db.orders.find({ status: "shipped", userId: "user123" })
// Performance Advisor가 제안하는 인덱스
// { status: 1, createdAt: -1 } → 위 두 쿼리 모두 커버
2025년 업데이트: Performance Advisor를 MongoDB MCP Server를 통해 Claude, Cursor, GitHub Copilot 같은 AI 클라이언트에서 자연어로 접근 가능해졌다. Query Profiler의 공유 가능한 URL 기능도 추가되어 슬로우 쿼리 분석 결과를 팀원과 링크로 공유할 수 있게 되었다.
단, Performance Advisor 추천은 그대로 적용하기 전에 기존 인덱스와의 중복, 쓰기 부하 영향, 인덱스 유지 비용을 함께 검토해야 한다.
1-2. Query Profiler — 개별 쿼리 해부
Performance Advisor가 "어떤 인덱스를 만들어야 하나"를 알려준다면, Query Profiler는 "왜 이 쿼리가 느린가"를 낱낱이 보여준다.
Query Profiler에서 확인해야 할 핵심 패턴:
위험 패턴 1: Docs Examined >> Docs Returned
예) Examined: 2,000,000 / Returned: 5
→ 컬렉션 전체를 스캔하고 있음. 인덱스 없거나 비효율적 인덱스.
위험 패턴 2: Keys Examined = 0 (인덱스 키 스캔 없음)
→ 완전한 컬렉션 스캔(COLLSCAN)
→ 즉시 인덱스 추가 필요
위험 패턴 3: hasSort: true + 인덱스 정렬 미사용
→ 메모리에서 정렬 (in-memory sort) 발생
→ 인덱스에 정렬 필드 포함 필요
건강한 패턴:
Docs Examined ≈ Docs Returned
Keys Examined > 0
hasIndexCoverage: true
1-3. explain() — 실행 계획 직접 분석
코드에서 직접 실행 계획을 확인하고 싶을 때 사용한다.
// 실행 계획 확인 (쿼리 실행 없이 계획만 확인)
const plan = await db.collection("orders")
.find({ status: "pending", userId: "user123" })
.sort({ createdAt: -1 })
.explain("executionStats");
// 핵심 확인 포인트
console.log(plan.queryPlanner.winningPlan.stage);
// "COLLSCAN" → 인덱스 없음
// "IXSCAN" → 인덱스 사용 중
// "FETCH" → 인덱스로 조회 후 문서 fetch
// "PROJECTION_COVERED" → 커버드 인덱스 (최고 성능)
console.log(plan.executionStats.totalDocsExamined); // 스캔 문서 수
console.log(plan.executionStats.totalDocsReturned); // 반환 문서 수
console.log(plan.executionStats.executionTimeMillis); // 실행 시간(ms)
실행 계획 stage별 의미:
COLLSCAN → 컬렉션 전체 스캔 (느림, 인덱스 필요)
IXSCAN → 인덱스 스캔 (좋음)
FETCH → 인덱스로 찾은 후 문서 가져오기 (일반적)
SORT → 메모리 정렬 (인덱스 정렬 사용 권장)
SORT_KEY_GENERATOR → 정렬 키 생성 (SORT와 함께 등장)
PROJECTION_COVERED → 커버드 인덱스 (최고 성능)
2. 인덱스 완전 정복 — 종류와 선택 기준
MongoDB는 다양한 인덱스 타입을 지원한다. 상황에 맞는 인덱스를 선택하는 것이 성능 최적화의 절반이다. 인덱스는 읽기를 빠르게 하지만 쓰기를 느리게 하고 RAM을 소비하므로, 필요한 인덱스만 만드는 것이 원칙이다.
2-1. Single Field Index (단일 필드 인덱스)
가장 기본. 하나의 필드에 대한 인덱스.
// userId로 자주 조회할 때
db.users.createIndex({ userId: 1 }); // 오름차순
db.users.createIndex({ createdAt: -1 }); // 내림차순 (최신 순 정렬 쿼리)
// 유니크 인덱스
db.users.createIndex({ email: 1 }, { unique: true });
2-2. Compound Index (복합 인덱스) — 가장 많이 쓰임
여러 필드를 하나의 인덱스로 묶는다. 쿼리에서 여러 필드를 동시에 필터링/정렬할 때 단일 인덱스 여러 개보다 훨씬 효율적이다.
// 상태별 + 날짜 정렬 조합 쿼리가 많다면
db.orders.createIndex({ status: 1, createdAt: -1 });
// 사용자별 + 상태별 조회가 많다면
db.orders.createIndex({ userId: 1, status: 1, createdAt: -1 });
Prefix 규칙 — Compound Index의 핵심 개념:
인덱스: { userId: 1, status: 1, createdAt: -1 }
이 인덱스가 커버하는 쿼리 패턴:
{ userId: "u1" } → 앞 1개 필드 (prefix)
{ userId: "u1", status: "pending" } → 앞 2개 필드 (prefix)
{ userId: "u1", status: "pending", createdAt: ... } → 전체 3개 필드
이 인덱스가 커버하지 못하는 패턴:
{ status: "pending" } → userId 없으면 인덱스 미사용!
{ createdAt: ... } → 중간 필드 건너뜀
2-3. Partial Index (부분 인덱스) — 인덱스 크기 절감의 숨겨진 보석
조건에 맞는 문서만 인덱싱한다. 전체 컬렉션 중 일부 문서만 대상으로 쿼리가 집중되는 경우 인덱스 크기를 대폭 줄이면서 성능을 높일 수 있다.
// 예시: orders 컬렉션에서 status: "pending"인 문서만 집중 조회
// 전체 문서 100만 건 중 pending은 5만 건만 존재
// → 전체 인덱스 대비 크기 95% 절감
db.orders.createIndex(
{ createdAt: -1, userId: 1 },
{
partialFilterExpression: { status: "pending" }
}
);
// 이 인덱스를 사용하려면 쿼리에 반드시 status: "pending" 포함
db.orders.find({ status: "pending", userId: "u1" }).sort({ createdAt: -1 });
2-4. Sparse Index (희소 인덱스) — Null 필드 제외
특정 필드가 없는 문서를 인덱스에서 제외한다. Optional 필드에 인덱스를 걸 때 유용하다.
// phoneNumber 필드가 있는 문서만 인덱싱
db.users.createIndex(
{ phoneNumber: 1 },
{ sparse: true }
);
2-5. TTL Index (만료 인덱스) — 데이터 자동 삭제
날짜 필드를 기준으로 일정 시간이 지난 문서를 자동으로 삭제한다. 세션, 로그, 캐시 데이터에 매우 유용하다.
// 세션 데이터를 24시간 후 자동 삭제
db.sessions.createIndex(
{ lastAccessedAt: 1 },
{ expireAfterSeconds: 86400 }
);
// 로그 데이터를 30일 후 자동 삭제
db.logs.createIndex(
{ createdAt: 1 },
{ expireAfterSeconds: 2592000 }
);
2-6. Wildcard Index (와일드카드 인덱스) — 동적 필드 구조 대응
필드 구조가 문서마다 다른 경우(예: 사용자 정의 메타데이터, 동적 속성) 유용하다.
// 이커머스에서 상품별로 다른 속성 구조
// { attributes: { color: "red", size: "M" } }
// { attributes: { voltage: "220V", wattage: "100W" } }
// attributes 내부 구조가 가변적일 때
db.products.createIndex({ "attributes.$**": 1 });
db.products.find({ "attributes.color": "red" }); // 인덱스 사용
db.products.find({ "attributes.voltage": "220V" }); // 인덱스 사용
주의: Wildcard Index는 다양한 필드 조합을 커버하지만, 잘 알려진 필드에는 Compound Index가 훨씬 효율적이다. "모든 필드에 인덱스"가 필요할 때만 사용한다.
2-7. Hidden Index — 인덱스 삭제 전 영향 테스트
인덱스를 실제로 삭제하기 전에 쿼리 플래너에서 숨겨서 영향을 사전 검증하는 기능이다.
// 인덱스 숨기기 (쿼리 플래너가 사용 안 함)
db.orders.hideIndex({ status: 1, createdAt: -1 });
// 이제 쿼리를 실행해보며 성능 영향 확인
// 문제없으면 → 실제 삭제
db.orders.dropIndex({ status: 1, createdAt: -1 });
// 문제 발생 시 → 즉시 복원
db.orders.unhideIndex({ status: 1, createdAt: -1 });
3. 인덱싱 황금 규칙 — ESR 법칙
Compound Index를 설계할 때 ESR(Equality → Sort → Range) 순서로 필드를 배치하면 최적의 성능을 얻을 수 있다.
E (Equality) → 동등 비교 필드: { field: "exact_value" }
S (Sort) → 정렬 필드: .sort({ field: 1 })
R (Range) → 범위 조회 필드: { field: { $gte: ..., $lte: ... } }
실전 예시:
// 쿼리 패턴:
// db.orders.find({
// status: "shipped", Equality (E)
// createdAt: { $gte: last7days } Range (R)
// }).sort({ createdAt: -1 }) Sort (S)
// 잘못된 인덱스 순서 (Range를 Sort 앞에)
db.orders.createIndex({ status: 1, createdAt: -1, userId: 1 });
// createdAt이 Range이자 Sort 필드인데, 인덱스 순서가 맞지 않으면 in-memory sort 발생
// ESR 법칙 적용
db.orders.createIndex({
status: 1, // E: Equality
createdAt: -1, // S: Sort (Range 필드이기도 하므로 Sort 역할 우선)
userId: 1 // R: 추가 필터
});
ESR 법칙의 직관적 이유:
E 필드로 먼저 → 동일한 값끼리 모이게 됨 (불필요한 스캔 최소화)
S 필드 다음 → 그룹 내에서 정렬 순서 유지 (메모리 정렬 불필요)
R 필드 마지막 → 범위 조건으로 최종 필터링
4. Aggregation Pipeline 최적화
Aggregation Pipeline은 강력하지만, 잘못 작성하면 메모리 100MB 제한에 걸리거나 수십 초가 걸리는 쿼리가 되기 쉽다.
4-1. 황금 규칙: $match와 $project를 파이프라인 앞으로
// 나쁜 예: 모든 문서를 $group한 후 필터링
db.orders.aggregate([
{ $group: { _id: "$userId", total: { $sum: "$amount" } } },
{ $match: { total: { $gte: 100 } } } // 그룹 후 필터 → 모든 데이터 처리
]);
// 좋은 예: 먼저 필터링 후 $group
db.orders.aggregate([
{ $match: { status: "completed", createdAt: { $gte: last30days } } }, // 먼저 축소
{ $group: { _id: "$userId", total: { $sum: "$amount" } } },
{ $match: { total: { $gte: 100 } } }
]);
// 나쁜 예: 모든 필드를 다음 단계로 전달
db.orders.aggregate([
{ $match: { status: "pending" } },
{ $lookup: { from: "users", localField: "userId", ... } },
{ $project: { orderId: 1, userName: 1 } } // 마지막에 필드 줄이기
]);
// 좋은 예: 가능한 빨리 필요한 필드만 남기기
db.orders.aggregate([
{ $match: { status: "pending" } },
{ $project: { userId: 1, orderId: 1, amount: 1 } }, // 일찍 투영
{ $lookup: { from: "users", localField: "userId", ... } }
]);
4-2. $lookup 최적화 — JOIN의 성능 함정
$lookup은 편리하지만 대용량 데이터에서 성능 저하의 주범이 되기 쉽다.
// 느린 $lookup: 필터 없이 전체 조인
db.orders.aggregate([
{ $lookup: {
from: "products",
localField: "productId",
foreignField: "_id",
as: "product"
}}
]);
// 빠른 $lookup: pipeline 활용 + 필드 제한
db.orders.aggregate([
{ $match: { status: "pending" } }, // 먼저 줄이고
{ $lookup: {
from: "products",
let: { pid: "$productId" },
pipeline: [
{ $match: { $expr: { $eq: ["$_id", "$$pid"] } } },
{ $project: { name: 1, price: 1 } } // 필요한 필드만 가져오기
],
as: "product"
}},
{ $unwind: "$product" }
]);
설계 팁: $lookup이 자주 필요하다면 스키마 설계를 재고해야 할 신호일 수 있다. 자주 함께 조회되는 데이터는 하나의 문서에 embed하는 것이 MongoDB의 권장 패턴이다.
4-3. $merge로 집계 결과 물질화 (Materialized View)
동일한 무거운 집계 쿼리를 반복 실행한다면, 결과를 별도 컬렉션에 미리 저장해두는 방식으로 성능을 크게 개선할 수 있다.
// 매일 새벽 3시에 실행 — 일별 매출 집계를 별도 컬렉션에 저장
db.orders.aggregate([
{ $match: { status: "completed" } },
{ $group: {
_id: { $dateToString: { format: "%Y-%m-%d", date: "$createdAt" } },
dailyRevenue: { $sum: "$amount" },
orderCount: { $sum: 1 }
}},
{ $merge: {
into: "daily_revenue_stats",
on: "_id",
whenMatched: "replace",
whenNotMatched: "insert"
}}
]);
// 리포팅 쿼리는 원본 수백만 건 대신 집계 컬렉션 조회
db.daily_revenue_stats.find({ _id: { $gte: "2026-01-01" } });
4-4. allowDiskUse — 메모리 100MB 제한 우회
집계 파이프라인의 각 스테이지는 기본적으로 100MB RAM 제한이 있다. 대용량 집계 시 이 제한에 걸려 에러가 발생할 수 있다.
// allowDiskUse로 100MB 초과 시 디스크 사용 허용
db.orders.aggregate(
[
{ $group: { _id: "$category", total: { $sum: "$amount" } } },
{ $sort: { total: -1 } }
],
{ allowDiskUse: true } // 디스크 I/O로 인한 속도 저하 있음
);
allowDiskUse는 임시방편이다. 근본적으로 파이프라인 앞단의$match로 데이터를 줄이거나, 인덱스를 활용해 스캔 양을 줄이는 방향으로 개선해야 한다.
5. Working Set과 RAM 크기 결정 원리
"클러스터 티어를 올려야 하나?"라는 질문에 답하려면 Working Set 개념을 이해해야 한다.
5-1. Working Set이란?
Working Set은 활성 쿼리가 실제로 접근하는 데이터 + 인덱스의 합이다. MongoDB의 스토리지 엔진(WiredTiger)은 Working Set이 RAM에 올라와 있을 때 최고 성능을 발휘하고, Working Set이 RAM을 초과하면 디스크 I/O가 급증하며 성능이 크게 저하된다.
WiredTiger Cache (물리 RAM의 50%)
인덱스 (항상 RAM에 있어야 함) 8GB
Hot Data (자주 접근하는 문서들) 6GB
여유 버퍼 2GB
합계: Working Set = 14GB
필요 WiredTiger Cache = 14GB 이상
필요 물리 RAM = 28GB 이상 (Cache = RAM의 50%이므로 × 2)
권장 클러스터 티어: M40 (32GB RAM)
5-2. Working Set 크기 측정
// 인덱스 크기 확인
const stats = await db.collection("orders").stats();
console.log("총 문서 크기:", (stats.size / 1024 / 1024).toFixed(2), "MB");
console.log("인덱스 크기:", (stats.totalIndexSize / 1024 / 1024).toFixed(2), "MB");
// 전체 DB 통계
const dbStats = await db.stats();
console.log("전체 데이터:", (dbStats.dataSize / 1024 / 1024 / 1024).toFixed(2), "GB");
console.log("전체 인덱스:", (dbStats.indexSize / 1024 / 1024 / 1024).toFixed(2), "GB");
RAM 크기 선택 공식:
최소 필요 RAM = (Hot Data 크기 + 전체 인덱스 크기) × 2
(WiredTiger Cache가 RAM의 50%이므로 × 2)
예시:
인덱스: 8GB
Hot Data (전체 데이터의 약 25%): 6GB
Working Set: 14GB
필요 RAM: 14GB × 2 = 28GB → M40 (32GB) 선택
인덱스는 항상 RAM에: 인덱스가 RAM을 초과하면 쿼리 성능이 극단적으로 저하된다. 인덱스 크기가 늘어난다면 미사용 인덱스를 제거하거나 Partial Index로 크기를 줄이는 것이 우선이다.
5-3. 미사용 인덱스 탐지 및 제거
인덱스는 읽기를 빠르게 하지만 쓰기를 느리게 하고 RAM을 소비한다. 사용되지 않는 인덱스는 과감히 제거해야 한다.
// 인덱스 사용 통계 조회 ($indexStats)
const indexUsage = await db.collection("orders").aggregate([
{ $indexStats: {} }
]).toArray();
indexUsage.forEach(idx => {
console.log({
인덱스명: idx.name,
누적사용횟수: idx.accesses.ops,
마지막사용: idx.accesses.since
});
});
// 사용 횟수가 0이거나 매우 낮은 인덱스는 제거 후보
// Hidden Index로 먼저 숨겨서 영향 확인 후 삭제
6. Connection Pooling — 놓치기 쉬운 병목
많은 개발자가 인덱스와 쿼리에만 집중하다가 Connection Pool 설정 미비로 인한 병목을 간과한다. 특히 서버리스 환경(Lambda, Cloud Run)에서 더욱 치명적이다.
6-1. Connection Pool 기본 개념
Pool 없이 고트래픽 시 Atlas 연결 수 한도를 쉽게 초과한다. Pool을 사용하면 연결 재사용으로 레이턴시를 최소화하고 연결 수를 통제할 수 있다.
6-2. 드라이버별 Connection Pool 설정
// Node.js (mongoose)
const mongoose = require('mongoose');
mongoose.connect(uri, {
maxPoolSize: 50, // 최대 연결 수 (기본값: 5 → 너무 낮음)
minPoolSize: 10, // 최소 유지 연결 수
serverSelectionTimeoutMS: 5000, // 서버 선택 타임아웃
socketTimeoutMS: 45000, // 소켓 타임아웃
maxIdleTimeMS: 30000, // 유휴 연결 최대 유지 시간
waitQueueTimeoutMS: 5000 // 연결 대기 최대 시간
});
# Python (pymongo)
from pymongo import MongoClient
client = MongoClient(
uri,
maxPoolSize=50,
minPoolSize=10,
serverSelectionTimeoutMS=5000,
maxIdleTimeMS=30000,
compressors=['snappy', 'zlib'] # 네트워크 압축도 함께
)
6-3. 서버리스 환경의 특별 주의사항
AWS Lambda, Google Cloud Run, Vercel 같은 서버리스 환경은 인스턴스가 요청마다 새로 생성될 수 있어 Connection Pool이 매번 초기화된다.
문제 상황:
Lambda 인스턴스 500개 동시 실행
각 인스턴스 maxPoolSize: 50
잠재적 연결 수: 25,000개
Atlas M30의 최대 연결 수: 3,000개
→ 연결 오류 폭발!
서버리스 환경 권장 패턴:
// 나쁜 예: 함수 내에서 매번 새 클라이언트 생성
exports.handler = async (event) => {
const client = new MongoClient(uri); // 매번 새 연결
await client.connect();
// ...
};
// 좋은 예: 모듈 수준에서 한번만 생성 (warm start 시 재사용)
let cachedClient = null;
exports.handler = async (event) => {
if (!cachedClient) {
cachedClient = await new MongoClient(uri, {
maxPoolSize: 1, // 서버리스에서는 1로 설정
serverSelectionTimeoutMS: 10000,
socketTimeoutMS: 45000,
}).connect();
}
const db = cachedClient.db("mydb");
// ...
};
7. Auto-scaling 완전 해부 — Reactive & Predictive
7-1. 두 가지 Auto-scaling 메커니즘
Atlas의 Auto-scaling은 Reactive와 Predictive 두 가지 방식이 병행된다.
Reactive Auto-scaling
트래픽 급증 → CPU/RAM 임계치 초과 확인 → 스케일업 결정 → 실행
반응적, 스파이크 후 대응
M10/M20: 5배 빠른 반응 속도로 개선 (2025년 업데이트)
M30+: 10분 이상 유지 시 스케일업, 24시간 이상 낮은 사용률 시 스케일다운
Predictive Auto-scaling
과거 패턴 학습 (ML) → 트래픽 급증 예측 → 미리 스케일업
선제적, 스파이크 이전 대응
주기적 배치 작업, 요일/시간대별 패턴에 특히 효과적
스케일다운은 여전히 Reactive 방식으로만 동작
Predictive Auto-scaling의 GA 상태 및 지원 티어는 MongoDB 공식 문서에서 확인한다. Auto-scaling은 잘못된 쿼리나 스키마 문제를 해결하지 않는다. 근본 원인을 먼저 해결한 뒤 필요한 경우에만 티어를 조정한다.
7-2. Auto-scaling 동작 조건 (M30+ 기준)
Scale-Up 트리거:
CPU 사용률 > 90%가 10분 이상 지속
또는
RAM 사용률이 지속적으로 높을 때
→ 다음 티어로 자동 업그레이드 (M30 → M40)
→ 마지막 스케일 이후 10분 경과 후부터 감지 시작
Scale-Down 트리거:
다음 조건 모두 충족 시:
CPU < 45%가 최근 10분 + 최근 4시간 지속
지난 24시간 스케일 없음
지난 24시간 프로비저닝/재시작 없음
다음 낮은 티어가 설정한 Minimum Tier 이상
→ 한 단계 아래 티어로 다운그레이드 (M40 → M30)
7-3. 스케일 범위 설정 전략
// Terraform으로 Auto-scaling 범위 설정
resource "mongodbatlas_advanced_cluster" "prod" {
// ...
replication_specs {
region_configs {
auto_scaling {
compute_enabled = true
disk_gb_enabled = true
compute_min_instance_size = "M30"
compute_max_instance_size = "M60"
compute_scale_down_enabled = true
}
}
}
}
범위 설정 기준:
너무 넓은 범위 (위험): M10 ~ M200
트래픽 버스트 시 비용 폭탄 가능성
적절한 범위 (권장): M30 ~ M60
일반 프로덕션 서비스의 트래픽 변동 커버
예산 우선 (보수적): M30 ~ M40
비용 통제 우선, 극단적 스파이크 대응 어려움
7-4. 독립 샤드 스케일링 (2025년 신규)
샤딩 클러스터에서 특정 샤드에만 트래픽이 집중되는 경우(핫샤드), 이제 샤드별로 개별 티어 설정이 가능하다.
8. 스키마 설계가 성능을 결정한다
아무리 인덱스를 잘 설계해도, 스키마 자체가 잘못되어 있으면 한계가 있다.
8-1. Embed vs Reference — 언제 어떻게
Embedding (한 문서에 모두 담기)
사용하는 경우:
항상 함께 조회되는 데이터
1:1 또는 1:Few 관계
서브 데이터가 독립적으로 존재할 필요 없음
예시: 주문 + 배송지 (항상 함께 조회)
{
_id: ObjectId("..."),
orderId: "ORD-001",
amount: 50000,
shippingAddress: {
street: "테헤란로 123",
city: "서울",
zip: "06234"
}
}
Referencing (별도 컬렉션으로 분리)
사용하는 경우:
1:Many, Many:Many 관계
서브 데이터가 자주 독립적으로 변경됨
배열이 무한히 커질 수 있는 경우 (Unbounded Array 방지)
예시: 사용자 + 주문 목록 (주문이 계속 늘어남)
users 컬렉션
{ _id: ObjectId("u1"), name: "홍길동", email: "hong@example.com" }
orders 컬렉션 (userId로 참조)
{ _id: ObjectId("o1"), userId: ObjectId("u1"), amount: 50000, ... }
{ _id: ObjectId("o2"), userId: ObjectId("u1"), amount: 30000, ... }
8-2. Unbounded Array 안티패턴 — 가장 흔한 성능 문제
// 안티패턴: 무한히 커지는 배열
{
_id: ObjectId("u1"),
userId: "user123",
orders: [ // 주문이 쌓일수록 문서가 무한히 커짐
{ orderId: "o1", amount: 50000 },
{ orderId: "o2", amount: 30000 },
// 수천 건이 쌓이면 문서 크기 16MB 한도 초과 가능
// 인덱스 성능도 급격히 저하
]
}
// 올바른 패턴: 별도 컬렉션으로 분리
// users: { _id, userId, ... }
// orders: { _id, userId, amount, ... } userId에 인덱스
8-3. Schema Performance Suggestions 활용
Atlas Performance Advisor는 인덱스 추천 외에도 스키마 설계 문제도 감지한다.
감지하는 스키마 문제들:
Array too large (너무 큰 배열 필드)
Collection join overuse (불필요하게 많은 $lookup 의존)
Redundant indexes (중복 인덱스)
Document too large (매우 긴 문서)
High null ratio (불필요한 null 값이 많은 필드)
9. 성능 최적화 실전 워크플로우
병목 유형별 첫 번째 행동:
CPU 높음 → Performance Advisor에서 인덱스 미사용 쿼리 확인
RAM 높음 → Working Set 크기 측정 후 티어 업그레이드 또는 미사용 인덱스 제거
IOPS 높음 → 디스크 읽기 과다, Working Set이 RAM 초과 → RAM 부족으로 연결
연결 수 높음 → Connection Pool maxPoolSize, 서버리스 클라이언트 캐싱 점검
Rolling Index Build — 프로덕션 무중단 인덱스 생성:
# Atlas UI에서는 기본적으로 Rolling Build 사용
# CLI로 직접 생성 시:
atlas clusters indexes create \
--clusterName prod-cluster \
--db myDatabase \
--collection orders \
--key '{"status": 1, "createdAt": -1}' \
--name "idx_status_createdAt"
Part 4 요약
| 최적화 영역 | 핵심 포인트 |
|---|---|
| 진단 도구 | Performance Advisor(자동 추천) → Query Profiler(상세 분석) → explain() 순서로 사용 |
| 인덱스 선택 | 상황별 타입 선택 (Compound, Partial, TTL, Wildcard), 삭제 전 Hidden으로 검증 |
| ESR 법칙 | Compound Index 필드 순서: Equality → Sort → Range |
| Aggregation | $match/$project 앞으로, $lookup 최소화, $merge로 물질화 |
| Working Set | 인덱스 + Hot Data < WiredTiger Cache = RAM × 50% 유지 |
| Connection Pool | maxPoolSize 적절히 설정, 서버리스 환경에서는 클라이언트 모듈 수준 캐싱 |
| Auto-scaling | Reactive + Predictive 병행, 적절한 Min/Max 범위 설정, 샤드별 독립 스케일링 |
| 스키마 설계 | Unbounded Array 금지, 함께 조회되는 데이터는 Embed |
참고 자료
- MongoDB Atlas Documentation: Performance Advisor
- MongoDB Atlas Documentation: Query Profiler
- MongoDB Manual: Indexes and Compound Indexes
- MongoDB Atlas Documentation: Cluster Auto-scaling
- MongoDB Manual: Aggregation Pipeline Optimization