[번역] 몽고 디비 복합 인덱스 최적화하기

gompro·2022년 3월 18일
0
post-thumbnail

이 글은 ALEX BEVILACQUA의 글 "Optimizing MongoDB Compound Indexes - The "Equality - Sort - Range" (ESR) Rule"을 번역한 글입니다.

나는 MongoDB Techinical Services로 일하면서, 인덱스가 어떤 경우에 효과적으로 동작하지 않을 수 있는지 몽고디비 사용자들이 쉽게 이해할 수 있게 도울 필요가 있다는 것을 깨달았다.

보통 인덱스 최적화에 관한 문서를 추천할 때 A. Jesse Jiryu Davis의 "몽고디비 복합 인덱스 최적화하기"가 가장 먼저 생각나는데, 아쉽게도 아직 이 주제에 대한 공식 문서는 존재하지 않는다.

나는 몽고디비 토론토 2019몽고디비 월드 2019에서 이 주제에 대해 몇 번 다룬적이 있고, 동료인 Chris Harris도 몽고디비 월드 2019몽고디비 휴스턴 2019에서 같은 주제에 대해 얘기한 적이 있다.

이번 기회에 Jesse의 글과 나의 예전 발표 내용을 바탕으로 인덱스를 최적화하기 위한 몇 가지 규칙에 대해 이야기해보려 한다.

ESR "규칙"

복합 인덱스(compound index)에서 인덱스를 구성하는 개별 키(index key)의 순서는 굉장히 중요하며, ESR 규칙을 통해 거의 모든 경우에 적용할 수 있는 최적의 키 순서를 찾을 수 있다.

"규칙"을 강조하는 이유는 이 규칙에 몇 가지 예외가 있기 때문인데, 예외에 대한 자세한 정보는 "Tips and Tricks for Effective Indexing" 발표를 참고하길 바란다.

규칙 1) 동등 조건자(equality predicates)를 맨 앞에 위치시킨다

동등 조건자는 어떤 값이 다른 값과 동일한지 비교한다.

find({ x: 123 })
find({ x: { $eq: 123 } })
aggregate([ { $match:{ "x.y": 123 } } ])

위 쿼리들에서 동등 조건자는 실행 계획(explain plan)의 indexBounds 상에서 밀접한 경계(tightly bounded)를 가진다.

"indexBounds" : {
    "x" : [
        "[123.0, 123.0]"
    ]
}

복합 인덱스에서 여러 동등 조건자를 배치할 때 중요한 점은 굳이 선택성(selectivity)이 높은 키를 먼저 배치하지 않아도 된다는 것이다.

이는 B-Tree 인덱스의 성질을 생각해보면 더 분명해지는데,

B-Tree는 키가 어떤 순서로 배치되든 각 노드(주: 원문은 page를 사용)에 가능한 모든 값을 저장할 것이므로 결과적으로 키 순서에 상관없이 항상 동일한 수의 조합이 존재하게 된다.

규칙 2) 정렬 조건자(sort predicates)는 동등 조건자 뒤에 위치해야 한다

정렬 조건자는 쿼리 결과의 정렬 순서를 결정한다.

find().sort({ a: 1 })
find().sort({ b: -1, a: 1 })
aggregate([ { $sort: { b: 1 } } ])

위 쿼리들은 결과를 정렬하기 위해 전체 키 범위를 스캔해야하므로 무경계(unbounded)한 쿼리가 된다.

"indexBounds" : {
    "b" : [
        "[MaxKey, MinKey]"
    ],
    "a" : [
        "[MinKey, MaxKey]"
    ]
}

규칙 3) 범위 조건자(range predicates)는 동등 조건자와 정렬 조건자 뒤에 위치해야 한다

범위 조건자는 어떤 값에 정확히 일치하는 키를 찾는 대신 여러 키를 스캔할 수 있다.

find({ z: { $gte: 5} })
find({ z: { $lt: 10 } })
find({ z: { $ne: null } })

위 쿼리에서 범위 조건자는 조건을 만족하는 모든 키를 스캔해야하므로 느슨한 경계(loosely bounded)를 가진다.

"indexBounds" : {
    "z" : [
        "[5.0, inf.0]"
    ]
}
"indexBounds" : {
    "z" : [
        "[-inf.0, 10.0)"
    ]
}
"indexBounds" : {
    "z" : [
        "[MinKey, undefined)",
        "(null, MaxKey]"
    ]
}

세 규칙은 쿼리가 인덱스를 어떤 방식으로 사용하고, 조건에 맞는 결과를 돌려주는지와 관련이 있다.

테스트 데이터

이번에는 아래 데이터를 이용해 위 규칙이 실제로 어떤 식으로 적용되는지 알아보려 한다.

{ name: "Shakir", location: "Ottawa",    region: "AMER", joined: 2015 }
{ name: "Chris",  location: "Austin",    region: "AMER", joined: 2016 }
{ name: "III",    location: "Sydney",    region: "APAC", joined: 2016 }
{ name: "Miguel", location: "Barcelona", region: "EMEA", joined: 2017 }
{ name: "Alex",   location: "Toronto",   region: "AMER", joined: 2018 }

쿼리가 어떤 식으로 실행되는지 확인하기 위해 실행계획의 executionStats 필드도 확인해본다.

find({ ... }).sort({ ... }).explain("executionStats").executionStats

(E) 동등 조건

선택성은 인덱스를 활용하여 쿼리 대상을 줄이는 것을 의미한다.

효과적인 인덱스는 선택성이 더 좋고, 몽고디비로 하여금 더 큰 데이터에서도 효과적으로 동작할 수 있게끔 한다.

동등 조건자는 인덱스의 앞 부분에 위치하여 선택성을 보장해야 한다.

(E → S) 동등 조건자가 정렬 조건자의 앞에 위치하는 경우

정렬 조건자를 동등 조건 뒤에 위치시키면

  • 논블로킹 정렬을 가능하게 한다
  • 스캔의 범위를 최소화한다

이를 이해하기 위해 아래 쿼리를 살펴보자.

// operation
createIndex({ name: 1, region: 1 })
find({ region: "AMER" }).sort({ name: 1 })

정렬 조건자가 더 앞에 위치하면서 전체 범위를 스캔한 뒤에야 좀 더 선택성이 높은 동등 조건자를 사용할 수 있다.

// execution stats
"nReturned" : 3.0,
"totalKeysExamined" : 5.0,
"totalDocsExamined" : 5.0,
"executionStages" : {
    ...
    "inputStage" : {
        "stage" : "IXSCAN",
        ...
        "indexBounds" : {
            "name" : [
                "[MinKey, MaxKey]"
            ],
            "region" : [
                "[MinKey, MaxKey]"
            ]
        },

name-region

결과적으로 위와 같이 배치된 인덱스를 사용하면 3개의 도큐먼트를 반환하기 위해 5개의 키 전부를 스캔해야만한다.

// operation
createIndex({ region: 1, name: 1 })
find({ region: "AMER" }).sort({ name: 1 })

이와 반대로 동등 조건자를 먼저 위치시키면 밀접한 경계를 통해 더 적은 키를 스캔하면서 필터 조건을 만족시킬 수 있다.

// execution stats
"nReturned" : 3.0,
"totalKeysExamined" : 3.0,
"totalDocsExamined" : 3.0,
"executionStages" : {
    ...
    "inputStage" : {
        "stage" : "IXSCAN",
        ...
        "indexBounds" : {
            "region" : [
                "[\"AMER\", \"AMER\"]"
            ],
            "name" : [
                "[MinKey, MaxKey]"
            ]
        },

equality-sort

(E → R) 범위 조건자 앞에 동등 조건자를 배치한 경우

범위 조건자는 정렬 조건자보다 적은 키를 스캔하지만 더 높은 선택성을 보장하려면 여전히 동등 조건자 뒤에 배치하는 것이 좋다.

// operation
createIndex({ joined: 1, region: 1 })
find({ region: "AMER", joined: { $gt: 2015 } })

범위 조건자를 동등 조건자보다 먼저 명시하면 조건에 맞는 도큐먼트를 반환하기 위해 더 많은 키를 스캔해야 한다.

// execution stats
"nReturned" : 2.0,
"totalKeysExamined" : 4.0,
"totalDocsExamined" : 2.0,
"executionStages" : {
    ...
    "inputStage" : {
        "stage" : "IXSCAN",
        ...
        "indexBounds" : {
            "joined" : [
                "(2015.0, inf.0]"
            ],
            "region" : [
                "[\"AMER\", \"AMER\"]"
            ]
        },

equality-range

위 예시에서는 2개의 도큐먼트를 반환하기 위해 4개의 키를 스캔했다.

이와 반대로 동등 조건자가 먼저 오게끔 배치하면 스캔해야하는 키를 줄일 수 있다.

// operation
createIndex({ region: 1, joined: 1 })
find({ region: "AMER", joined: { $gt: 2015 } })
// execution stats
"nReturned" : 2.0,
"totalKeysExamined" : 2.0,
"totalDocsExamined" : 2.0,
"executionStages" : {
    ...
    "inputStage" : {
        "stage" : "IXSCAN",
        ...
        "indexBounds" : {
            "region" : [
                "[\"AMER\", \"AMER\"]"
            ],
            "joined" : [
                "(2015.0, inf.0]"
            ]
        },

equality-range

동등 조건자를 범위 조건 앞에 배치하면 정확히 필요한 만큼만 스캔할 수 있다.

(S → R) 범위 조건자 앞에 정렬 조건자를 배치한 경우

범위 조건자를 정렬 조건자 앞에 배치하면 정렬에 인덱스를 사용할 수 없으므로 정렬이 블로킹(blocking)을 유발할 수 있다.

// operation
createIndex({ joined: 1, region: 1 })
find({ joined: { $gt: 2015 } }).sort({ region: 1 })
// execution stats
"nReturned" : 4.0,
"totalKeysExamined" : 4.0,
"totalDocsExamined" : 4.0,
"executionStages" : {
    ...
    "inputStage" : {
        "stage" : "SORT",
        ...
        "sortPattern" : {
            "region" : 1.0
        },
        "memUsage" : 136.0,
        "memLimit" : 33554432.0,
        "inputStage" : {
            "stage" : "SORT_KEY_GENERATOR",
            ...
            "inputStage" : {
                "stage" : "IXSCAN",
                ...
                "indexBounds" : {
                    "joined" : [
                        "(2015.0, inf.0]"
                    ],
                    "region" : [
                        "[MinKey, MaxKey]"
                    ]
                },

sort-range

위에서 범위 조건자를 사용해 조건을 만족하는 4개의 키를 찾을 수 있었지만 쿼리 결과는 정렬되어 있지 않으므로 결과를 반환하기 전에 인메모리 정렬을 해야만 했다.

정렬 조건자를 범위 조건자 앞으로 옮기면 더 많은 키를 스캔해야 하지만 별도의 정렬 없이 결과를 반환할 수 있게 된다.

// operation
createIndex({ region: 1, joined: 1 })
find({ joined: { $gt: 2015 } }).sort({ region: 1 })
// execution stats
"nReturned" : 4.0,
"totalKeysExamined" : 5.0,
"totalDocsExamined" : 5.0,
"executionStages" : {
    ...
    "inputStage" : {
        "stage" : "IXSCAN",
        ...
        "indexBounds" : {
            "region" : [
                "[MinKey, MaxKey]"
            ],
            "joined" : [
                "[MinKey, MaxKey]"
            ]
        },

sort-range

이 방식은 좀 더 많은 키를 스캔하지만 블로킹한 정렬이 수반되지 않으므로 일반적으로 더 좋은 성능을 보여준다.

마무리

ESR 규칙을 통해 몽고 디비 인덱스를 최적화하고 쿼리 성능을 향상시킬 수 있길 바라고, 질문이 있다면 댓글을 남기거나 몽고디비 개발자 커뮤니티 포럼을 이용하길 바란다.

세부적인 도움이 필요하다면 아틀라스 개발자 서포트나 엔터프라이즈 서포트를 찾아보길 바란다.

행복한 최적화가 되기를 바라며!

profile
다양한 것들을 시도합니다

0개의 댓글

관련 채용 정보