본문으로 바로가기

MongoDB - 인덱싱

category software engineering/backend 2023. 6. 24. 14:36
728x90

인덱싱

몽고디비 인덱스는 전형적인 RDB 인덱스와 거의 흡사하게 동작한다. 인덱스 없이 모든 쿼리를 스캔 했다면 일반적으로 큰 콜렉션의 경우 매우 느리기 때문에 쿼리의 최적화를 위해 어떤 인덱스가 필요한데 상황에 따라 어떤 인덱스를 사용해야하는지 알아보자.

복합 인덱스

인덱스는 모든 값을 정렬된 순서로 보관하기 떄문에 인덱스 키로 문서를 정렬하는 작업에 훨씬 빠른 성능을 제공한다. 하지만 db.users.find().sort({"age": 1, "username": 1})와 같은 경우 한가지 인덱스 키로만은 도움이 되지 않아 복합 인덱스가 필요하다. db.users.ensureIndex({"age": 1, "username": 1})와 같이 설정하면 여러 정렬 방향이나 검색 조건에 효과적이다. db.users.find({"age": 21}).sort({"username": -1})는 위의 인덱스가 효율적으로 사용 되는 좋은 예시이다.

[0, "user..."] -> xxx
[0, "user..."] -> xxx

... // --> query start
[21, "user999977"] -> 0x9b3160cf
[21, "user999954"] -> 0xfe039231
[21, "user999902"] -> 0x79996aa
... // --> query end

[22, "user999977"] -> 0x0c965148
...

바로 age가 21인 유저로 건너 뛸 수 있고 몽고 디비의 경우 어느 방향으로도 인덱스를 쉽게 탐색 하기 때문에 정렬 방식은 문제가 되지 않아 효율적으로 인덱스를 사용할 수 있다. db.users.find({"age": {"$gte": 21, "$lte" :30}}) 또한 해당 인덱스에 적합하게 쿼리가 된다.
하지만 db.users.find({"age": {"$gte": 21, "$lte" :30}}).sort({"username": 1})의 경우를 살펴보면

[21, "user100000"] -> 0x3... <-> ["user0", 69]
[21, "user100069"] -> 0x6... <-> ["user1", 50]
[21, "user1001"] -> 0x9...   <-> ["user10", 80]  
[21, "user100253"] -> 0xd... <-> ["user100", 48]
...
[22, "user100004"] -> 0x81.. <-> ["user10000", 21] -> 0x7..
[22, "user100328"] -> 0x83.. <-> ["user10001", 60]
[22, "user100335"] -> 0x55.. <-> ["user10004", 27] -> 0x0..
...

처럼 username에 맞게 정렬되지 않아 결과를 반환하기 전에 메모리에서 정렬해야하여 비효율적이다. 결과가 적다면 정렬하는데 문제가 되지 않지만, 결과가 32MB 이상이면 몽고디비는 데이터가 많아 정렬을 거부한다는 오류를 내보낸다. 이런 경우는 인덱스를 역순으로 한 {"username": 1, "age": 1} 의 인덱스를 추가하면 거대한 in-memory 정렬이 필요하지 않다는 점이 좋지만 모든 쿼리를 훑어야 한다.

위에 두 쿼리를 비교해보려면 explain()을 이용하면 어떻게 수행하는지 진단할 수 있다. 우선적으로 봐야할 부분은 아래와 같다.

  1. cursor: 만약 BtreeCursor age_1_username_1 이라면 {"age": 1, "username": 1}의 인덱스를 사용한다는 뜻
  2. millis: 만약 2766이라면 실행하는데 3초정도 걸린다는 뜻
  3. scanAndOrder: 만약 true라면 메모리에서 데이터를 정렬해야한다는 뜻

만약, 위에서 말한 인덱스를 추가하면 퍼포먼스 향상이 있는지 그리고 in-memory 정렬을 사용하지 않는지 테스트 해보기 위해서는 hint를 포함한 explain 쿼리를 사용해보자.
db.users.find({"age": 21, "$lte": 30}}).sort({"username": 1}).hint({"username": 1, "age": 1}).explain() 을 실행해보면 더 많은 정렬을 필요로 하지만 in-memory 정렬이 필요 없어진다. 책에서 소개한 예시에서는 "nsscanned" 기준으로 83484 => 984434 그리고 "mills" 기준으로 2766 => 14820 으로 오히려 퍼포먼스가 안좋아졌다. 하지만 limit(1000) 으로 제한하는 경우에는 "mills" 가 2031 => 181 로 많은 성능 향상을 만들어 낼 수 있다. 대부분의 어플리케이션의 경우는 모든 쿼리 결과가 아닌 처음 몇 개를 원하기 때문에 효율적으로 사용할 수 있다.

<나중에 어딘가 다시 넣어야 할 정보>
일반적으로 인덱스는 최말단 리프에는 가장 작은 값이, 오른쪽에는 가장 큰 값이 있는 트리 구조다. "정렬 키"가 날짜라면 트리의 왼쪽부터 오른쪽으로 탐색하기 때문에 기본적으로 시간에 따라 탐색할 것이다. 따라서 오래된 데이터보다 최신의 데이터를 사용하는 경우 몽고디비 전체가 아닌 단지 트리의 오른쪽 최말단 (최신) 브랜치들만 메모리에 보관해야하고 이러한 인덱스를 우편향(right-balanced)라고 하며 가능하다면 인덱스를 우편향으로 만들어야한다. (_id가 우편향 인덱스이다.)

키 방향

만약 age는 오름차순으로 정렬되고 username은 내림차순으로 정렬되길 원한다면 {"age": 1, "username": -1}을 추가하자. 만약, 둘다 오름차순으로 정렬도 필요하다면 {"age": 1, "username": 1} 또한 추가하자. age가 내림차순으로 정렬되고 username이 오름차순으로 정렬되는것 또한 인덱스는 양방향 정렬을 지원하기에 이미 포함하고 있으니 중복해서 {"age": -1, "username": 1}을 추가하지 않도록 하자.

커버드 인덱스

인덱스가 사용자에 의해 요구되는 모든 값을 포함하고 있다면 문서를 다시 가져올 필요 없이 포인터를 따라가기 때문에 쿼리가 커버링 된다고 할 수 있다.
실무에서는 커버드 인덱스를 사용하여 작업 셋을 훨씬 작게 만들 수 있으며 우편향 인덱스와 묶으면 좋다.
<<<이건 뭔말? 아직 이해 안감>>> 쿼리가 확실히 인덱스만 사용하도록 만들기 위해 "_id"를 반환받지 않도록 하려면 반환 받을 키에 대한 지정을 해야한다. (쿼리 하지 않는 필드에 인덱스를 만들어야 할 수도 있기 때문에 더 빠른 쿼리에 대한 필요과 쓰기로 인한 부하가 생길 수 있다.)

암시적 인덱스

복합 인덱스 {"age": 1, "username": 1}를 갖고 있다면 단일 인덱스 {"age": 1} 는 공짜로 갖게 되는 보너스 인덱스다.

$ 연산자의 인덱스 사용

비효율적인 연산자

"$where" 쿼리와 {"키": {"$exists": true}} 처럼 인덱스를 전혀 사용할 수 없는 쿼리도 있고, 인덱스를 사용하지만 그다지 효율적이지 못한 쿼리도 있다. 만약 "x"에 일반적인 인덱스가 있다면 "x"가 존재하지 않는 문서에 대한 쿼리는 {"x": {"$exists": true}}로 나타낼 수 있지만 존재하지 않는 필드는 null 필드와 동일한 방식으로 인덱스에 저장되어 null인지 존재하지 않는지는 직접 방문해야 알 수 있어 효율적이지 않다.

일반적으로 부정조건 또한 비효율적이다. "$ne" 쿼리는 인덱스를 사용하지만 아주 잘 활용하지는 않는다. "$ne"로 지정된 항목을 제외한 모든 인덱스 항목을 살펴봐야 하기 때문에 모든 인덱스를 살펴봐야한다. 예를 들어, db.example.find({"i": {"$ne": 3}}).explain() 이 쿼리는 3보다 작은 모든 인덱스 항목과 3보다 큰 모든 인덱스 항목을 조사한다. 이 경우 광범위한 부분이 3인 경우엔 효율적이나 그렇지 않은 경우는 거의 모두를 확인해야 할 수도 있다.

"$not", "$nin" 는 대부분 경우에 인덱스를 타지 않고 테이블을 스캔하니 주의하도록 하자.
이러한 종류의 쿼리를 해야한다면 작업 셋을 줄이는데 신경을 써야한다. 예를 들어, "birthday" 필드를 갖고 있지 않은 모든 사용자를 찾고 있었다면 "birthday" 필드가 언제 추가 됐는지 확인하고 만약 3월 20일날 추가되었다면 "_id": {"$lt": march20Id} 를 추가하자.

범위

다중 필드로 인덱스를 설계 할 때 완전 일치가 사용될 필드(예를 들면 "x": "foo")를 첫 번째에 놓고 범위가 사용될 필드(예를 들면, "y": {"$gt": 3, "$lt": 5})를 마지막에 놓는다. 이는 쿼리가 첫 번째 인덱스 키에 대해 정확한 값을 찾고 두 번째 인덱스 범위 안에서 검색하도록 도와준다.

OR 쿼리

몽고 디비는 쿼리당 오직 하나의 인덱스를 사용할 수 있다. 만약 {"x": 1}로 한 인덱스를 생성하고 {"y": 1}로 또 다른 인덱스를 생성한 다음에 {"x": 123, "y": 456} 으로 쿼리를 실행한다면 둘 중 하나의 인덱스만 사용된다. 하지만 "$or"은 유일한 예외이다. 두 개의 쿼리를 수행하고 결과를 합치기 때문에 "$or" 절마다 하나의 인덱스를 사용할 수 있다. 하지만 일반적으로 두 개의 쿼리를 수행하는건 비효율적이다. 따라서 가능하다면 "$or" 보다는 "$in"을 사용하자("$or"를 사용하면 양쪽 쿼리의 결과를 조사해서 모든 중복 또한 제거해야 함을 명심하자). 하지만 "$in" 쿼리는 정렬의 순서를 관여할 방법이 없는 문제가 있다. {"x": {"$in": [1,2,3]}}은 {"x": {"$in": [3,2,1]}}과 같다.

객체 및 배열 인덱싱하기

내장된 문서 인덱싱하기

{
    "username": "sid",
    "loc": {
        "ip": "1,2,3,4",
        "city": "Springfield",
        "state": "NY"
    }
}

위와 같이 각 사용자의 위치가 명시된 내장문서가 있다고 가정해보자. "loc"의 하위필드 중 하나인 "loc.city"에 해당하는 필드를 이용하여 쿼리를 할 때 속도를 높히기 위해서는 db.users.ensureIndex({"loc.city": 1}) 과 같은 인덱스를 만들 수 있다. 하지만 이는 내장 문서 자체 "loc"를 인덱싱 하는것과는 "loc.city"를 인덱싱 하는것은 동작하는 방법이 다르다. 위의 예제에서 쿼리 옵티마이저는 정확한 순서의 필드로 전체 하위문서가 기술된 쿼리를 위해서만 "loc" 인덱스를 사용할 수 있다. db.users.find({"loc": {"ip": "123.456...", "city": : "Shelbyville", "state": "NY"}})의 경우에 db.users.find({"loc.city": "Shelbyville"}) 처럼 생긴 쿼리에 대해서는 인덱스를 사용할 수 없다.

배열 인덱싱하기

배열을 인덱싱하는 것은 배열의 각 요소에 인덱스 항목을 생성하기 때문에 한 게시물에 20개의 의견이 달려 있다면 이는 20개의 인덱스 항목을 갖는다. 이는 단일 값 인덱스보다 배열 인덱스를 더 비싸게 만드는데, 하나의 입력, 갱신, 제거를 위해 모든 배열 요소(20개가 아니라 수천개라면..?)가 갱신되어야 하기 때문이다.
내장된 문서와 달리 하나의 배열 전체를 단일 개체처럼 인덱싱 할 수 없다. 가장 최근에 의견이 달린 블로그 게시물을 찾을 수 있도록 하기 위해 블로그 게시물 콜렉션에 내장된 "comments" 문서의 배열에 들어 있는 "date" 키에 인덱스를 생성할 수 있다. db.blog.ensureIndex({"comments.date": 1}).

몽고디비에서는 다중키 인덱스에 의해 생기는 인덱스가 폭발적으로 늘어나 n*m 개의 인덱스 항목이 생김을 방지하기 위해 인덱스 항목에 들어 있는 단 하나의 필드만 배열이 될 수 있다. 예를 들어 {"x": 1, "y": 1} 인덱스의 경우에 db.multi.insert({"x": 1, "y": [4,5,6]}) 은 요소 중 하나(y) 만 배열을 갖기에 허용한다. 하지만 db.multi.insert({"x": [1,2,3], "y": [4,5,6]}) 과 같은 경우는 허용하지 않는다.

다중키 인덱스의 영향

어떤 문서가 인덱스 키로 배열 필드를 갖는다면 인덱스는 즉시 다중키 인덱스로 표시된다. 일단 인덱스가 다중키로 표시되면 필드 안에 배열을 포함하는 모든 문서가 제거되더라도 비-다중키가 될 수 없다. 이를 해결하기 위한 유일한 방법은 삭제하고 다시 생성해야한다. 다중키 인덱스는 하나의 문서를 가리킬 수 있으므로 결과를 반환하기 전에 약간의 중복 제거를 해야하고 약간 느리다.

카디널리티

gender 와 같은 필드는 'male', 'female' 두 가지 값만 가능한 낮은 카디널리티 필드이다. 하지만 email 과 같은 필드는 유일한 값을 갖는 높은 카디널리티 필드이다. 일반적으로 높은 카디널리티 키를 생성하거나 적어도 복합 인덱스의 첫 번째 키로 두자.

인덱스를 생성하지 않는 경우

인덱스는 데이터의 작은 일부분을 조회하는 경우에 가장 효율적이다. 콜렉션의 더 많은 부분을 가져와야할 때는 오히려 인덱스 항목을 살펴보고, 또 한 번은 문서를 가리키는 인덱스 포인터를 따라가기 위한 두 번의 검색을 요구하기 때문에 이런 경우는 오히려 비효율적이다.
실제 데이터 크기, 인덱스 크기, 문서 크기, 결과 셋의 평균 크기 등 어떤 경우에 효율적인지에 대한 엄밀한 공식은 불행히도 없지만 쿼리가 콜렉션의 30%나 그 이상을 반환하면 인덱스와 테이블 스캔 중 어느것이 빠른지 살펴보기 시작한다.

머리가 아프니 일반적으로 큰 콜렉션, 큰 문서, 선택적 쿼리에 있어서는 인덱스를 사용하도록 하자. 하지만 인덱스가 이미 있는 경우 데이터를 내보내거나 일괄 작업을 위해 모든 데이터가 필요한 경우는 .hint({"$natural": 1})을 강제하면 인덱스를 무시하고 디시크에 기록된 순서의 정렬로 지정할 수 있다.

인덱스의 종류

고유 인덱스

db.users.ensureIndex({"username": 1}, {"unique": true}) 와 같이 "unique" 옵션을 주면 "username"에 대해 두 문서에서 동일한 값을 가질수 없게 할 수 있다. db.users.ensureIndex({"username": 1, "age": 1}, {"unique": true})와 같이 복합 키 또한 고유 인덱스를 만들 수 있다. 기존 콜렉션에 고유 인덱스를 구축하려 하는데 중복된 값이 있다면, db.users.ensureIndex({...},{"unique": true, "dropDups": true})와 같이 "dropDup" 옵션을 제공하면 극단적이지만 첫 번째 문서를 남겨두고 중복된 값을 갖는 모든 문서를 삭제한다 (중요한 데이터라면 사용하면 어느 문서가 남는지 알 수 없어 사용하지 말자)

희소 인덱스

고유 인덱스는 null을 하나의 값으로 취급하기 떄문에 키가 없는 문서가 여러 개 있는 고유 인덱스를 가질 수 없다. 존재하거나 존재하지 않을 수도 있는 필드가 고유해야 한다면 희소 옵션과 고유 옵션을 포함하여 "unique"와 "sparse" 옵션을 포함하면 된다.

> db.foo.find()
{"_id": 0}, {"_id": 1, "x": 1}, {"_id": 2, "x": 2}, {"_id": 3, "x": 3}

예를 들어 위와 같이 한 필드만 "x"를 갖고 있지 않은 경우를 생각해보자. db.foo.find({"x": {"$ne": 2}}) 와 같은 쿼리를 보낼 때 원래는 {"_id": 0} 와 같이 "x"를 갖고 있지 않은 경우도 포함시키지만 null을 제외하고 싶은 경우는 희소 인덱스를 생성하면 제외 시킬 수 있다.

Full-text 인덱스

Full-text(전문) 인덱스는 문장을 빠르게 검색할 뿐만 아니라 다국어 형태소 분석 및 정지단어를 위해 내장된 기능을 제공하지만 비용이 너무 비싸다. 이런 형태의 인덱스를 추가하려면 오프라인으로 생성하거나 성능이 문제되지 않을 때 생성해야 한다.

db.blobs.ensureIndex({"title": "text"}) 와 같이 필드에 "text" 를 추가하면 되고, db.blobs.ensureIndex({"title": "text", "desc": "text", "author": "text"}, {"weights": {"title": 3, "author": 2}}) 와 같이 여러 필드를 포함 할 수도 있고 가중치 또한 제공할 수 있다. db.blobs.ensureIndex({"$**": "text"}) 와 같이 모든 필드에 내장된 문서와 배열에 대한 검색 까지도 포함할 수 있다.

추가적으로 db.users.ensureIndex({...},{"default_language": "french"}) 와 같이 프랑스어에 대한 인덱스를 생성할 수도 있지만 한국어를 별도로 제공하지 않아 MongoDB Community 대신 만족스러운 검색을 위해 Percona MongoDB "default_language"에 "ngram"을 제공하여 n-gram 알고리즘을 사용하도록 하거나 상황에 맞게 Kibana 혹은 Kafka 와 같은 툴을 이용해서 다른 데이터베이스에 이전시키거나 ElasticSearch 와 같이 전문 검색 엔진을 사용하는것이 더 나은 대안이 될 수도 있다고 실제 검색 엔진을 사용해본 사람들은 이야기합니다.

공간 정보 인덱스

가장 많이 사용되는 2dsphere 를 보면 db.world.ensureIndex({"loc", "2dshpere"})로 공간 인덱스를 생성할 수 있다. 이렇게 지정하면 무조건 "type" 필드와 "coordinates" 필드를 강제하게 되고 만약 var eastVillage = {"type": "Polygon"}, "coordinates": [[-73.88, 40.27], ...] 와 같은 공간 정보를 갖고 있다면 "$within" 쿼리를 사용하여 {"$within": {"$geometry": eastVillage}} 와 같이 한 지역에 포함되는지 또는 "$near" 를 사용하여 db.open.street.map.find({"loc": {"$near": {"$geometry": eastVillage}}}) 가까운 곳부터 쿼리 할 수도 있다.

만약 가까운 곳에서 특정 키워드를 검색하는 조건을 위해서는 db.open.street.map.ensureIndex({"tags": 1, "location": "2dshpere"}) 와 같이 복합 인덱스를 생성하고 db.open.street.map.find({"loc": {"$within: {"$geometry": eastVillage}"}, ... "tags": "pizza"})와 같이 필터링 하면 효율적으로 사용할 수 있다.

게임 지도나 시계열 데이터 같은 경우는 "2dshpere" 보다 "2d" 를 사용하자. 구체가 아닌 완전한 평면인 경우 단지 인덱스 점이기에 더 단순하다. 강제하던 "geometry" 필드도 없고 오직 "$near"와 "$within"만 사용할 수 있다. {"$within": {"$center": [12,25], 5}} 로 원안에 모든 점을 찾을 수도 {"$polygon": [[0,20],[10,0],[-10,0]]} 처럼 삼각현 안에 점을 포함하는 모든 점의 위치를 알아낼 수도 있다.

'software engineering > backend' 카테고리의 다른 글

MongoDB - 어플리케이션 설계  (0) 2023.06.25
MongoDB - 문서의 갱신/쿼리  (0) 2023.06.24
MongoDB - 집계  (0) 2023.06.18
Locking - Optimistic concurrency control (OCC)  (0) 2022.08.31
Rest? gRPC? GraphQL?  (0) 2022.08.31