MongoDB 인덱싱 마스터하기: 최적의 쿼리 성능을 위한 가이드

인덱싱 기술을 마스터하여 MongoDB에서 최적의 쿼리 성능을 얻으세요. 이 포괄적인 가이드는 단일 필드, 복합 및 멀티키 인덱스를 다루고, 커버링 쿼리의 힘을 설명하며, 성능 분석을 위해 `explain()`을 사용하는 방법을 안내합니다. 읽기 작업 속도를 높이고, 데이터베이스 부하를 줄이며, 더 응답성 높은 애플리케이션을 구축하기 위한 모범 사례를 배우세요. 성능 튜닝에 집중하는 모든 MongoDB 개발자에게 필수적인 읽을거리입니다.

MongoDB 인덱싱 마스터하기: 최적의 쿼리 성능을 위한 가이드

데이터베이스가 더 이상 운에 맡길 수 없을 정도로 작지 않을 때 MongoDB 인덱싱이 흥미로워집니다. 개발 중에는 즉각적으로 느껴졌던 쿼리가 프로덕션에서 컬렉션에 수백만 개의 문서가 있고, 대시보드에 정렬이 추가되거나, API 엔드포인트가 여러 필드로 필터링을 시작하면 고통스러워질 수 있습니다.

목표는 모든 필드를 인덱싱하는 것이 아닙니다. 그렇게 하면 일반적으로 쓰기 속도가 느려지고, 메모리와 디스크를 소비하며, 중요한 쿼리가 여전히 해결되지 않은 상태로 남습니다. 목표는 애플리케이션이 실제로 의존하는 소수의 쿼리 형태를 이해하고, 그 형태와 일치하는 인덱스를 구축하는 것입니다.

MongoDB 인덱스 이해하기

핵심적으로 인덱스는 책의 색인과 같습니다. 주제를 찾기 위해 책 전체를 읽는 대신 정렬된 참조를 보고 올바른 페이지로 바로 이동합니다. MongoDB 인덱스는 쿼리 플래너가 전체 컬렉션을 스캔하지 않고 일치하는 문서를 찾을 수 있도록 도와줍니다. 유용한 인덱스가 없으면 MongoDB는 컬렉션 스캔을 수행하여 일치하는 항목을 찾을 때까지 문서를 하나씩 검사할 수 있습니다.

컬렉션 스캔이 항상 나쁜 것은 아닙니다. 작은 컬렉션을 스캔하는 것은 괜찮을 수 있습니다. 한 달에 한 번 관리 보고서를 실행하는 것도 괜찮을 수 있습니다. 그러나 트래픽이 많은 요청 경로 내에서의 컬렉션 스캔은 다릅니다. 일반적인 읽기 및 쓰기와 경쟁하고, 데이터가 증가함에 따라 악화되며, 종종 예측할 수 없는 지연 시간으로 나타납니다.

인덱스 작동 방식

MongoDB는 일반 필드 인덱스에 대해 일반적으로 B-트리 스타일 인덱스를 사용합니다. 중요한 실용적인 세부 사항은 인덱싱된 값이 순서대로 저장된다는 것입니다. 이 순서는 MongoDB가 쿼리 형태가 인덱스와 일치할 때 동등성 필터, 범위 필터 및 정렬에 도움이 됩니다.

예를 들어, { email: 1 } 인덱스는 다음에 적합합니다:

db.users.findOne({ email: "[email protected]" })

다음에는 유용하지 않습니다:

db.users.find({ lastLoginAt: { $lt: ISODate("2025-01-01") } })

두 번째 쿼리는 lastLoginAt으로 시작하는 인덱스가 필요하거나 스캔해야 합니다.

인덱스를 사용해야 하는 경우

인덱스는 다음에 자주 사용되는 필드에 가장 유용합니다:

  • 쿼리 기준 (find(), findOne()): 쿼리의 filter 문서에 사용되는 필드.
  • 정렬 기준 (sort()): 쿼리 결과를 정렬하는 데 사용되는 필드.
  • _id 필드: 기본적으로 MongoDB는 _id 필드에 인덱스를 생성하여 고유성과 ID별 빠른 조회를 보장합니다.

그러나 인덱스에는 비용도 있습니다:

  • 저장 공간: 인덱스는 디스크 공간을 소비합니다.
  • 쓰기 성능: 문서가 삽입, 업데이트 또는 삭제될 때마다 인덱스를 업데이트해야 하므로 쓰기 작업이 느려질 수 있습니다.
  • 메모리 압력: 자주 사용되는 인덱스 페이지는 캐시를 놓고 경쟁합니다. 너무 많은 대형 인덱스는 작업 세트를 메모리에 유지하기 어렵게 만들 수 있습니다.

따라서 일반적인 읽기 작업에 가장 큰 성능 향상을 가져올 필드에 초점을 맞춰 전략적으로 인덱스를 생성하는 것이 중요합니다.

인덱스 생성 및 관리

MongoDB는 인덱스를 생성하는 createIndex() 메서드와 기존 인덱스를 보는 getIndexes() 메서드를 제공합니다. dropIndex() 메서드는 인덱스를 제거하는 데 사용됩니다.

기본 인덱스 생성

단일 필드 인덱스를 생성하려면 필드 이름과 인덱스 유형(일반적으로 오름차순의 경우 1, 내림차순의 경우 -1)을 지정합니다.

db.collection.createIndex( { fieldName: 1 } );

예시: username 필드를 오름차순으로 인덱싱:

db.users.createIndex( { username: 1 } );

인덱스 보기

컬렉션의 인덱스를 보려면:

db.collection.getIndexes();

예시: users 컬렉션의 인덱스 보기:

db.users.getIndexes();

이것은 기본 _id 인덱스를 포함한 인덱스 정의의 배열을 반환합니다.

바쁜 컬렉션에서는 의도적으로 인덱스를 생성하세요. 최신 MongoDB 버전은 많은 일반적인 경우에 온라인 인덱스 빌드를 지원하지만, 인덱스 빌드는 여전히 CPU, 디스크 I/O 및 메모리를 소비합니다. 프로덕션 시스템에서는 조용한 기간 동안 대규모 인덱스 빌드를 예약하고 복제 세트를 실행하는 경우 복제 지연을 주시하세요.

인덱스 삭제

인덱스를 제거하려면:

db.collection.dropIndex( "indexName" );

getIndexes()의 출력에서 indexName을 찾을 수 있습니다. 또는 createIndex()와 동일한 형식으로 인덱싱된 필드를 지정하여 인덱스를 삭제할 수 있습니다:

db.collection.dropIndex( { fieldName: 1 } );

예시: username 인덱스 삭제:

db.users.dropIndex( "username_1" ); // 인덱스 이름 사용
// 또는
db.users.dropIndex( { username: 1 } ); // 인덱스 정의 사용

인덱스를 삭제하기 전에 여전히 사용 중인지 확인하세요:

db.users.aggregate([{ $indexStats: {} }])

이것은 서버가 시작된 이후의 액세스 카운터를 보여줍니다. 카운터가 0이라는 것은 단서일 뿐 절대적인 증거는 아닙니다. 서버가 최근에 다시 시작되었거나 쿼리가 주간 작업 중에만 실행될 수 있습니다. 중요한 시스템의 경우 $indexStats, 애플리케이션 코드 검색, 쿼리 로그 및 짧은 관찰 기간을 결합하세요.

복합 인덱스

복합 인덱스는 여러 필드를 포함합니다. 복합 인덱스에서 필드의 순서는 매우 중요합니다. MongoDB는 filter 또는 sort 절에 여러 필드가 포함된 쿼리에 대해 복합 인덱스를 사용합니다.

복합 인덱스를 사용해야 하는 경우

복합 인덱스는 쿼리가 자주 여러 필드 조합으로 필터링하거나 정렬할 때 가장 효과적입니다. 인덱스는 인덱스에 정의된 순서와 동일한 순서로 필드와 일치하거나 인덱스의 접두사와 일치하는 쿼리를 충족할 수 있습니다.

예시: userId, orderDatestatus와 같은 필드가 있는 orders 컬렉션을 고려하세요. 특정 사용자의 주문을 자주 쿼리하고 날짜별로 정렬하는 경우 { userId: 1, orderDate: 1 }의 복합 인덱스가 매우 유용할 것입니다.

db.orders.createIndex( { userId: 1, orderDate: 1 } );

이 인덱스는 다음과 같은 쿼리를 효율적으로 지원할 수 있습니다:

  • db.orders.find( { userId: "user123" } ).sort( { orderDate: -1 } )
  • db.orders.find( { userId: "user123", orderDate: { $lt: ISODate() } } )

그러나 userId도 지정되지 않은 경우 orderDate만으로 필터링하는 쿼리나 필드 순서가 다른 경우에는 효과적이지 않을 수 있습니다.

필드 순서의 중요성

복합 인덱스에서 필드의 순서는 인덱스가 잘 지원할 수 있는 쿼리 패턴을 결정합니다. 유용한 경험 법칙은 동등성 필드를 먼저, 그 다음 정렬 필드, 마지막으로 범위 필드를 배치하는 것입니다. 이것은 종종 ESR 지침(equality, sort, range)이라고 합니다. 법칙은 아니지만 많은 잘못된 인덱스 설계를 방지합니다.

주문 페이지가 다음 쿼리를 실행한다고 가정해 보겠습니다:

db.orders.find({
  tenantId: "t1",
  status: "paid",
  createdAt: { $gte: ISODate("2025-01-01") }
}).sort({ createdAt: -1 })

합리적인 인덱스는 다음과 같을 수 있습니다:

db.orders.createIndex({ tenantId: 1, status: 1, createdAt: -1 })

tenantIdstatus는 동등성 필터입니다. createdAt은 정렬과 범위를 지원합니다. 대신 { createdAt: -1, status: 1, tenantId: 1 }을 생성하면 MongoDB가 일부 경우에 여전히 사용할 수 있지만 일반적으로 이 쿼리와 덜 일치합니다.

결과를 정렬하는 쿼리의 경우 인덱스의 필드 순서는 최적의 성능을 위해 sort() 작업의 필드 순서와 일치해야 합니다. 쿼리에 필터와 정렬이 모두 포함되어 있고 인덱스가 필터 필드와 일치하면 정렬을 위한 별도의 컬렉션 스캔 없이 정렬에도 사용할 수 있습니다.

복합 인덱스는 접두사 쿼리도 제공할 수 있습니다. { tenantId: 1, status: 1, createdAt: -1 } 인덱스는 tenantId만 사용하는 쿼리나 tenantIdstatus를 사용하는 쿼리에 도움이 될 수 있습니다. status가 선행 필드가 아니기 때문에 일반적으로 status만 사용하는 쿼리에는 별로 도움이 되지 않습니다.

커버링 쿼리

커버링 쿼리는 MongoDB가 인덱스만 사용하여 전체 쿼리를 충족할 수 있는 쿼리입니다. 즉, 인덱스에 쿼리되고 프로젝션되는 모든 필드가 포함되어 있습니다. 커버링 쿼리는 컬렉션 자체에서 문서를 가져오지 않으므로 매우 빠릅니다.

커버링 쿼리를 달성하는 방법

커버링 쿼리를 달성하려면 다음을 확인하세요:

  1. 쿼리 필터에 사용된 모든 필드를 포함하는 인덱스가 있습니다.
  2. 프로젝션에 인덱싱된 필드(또는 그 하위 집합)만 포함합니다.

예시: name, agecity 필드가 있는 employees 컬렉션을 고려하세요. { city: 1, age: 1 } 인덱스가 있고 특정 도시의 직원 이름과 나이를 검색하려는 경우 커버링 쿼리를 만들 수 있습니다:

db.employees.find( { city: "New York" }, { name: 1, age: 1, _id: 0 } ).explain()

이 쿼리에서 city는 인덱스에 있고 nameage는 프로젝션에 포함됩니다. 인덱스에 nameage도 포함되어 있으면 커버링 쿼리가 됩니다.

진정한 커버링 쿼리를 위해 인덱스와 쿼리를 개선해 보겠습니다:

// 쿼리 및 프로젝션에 필요한 모든 필드를 포함하는 인덱스 생성
db.employees.createIndex( { city: 1, age: 1, name: 1 } );

// 이제 도시별로 필터링하고 이름과 나이를 프로젝션하는 쿼리를 커버할 수 있습니다.
db.employees.find( { city: "New York" }, { name: 1, age: 1, _id: 0 } )

이 쿼리에서 explain("executionStats")를 실행하면 커버된 계획이 컬렉션에서 전체 문서를 가져오지 않고 인덱스 키를 검사해야 합니다. 많은 설명 계획에서 이는 FETCH 단계 없이 IXSCAN이 표시되고 totalDocsExamined0이어야 함을 의미합니다. 설명 출력은 MongoDB 버전과 쿼리 형태에 따라 다르므로 하나의 정확한 레이블을 찾기보다는 실제 계획 단계와 검사된 카운트에 집중하세요.

커버링 쿼리는 자동 완성, 작은 목록 보기 또는 권한 확인과 같은 핫 읽기 경로에 유용합니다. 프로젝션에 큰 필드, 많은 필드 또는 지속적으로 변경되는 필드가 포함된 경우에는 덜 유용합니다. 쿼리를 커버하기 위해 인덱스에 너무 많은 필드를 추가하면 쓰기 성능에 영향을 미치는 부피가 큰 인덱스가 생성될 수 있습니다.

기타 중요한 인덱스 유형

MongoDB는 특정 사용 사례를 위한 다양한 인덱스 유형을 제공합니다:

멀티키 인덱스

멀티키 인덱스는 배열 필드를 인덱싱할 때 자동으로 생성됩니다. 이를 통해 배열 내의 요소를 쿼리할 수 있습니다.

예시: tags 배열 필드 ["electronics", "gadgets"]가 있는 products 컬렉션이 있는 경우:

db.products.createIndex( { tags: 1 } );

이 인덱스는 db.products.find( { tags: "electronics" } )와 같은 쿼리를 지원합니다.

배열은 복합 인덱스에서 추가적인 주의가 필요합니다. 멀티키 인덱스는 배열 요소에 대한 항목을 저장하므로 인덱스 크기가 빠르게 증가할 수 있습니다. 또한 MongoDB는 동일한 문서에 여러 인덱싱된 필드에 배열이 포함될 수 있는 경우 복합 멀티키 인덱스에 대한 제한 사항이 있습니다. 데이터 모델에 여러 배열과 복잡한 필터가 있는 경우 복합 인덱스가 스칼라 필드 인덱스처럼 동작할 것이라고 가정하기 전에 대표 데이터로 정확한 쿼리를 테스트하세요.

텍스트 인덱스

텍스트 인덱스는 문서의 문자열 콘텐츠를 효율적으로 검색할 수 있도록 지원합니다. $text 연산자를 사용한 텍스트 검색 쿼리에 사용됩니다.

db.articles.createIndex( { content: "text" } );

이를 통해 db.articles.find( { $text: { $search: "database performance" } } )와 같은 검색이 가능합니다.

텍스트 인덱스는 기본적인 텍스트 검색에 유용하지만 완전한 검색 플랫폼은 아닙니다. 고급 관련성 튜닝, 오타 허용, 패싯, 하이라이팅 또는 언어별 검색 동작이 필요한 경우 MongoDB Atlas Search 또는 전용 검색 엔진이 더 적합할 수 있습니다.

지리 공간 인덱스

지리 공간 인덱스는 $near, $geoWithin$geoIntersects 연산자를 사용하여 지리적 데이터를 효율적으로 쿼리하는 데 사용됩니다.

db.locations.createIndex( { loc: "2dsphere" } ); // 2dsphere 인덱스의 경우

고유 인덱스

고유 인덱스는 필드 또는 필드 조합에 대해 고유성을 강제합니다. 중복 값이 삽입되거나 업데이트되면 MongoDB는 오류를 반환합니다.

db.users.createIndex( { email: 1 }, { unique: true } );

프로덕션 사용자 테이블의 경우 고유성을 강제하기 전에 정규화하세요. 이메일 주소는 일반적인 예입니다. 애플리케이션이 [email protected][email protected]을 동일한 사용자로 취급하는 경우 emailLower와 같은 정규화된 필드를 저장하고 거기에 고유 인덱스를 배치하세요. 동시성 하에서 중복을 방지하기 위해 애플리케이션 코드에만 의존하지 마세요.

부분 인덱스

부분 인덱스는 필터 표현식과 일치하는 문서만 인덱싱합니다. 쿼리가 컬렉션의 하위 집합에 초점을 맞출 때 유용합니다.

db.orders.createIndex(
  { tenantId: 1, createdAt: -1 },
  { partialFilterExpression: { status: "open" } }
)

이것은 애플리케이션이 열린 주문을 자주 읽고 닫힌 주문이 컬렉션의 대부분을 차지하는 경우 도움이 될 수 있습니다. 부분 필터와 일치하지 않는 문서를 제외하므로 인덱스가 더 작습니다. MongoDB가 이를 사용하려면 쿼리에 호환 가능한 조건이 포함되어야 합니다.

TTL 인덱스

TTL 인덱스는 구성된 시간이 지나면 문서를 자동으로 제거합니다. 세션, 임시 토큰 또는 수명이 짧은 이벤트에 일반적으로 사용됩니다.

db.sessions.createIndex(
  { expiresAt: 1 },
  { expireAfterSeconds: 0 }
)

TTL 삭제는 정확한 만료 시간에 즉시 이루어지지 않습니다. MongoDB는 백그라운드에서 만료된 문서를 제거합니다. 토큰이 즉시 무효화되어야 하는 정확한 보안 타이밍이 아닌 정리 작업에 사용하세요. 애플리케이션은 읽기 중에도 만료를 확인해야 합니다.

explain()을 사용한 성능 분석

MongoDB가 쿼리를 실행하는 방법을 이해하는 것은 최적화에 중요합니다. explain() 메서드는 인덱스가 사용되었는지 여부와 방법을 포함하여 쿼리 실행 계획에 대한 통찰력을 제공합니다.

db.collection.find( {...} ).explain( "executionStats" );

explain() 출력에서 찾아야 할 주요 필드:

  • winningPlan.stage: 실행 계획의 단계를 나타냅니다(예: COLLSCAN은 컬렉션 스캔, IXSCAN은 인덱스 스캔).
  • executionStats.totalKeysExamined: 검사된 인덱스 키의 수.
  • executionStats.totalDocsExamined: 검사된 문서의 수.

좋은 실행 계획은 totalDocsExamined가 반환된 문서 수에 가깝거나 같고 totalKeysExamined가 컬렉션의 총 문서 수보다 훨씬 적습니다. totalDocsExamined가 매우 높거나 COLLSCAN이 사용되면 인덱스가 없거나 효과적으로 사용되지 않고 있음을 나타냅니다.

제가 설명 계획을 읽는 빠른 방법은 다음과 같습니다:

  1. COLLSCAN을 찾으세요. 이것이 핫 경로이고 컬렉션이 크면 일반적으로 첫 번째 문제입니다.
  2. IXSCAN 다음에 FETCH가 오는 것을 찾으세요. 쿼리에 인덱스 외부의 필드가 필요할 때 가져오기는 정상이지만 과도한 문서 검사는 인덱스가 충분히 선택적이지 않음을 의미합니다.
  3. nReturned, totalKeysExaminedtotalDocsExamined를 비교하세요. 25개의 키를 검사한 후 20개의 문서를 반환하는 것은 건강합니다. 500,000개의 키를 검사한 후 20개의 문서를 반환하는 것은 그렇지 않습니다.
  4. 메모리 내 정렬을 주시하세요. MongoDB가 필터링 후 큰 결과 집합을 정렬해야 하는 경우 정렬을 지원하는 복합 인덱스가 도움이 될 수 있습니다.

테스트 시 현실적인 필터를 사용하세요. tenantId: "demo"에 대한 설명 계획은 수백만 개의 문서가 있는 대규모 테넌트와 일치하지 않을 수 있습니다. 데이터 분포가 중요합니다.

실용적인 인덱스 설계 워크스루

tickets 컬렉션이 있는 애플리케이션을 상상해 보세요. 지원 에이전트는 다음 필터가 있는 대기열 페이지를 사용합니다:

db.tickets.find({
  tenantId: "acme",
  status: "open",
  assigneeId: "u123"
}).sort({ updatedAt: -1 }).limit(50)

필드 목록이 아닌 쿼리 형태부터 시작하세요. 컬렉션은 멀티 테넌트이며, 에이전트는 일반적으로 상태와 담당자별로 필터링하고 UI는 가장 최근 업데이트를 먼저 정렬합니다. 실용적인 인덱스는 다음과 같습니다:

db.tickets.createIndex({
  tenantId: 1,
  status: 1,
  assigneeId: 1,
  updatedAt: -1
})

이제 다른 페이지를 고려하세요: 관리자는 담당자에 관계없이 모든 열린 티켓을 봅니다:

db.tickets.find({
  tenantId: "acme",
  status: "open"
}).sort({ updatedAt: -1 }).limit(100)

이전 인덱스는 접두사 { tenantId, status }를 사용할 수 있지만 assigneeIdupdatedAt 앞에 있으므로 이 관리자 쿼리에 대한 정렬을 잘 지원하지 못할 수 있습니다. 두 번째 인덱스가 필요할 수 있습니다:

db.tickets.createIndex({
  tenantId: 1,
  status: 1,
  updatedAt: -1
})

이것은 정상적인 절충안입니다. 하나의 인덱스가 모든 화면에 완벽하게 서비스되지는 않습니다. 중요한 것은 모든 쓰기 비용을 발생시키는 중복 인덱스를 많이 만들지 않고 중요한 경로를 지원하는 것입니다.

MongoDB 인덱싱 모범 사례

  • 필요한 것만 인덱싱하세요: 거의 쿼리되거나 정렬되지 않는 필드에 인덱스를 생성하지 마세요. 각 인덱스는 오버헤드를 추가합니다.
  • 복합 인덱스를 현명하게 사용하세요: 쿼리 패턴에 따라 필드를 올바르게 정렬하세요. 가장 선택적인 필드를 먼저 고려하세요.
  • 커버링 쿼리를 목표로 하세요: 읽기 성능이 중요한 경우 일반적인 읽기 작업을 커버하도록 인덱스를 설계하세요.
  • 인덱스 사용량을 모니터링하세요: explain()db.collection.aggregate([{ $indexStats: {} }])를 사용하여 정기적으로 인덱스 사용량을 검토하여 사용되지 않거나 비효율적인 인덱스를 식별하세요.
  • 인덱스 선택성을 고려하세요: 카디널리티가 낮은(고유 값이 적은) 필드의 인덱스는 카디널리티가 높은 필드의 인덱스만큼 효과적이지 않을 수 있습니다.
  • 인덱스를 작게 유지하세요: 커버링 쿼리에 절대적으로 필요하지 않은 한 인덱스에 큰 필드나 배열을 포함하지 마세요.
  • 인덱스를 테스트하세요: 현실적인 부하 조건에서 읽기 및 쓰기 성능 모두에 대한 새 인덱스의 영향을 항상 테스트하세요.
  • 중복 인덱스를 신중하게 제거하세요: { a: 1, b: 1 }이 있는 경우 별도의 { a: 1 } 인덱스는 많은 워크로드에서 중복될 수 있습니다. 삭제하기 전에 사용을 확인하세요.
  • 실제 화면과 작업을 중심으로 설계하세요: 인덱스는 애플리케이션 동작(로그인 조회, 대기열 페이지, 보고서 필터, 백그라운드 워커 스캔)에 매핑되어야 합니다.
  • 스키마 변경 후 재검토하세요: 새 필드, 새 정렬 순서 또는 새 테넌트 모델로 인해 이전 인덱스가 덜 유용해질 수 있습니다.

좋은 인덱싱이 느껴지는 방식

좋은 MongoDB 인덱싱은 일반적으로 조용합니다. 중요한 쿼리는 반환하는 데이터의 양과 거의 같은 양의 데이터를 검사합니다. 정렬은 값비싼 작업으로 이어지지 않습니다. 쓰기는 수많은 투기적 인덱스로 인해 부담을 받지 않습니다. 새로운 기능이 새로운 쿼리 형태를 추가할 때 프로덕션 사고가 되기 전에 explain("executionStats")로 테스트합니다.

실용적인 습관은 간단합니다: 실제 쿼리를 수집하고, 해당 쿼리 형태에 대해 가장 작은 유용한 인덱스를 설계하고, 대표 데이터로 테스트하고, 시간이 지남에 따라 인덱스 사용량을 계속 확인하는 것입니다. 이 습관은 모든 인덱스 유형을 암기하는 것보다 MongoDB 성능에 더 많은 도움이 될 것입니다.