MongoDB 성능 함정 방지를 위한 모범 사례
MongoDB의 유연한 스키마와 분산 아키텍처는 놀라운 확장성과 개발 용이성을 제공합니다. 하지만 이러한 유연성 때문에 성능이 기본적으로 보장되지는 않습니다. 데이터 모델링, 인덱싱 및 쿼리 패턴에 대한 신중한 계획 없이는 데이터 볼륨이 증가함에 따라 애플리케이션이 빠르게 병목 현상에 직면할 수 있습니다.
본 문서는 MongoDB에서 사전 예방적 성능 관리를 위한 포괄적인 가이드 역할을 합니다. 장기적인 데이터베이스 속도와 상태를 보장하는 데 필요한 스키마 설계, 고급 인덱싱 전략 및 쿼리 최적화 기술과 같은 기본 개념에 중점을 두고 중요한 모범 사례를 살펴보겠습니다. 이러한 일반적인 함정을 조기에 해결함으로써 개발자와 운영팀은 빠른 쿼리 시간과 효율적인 리소스 활용을 유지할 수 있습니다.
1. 스키마 설계: 성능의 기반
성능 튜닝은 첫 쿼리가 작성되기 훨씬 전부터 시작됩니다. 데이터를 구성하는 방식이 읽기 및 쓰기 효율성에 직접적인 영향을 미칩니다.
문서 크기 제한 및 비대화 방지
MongoDB 문서는 기술적으로 16MB까지 도달할 수 있지만, 매우 큰 문서(심지어 1-2MB를 초과하는 문서)에 접근하고 업데이트하는 것은 상당한 성능 오버헤드를 초래할 수 있습니다. 큰 문서는 더 많은 메모리를 소비하고, 더 많은 네트워크 대역폭을 필요로 하며, 제자리 업데이트 시 조각화 위험을 증가시킵니다.
모범 사례: 문서는 핵심 내용만 포함
문서를 부모 문서와 함께 자주 필요하지 않은 가장 필수적이고 자주 액세스하는 데이터만 포함하도록 설계하십시오. 큰 배열이나 관련 엔티티에는 참조(referencing)를 사용합니다.
함정: 운영 문서 내부에 대규모 기록 로그나 큰 바이너리 파일(고해상도 이미지 등)을 직접 저장하는 것.
임베딩 대 참조의 트레이드오프
읽기 성능 최적화의 핵심은 임베딩(관련 데이터를 기본 문서 내부에 저장)과 참조( _id 및 $lookup을 통한 링크 사용) 중 하나를 결정하는 것입니다.
| 전략 | 최적 사용 사례 | 성능 영향 |
|---|---|---|
| 임베딩 | 작고, 자주 액세스되며, 밀접하게 연결된 데이터(예: 제품 리뷰, 주소 세부 정보). | 빠른 읽기: 더 적은 쿼리/네트워크 왕복 필요. |
| 참조 | 크거나, 자주 액세스하지 않거나, 빠르게 변경되는 데이터(예: 큰 배열, 공유 데이터). | 느린 읽기: $lookup(조인 등가물)가 필요하지만, 문서 비대를 방지하고 참조된 데이터의 쉬운 업데이트를 허용합니다. |
⚠️ 경고: 배열 성장
임베드된 문서 내의 배열이 무한정 커질 것으로 예상되는 경우(예: 모든 사용자 활동 목록), 대신 활동을 참조하는 것이 더 나은 경우가 많습니다. 무제한 배열 성장은 문서가 초기 할당량을 초과하게 만들어 MongoDB가 문서를 재배치하도록 강제할 수 있으며, 이는 비용이 많이 드는 작업입니다.
2. 인덱싱 전략: 컬렉션 스캔 제거
인덱스는 MongoDB 성능에서 가장 중요한 단일 요소입니다. 컬렉션 스캔 (COLLSCAN)은 쿼리를 만족시키기 위해 MongoDB가 컬렉션의 모든 문서를 읽어야 할 때 발생하며, 특히 대규모 데이터셋에서 성능이 급격히 저하됩니다.
사전 예방적 인덱스 생성 및 검증
쿼리의 filter 절, sort 절 또는 projection(커버드 쿼리의 경우)에 사용되는 모든 필드에 인덱스가 있는지 확인하십시오.
explain('executionStats') 메서드를 사용하여 인덱스가 사용되고 있는지 확인하고 컬렉션 스캔을 식별하십시오.
// 이 쿼리가 인덱스를 사용하는지 확인
db.users.find({ status: "active", created_at: { $gt: ISODate("2023-01-01") } })
.sort({ created_at: -1 })
.explain('executionStats');
복합 인덱스의 ESR 규칙
복합 인덱스(여러 필드에 대해 생성된 인덱스)는 최대 효과를 얻으려면 올바르게 순서가 지정되어야 합니다. ESR 규칙을 사용하십시오:
- Equality(같음): 정확히 일치하는 데 사용되는 필드가 먼저 옵니다.
- Sort(정렬): 정렬에 사용되는 필드가 두 번째로 옵니다.
- Range(범위): 범위 연산자(
$gt,$lt,$in)에 사용되는 필드가 마지막에 옵니다.
ESR 규칙의 예시:
쿼리: category(같음)로 제품을 찾고, price(정렬)로 정렬하며, rating 범위 내에 있는 제품을 찾습니다.
// ESR 기반의 올바른 인덱스 구조
db.products.createIndex({ category: 1, price: 1, rating: 1 })
커버드 쿼리
커버드 쿼리는 쿼리 필터와 프로젝션에서 요청된 필드를 포함하여 전체 결과 집합이 인덱스만으로 충족될 수 있는 쿼리입니다. 이는 MongoDB가 실제 문서를 검색할 필요가 없음을 의미하므로 I/O가 극적으로 감소하고 속도가 향상됩니다.
커버드 쿼리를 달성하려면 반환되는 모든 필드는 인덱스의 일부여야 합니다. _id 필드는 명시적으로 제외(_id: 0)되지 않는 한 암시적으로 포함됩니다.
// 인덱스는 요청된 모든 필드(name, email)를 포함해야 함
db.users.createIndex({ name: 1, email: 1 });
// 커버드 쿼리 - 인덱스에 포함된 필드만 반환
db.users.find({ name: 'Alice' }, { email: 1, _id: 0 });
3. 쿼리 최적화 및 검색 효율성
완벽한 인덱스가 있더라도 비효율적인 쿼리 패턴은 성능을 심각하게 저하시킬 수 있습니다.
항상 프로젝션 사용
프로젝션은 네트워크를 통해 전송되는 데이터 양과 쿼리 실행기가 소비하는 메모리를 제한합니다. 데이터 하위 집합만 필요한 경우 절대로 모든 필드({})를 선택하지 마십시오.
// 함정: 전체 대용량 사용자 문서 검색
db.users.findOne({ email: '[email protected]' });
// 모범 사례: 필요한 필드만 검색
db.users.findOne({ email: '[email protected]' }, { username: 1, last_login: 1 });
대규모 $skip 작업 피하기 (키셋 페이지네이션)
깊은 페이지네이션을 위해 $skip을 사용하는 것은 MongoDB가 건너뛴 문서를 계속 스캔하고 버려야 하므로 매우 비효율적입니다. 대규모 결과 집합을 다룰 때는 키셋 페이지네이션(커서 기반 또는 오프셋 없는 페이지네이션이라고도 함)을 사용하십시오.
페이지 번호를 건너뛰는 대신, 마지막으로 검색된 인덱싱된 값(예: _id 또는 타임스탬프)을 기준으로 필터링합니다.
// 함정: 페이지가 증가함에 따라 기하급수적으로 느려짐
db.logs.find().sort({ timestamp: -1 }).skip(50000).limit(50);
// 모범 사례: 마지막 _id부터 효율적으로 계속 진행
const lastId = '...id_from_previous_page...';
db.logs.find({ _id: { $gt: lastId } }).sort({ _id: 1 }).limit(50);
4. 작업 및 집계의 고급 함정
쓰기 및 데이터 변환과 같은 복잡한 작업에는 전문적인 최적화 기술이 필요합니다.
집계 파이프라인 최적화
집계 파이프라인은 강력하지만 리소스 집약적일 수 있습니다. 핵심 성능 규칙은 가능한 한 일찍 데이터셋 크기를 줄이는 것입니다.
모범 사례: $match 및 $limit을 맨 앞으로 이동
$match 단계(문서를 필터링함)와 $limit 단계(처리할 문서 수를 제한함)를 파이프라인의 맨 처음에 배치하십시오. 이렇게 하면 $group, $sort, $project와 같은 후속적이고 비용이 더 많이 드는 단계가 가능한 가장 작은 데이터셋에서 작동하게 됩니다.
// 효율적인 파이프라인 예시
[
{ $match: { status: 'COMPLETE', date: { $gte: '2023-01-01' } } }, // 조기에 필터링(인덱스 사용)
{ $group: { _id: '$customer_id', total_spent: { $sum: '$amount' } } },
{ $sort: { total_spent: -1 } }
]
쓰기 고려 사항(Write Concerns) 관리
쓰기 고려 사항은 MongoDB가 쓰기 작업에 제공하는 승인 수준을 결정합니다. 높은 내구성이 반드시 필요하지 않을 때 지나치게 엄격한 쓰기 고려 사항을 선택하면 쓰기 대기 시간에 심각한 영향을 미칠 수 있습니다.
| 쓰기 고려 사항 설정 | 대기 시간 | 내구성 |
|---|---|---|
w: 1 |
낮음 | 기본 노드만 확인. |
w: 'majority' |
높음 | 복제본 세트 멤버의 대다수가 확인. 최대 내구성. |
팁: 높은 처리량의 중요하지 않은 작업(분석 또는 로깅 등)의 경우 속도를 우선시하려면 w: 1과 같은 낮은 쓰기 고려 사항을 고려하십시오. 금융 거래나 중요한 데이터의 경우 항상 w: majority를 사용하십시오.
5. 배포 및 구성 모범 사례
데이터베이스 스키마 및 쿼리 외에도 구성 세부 사항이 전반적인 시스템 상태에 영향을 미칩니다.
느린 쿼리 모니터링
느린 쿼리 로그를 정기적으로 확인하거나 $currentOp 집계 파이프라인을 사용하여 과도한 시간이 소요되는 작업을 식별하십시오. MongoDB 프로파일러는 이 작업을 위한 필수 도구입니다.
연결 풀링 관리
애플리케이션이 효과적인 연결 풀을 사용하고 있는지 확인하십시오. 데이터베이스 연결을 생성하고 제거하는 것은 비용이 많이 듭니다. 잘 조정된 풀은 대기 시간과 오버헤드를 줄입니다. 애플리케이션 트래픽 패턴에 적합한 최소 및 최대 연결 풀 크기를 설정하십시오.
시간 기반 인덱스(TTL) 사용
일시적인 데이터(세션, 로그 항목, 캐시된 데이터 등)가 포함된 컬렉션의 경우 TTL 인덱스를 구현하십시오. 이를 통해 MongoDB는 정의된 기간 후에 문서를 자동으로 만료시켜 컬렉션이 통제 불능으로 커지거나 시간이 지남에 따라 인덱싱 효율성이 저하되는 것을 방지할 수 있습니다.
// session 컬렉션의 문서는 생성 후 3600초 후에 만료됨
db.session.createIndex({ created_at: 1 }, { expireAfterSeconds: 3600 })
결론
일반적인 MongoDB 성능 함정을 피하려면 반응적 튜닝에서 사전 예방적 설계로 전환해야 합니다. 문서 크기에 대한 합리적인 경계를 설정하고, ESR 규칙과 같은 인덱싱 모범 사례를 엄격히 준수하며, 컬렉션 스캔을 방지하기 위해 쿼리 패턴을 최적화함으로써 개발자는 안정적으로 확장되는 애플리케이션을 구축할 수 있습니다. explain() 및 모니터링 도구를 정기적으로 사용하는 것은 데이터와 트래픽이 계속 증가함에 따라 이러한 높은 수준의 성능을 유지하는 데 필수적입니다.