모범 사례: 일반적인 MongoDB 성능 함정 피하기

집중된 스키마, 유용한 인덱스, 프로젝션, 키셋 페이지네이션 및 쿼리 모니터링을 통해 MongoDB 성능 함정을 피하세요.

모범 사례: 일반적인 MongoDB 성능 함정 피하기

MongoDB 성능 함정은 대개 작게 시작합니다: 하나의 제한 없는 배열, 하나의 누락된 복합 인덱스, 또는 예상보다 훨씬 많은 문서를 스캔하는 대시보드 쿼리. 데이터가 성장함에 따라 이러한 선택은 느린 페이지, 높은 CPU, 그리고 고통스러운 유지 관리 시간으로 이어질 수 있습니다.

이 리뷰를 스키마 설계, 인덱싱, 쿼리 형태 및 운영 습관에 대한 체크리스트로 사용하세요.

1. 스키마 설계: 성능의 기초

성능 튜닝은 첫 번째 쿼리가 작성되기 훨씬 전에 시작됩니다. 데이터를 구성하는 방식은 읽기 및 쓰기 효율성에 직접적인 영향을 미칩니다.

문서 크기 제한 및 비대화 방지

MongoDB 문서는 16MB BSON 문서 크기 제한이 있습니다. 일반적으로 핫 운영 데이터의 경우 그보다 훨씬 낮게 유지해야 합니다. 매우 큰 문서는 더 많은 메모리를 소비하고, 더 많은 네트워크 대역폭이 필요하며, 업데이트를 더 비용이 많이 들게 만듭니다.

모범 사례: 문서를 집중적으로 유지

가장 필수적이고 자주 액세스하는 데이터만 포함하도록 문서를 설계하세요. 큰 배열이나 부모 문서와 함께 거의 필요하지 않은 관련 엔터티에는 참조를 사용하세요.

함정: 대규모 히스토리 로그나 큰 바이너리 파일(예: 고해상도 이미지)을 운영 문서 내에 직접 저장하는 것.

임베딩 vs. 참조 트레이드오프

임베딩(기본 문서 내에 관련 데이터 저장)과 참조(_id$lookup을 통한 링크 사용) 중에서 결정하는 것은 읽기 성능을 최적화하는 데 핵심입니다.

전략 최적 사용 사례 성능 영향
임베딩 작고 자주 액세스하며 밀접하게 결합된 데이터(예: 제품 리뷰, 주소 세부 정보). 빠른 읽기: 더 적은 쿼리/네트워크 왕복 필요.
참조 크고 드물게 액세스하거나 빠르게 변경되는 데이터(예: 큰 배열, 공유 데이터). 느린 읽기: $lookup(조인과 동등) 필요하지만 문서 비대화를 방지하고 참조된 데이터를 더 쉽게 업데이트할 수 있음.

경고: 배열 성장

임베디드 문서 내의 배열이 무한정 커질 수 있는 경우(예: 모든 사용자 작업 목록), 대신 별도의 컬렉션에서 해당 작업을 참조하세요. 제한 없는 배열은 문서를 더 크게 만들고, 업데이트를 느리게 하며, 결국 문서 크기 제한에 도달할 수 있습니다.

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 규칙을 사용하세요:

  1. Equality(동등): 정확히 일치하는 데 사용되는 필드가 먼저 옵니다.
  2. Sort(정렬): 정렬에 사용되는 필드가 일반적으로 다음에 옵니다.
  3. Range(범위): $gt$lt와 같은 범위 연산자에 사용되는 필드가 일반적으로 마지막에 옵니다.

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에서 효율적으로 계속
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 } }
]

쓰기 고려 사항 관리

쓰기 고려 사항은 MongoDB가 쓰기 작업에 대해 제공하는 확인 수준을 결정합니다. 높은 내구성이 엄격히 필요하지 않은 경우 지나치게 엄격한 쓰기 고려 사항을 선택하면 쓰기 지연 시간에 심각한 영향을 미칠 수 있습니다.

쓰기 고려 사항 설정 지연 시간 내구성
w: 1 낮음 기본 노드에서만 확인됨.
w: 'majority' 높음 복제 세트 멤버의 과반수에 의해 확인됨. 최대 내구성.

팁: 높은 처리량이 필요하고 중요하지 않은 작업(예: 분석 또는 로깅)의 경우 속도를 우선시하기 위해 w: 1과 같은 낮은 쓰기 고려 사항을 사용하는 것을 고려하세요. 금융 거래나 중요한 데이터의 경우 항상 w: majority를 사용하세요.

5. 배포 및 구성 모범 사례

데이터베이스 스키마와 쿼리 외에도 구성 세부 사항이 전체 시스템 상태에 영향을 미칩니다.

느린 쿼리 모니터링

느린 쿼리 로그를 정기적으로 확인하거나 $currentOp 집계 파이프라인을 사용하여 과도한 시간이 소요되는 작업을 식별하세요. MongoDB 프로파일러는 이 작업에 필수적인 도구입니다.

연결 풀 관리

애플리케이션이 효과적인 연결 풀을 사용하는지 확인하세요. 데이터베이스 연결을 생성하고 파괴하는 것은 비용이 많이 듭니다. 적절한 크기의 풀은 지연 시간과 오버헤드를 줄입니다. 애플리케이션 트래픽 패턴에 적합한 최소 및 최대 연결 풀 크기를 설정하세요.

TTL(Time-to-Live) 인덱스 사용

임시 데이터(예: 세션, 로그 항목, 캐시된 데이터)를 포함하는 컬렉션의 경우 TTL 인덱스를 구현하세요. 이를 통해 MongoDB는 정의된 기간 후에 문서를 자동으로 만료시켜 컬렉션이 통제 불능으로 성장하고 시간이 지남에 따라 인덱싱 효율성이 저하되는 것을 방지할 수 있습니다.

// 세션 컬렉션의 문서는 생성 후 3600초 후에 만료됨
db.session.createIndex({ created_at: 1 }, { expireAfterSeconds: 3600 })

실제 쿼리 계획을 계속 확인하세요

MongoDB 성능 함정을 피하는 것은 대부분 쿼리 플래너에 대해 정직하게 유지하는 것입니다. 문서를 집중적으로 유지하고, 실제 쿼리 패턴에 대한 복합 인덱스를 만들고, 프로젝션을 사용하고, 깊은 $skip을 피하고, 쿼리가 애플리케이션에 중요해질 때마다 explain('executionStats')를 확인하세요. 트래픽이 변경됨에 따라 어제의 인덱스가 여전히 올바른 것이라고 가정하지 말고 계획을 다시 검토하세요.