본문 바로가기

MongoDB

[MongoDB] 확장 검색 쿼리 - Aggregation의 목적 및 작동방식

MongoDB의 Find명령으로는 데이터를 그룹핑해서 특정 조건에 일치하는

 

도큐먼트의 개수를 확인한다거나 하는 복잡한 처리는 수행할 수 없었다.

 

MongoDB의 Aggregation은 FIND 명령으로는 처리할 수 없는 복잡한 데이터 분석 기능들을 제공하는데,

 

일반적으로 SQL에서 GROUP BY 절로 처리할 수 있는 기능들ㅇ르 샤딩된 환경에서 실행할 수 있게 해준다.

 

 


 

#01. Aggregation의 목적

 

MongoDB 서버는 이미 맵리듀스라는 분석기능을 가지고 있는데,

 

MongoDB에서 새롭게 Aggregation 기능을 도입한 데이는 대표적으로 두 가지 이유를 생각할 수 있다.

 

첫 번째는 간단한 분석 쿼리에도 자바스크립트를 이용해서 맵리듀스 프로그램을 작성해야 한다.

 

자바스크립트 기반의 맵리듀스는 많은 제약을 가지고 있으며, 자바스크립트 언어를 알고 있어야 한다.

 

두 번째는 자바스크립트로 작성된 맵리듀스 작업은

 

자바스크립트 엔진과 MongoDB 엔진 간의 빈번한 데이터 맵핑으로 인해서 성능적 제약이 심했다.

 

아래 그래그프는 jsMode가 false일 때와 true일 때의 맵리듀스 그리고( 참고를 위해서 ) 같은 기능을

 

Aggregation으로 작성했을 때의 성능을 테스트해본 것이다.

 

 

 

위 테스트는 백만건의 도큐먼트를 가진 컬렉션에서 유니크한 키의 개수를 확인하는 분석 쿼리로 간단하게 성능을 테스트해본 것이다.

 

이 테스트 결과를 보면 jsMode가 true일 떄와 false일 때 3배정도의 성능 차이가 발생한 것을 확인 할 수 있다.

 

즉 jsMode가 true일 때 MongoDB 엔진과 자바스크립트 엔진 간의 변수 맵핑 과정이 사라지면서 성능이 매우 빨라졌다.

 

하지만 jsMode는 처리해야 할 도큐먼트의 개수가 소규모일 때만 사용할 수 있다.

 

그래서 데이터가 많은 경우에는 결국 느린 모드( jsMode = false )의 맵리듀스를 사용해야 한다.

 

 

하지만 Aggregation은 jsMode가  true일 때보다 3배정도 더 빠르게 실행되는 것을 확인할 수 있다.

 

Aggregation은 순수하게 C++로 개발돼서 MongoDB엔진의 일부로 내장된 기능이다.

 

그래서 별도의 자바스크립트 언어 엔진이 필요하지 않으며, 변수 맵핑 과정도 필요 없다.

 

또한 C++언어로 Aggregation의 각 처리 과정을 구현했기 때문에 훨씬 빠르게 데이터를 처리할 수 있다.

 

그래서 Aggregation은 맵리듀스가 가지고 있던 2가지 큰 문제점을 한번에 해결해준 것이다.

 

 

MongoDB의 Aggregation은 2.2 버전부터 도입됐으며, 지금까지 많은 문제점과 기능을 보완해 왔다.

 

물론 세상의 모든 서비스 요건을 Aggregation이처리해줄 수는 없겠지만,

 

우리가 필요로 하는 대부분 기능은 Aggregation으로 처리할 수 있게 돼었다.

 

그래서 항상 분석 처리가 필요한 경우에는 Aggregation으로 처리할 수 있는지 먼저 확인한 다음에

 

원하는 요건을 Aggregation으로 처리할 수 없을 때 맵리듀스를 활용한 방법으로 개발을 진행하는 것이 좋다.

 

 


 

#02. Aggregation의 작동방식

 

 

샤딩되지 않은 MongoDB 서버에서의 Aggregation 실행에은 다른 오퍼레이셔노가 비교했을 때 별다른 차이는 없다.

 

하나의 샤드만 사용할 수 있기 때문에 단순한 FIND 쿼리나 복잡한 Aggregation 쿼리 모두

 

단일 샤드에서 실행되고 결과가 클라이언트로 전송될 것이다.

 

하지만 샤딩된 환경의 MongoDB에서는 Aggregation 파이프라인의 각 스테이지가

 

어떤 샤드에서 실행되는지가 부하 분산 차원에서 상당히 중요한 문제가 된다.

 

대표적으로 여러 샤드에 걸쳐서 분산된 데이터를 읽어서 그룹핑한 다음 정렬하는 경우의 쿼리를 한번 생각해보자.

 

mongo> db.login_history.aggregate(

  [

      { $match : { status : 'SUCCED' } }

    , { $group : { _id : "$name", total_count : { $sum : 1 } } }

    , { $sort : { total_count : -1 } }

  ]

};

 

예제 쿼리는 모든 샤드로부터 status 필드가 "SUCCEED"인 도큐먼트를 조회해서 name 필드를 기준으로 그룹핑해야 한다.

 

status와 name필드가 샤드 키가 아니라면 이 작업을 위해서 각 샤드에서 검색된 결과를 하나의 샤드로 모두 모아야만

 

name 필드를 기준으로 그룹핑하고 도큐먼트의 건수를 확인 할 수 있다.

 

그 뒤에 total_count 필드로 정렬하는 작업도 그룹핑 작업을 완료해야만 수행할 수 있기 때문에

 

단일 샤드에서만 처리할 수 있다.

 

MongoDB 서버는 이렇게 여러 샤드에서 데이터를 수집애햐 하는 처리를 위해서 프라이머리 샤드를 활용한다.

 

임의의 샤드 서버를 "대표 샤드"로 선택한다.

 

이때 대표 샤드는 MongoDB  클러스터내의 모든 샤드중에서 임의의 샤드 서버가 대표 샤드로 선택될 수 있다.

 

 

아래 그림은 Aggregatio 쿼리 실행에 있어서 대표 샤드의 역할을 보여주고 있다.

 

 

 

 

MongoDB 라우터( Mongos )는 사용자로부터 Aggregation 쿼리를 전달받으면

 

우선 요청된 Aggregation 쿼리의 파이프라인 선두에 "$match" 스테이지가 있는지와

 

$match 스테이지의 검색 조건이 샤드 키를 포함하는지를 비교한다.

 

만약 검색 조건이 필요로 하는 도큐먼트가 단일 샤드에만 있다면 MongoDB 라우터는 해당 샤드로만 Aggregation 쿼리를 전송한다.

 

만약 그렇지 않다면 MongoDB 라우터는 Aggregation 쿼리를 대표 샤드로 전달한다.

 

대표 샤드는 Aggregation 쿼리를 전달받으면 필요한

 

나머지 샤드로 쿼리( Aggregation 파이프라인에서 필요한 일부 스테이지만 )를 전송한다.

 

그리고 요청을 전달받은 샤드들이 쿼리 결과를 반환하면 대표 샤드는

 

그 결과를 병합하고 정렬하는 작업을 수행해서 MongoDB라우터로 최종 결과를 전달한다.

 

 

MongoDB 서버는 Aggregation파이프라인을 각 샤드가 실행할 수 있는 부분과 그렇지 않은 부분으로 나눠서 처리를 수행한다.

 

대표적으로 $match나 $project와 같은 스테이지는 각 샤드가 개별로 실행할 수 있는 부분에 속하며,

 

$sort 또는 $group 스테이지는 각 샤드가 개별로 실행하지 못하는 부분( $match 조건이 2개 이상의 샤드를 참조야 하는 경우 )에 속한다고 볼 수 있다.

 

그리고 $out 이나 $looukp 그리고 $graphlookup 등과 같은 스테이지도 각 샤드가 개별로 실행하지 못하는 부분이다.

 

그런데 $out과 $graph그리고 $raphlookup 스테이지는 반드시 프라이머리 샤드만 실행할 수 있는 스테이지이기도 하다.

 

여기에서 각 샤드가 개별적으로 실행하지 못하는 부분은 누군가( MOngoDB 라우터나 MongoDB 서버 )가 모아서 처리해야 하는데,

 

이 부분은 MongoDB에서 최적화를 위해서 자주 변경하고 있는 부분이기도 하다.

 

다음 표는 MongoDB 버전별로 이 부분이 어떻게 개선돼 왔는지 보여주고 있다.

 

버전

처리방식

2.4

검색 조건( $match )을 기준으로 MongoDB 라우터는 필요한 모든 샤드로 쿼리를 전송한다. MongoDB 라우터는 각 샤드로부터 결과를 전달받으면 MongoDB 라우터가 직접 결괄르 정렬하고 그룹핑 하는 작업을 수행해서 최종 결과를 클라이언트로 반환한다.

2.6

검색 조건( $match )을 기준으로 MongoDB 라우터는 필요한 모든 샤드로 쿼리를 전송한다. 그리고 결과를 정렬하고 그룹핑하는 작업이 필요한 경우에에는 프라이머리 샤드가 다른 샤드로부터 결과를 전달받아서 정렬 및 그룹핑하는 작업을 수행하고, 최종 결과를 MongoDB 라우터를 통해서 클라이언트로 반환한다.

3.2

검색 조건( $match )을 기준으로 MongoDB 라우터는 필요한 모든 샤드로 쿼리를 전송한다. 그리고 결과를 정렬하고 그룹핑하는 작업이 필요할 때에는 대표 샤드가 다른 샤드로부터 결과를 전달받아서 정렬 및 그룹핑하는 작업을 수행한다. 이때 다른 샤드는 프라이머리 샤드일 수도 있지만, 프라이머리 샤드가 아닐 수도 있다.

 

단순히 정렬 및 그룹핑만 수행한다면 MongoDB는 랜덤하게 대표 샤드를 선정해서 대표 샤드가 이런 작업을 수행하도록 할 것이다. 하지만 $out이나 $lookup과 같은 스테이지가 포함돼 있다면 MongoDB는 프라이머리 샤드로만 이런 병합과 정렬 작업을 수행하도록 할 것이다.

 

MongoDB의 $lookup이나 $graphLookup 등과 같은 조인을 수행하는 Aggregation 쿼리는

 

샤당이 되지 않은 컬렉션만 조인을 수행할 수 있기 떄문에 $lookup이나 $graphLookup과 같은 기능을 사용하려면 조인되는 컬렉션을 샤딩할 수가 없다.

 

즉 $lookup이나 $graphLookup과 같은 Aggregation 쿼리를 사용하려면 프라이머리 샤드를 사용할 수밖에 없다.

 

이 경우에는 MongoDB 라우터가 랜덤하게 샤드를 선택할 수가 없고, 아 래 그림과 같이

 

데이터베이스별로 프라이머리 샤드로만 Aggregation 쿼리를 전송한다.

 

 

 

 

 

그래서 $lookup이나 $graphLookup과 같은 기능이 많이 사용되는 경우에는

 

컬렉션 단위로 데이터베이스를 분산하고 각 데이터베이스의 프라이머리 샤드를 적절히 분산해 두는 것이 좋다.

 

 

 

#03. 단일 목적의 Aggregation

 

MongoDB 서버에서는 Aggregate와 같이 집계 처리를 수행하는 3개의 명령이 있는데,

 

일반적인 Aggregate 보다는 조금 더 단순하지만 더 사용빈도가 높아서

 

쉽게 Aggregate 기능을  사용할 수 있도록 별도의 명령을 지원하는 것이다.

 

이렇게 전용의 명령이 제공되는 Aggregate 명령을 3개 지원하는데, 그중에서 group( ) 명령은 최근부터 지원하지 않게 되면서

 

현재는 count( )와 distinct( )2개가 지원되고 있다.

 

 

count( ) 명령은 특정 조건에 부합하는 도큐먼ㅌ의 검수를 확인하는 Aggregate명령이다.

 

다음과 같이 옵션을 사용할 수 있다.

 

query 조건은 FIND명령에서 사용했던 것처럼 일치하는 도큐먼트를 검색할 조건을 사용할 수 있다.

 

mongo>  db.collection.count( query, options );

 

count( ) 명령은 선택적으로 options라는 두 번째 인자를 사용할 수 있는데,

 

options 필드에는 다음과 같은 선택 사항을 설정할 수 있다.

 

 

  • limit : 조건에 일치하는 도큐먼트의 최대 개수를 설정한다.
  • skip : 조건에 일치하는 도큐먼트의 개수를 확인할 때, 먼저 건너뛸 도큐먼트의 개수를 설정한다.
  • hint : count( ) 명령이 사용하도록 유도할 인덱스의 이름이나 스펙을 설정한다.
  • maxTimeMS : count( ) 명령이 실행될 최대 시간을 설정한다.
  • readConcern : count( ) 명령이 도큐먼트의 개수를 확인 할 때 사용할 eadConcern 옵션을 설정한다. 아무런 readConcern 옵ㄱ션을 설정하지 않으면 "local" readConcern이 사용된다.

 

MongoDB 매뉴얼에서는 db.collection.count( query ) 명령은 db.collection.find( query ).count( )와 같다고 언급하고 있다.

 

하지만 이 둘은 skip과 limit 옵션이 사용되면 다음과 같은 차이가 발생한다.

 

mongo> db.users.count( { name : "matt" }, { limit :  } );

5


mongo> db.users.find( { name : "matt" } ).limit( 5 ).count();

24

 

실제 count( ) 명령은 skip과 limit 옵션을 활용해서 도큐먼트의 건수를 확인하는 반면,

 

find( ).count( ) 명령은 limit나 skip 옵션을 무시하기 때문이다.

 

만약 find().count() 명령을 사용할 때 skip이나 limit가 적용되게 하고자 한다면 다음 예제처럼

 

applySkipLimit옵션을 true로 설정해야 한다.

 

mongo> db.users.count( { name : "matt" }, { limit :  } );

5


mongo> db.users.find( { name : "matt" } ).limit( 5 ).count( { applySkiplImit:true } );

5

 

count( ) 쿼리에서 skip이나 limit가 무슨 의미가 있겠냐고 생각할 수도 있다.

 

하지만 특정 조건에 일치하는 도큐먼트의 개수를 확ㅇ니하고자 하는데, 전체 개수가 100건 이상이면 더 이상 카운트를 하지 않도록 하는 이런 요건은 의외뢰 꼭 필요한 경우가 있다.

 

특정 조건에 일치하는 count( ) 쿼리는 실제 도큐먼트를 찾아서 건수를 카운트해야 한다.

 

그런데 예를 들어, 특정 사용자가 작성한 글의 개수가 몇십만  개를 넘어서는 경우가 있다고 가정해 보자.

 

이때 서비스의 요건은 사용자가 작성한 글의 개수가 100개 미만이면 정확한 개수를 그리고 100개 이상이면 "100+"라고 표현하고자 한다면 count( ) 쿼리에 limit 옵션을 사용하는 것이 MongoDB 서버의 부하 감소에 상당한 도움이 될 수 있다.

 

 

MongoDB의 조건없이 컬렉션의 모든 도큐먼트 개수를 확인하는 db.collection.count( ) 명령은

 

실제 컬렉션의 도큐머느를 일일이 확인해서 최종 건수를 반환하는 것이 아니다.

 

MongoDB는 모든 컬렉션의 메타 정보에 전체 도큐먼트의 건수를 관리하고 있는데,

 

조건이 없는 db.collection.count( ) 명령은 단순한 메타 정보의 도큐먼트 건수만 반환하도록 작동한다.

 

그래서 조건 없이 컬렉션의 도큐먼트 건수를 확인하는 명령은 전혀  MongoDB 서버의 부하를 유발하지 않는다.

 

이는 조건 없이 find( ) 명령과 count( ) 명령이 결합된 db.collection.find( ).count( )에서도 동일하다.

 

 

단일 목적의 Aggregate 명령은distinct( )이다. distinct( ) 명령은 다음과 같은 옵션을 사용할 수 있다.

 

field옵션은 유니크한 값을 확인할 필드의 이름이며, query 옵션은 특정 조건에 일치하는 도큐먼트에 대해서만 유니크한 값을 확인 할 수 있도록 검색 조건을 사용할 수 있다.

 

options필드에는 query필드에 명시되는 검색 조건이 사용할 콜레이션을 명시할 수 있다.

 

mongo>db.collection.distinct( field, query, options );

 

 

distinct( ) 명령은 지정된 필드의 유니크한 값들을 배열로 반환하는데,

 

만약 유니크한 값의 개수를 확인하고자 한다면 Mongo 셸에서 간단히 distinct( ) 명령의 결과에

 

length( 배열의 길이를 확인하는 속성 )를 출력해보면 된다.

 

mongo> db.users.distinct( "name" );

[ "matt", "lara", "todd" ]


mongo> db.users.distinct( "name" ).length;

3

 

MongoDB의 distinct( )와 count( ) 명령은 모두 query 필드가 적절히 상요할 수 있는 인덱스가 있다면

 

그 인덱스를 이용해서 쿼리의 처리를 최적화할 수 있으며, 커버링 인덱스로 최적화도 가능하므로 인덱스만을 이용해서 쿼리가 완료될 수 있다.

 

예를 들어, 이 예제에서 users 컬렉션의 name 필드에 인덱스가 있다면 다음과 같은 쿼리의 실행 계획이 커버링 인덱스가 될 수 있다.

 

monbo> db.users.explain().distinct("name");

{

  "queryPlanner" : {

    "winningPlan" : {

        "stage" : "PROJECTION"

      , "transformBy" : {

          "_id" : 0

        , "name" :1

      }

      , "inputStage" : {

          "stage" : "DISTINCT_SCAN"

        , "keyPattern" : {

          "name" : 1

        }

        , "indexName" : "name_1"

        , "isMultiKey" : false

        , "multiKeyPaths" : {

          "name" : []

        }

        , ...

      }

    }

  }

  , "ok" : 1

}