본문 바로가기

MongoDB

[MongoDB] 확장 검색 쿼리 - Aggregation 파이프라인 최적화

MongoDB의 Aggregation은 각 스테이지가 순차적으로 처리되며,

 

그 결과를 다음 스테이지로 전달하면서 사용자의 요청을 처리한다.

 

그래서 각 스테이지들의 배열 순서는 처리 성능에 많은 영향을 미친다.

 

예를 들어, 필요한 도큐먼트만 필터링 하는 스테이지는 데이터를 그룹핑하는 스테이지보다

 

앞쪽에 위치해야 그룹핑해야 할 도큐먼트의 건수를 줄일 수 있고, Aggregation의 성능을 높일 수 있다.

 

MongoDB 서버도 내부적으로 이러 형태의 기본적인 최적화 기능을 내장하고 있는데,

 

 

여기에서는 MongoDB가 어떤 최적화를 자동으로 처리할 수 있는지와 어떤 최적화가 불가능한지 살펴보겠다.

 

또한 MongoDB 서버가 Aggregation의 최적화를 해준다고 하더라도 Aggregation의 스테이지 구성에 따라서

 

자동 최적화가 불가능할 수도 있고 메뉴얼에 명시된 대로 작동하지 않을 수도 있다.

 

그러므로 가능하면 Aggregation을 작성할 때, 이런 부분을 미리 적용해 둔다면 성능 이슈가 많이 줄어들 것이다.

 

 

#01. $project 스테이지

 

다음 예제는 휴면 상태의 사용자들을 조회한 다음 마지막으로 로그인 한 날짜별로

 

서비스를 사용한 시간의 평균을 조회하는 Aggregation 쿼리다.

 

mongo> db.users.aggregate(
  [
      { $match : { type : "idle" } }
    , { $sort : { last_login_dt : 1 } }
    , { $project : { last_login_dt : 1, seconds : 1, _id : 0 } }
    , { $group : { _id : "$last_login_dt", avgUsingTime : { $avg : "seconds" } } }
  ]
);

 

$project 스테이지는 전체 도큐먼트에서 필요한 필드만 뽑아서 다음 스테이지로 전달하는 역할을 한다.

 

그래서 이 예제에서 $project 스테이지는 전달받은 도큐먼트의 필드 중에서

 

last_login_dt와 seconds 필드만 뽑아서 그룹 스테이지로 데이터를 전달한다.

 

이렇게 작업에 꼭 필요한 필드만 뽑아서 처리하면

 

MongoDB 서버의 Aggregation 처리에서 CPU 사용량이나 메모리 사용량을 많이 낮출수 있다.

 

하지만 MongoDB 서버는 파이프라인을 실행하기 전에 먼저 각 스테이지를 스캔해서 사용되는 필드를 먼저 확인하고,

 

원본 도큐먼트에서 필요한 필드만 뽑아서 처리한다.

 

그래서 단순히다음 스테이지로 전달할 필드를 필터링해서 처리해야 할 도큐먼트의 크기를 줄이기 위한 용도라면

 

굳이 $project 스테이지를 명시하지 않아도 된다.

 

 

즉 기존 도큐먼트의 필드들을 조합해서 새로운 서브 도큐먼트나 배열 필드 또는 가공된 값을

 

생성하는 경우가 아니라면 $project 스테이지는 사용하지 않아도 된다.

 

 

#02. 스테이지 순서 최적화( $match와 $sort 그리고 $project와 $skip )

 

$sort 스테이지와 $match 스테이지가 순서대로 연결된 경우에는 MongoDB 서버가

 

$match아 $sort 스테이지의 순서를 바꾸서 실행하도록 최적화 한다.

 

먼저 $match 조건에 일치하는 도큐먼트만 필터링 한 다음, 남은 도큐먼트만 정렬하도록 최적화 한다.

 

mongo> db.example.aggregate(
  [
      { $sort : { age : -1 } }
    , { $match : { status : "A" } }
  ]
);

// 다음과 같이 $match와 $sort 스테이지의 순서를 자동으로 변경해서 실행
mongo> db.example.aggregate(
  [
      { $match : { status : "A" } }
    , { $sort : { age : -1 } }
  ]
);

 

$project 스테이지 뒤에 바로 $skip 스테이지( 또는 $skip + $limit 스테이지 )가 사용되면

 

MongoDB 서버는 $skip 스테이지를 $project 스테이지 앞쪽으로 옮겨서 실행한다.

 

이렇게 함으로써 다음 스테이지에서 버려질 도큐먼트를 불필요하게 가공하는 일을 줄여줄 수 있다.

 

그뿐만 아니라 다음과 같이 단순한 형태의 $project와 $match가 사용되는 경우 MongoDB 서버는

 

$project보다는 $match를 먼저 처리해서 꼭 필요한 결과에 대해서만 $project 처리르 수행하도록 최적화 한다.

 

// 01) $project 스테이지에서 새롭게 가공된 필드가 추가된 것이 아니라면
mongo> db.users.aggregate(
  [
      { $project : { name : 1, phone : 1 } } 
    , { $match : { name : "matt" } }
  ]
);

// 02) $MongoDB 서버는 아래와 예제와 같이, $project 스테이지보다 $matc를 먼저 실행해서
//     꼭 필요한 도큐먼트에 대해서만 $project 처리를 수행하도록 최적화 한다.
mongo> db.users.aggregate(
  [
      { $match : { name : "matt" } }
    , { $project : { name : 1, phone : 1 } } 
  ]
);

 

#03. 스테이지 결합

 

MongoDB 서버는 처리 성능을 향상시키기 위해서

 

Aggregation 파이프라인에서 2개 이상의 스테이지를 하나의 스테이지로 결합해서 처리하기도 한다.

 

기본적으로 같은 스테이지가 여러 번 반복해서 사용되는 경우에는 이를 하나의 스테이지로 결합해서 처리한다.

 

// $limit 스테이지가 반복해서 사용되는 경우
mongo> db.example.aggregate(
  [
      { $limit : 100 }
    , { $limit : 10 }
  ]
);

// $limit 스테이지를 결합해서 10개의 도큐먼트만 반환하도록 하나의 $limit 스테이졸 결합
monog> db.example.aggregate(
  { $limit : 10 }
);

// $skip 스테이지가 반복해서 사용되는 경우
mongo> db.example.aggregate(
  [
      { $skip : 5 }
    , { $skip : 2 }
    , { $limit : 10 }
  ]
);

// $skp 스테이지를 결합해서 하나의 $skip 스테이지로 처리
monog> db.example.aggregate(
  [
      { $skip : 7 }
    , { $limit : 10 }
  ]
);

// $match 스테이지가 반복해서 사용되는 경우
mongo> db.example.aggregate(
  [
      { $match : { year : 2014 } }
    , { $match : { status : "A" } }
  ]
);

// $match 스테이지를 결합해서( 가용 인덱스가 있다면 ) 모든 조건이 인덱스를 이용하도록 최적화
mongp> db.example.aggregate(
  [
    { $match : { $and : [ { "year" : 2014 }, { "status" : "A" } ] } }
  ]
);

 

그뿐만 아니라 $sort와 $limit 스테이지가 연속으로 사용되는 경우에는 하나의 스테이지로 결합해서

 

$limit에 지정된 도큐먼트 건수만큼 정렬이 수행되면 중간에 정렬을 멈출 수 있게 해준다.

 

// $sort와 $limit가 연속으로 사용되면 두 스테이지를 결합
// MongoDB 서버는 정렬 작업에서 상위 10개가 준비되면 정렬 작업을 멈추도록 최적화 한다.
mongo> db.example.aggregate(
  [
      { $sort : { year : 1 } }
    , { $limit : { 10 } }
  ]
);

 

#04. 인덱스( INDEX ) 사용

 

Aggregation의 각 각 스테이지도 가능하다면 인덱스를 활용할 수 있는 형태로 최적화 된다.

 

하지만 Aggregation의 각 스테이지는 이전 스테이지의 가공된 결과를 전달받기 때문에 파이프라인의 앞쪽 스테이지 한두 개만 인덱스를 활용해서최적화할 수  있는 경우가 대부분이다.

 

그리고 Aggregation 처리 과정에서 한번 데이터를 가공하면 그 데이터는 컬렉션이 아니라

 

메모리나 디스크의 임시 버퍼 공간에 저장되므로 인덱스를 사용할 수 없게 된다.

 

다음 두 예제를 비교해보자.

 

// $project에서 단순히 필드를 필터링( 다음 스테이지로 전달한 것인지 여부만 선택 )
mongo> db.users.aggregate(
  [
      { $project : { name : 1, phone : 1 } }
    , { $metch : { name : "matt" } }
  ]
);

// $project에서 원래 도큐먼트의 필드로 가공( 문자열 일부만 다음 스테이지로 전달 )
mongo> db.users.aggregate(
  [
      { $project : { name : { $substr : [ "$name", 0, 4 ] }, phone : 1 } }
    , { $metch : { name : "matt" } }
  ]
);

 

첫 번째 예제에서는 MongoDB가 $project에 의해서 필드 데이터의 변경이 전혀 발생하지 않고,

 

단순히 필드를 다음 스테이지로 전달할 것인지 아닌지만 결정한다는 것을 알ㅇ아채고,

 

$project와 $match 스테이지의 순서를 서로 변경해서 실행한다.

 

하지만 두 번째 예제는 name 필드의 값이 substr 함수에 의해서 변경됏기 때문에

 

MongoDB 서버가 $project와 $match 스테이지의 실행 순서를 변경하면 결과가 달라진다는 것을 알고

 

스테이지의 순서를 변경하지 못한다.

 

 

만약 users 컬렉션의 name 필드에 인덱스가 있었다면 첫 번째 인덱스를 먼저 필요한 도큐먼트만 가져온 다음

 

$project 스테이지를 처리하게 된다.

 

하지만 두 번째 쿼리는 $match 스테이지보다 $project 스테이지를 먼저 처리해야 할 뿐만 아니라

 

$project 스테이지가 인덱스 필드인 name 필드의 값을 변경하므로 인덱스를 사용할 수 없다.

 

MongoDB의 Aggregation은 가능하다면 컬렉션의 인덱스를 사용하려고 노력할 것이다.

 

하지만 이는 컬렉션의 필드나 도큐먼트가 변경되지 않은 상태에서만 가능하므로 실질적으로

 

Aggregation 파이프라인의 앞쪽에 위치한 1~2개의 스테이지만 인덱스를 사용하게 된다.

 

MongoDB 서버에서 Aggregation 쿼리가 인덱스를 최대한 활용해서 최적화 할 수 있는 일반적인 형태는

 

다음과 같이 $match와 $sort 스테이지가 연속으로 파이프라인의 앞쪽에 위치한 경우다.

 

// name 필드에 인덱스가 있는 경우
mongo> db.users.createIndex( { name : 1 } );
mongo> db.users.aggregate(
  [
      { $match : { name : "matt" } }
    , { $sort : { name : 1 } }
  ]
);

// name + score 필드로 인덱스가 있는 경우
mongo> db.users.createIndex( { name : 1, score : 1 } );
mongo> db.users.aggregate(
  [
      { $match : { name : "matt" } }
    , { $sort : { score : 1 } }
  ]
);

 

하지만 MongoDB 3.6 버전까지는 아직 도큐먼트를 그룹핑하는 $group 스테이지는 인덱스를 이용하지 못한다.

 

다음 Aggregation은 name 필드를 기준으로 그룹핑해서 도큐먼트의 건수를 카운트하는 기능만 수행한다.

 

충분히 name 필드의 인덱스만으로 처리될 수 있지만, 여전히 MongoDB 3.4 버전까지는 인덱스를 사용하지 못하고

 

users 컬렉션을 풀 스캔해서 그 결과를 정렬한 다음 그룹핑을 수행한다.

 

// name 필드의 인덱스 생성
mongo> db.users.createIndex( { name : 1 } );
mongo> db.users.aggregate(
  [
    $group : { _id : "$name", count : { $sum : 1 } } }
  ]
);

 

#05. 메모리 사용

 

Aggregate() 명령은 내부적으로 그룹핑 작업을 처리하기 위해서 메모리를 사용하는데,

 

이때 메모리 사용량은 100MB로 제한돼 있다.

 

쿼리 결과의 정렬 작업에 사용되는 메모리 공간과 그룹핑 작업에 사용되는 메모리 공간의 크기 제한은 설

로 다르게 적용된다.

 

정렬 작업을 위한 메모리 공간의 크기는 internalQueryExecMaxBlockingSortBytes 파라미터를 이용해서 조정할 수 있다.

 

하지만 Aggregate() 명령의 그룹핑의 정렬을 위한 메모리 공간은 소스 코드에 100MB로 고정돼 있기 때문에

 

파라미터로 조정할 수가 없다.

 

 

그래서 만약 다음과 같이 그룹핑을 위한 메모리 공간이 부족하다는 에러 메시지가 발생하면

 

Aggregate() 명령의 옵션에 "{ allowDiskUse : true }"를 사용하여 Aggregate( ) 명령을 실행하는 것이 좋다.

 

allowDiskUse 옵션을 true로 설정하면 MongoDB 서버는 MongoDB 데이터 디렉토리 하위에

 

"_tmp"라는 디렉토리를 만들어서 임시 가공용 데이터 파일을 저장한다.

 

mongo> db.users.aggregte(
  [
    { $group : { _id : "$name", cnt : { $sum : 1 } } }
  ]
);

assert : comman failed : {
    "ok" : 0
  , "errmsg" : "Exceeded memory limit for $group, but didn't allow external sort. Pass allowDiskUse : true to opt in."
  , "code" : 16945
  , "codeName" : "Location16945"
} : aggregate failed

 

여기에서 한가지 주의해야 할 점은 그룹핑해야 할 도큐먼트의 전체 크기가 100MB를 넘어서는 경우

 

에러가 발생하는 것이 아니라, 그룹핑된 임시 결과가 100MB를 넘어서는 경우 위 에러가 발생하는 것이다.

 

즉 다음과 같이 동일한 상수 값("1")으로 그룹핑 하는 경우와 같이 그룹핑된 결과가 많지 않을 때는

 

처리해야할 도큐먼의 건수가 많아도 메모리부족에러 없이 쿼리가 실행되기도 한다.

 

mongo> db.users.aggregate( [ $group : { _id : 1, cnt : { $sum : 1 } } } ] );
{ "_id" : 1, "cnt" : 5000000 }