MongoDB - Index 인덱스 사용하기 - (1)

Share

Last Updated on 1월 18, 2023 by Jade(정현호)

안녕하세요 
이번 포스팅에서는 MongoDB 의 Index(인덱스) 의 종류 와 사용에 관한 전반적인 내용에 대해서 확인 해보려고 합니다.  

인덱스(Index)

인덱스는 데이터베이스에서 데이터(테이블)에 대해서 빠르게 조회할 수 있게 해주는 자료구조 입니다. 인덱스는 비유 또는 표현 할 때에는 색인 이라는 책 마지막에 있는 정보로  사전 순으로 나열 되어 있어서 원하는 내용을 빠르게 찾을수 있게 도움을 주는 역할로 자주 표현 되고 있습니다.

이러한 설명대로 책의 내용(페이지)를 찾기 위해서 사전 순으로 나열되어 있고 그 정보를 통해서 원하는 페이지 정보를 얻고, 실제 페이지로 이동하여 책의 내용을 읽게 되는 과정과 데이터베이스에서 인덱스를 사용하여 데이터를 액세스 하는 과정은 매우 흡사 하다고 할 수 있습니다.


다시 책을 예를 들어 책을 빠르게 책장에 넣으려는 행동과 책을 빠르게 찾으려는 행동은 사실은 상반되게 됩니다.

즉, 빠르게 책을 책장에 넣기 위해서는 순서와 상관없이 책을 넣을 수 있는 빈공간에 넣는 것이 제일 빠른 방법이지만, 이럴 경우 책을 찾을때(꺼낼때) 는 찾는데 시간이 많이 소요 됩니다.
반대로 책을 빠르게 찾기 위해서는 책을 제목이나 장르등의 기준으로 순서대로 책장에 넣게 되면 찾을 때 빠르게 찾을 수 있습니다.

이와 같이 빠르게 찾기 위해서는 조회하는 정보가 정렬이 되어 있어야 하며 책 끝에 찾아보기(색인) 과 같이 A,B,C, .. ㄱ,ㄴ,ㄷ 과 같은 순서대로 정렬하여 데이터베이스의 인덱스는 정렬되어 저장되어 있습니다.

DBMS 의 데이터(테이블) 의 경우 DB마다 또는 사용 하는 테이블의 구조에 따라서 인덱스 순서와 동일하게 데이터가 저장되는 유형의 Clustered Index(MySQL) 형태가 있으며 그렇지 않은 유형의 Heap organized table(Oracle Database) 가 있습니다.

Index 에는 Primary Key 와 일반 인덱스(또는 Secondary Index) 로 구분할 수 있으며, Primary Key 에서는 NULL 을 허용하지 않으며, 고유한(Unique) 한 값만 가질 수 있기 때문에(중복 허용 불가) 테이블내에서 레코드에 대해서 식별할 수 있는 기준 값이 되게 됩니다. 

유니크(Unique) 인덱스와 Primary Key 의 차이점은 NULL 의 허용 여부 차이로 유니크(Unique) 인덱스(또는 유니크 제약조건) 에서는 NULL 이 허용 됩니다.
                

MongoDB의 인덱스 구조

인덱스는 사용되는 데이터스토어에 따라서 아키텍처나 사용상의 일부 차이가 있으며, MySQL 에서 사용되는 Clustered Index 와 같은 구조는 MongoDB 에서는 지원하지 않으며 일반 B-Tree 인덱스로 구현 되어 있습니다. 그에 따라서 MySQL 과 달리 PK 와 Non-PK(일반 인덱스)의 구조적인 차이는 없습니다.

[medium.com/mongodb-indexes-deep-dive]


일반적인 B-Tree 를 구조를 사용함에 따라서 아래와 구조로 되어 있습니다.

  • 브랜치 노드와 리프노드에서는 인덱스 키 엔트리와 Record-ID로 구성되어 있습니다.
  • 키 엔트리에는 키와 값 쌍으로 구성되어 있으며, 키 값은 인덱스 생성시 인덱스 구성 컬럼의 필드 값 입니다.
  • Record-ID 는 MongoDB 에서 내부적으로 관리 되는 값으로 인덱스 키 엔트리의 키 값과 연결된 도큐먼트의 저장 주소를 의미 하며, 논리적인 주소이거나 물리적인 주소가 되게 됩니다.

          

Record-Id

MMAPv1 스토리지 엔진에서는 인덱스 키의 "Record-Id" 에 실제 도큐먼트가 저장된 주소(물리적인 주소)를 저장 합니다.

MMAPv1 스토리지 엔진에서 도큐먼트의 주소는 데이터 파일 과 파일 내 도큐먼트의 저장 위치(offset) 으로 구성 되며, 이전에는 DiskLoc 이라고 불렀으며 3.2 버전 부터는 Record-Id 라는 이름의 객체로 통합 되었습니다.

RecordId 를 조회하는 방법으로는 아래와 같습니다.

  • 3.2 버전 이전(3.2 버전부터는 Deprecated 됨)
    • db.collection.find( { <query> } )._addSpecial("$showDiskLoc" , true)
  • 3.2 버전 부터
    • db.pizzas.find().showRecordId()


MMAPv1 스토리지 엔진 내부적으로 DiskLoc 객체를 아직도 사용하며, Record-Id 가 실제 도큐먼트의 물리 저장 주소이기 때문에 듀큐먼트가 데이터 파일의 다른 위치로 이동되면 프라이머리 키를 포함해서 모든 인덱스의 엔트리를 변경이 되어야 합니다.

MongoDB 의 MMAPv1 스토리지 엔진에서 도큐먼트의 크기가 증가 하는 경우를 대비하여 paddingFactor 라는 옵션을 도입하여 도큐먼트 저장시 일부로 도큐먼트 사이에 빈 공간을 할당해 두었다가 도큐먼트의 크기가 증가시 해당 빈 공간을 활용하여 최대한 도큐먼트의 위치가가 이동되지 않도록 하고 있습니다.

MMAPv1 스토리지 엔진에서는 도큐먼트가 옮겨 질 때마다 물리 주소를 가지고 있는 인덱스의 엔트리를 모두 변경 해야 하기 때문에 MMAPv1 스토리지 엔진에서는 이런 점이 주의 및 고려가 되고 있으며 보완 하기 위해 도입된 paddingFactor 라는 개념이 데이터 파일 용량을 늘리는데 주요 원인이 되었습니다.


WiredTiger 스토리지 엔진은 MMAPv1 스토리지 엔진과는 다르게 인덱스 키 엔트리에 실제 물리 주소가 아닌 논리 주소를 사용 합니다. 조금 더 설명하면 논리 주소라기 보다는 도큐먼트 마다 고유의 식별자를 할당해서 Record-Id 로 사용하며, 엔진에서 부여하는 도큐먼트 고유 식별자는 MySQL 의 Auto-Increment(또는 시퀀스) 와 같은 숫자 자동 증가 방식을 사용 합니다.

WiredTiger 의 Record-Id 도 64비트(8바이트, Long) 정수 타입을 사용하며, 컬렉션 단위로 별도의 자동 증가 값을 사용 합니다.

MMAPv1 스토리지 엔진과 달리 WiredTiger 스토리지 엔진의 Record-Id 는 도큐먼트의 크기가 커져서 파일내에서 위치가 변경 되었을 경우에도 처음 할당된 논리적인 주소 값은 변하지 않고 계속 유지가 됩니다.


자동 증가 값 기반의 Record-Id 를 찾아가기 위해서 WiredTiger 스토리지  엔진에서는 Record-Id 를 찾아가기 위한 내부 인덱스를 하나 더 가지게 됩니다.(클러스터링 인덱스)

그래서 인덱스를 통해서 도큐먼트를 검색할 때 두번의 인덱스 검색(사용자 인덱스 + 내부 Record-Id 인덱스) 을 해야지 결과를 얻을 수 있게 됩니다. 
         

LOCAL INDEX

MongoDB가 지원하는 인덱스의 종류는 범용의 RDBMS 보다 상대적으로 많아서 OLTP 서비스에서 사용하는데 사실은 전혀 문제는 되지 않습니다. 

MongoDB 의 세컨더리 인덱스는 LOCAL(로컬) 인덱스로 관리 되므로 각 샤드가 저장하고 있는 도큐먼트에 대한 인덱스만 가지게 됩니다. 그래서 MongoDB 인덱스는 샤드 단위로 로컬 데이터에 대한 인덱스를 관리하게 되며 프라이머리 인덱스나 유니크 인덱스는 샤드 키를 반드시 포함해야 하거나 애플리케이션 레벨에서의 유니크함을 보장해야 합니다.

즉, 샤드 클러스터에서는 해당 샤드가 저장하고 있는 도큐먼트에 대해서만 인덱스가 구성되어 있습니다.

MongoDB 의 샤드 클러스터는 데이터를 균등하게 배치하기 위해서 밸런서가 백그라운드로 샤드간의 데이터를 자동으로 분산하며, 이때 분산하기 위해서 밸런서는 데이터를 생성 및 삭제 와 같은 작업이 이루어지게 됩니다. 데이터 삭제와 입력 과정에서 컬렉션 생성된 인덱스도 같이 갱신이 되어야 하기 때문에 생성된 인덱스 수가 많다면 그만큼의 밸런싱 속도의 저하가 발생되게 됩니다.
        

인덱스 키 엔트리 구조

MongoDB 는 도큐먼트 형태의 DB로 컬렉션안의 도큐먼트들은 BSON 이라는 JSON의 변형된 포맷으로 저장이 되며, 도큐먼트는 키-값 형태의 쌍으로 구성된 JSON 을 사용 합니다.

그래서 통상적으로 보통 사용하는 RDB 보다는 상대적으로 디스크에 저장되는 용량이 큽니다. 이러한 JSON 및 키-값 구조의 형태는 컬렉션, 도큐먼트에 해당 하는 내용이며, 컬렉션에 생성된 인덱스의 경우 BSON 자료 구조를 사용하지는 않습니다.

도큐먼트에 저장되는 형태는 스키마 프리 또는 스키마 리스(schema-less) 이지만, 인덱스의 경우는 내부적으로 별도의 스키마를 가지고 있으며, 어떠한 필드로 인덱스가 생성 되었는지 등에 대한 인덱스에 대한 메타 데이터가 관리되고 있습니다.

그래서 생성시에 설정된 필드 이외에 다른 필드가 필요시에 다시 생성하거나 추가로 생성을 해야 합니다. 즉 인덱스는 Schema-less 가 아니다 라는 의미 입니다.

인덱스 라는 자료 구조 자체가 비정규화 된 형태를 가질 수 없기 때문에 보통의 정석의 스키마 형태로 구성되며, 인덱스 리프에는  키-값 쌍이 아닌 필드의 값만 저장되어 있게 됩니다.
         

MongoDB 기본 인덱스(B-Tree )

B-Tree는 데이터베이스의 인덱싱 알고리즘으로 가장 일반적이고 오래된, 그러면서도 가장 데이터베이스의 범용성을 만족시키는 인덱스 알고리즘 이며 MongoDB 에서도 기본 인덱스 구조체로 사용되고 있습니다.

B-Tree 에는 여러가지 변형된 형태의 알고리즘이 있으며 일반적으로 DBMS에서는 주로 B+-Tree 또는 B*-Tree 가 사용 됩니다.


B-Tree 에서 "B" 는 통상적으로 알고 있는 Binary(이진) 의 B 는 아니며, B-트리의 창시자인 루돌프 바이어는 'B'가 무엇을 의미하는지 따로 언급하지는 않았습니다. 다만 가장 가능성 있는 대답은 리프 노드를 같은 높이에서 유지시켜주므로 균형잡혀있다(balanced)는 뜻에서의 'B' 즉 balanced 라는 것이 보통적으로 알려지거나 통용적으로 인지하고 있는 내용입니다.

또는 '바이어(Bayer)'의 'B'를 나타낸다는 의견도, 혹은 그가 일했던 보잉 과학 연구소(Boeing Scientific Research Labs)에서의 'B'를 나타낸다는 의견도 있긴 합니다.

[B 트리 (Bayer & McCreight 1972) (Knuth 1998) Order 5]


인덱스 구조체 내에서 항상 정렬된 상태로 유지한다는 점이 B-Tree 인덱스의 큰 특징이라고 할 수 있습니다.
                 

B-tree 구조 및 특성

범용적인 목적의 인덱스로 B-Tree 인덱스는 여러 곳에서 많이 사용 되고 있습니다.

인덱스 리프 노드의 각 키 값은 테이블의 데이터 레코드를 찾아가기 위한 물리적 주소 값을 가지고 있으며, 인덱스 키 값들은 모두 정렬이 되어 있지만, 테이블의 데이터 레코드는 기본적으로 정렬 되어 있지 않으며 INSERT 된 순서로 저장이 되게 됩니다.

인덱스 레코드에서 레코드 주소 정보란 도큐먼트의 물리적인 위치일 수 도 있고, 논리적인 시퀀스 등의 값일 수도 있으며, MongoDB 의 인덱스는 필드의 값 과 주소 값(Record-Id) 의 조합이 인덱스 레코드로 구성됩니다.

MongoDB의 컬렉션은 별도의 컬럼 정의를 가지지 않지만(스키마 리스), 컬렉션에 생성되는 인덱스는 반드시 컬럼의 명세를 가지게 되며, 인덱스 데이터에는 필드 이름을 관리하지는 않고 값이 저장 되어있으며 인덱스의 메타 정보에만 필드 이름이 관리 되고 있습니다.


다만 예외적인 부분으로 아래와 같이 서브 도큐먼트를 가지는 경우 입니다.

> db.users.insert({
    userid: 1,
    name:"jade",
    address: {
        add1 : "중원구",
        add2 : "성남시",
        add3 : "경기도",
        zipcode : 12345
    }
})

> db.users.find().pretty();
{
    "_id" : ObjectId("6363d739f295a4a3c75b3747"),
    "userid" : 1,
    "name" : "jade",
    "address" : {
        "add1" : "중원구",
        "add2" : "성남시",
        "add3" : "경기도",
        "zipcode" : 12345
    }
}

위와 같은 도큐먼트가 있을 때 address 필드는 서브 도큐먼트를 가지고 있습니다.

인덱스를 생성시에 name 필드에 인덱스를 생성하면 name 필드의 값만 인덱스에 저장하지만(필드명 없이), address 필드의 경우에 데이터 및 add1~add3, zipcode 필드명을 모두 포함하게 됩니다.

address 가 필드명이기 때문에 포함되지 않으며, 필드의 값인 서브 도큐먼트가 모두 인덱스에 저장 되기 때문에 서브도큐먼트의 필드값인 add1, add2, add3, zipcode 는 인덱스에 저장되게 됩니다.

            

인덱스 키 추가, 변경(삭제) 및 조회

B-Tree 인덱스를 사용하는 과정에서 각 오퍼레이션 별 확인 해야할 사항에 대해서 알아보도록 하겠습니다.


인덱스 키 추가

B-Tree 인덱스의 리프 노드에 인덱스 값을 추가하면 리프 노드에 인덱스 값을 추가하고 그 하위에 데이터 레코드가 저장된 위치(논리적/물리적) 정보를 저장 합니다. 리프노드가 꽉 차게 된 상태에서 인덱스 키가 추가 되면 밸런스를 맞추기 위해서 Split(분리) 이 발생되게 됩니다.

Index Split 발생시에 브랜치 노드의 변경이 발생되며 필요에 따라서 상위의 연관된 노드도 Split 이 일어나게 됩니다. 그래서 B-Tree 인덱스는 MongoDB 이외 다른 DBMS 에서도 인덱스의 오른쪽 최말단 리프 노드에만 인서트(Insert)가 집중되어 경합이 발생하는 현상인 우편향 인덱스 현상이 발생될 경우 Index Split 에 의해서 성능 저하가 발생되게 되는 경우가 많습니다.

그래서 테이블 또는 컬렉션에 인덱스가 많을 수록 INSERT 나 UPDATE 와 같은 데이터 추가/변경의 작업시 성능상 영향을 주게 됩니다.


인덱스 키 삭제

B-tree 인덱스의 키 값을 삭제할 때는 작업 자체는 매우 간단하게 수행이 됩니다. 해당 키 값을 찾아서 삭제하였다는 마킹 또는 flag 표시를 하면 됩니다. 실제로(물리적으로) 삭제 하지 않고 있다가 해당 공간은 재사용 하게 됩니다.

삭제시 물리적으로도 삭제할 하게 되면 할당된 공간에 대해서 회수를 하고 그에 따라서 디스크의 쓰기 작업이 수행됨에 따라서 불필요한 작업 수행 이나 자원 소모가 발생될수도 있기 때문에 재사용을 위해서 삭제를 하지는 않습니다.

위에서 설명한 내용처럼 Index Split 이 발생될 수 있는 부분에 대해서도 보완적인 측면으로 실제로 모두 삭제하게 된다면 삭제가 된 이후 추가될 때 다시 Index Split 이 동반될 수 있기 때문입니다.


인덱스 키 변경

인덱스의 키 값은 그 값에 의해서 위치가 결정됨에 따라 B-Tree 의 키 값을 변경하는 경우에는 키 값을 찾아서 그 키 값을 변경하면 되는 것은 아니며, 먼저 키 값을 삭제 한 다음에 새로운 키 값을 추가하는 방식으로 처리가 진행 됩니다.


인덱스 키 검색

인덱스를 검색하는 방법은 루트 노드로 부터 시작하여 브랜치 노드를 거쳐서 리프노드까지 비교하는 과정을 통해서 단계적 이동을 하게 되며 해당 과정에 대해서는 트리 검색 이라고 표현 합니다.

인덱스를 사용한 검색은 find(db에서는 select) 쿼리에서만 사용되는 것은 아니며, 데이터를 업데이트하거나 삭제(delete) 할 때에도 변경(삭제) 대상을 빠르게 찾기 위해서 인덱스 검색을 사용하게 됩니다.

B-tree 인덱스 검색은 조회 검색어가 100% 일치 하거나 검색값의 앞부분(Left-most part) 이 일치할때만 사용할 수 있고, 부등호(<>) 비교 에는 인덱스 검색 기능을 사용할 수 없습니다.

중요한 점은 인덱스 컬럼 값에 변형이 가해지면 비교 검색을 할수 가 없게 됩니다. 변형된 값은 인덱스에 존재하지 않기 때문에 함수나 어떠한 연산을 적용한 결과로 정렬 한다던가 검색하는 작업은 B-Tree 인덱스 특성을 이용할 수 없게 만들게 됩니다.


인덱스 키 값의 사이즈


일반적인 RDBMS 처럼 MongoDB에서도 디스크에 데이터를 저장하는 가장 기본 단위를 페이지(Page) 또는 블럭(Block) 이라고 하며 디스크의 읽기 / 쓰기 작업의 단위 입니다. 즉 인덱스는 페이지 단위로 관리되며 루트, 브랜치, 리프 노드를 구분하는 기준도 페이지 단위 입니다.

WiredTiger 스토리지 엔진에서 페이지 크기 16KB 을 사용하면서 인덱스 키가 16바이트 라고 할때 하나의 페이지(16KB)에서는 약 585개를 저장할 수 있으며, 키 사이즈가 두배인 32바이트 일 경우에는 약 372개 저장할 수 있게 됩니다.

그래서 find 로 쿼리를 조회시에 위의 예시 상황 처럼 인덱스 페이지를 한번 읽느냐 두번 읽어야 하느냐 그 이상이 발생될 수도 있습니다. 결국 디스크 I/O 가 느려지거나 I/O 횟수가 늘어나게 됩니다.


B-Tree 인덱스의 깊이(depth)

B-Tree 인덱스에서 인덱스 깊이를 표현하는 단어로 depth 를 사용하며, 이러한 depth(깊이) 레벨에 대해서 사용자가 직접 제어 할 수 있는 방법은 따로 없습니다.

위에서의 예시처럼 키 사이즈가 커질 경우 하나의 인덱스 페이지가 담을 수 있는 인덱스 키 값의 개수가 작아지고, 그에 따라서 동일한 레코드 건수라고 해도 depth 레벨이 깊어져서 디스크 읽기가 더 많이 필요 해진다는 것을 의미하게 됩니다.
              

B-Tree 인덱스를 사용한 데이터 읽기

MongoDB 에서 여러개의 인덱스 조회방식(스캔방식) 에 대해서 확인 해보도록 하겠습니다.


Range Scan

인덱스 스캔에서 Range Scan(레인지 스캔)은 인덱스의 접근 방식(방법) 중에서 가장 대표적이면서 일반적인 접근 방법 입니다.

인덱스 레인지 스캔은 검색해야 할 인덱스의 범위가 결정된 경우에 사용할 수 있는 인덱스 접근 방식으로 검색하고자 하는 값의 수나 검색 결과 레코드의 건수와 상관 없이 레인지 스캔이라고 합니다.

[database-btree-indexing]

원하는 시작점을 찾기 위해서 루트 노드부터 비교를 시작해서 브런치 노드를 거쳐서 최종적으로 리프 노드의 시작 지점을 찾게 됩니다. 리프 노드에서 시작할 위치를 찾게 되면 그때부터는 리프 노드 간의 링크를 이용해서 리프 노드만 스캔하게 되고 최종 스캔이 완료되는 위치에서 스캔을 마치고 사용자에게 결과를 리턴하게 됩니다.

인덱스를 읽는 순서에 따라서 오름차순이 될수도, 내림차순이 되게 되며 중요한점은 어떠한 방향으로(순서로) 라도 정렬된 상태로 저장되어 있고 저장된 상태로 가져온다는 것 입니다.
             

인덱스 프리픽스 스캔

인덱스가 있는 필드 값에 대해서 일부만 일치하는 패턴을 검색하고자 할 때 사용 하는 방식(방법)으로 MongoDB 서버에서는 정규 표현식을 이용하여 좌측 일치(Left most match) 문자열 검색을 수행하게 됩니다.

db.users.find({ name: { $regex: /^jade/ }}).pretty();

db.users.find({ name: /^jade/ }).pretty();

db.users.find({ name: { $in : [/^jade/]}}).pretty();


위의 조회는 같은 결과를 출력 하며, 문자열 좌측 일치 검색을 수행시 정규 표현식(regular expression)이 인덱스를 정상적으로 활용하기 위해서는 몇가지 조건을 지켜줘야 합니다.

  • 반드시 문자열의 처음부터 일치하도록, 문자열 시작 표시(^) 로 정규 표현식이 시작되어야 합니다.
  • 검색 문자열이 "시작표시(^)" 이외에 다른 정규 표현식을 포함 하지 않습니다.
  • 문자열의 마지막을 표현하는 "$" 표시는 없어야 합니다.


위의 조회는 SQL 문으로 보면 좌측 일치 LIKE 연산자와 유사하며 name like 'jade%' 와 동일(유사) 합니다.

> db.users.createIndex({ name:1 })

> db.users.find({name: {$gte: "jade", $lt:"jay"}})

MongoDB의 인덱스 프리픽스(Prefix) 스캔은 사실 일반적인 레인지 스캔(Range Scan) 과 동일한 방식으로 동작 합니다.

그래서 위의 조회의 경우도 jade 보다는 크거다 같고, jay 보다 작은 문자열을 검색하게 되는 것입니다.

이처럼 B-Tree 인덱스 스캔은 입력된 조건 과 필드 값 모두 같은지를 비교하는 "=(동등)"비교 뿐만 아니라 좌측부터 일치하는지도 검색하는 인덱스 레인지 스캔을 수행할 수 있습니다.

이와 같은 레인지 범위 스캔은 문자열 뿐만 아니라 날짜나 숫자 타입의 필드에도 동일하게 사용할 수 있습니다.
               

커버링 인덱스

다른 RDBMS, 예를 들어 MySQL 의 경우 인덱스만으로 쿼리를 처리할 수 있는 경우의 인덱스 사용에 대해서 커버링 인덱스 라고 합니다.

MongoDB 에서도 이와 같이 인덱스 만으로 쿼리를 처리할 수 있는 경우에는 도큐먼트가 저장된 데이터파일을 읽지 않고 쿼리를 수행할 수 있습니다.

## 데이터 입력
> db.users.insert({
    userid: 2,
    name: "saniya",
    score: 95,
    gender: 1
})


## 인덱스 생성
> db.users.createIndex({score:1, name:1 })

위와 같이 데이터를 하나 입력하고 인덱스를 만들었습니다.

이러한 인덱스와 데이터 구성시에 수행하는 쿼리에 따라서 커버링 인덱스 형태로 수행할 수 있습니다.

> db.users.find( { score : {$gte:90}}).explain()

위의 쿼리는 score 필드가 90 이상인 사용자에 대해서 조회하는 쿼리이고 90이상인 사용자를 찾고 난 다음 출력할 때에는 인덱스 필드이외에 다른 필드 정보도 필요함에 따라서 도큐먼트를 읽어야 합니다.

> db.users.find( { score : {$gte:90}},{name:1,gender:1})

이번 쿼리에서는 projection 파라미터에 인덱스 포함 필드인 name이 선언되어 있지만, 인덱스 구성 필드에 포함되어 있지 않은 gender도 포함되어 있기 때문에 이 쿼리도 인덱스만으로 조회 처리를 할 수는 없습니다.

> db.users.find( { score : {$gte:90}},{_id:0, name:1})

이번 쿼리는 필터인 query 파라미터에 score 가 90 이상인 조건이 선언 되어 있고, projection 파라미터에는 name 이 출력 되도록 선언되어 있습니다. 그러므로 해당 쿼리는 도큐먼트가 포함된 데이터파일을 엑세스 하지 않고 인덱스 만으로 쿼리 결과를 처리 할 수 있게 됩니다. 

이처럼 도큐먼트를 참조하지 않고 인덱스만을 사용해서 조회하는 것을 인덱스 커버 쿼리 또는 Covered Query 라고 합니다.


Covered Query 로 수행이 되면 실행 계획에서도 명확히 확인 할 수 있습니다.

> db.users.find( { score : {$gte:90}},{_id:0, name:1} ).explain('executionStats')
{
<.. 중략 ..>
    },
    "executionStats" : {
        "executionSuccess" : true,
        "nReturned" : 1,
        "executionTimeMillis" : 0,
        "totalKeysExamined" : 1,
        "totalDocsExamined" : 0,
        "executionStages" : {
        --!!!>>	"stage" : "PROJECTION_COVERED",
            "nReturned" : 1,
            "executionTimeMillisEstimate" : 0,
            "works" : 2,
            "advanced" : 1,
            "needTime" : 0,
            "needYield" : 0,
            "saveState" : 0,
            "restoreState" : 0,
            "isEOF" : 1,
            "transformBy" : {
                "_id" : 0,
                "name" : 1
            },
            "inputStage" : {
                "stage" : "IXSCAN",
                "nReturned" : 1,
                "executionTimeMillisEstimate" : 0,
                "works" : 2,
                "advanced" : 1,
                "needTime" : 0,
                "needYield" : 0,
                "saveState" : 0,
                "restoreState" : 0,
                "isEOF" : 1,
                "keyPattern" : {
                    "score" : 1,
                    "name" : 1
                },
                "indexName" : "score_1_name_1",
                "isMultiKey" : false,
                "multiKeyPaths" : {
                    "score" : [ ],
                    "name" : [ ]
                },
                "isUnique" : false,
                "isSparse" : false,
                "isPartial" : false,
                "indexVersion" : 2,
                "direction" : "forward",
                "indexBounds" : {
                    "score" : [
                        "[90.0, inf.0]"
                    ],
                    "name" : [
                        "[MinKey, MaxKey]"
                    ]
                },
                "keysExamined" : 1,
                "seeks" : 1,
                "dupsTested" : 0,
                "dupsDropped" : 0
            }
        }
    },
    "serverInfo" : {
        "host" : "finda",
        "port" : 27017,
        "version" : "4.4.17",
        "gitVersion" : "85de0cc83f4dc64dbbac7fe028a4866228c1b5d1"
    },
    "ok" : 1
}

위의 실행 계획의 내용중에서 "stage" : "PROJECTION_COVERED" 을 통해서 해당 쿼리가 Covered Query 로 수행되고 있음을 알수 있습니다.

이와 같이 Covered Query 로 수행되면 인덱스 키 엔트리만 읽으면 되게 되고, 검색 조건에 일치하는 도큐먼트의 데이터 파일을 검색하는 작업이 필요하지 않기 때문에 성능 상으로 좋은 방법이라고 할 수 있습니다.
              

Intersection(인터섹션)

MongoDB 2.6 버전 부터는 1개의 컬렉션 조회시 2개 이상의 인덱스를 사용할 수 있는 Index Intersection 기능이 제공 되고 있습니다.

방식은 MongoDB 서버에서 하나의 컬렉션을 조회시에 2개 이상의 인덱스를 검색하여 각 검색 결과를 만들고 나서 그 다음 그 결과를 교집합(Intersection) 하여 찾는 과정으로 진행 됩니다.

이것은 여러 RDB 에서도 유사한 기능을 제공하고 있으며, MySQL 에서는 Index Merge Optimization 으로 유사한 기능이라고 보시면 될 것 같습니다.

Oracle 에서도 MySQL 에서 Index Merge 오퍼레이션 플랜은 사실 많이 볼 수 있는 플랜 오퍼레이션은 아니며, 사용할 수 있는 그 상황이 딱 맞으면서 옵티마이저가 판단하였을 때 Cost 상으로 적은 비용이 측정되면 사용이 되다보니 실제 쿼리 수행에서는 많이 볼수 없는 플랜 오퍼레이션 이긴 합니다.

이처럼 MongoDB 에서의 Index Intersection 또한 왠만해서는 경험하기 어려운 최적화 인덱스 방안 이긴 합니다. 이유로는 위에서 설명한 내용 처럼 효율이 좋은 경우가 많이 없기 때문에 옵티마이저에 의해서 선택되는 케이스가 적기 때문입니다.

MongoDB 에서 Index Intersection 이 사용 되었는지를 확인 하기 위해서는 Plan 에서 "AND_SORTED" 또는 "AND_HASHED" 스테이지가 있는지를 확인 하시면 됩니다.

쿼리에서 2개 조건중 1개가 범위 조회조건 형태일 경우 2개의 조건에 의한 인덱스 검색 결과가 정렬이 되었다고 보장할 수 없기 때문에 이럴 경우 AND_HASHED 가 사용 되게 됩니다.
                  

Index Full Scan

수행된 쿼리가 인덱스에 명시된 컬럼만으로 조건을 처리 할 수 있거나 전체 쿼리를 처리 할 수 있는 경우에 주로 이 방식이 사용되며, 인덱스 뿐만 아니라 도큐먼트도 읽어야 한다면 이 방식이 사용되지는 않습니다.

보통의 경우 인덱스는 인덱스가 생성된 컬렉션(테이블) 보다 사이즈가 작기 때문에 모두 읽어야 하는 경우 그리고 인덱스로만 읽어서 처리가 가능한 경우에는 이와 같이 Index Full Scan 을 선택해서 진행 하게 됩니다.

Note) 다만 모든 데이터스토어 솔루션이 같은 형태로 진행 되는 것은 아니며, 예를 들어 Oracle 의 경우 Full Table Scan 과 Index Full Scan 시의 Block I/O 방식에서 차이가 있고 Full Table Scan 은  Multi Block I/O 를 하게 되며 리눅스 OS 기준 16 Block 단위가 기본 값 입니다. 그에 반해 Index Full Scan 은 Single Block I/O 로 수행이 되게 됩니다.
그래서 이런 경우 Multi Block I/O 가 유리하다고 판단해 Full Table Scan 이 대부분 선택 되거나 더 빠르게 처리 될 수 있습니다.

위와 같은 특별한 차이점이 있는 케이스를 제외하고 Index 오브젝트는 테이블(컬렉션) 보다 작기 때문에 모두 읽어야 하는 경우라면 사이즈가 더 작은 Index 를 Full Scan 하는 것이 좋은 경우가 되게 됩니다.


단어의 명칭 그대로 Full Scan 이기 때문에 인덱스 리프노드의 제일 처음 부분 부터 ~ 순차적으로 제일 마지막 까지 리프 블럭 까지 연결된 링크드 리스트 자료 구조를 따라서 스캔하는 방식 입니다.


MongoDB의 인덱스에 관한 내용은 다음 포스팅에서 이어서 진행 됩니다.

                         

Reference

Reference URL
mongodb.com/indexes
mongodb.com/covered-query
medium.com/mongodb-indexes-deep-dive


Reference Book
• Real MongoDB


연관된 다른 글

 

              

0
글에 대한 당신의 생각을 기다립니다. 댓글 의견 주세요!x