MongoDB 인덱스에 대해

효과적인 인덱싱 전략을 위해서 가장 중요한 것은 “Selectivity(선택성)“를 높이는 것이다.

즉, 최대한 좁은 범위를 탐색할 수 있도록 인덱스를 만들어야 한다. 그리고 읽기보다 쓰기 작업이 많은 컬렉션에는 인덱스를 복잡하게 설계하지 않아야 한다.

MongoDB는 쿼리를 실행할 때 어떤 방식으로 검색할지 스스로 여러 가지 계획을 세우고, 적합한 인덱스를 사용한 계획을 채택한다. 그래서 사용되지 않는 인덱스는 없애는 것이 좋다.

인덱스를 어떻게 만들지 판단하는 방법

좋은 인덱스를 설계하는 가장 좋은 방법은 실행할 쿼리에 대해 MongoDB가 어떤 계획을 세우는지 직접 확인하는 것이다.
그리고 자주 쓰는 쿼리와 빨리 수행해야 하는 쿼리를 조사해서 인덱스를 정의할 공통적인 키 셋을 찾는다.

결국 MongoDB가 쿼리에 대해 효율적으로 실행하려면 대부분의 쿼리에 인덱스가 사용되도록 해야 한다.

MongoDB는 인덱스를 검사하기 위해 explain() 메소드를 제공한다.
explain() 메서드에 “executionStats” 을 전달하면 인덱스를 이용한 쿼리의 효과를 이해하는 데 도움이 된다.

“executionStats”는 explain() 메서드의 출력에서 선정된(winning) 쿼리 플랜의 통계를 포함한다.
이 통계에서 “nReturned”, “totalDocsExamined”, “totalKeysExamined”, “executionTimeMillis“를 중요하게 확인해야 한다.

  • nReturned: 쿼리에 의해 반환된 도큐먼트 개수
  • totalDocsExamined: MongoDB가 디스크 내 실제 도큐먼트를 가리키는 인덱스 포인터를 따라간 횟수. 쿼리가 인덱스의 일부가 아닌 검색 조건을 포함하거나, 인덱스에 포함되지 않은 필드를 반환(projection)하도록 요청한다면 MongoDB는 각 인덱스 항목이 가리키는 도큐먼트를 봐야한다.
  • totalKeysExamine: 인덱스가 사용됐다면 살펴본 인덱스 항목 개수
  • needYields: 쓰기 요청을 처리하도록 쿼리가 양보(일시 중지)한 횟수
  • executionTimeMillis: 데이터베이스가 쿼리하는 데 걸린 시간

explain() 출력은 쿼리 플랜을 단계 트리(tree of stages)로 표시한다.
각 단계에는 하위 단계(Child stage)마다 하나 이상의 입력 단계(Input stage)가 있을 수 있으며, 입력 단계에서 도큐먼트나 인덱스 키를 상위 단계(Parent stage)에 제공한다.

쿼리 플랜에 “SORT”라는 단계가 나타나면, 이것은 MongoDB가 쿼리 결과를 정렬할 때 인덱스를 사용할 수 없었으며 대신 인-메모리 정렬을 했다는 의미다.

복합 인덱스는 어떻게 정렬된 결과를 반환할까?

단일 인덱스는 하나의 필드를 대상으로 인덱스를 생성한 것이고, 복합 인덱스는 두 개 이상의 필드를 기반으로 생성한 인덱스이다.

단일 인덱스를 만들 때는 정렬 순서의 방향은 크게 중요하지 않다. 그 이유는 인덱스를 어느 방향으로 탐색해도 정렬이 되어 있기 때문이다.

일반적으로 복합 인덱스를 활용해야 하는 경우가 많을 것이다.
복합 인덱스를 만들 때는 정렬 키를 첫 번째에 두는 것이 좋다. 그 이유를 알아보자.

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

db.users.find({"age": {"$gte": 21, "$lte": 30}}).sort({"username": 1});

위 쿼리는 age 필드에 대한 범위 탐색에 인덱스를 사용한다. 하지만 이 인덱스는 username으로 정렬된 순서를 갖고 있지 않기 때문에 결과를 반환하기 전에 반드시 메모리에서 정렬해야 한다. 그래서 비효율적이다.
그리고 만약 결과가 너무 크다면 MongoDB는 정렬을 거부한다는 오류를 발생시키게 된다.

만약 이 인덱스와 동일한 필드를 역순으로 해서 {"username": 1, "age": 1} 인덱스를 만들어서 사용한다면, 일치하는 값을 찾기 위해 인덱스 전체를 훑겠지만, 인-메모리 정렬이 필요하지 않다는 장점이 있다. (오히려 좋아)

복합 인덱스를 사용할 때 선택성을 높일 수 있도록 범위를 좁힌다.

인덱스의 항상 선택성(Selectivity) 를 고려해야 한다고 했다.
그래서 보통 동등 필터 조건에서 사용될 필드가 다중값 필터(범위 필터 등)의 필드보다 앞에 오도록 복합 인덱스를 설계해야 한다.

쿼리에서 선정된 인덱스는 정렬된 결과를 반환할 수 있는 인덱스여야 하며, winningPlan으로 선정되려면 정렬된 결과의 도큐먼트 시험 수에 도달해야 한다. 그렇지 않고 다른 계획이 선정되려면 해당 쿼리 스레드의 전체 결과가 먼저 반환되어야 한다.

만약 특정 인덱스를 사용하도록 강제하기 위해서는 hint()메서드를 사용하면 된다. 여기에 인덱스 모양(필드명)이나 인덱스 이름을 전달해서 사용할 인덱스를 지정할 수 있다.
그러나 쿼리 플래너의 결과를 재정의하는 방법은 신중해야 하며, 운영 환경에서는 사용해서는 안 된다.

복합 인덱스에서 자주 발생하는 문제는 인-메모리에서 정렬하는 도큐먼트 개수보다 인덱스에서 더 많은 키를 검사하는 상황이다. (⚠️ 예제가 있으면 좋겠다)

인덱스를 사용해 정렬하고자 한다면 MongoDB가 인덱스 키를 순서대로 살펴볼 수 있어야 한다. 즉, 복합 인덱스를 만들 때 탐색 필드 사이에 정렬 필드를 포함해야 한다.
이렇게 되면 MongoDB가 결과에 포함될 도큐먼트보다 더 많은 인덱스의 키를 검사하게 되었지만, 인덱스를 사용해 정렬된 결과를 사용함으로써 실행 시간을 절약할 수 있다. (한 문장으로 요약: “더 많이 봤지만 정렬하지 않아도 됨”)

복합 인덱스를 설계할 때 지켜야 할 규칙을 정리하면 다음과 같다.

  1. 동등 필터에 대한 키를 맨 앞에 표시해야 한다.
  2. 정렬에 사용되는 키는 다중값 필드(예: 범위 필터) 앞에 표시해야 한다.
  3. 다중값 필터에 대한 키는 마지막에 표시해야 한다.

이렇게 하면 인덱스의 첫 번째 필드와 정확히 일치하는 값을 먼저 찾은 후 두 번째 필드로 정렬된 결과에 마지막 세 번째 필드 범위 안에서 검색하게 된다. 🤗

그밖에 복합 인덱스에 대해 알아둘 것

<인덱스 키 방향>

두 개 이상의 필드 조건으로 정렬하는 쿼리는 인덱스의 키 방향과 일치시켜야 한다.
따라서 인덱스에서 사용할 정렬 방향을 결정할 때는 실제로 실행될 쿼리의 정렬의 방향을 파악하면 된다.

역방향 인덱스(inverse index, 각 방향에 -1을 곱한다)는 정방향 인덱스와 동등하기 때문에 괜히 둘 다 생성할 필요가 없다.

1. 커버드 쿼리 사용

쿼리에서 찾는 필드(projection)가 인덱스에 포함된 필드만 해당하면 도큐먼트를 전부 가져올 필요가 없다.
인덱스가 쿼리가 요구하는 값을 모두 포함하면 쿼리가 커버드(covered)된다고 하는데 이 방법으로 작업 결과를 훨씬 작게 만들 수 있다.

쿼리가 확실히 인덱스만 사용하게 해서 필드를 반환하게 하려면, “_id” 필드를 반환하지 않도록 반환할 필드를 직접 지정해야 한다.
커버드 쿼리의 explain() 결과를 살펴보면 “FETCH” 단계의 하위 단계가 아닌 “IXSCAN” 단계가 있으며, “totalDocsExamined” 값은 0이 된다.

2. 인덱스 접두사, Index Prefix

사실상 복합 인덱스는 여러 임무를 수행한다고 할 수 있다.
예를 들어{"age": 1, "username": 1}로 인덱스를 만들면 {"age": 1}로만 인덱스를 가지는 것과 같다.
이 규칙은 어느 서브 셋에나 적용되는 것은 아니며 인덱스의 접두사(Index Prefix)를 이용하는 쿼리에만 적용할 수 있다.

즉, 인덱스의 필드 순서상 앞에서부터 일치하는 패턴만 Index Prefix가 적용된다.

3. ‘$’ 연산자의 인덱스 사용법

일반적으로 부정 조건은 비효율적이다.
$ne 쿼리는 인덱스를 사용하긴 하지만 잘 활용하지 못한다. $ne로 지정된 값을 제외한 모든 값을 인덱스에서 살펴봐야 하기 때문이다.

$not 쿼리는 거의 컬렉션 스캔을 수행하고, $nin은 항상 컬렉션 스캔을 수행한다.
$not 쿼리 대신 기초적인 범위 쿼리나 정규 표현식을 반대로 뒤집어서 대체할 수 있다.

MongoDB는 쿼리당 하나의 인덱스만 사용할 수 있다.
하지만 $or 쿼리는 예외다. $or 쿼리는 두 개의 쿼리를 수행하고 결과를 합치므로 $or절마다 하나씩 인덱스를 사용할 수 있다.
일반적으로 두 번 쿼리 해서 결과를 합치면 한 번 쿼리 할 때보다 당연히 느리다. 그러니 가능하면 $or보다 $in을 사용하자.

객체(Object)와 배열(Array) 인덱싱

내장 도큐먼트(객체)와 배열 필드도 일반적인 인덱싱 방식과 동일하게 인덱스를 만들 수 있다.

db.users.createIndex({"location.city": 1})

하지만 내장 도큐먼트 전체(예: location자체)를 인덱싱하면 내장 도큐먼트의 필드( location.city)를 인덱싱한 것과 완전히 다르다.
내장 도큐먼트 전체를 인덱싱하면, 내장 도큐먼트 전체를 쿼리 할 때만 도움이 된다.

객체와 달리 배열은 배열 전체를 단일 개체처럼 인덱싱할 수 없다.
배열 필드의 인덱싱은 배열 자체가 아니라 배열의 각 요소를 인덱싱하기 때문이다.

db.blog.createIndex({"comments.date": 1})

그리고 배열 요소에 대한 인덱스에는 위치 개념이 없다. 그래서 comments.4와 같이 특정 요소를 찾는 쿼리에 인덱스를 사용할 수 없다.

배열을 인덱싱하면 배열의 요소마다 인덱스 항목으로 생성하기 때문에 도큐먼트에 어떤 배열 필드가 N개의 요소(length=N)를 가졌다면, 이 인덱스의 항목은 N개가 된다.

인덱스 항목의 한 필드만 배열로부터 가져올 수 있다. 이는 복합 인덱스의 다중키에 의해 인덱스 항목이 폭발적으로 늘어나는 것을 피하기 위함이다. (⚠️ 예제가 있으면 좋겠다)

어떤 도큐먼트가 배열 필드를 인덱스 키로 가지면 그 인덱스는 즉시 다중키 인덱스로 표시된다.
이 인덱스를 사용한 계획을 explain() 결과에서 살펴보면 isMultiKey필드가 true로 되어 있는 것을 확인할 수 있다.

일반적으로 다중키 인덱스는 비다중키 인덱스보다 약간 느릴 수 있다.
만약 다중키 인덱스가 되면, 해당 필드 내 배열을 포함하는 모든 도큐먼트가 삭제되어도 비다중키 인덱스가 될 수 없기 때문에 다시 생성해야 한다.

인덱스가 없는 게 더 빠른 경우

인덱스는 컬렉션에서 가져와야 하는 필드가 많을수록 비효율적인데, 그 이유는 인덱스를 하나 사용하려면 두 번의 조회를 해야 하기 때문이다.

  1. 인덱스 항목 살펴보기
  2. 인덱스 포인터가 가리키는 도큐먼트를 살펴보기

반면 컬렉션 스캔을 할 때는 도큐먼트만 살펴보면 되기 때문에 최악의 경우 컬렉션의 모든 도큐먼트를 반환해야 할 때 인덱스를 사용하는 게 더 느릴 수 있다.

함께 보면 좋은 자료