본문 바로가기

MongoDB

[MongoDB] 확장 검색 쿼리 - 범용 Aggregation

MongoDB의 Aggregation은 단일 목적 뿐만 아니라 다양한 서비스 요건을 위해서

 

사용자가 직접 작업 내용을 구현할 수 있도록 일반적인 Aggregation 기능도 제공하고 있다.

 

이를 범용 Aggregation이라고 하는데, 범용 Aggregation은 사용자가 필요한 데이터 가공 작업을 직접 작성해야 한다.

 

이때 데이터를 가공하는 작업은 스테이지( Stage )라는 단위 작업들로 구성되며,

 

데이터는 이렇게 작성된 스테이지들을 하나의 관( Pipeline ) 처럼 흘러가면서 원하는 형태의 데이터로 변환된다.

 

그래서 스테이지를 파이프라인( Pipeline )이라고도 한다.

 

MongoDB 서버의 각 스테이지는 입력과 출력을 가지며, 사용자가 정의한 각 스테이지는 출력과 입력이

 

서로 파이프로 연결돼서 실행되는 형태를 가지기 때문에 이를 파이프라인이라고 지칭하는 것이다.

 

 

Aggregate 명령은 다음과 같이 pipeline과 option 두 개의 파라미터를 사용한다.

 

mongo> db.collection.aggregate( pipeline, options );

 

 

다음 예제는 간단히 user_scores 컬렉션에서 점수가 50점 이상인 경우의 사용자별 평균을 계산하는 Aggregation 명령이다.

 

mongo> db.user_scores.aggregate(
  [
      { $match : { score : { $gt : 50 } } }
    , { $group : { _id : "$name", avg :  { $avg : "$score" } } }
  ]
  , { allowDiskUse : true }
);

 

위 예제에서는 aggregate( ) 명령이 2개의 인자를 사용하는데,

 

첫 번째 pipeline 인자는 2개의 스테이지를 가지는 배열로 구성돼 있다.

 

그리고 두 번째 options 인자에는 allowDiskUse 옵션이 true로 설정된 것을 확인 할 수 있다.

 

Aggregate( ) 멸령의 파이프라인은 여러 개의 스테이지를 가질 수 있는데,

 

이 파이프라인은 얼마든지 사용자의 의도대로 구성할 수 있지만,

 

반드시 MongoDB의 Aggregation 프레임워크가 인식할 수 있도록 미리 정의된 스테이지로만 구성해야 한다.

 

그리고 두번째 파라미터는 다음과 같이 다양한 설정을 가질 수 있다.

 

옵션

설명

explain

Aggregation 명령의 실행 계획을확인할 수 있는 옵션이다. 기본값은 false이며, Aggregate( ) 명령이 어떤 실행 계획으로 실행될지 확인하고자 한다면 explain 필드를 true로 설정하면된다.

allowDiskUse

MongoDB의 Aggregate( ) 명령은 기본적으로 정렬을 위해서 100MB의 메모리까지 사용할 수 있다.

하지만 100MB 이상의 데이터를 정렬해야 하는 경우라면 Aggregate( ) 명령은 실패하게 된다. 이런 경우에는 allowDiskUse 옵션을 true로 설정해서 Aggregate( ) 처리가 디스크를 이용해서 정렬을 처리할 수 있게 한다. allowDiskUse 옵션을 true로 설정하면 MongoDB 서버는 MongoDB의 데이터 디렉터리 하위에 "_tmp"라는 디렉터리를 만들어서 임시 가공용 데이터 파일을 저장한다.

cusor

Aggregate( ) 명령의 결과로 반환되는 커서의 배치 사이즈를 설정할 수 있다.

maxTimeMS

Aggregate( ) 명령이 실행될 최대 시간을 설정한다.

readConcern

Aggregate( ) 명령이 도큐먼트의 개수를 확인할 때, 사용할 readConcern 옵션을 설정한다. 아무런 readConcern 옵션도 설정하지 않으면 "local" readConcern이 사용된다.

bypassDocumentValidation

Aggregate( ) 명령의 결과를 다른 컬렉션으로 저장하는 경우에 저장되는 컬렉션의 도큐먼트 유효성체크를 무시할 것인지 설정할 수 있다.

collation

Aggregate( ) 명령이 필요한 도큐먼트를 검색하 쿼리에서 사용할 콜레이션을 설정할 수 있다.

 

MongoDB 2.4 버전까지의 aggregate( ) 명령은 하나의 단일 도큐먼트를 반환했다.

 

그래서 aggreage( ) 명령의 결과는 16MB를 초과할 수 없었다.

 

하지만 MongoDB 2.6 버전부터는 aggreage( )의 결과가 커서로 반환되도록 개선되면

 

16MB 용량 제한이 없어졌을 뿐만 아니라 Aggregation( ) 명령에 커서 옵션을 설정할 수 있게 지원된다.

 

그뿐만 아니라 aggregate( ) 명령의 "out" 스테이지를 이용하면 aggregate( )의 결과를 별도의 컬렉션에 저장하도록 설정할 수도 있다.

 

만약 aggregate( ) 명령의 결과가 매우 클 때에는 $out 스테이지를 이용하는 것이 대용량 처리에 도움이 될 것이다.

 

mongo> db.user_scores.aggregate(
  [
      { $match : { score : { $gt : 50 } } }
    , { $group : { _id : "$name", avg : { $avg : "$score" } } }
    , { $out : "user_score_result" }
  ]
);

 

지금까지 살펴본 aggregate( )명령의 사용법은 크게 복잡하지 않지만,

 

Aggregateion 프레임워크의 파이프라인을 코드를 작성하는 것은 그렇게 간단하지 않다.

 

무엇보다 Aggregation 쿼리 작성에 친숙해 지려면 파이프라인 스테이지와

 

각 스테이지 처리에서 사용할 수 있는 연산자에 익숙해져야 한다.

 

여기에서는 간단하게 SQL 문자에서 일반적으로 자주 사용하는 GROUP BY 에 해당하는

 

Aggregate( ) 쿼리 예제를 살펴보고, 나중에 파이프라인을 구성하는 각 스테이지에서 사용할 수 있는 명령을 살펴보겠다.

 

 

ex) 사용자의 이름별로 도큐먼트 건수 쿼리

SQL> SELECT name, COUNT(*) FROM users GROUP BY name;

mongo> db.users.aggregate(
  [
    { $group : { _id : "$name", counter : { $sum : 1 } } }
  ]
);
{ "_id" : "lara", "counter" : 1 }
{ "_id" : "todd", "counter" : 1 }
{ "_id" : "matt", "counter" : 1 }

 

 

ex) score 필드의 값이 50 이상인 도큐먼트만 조회해서 사용자의 이름으로 그룹핑하고, scroe 평균값 쿼리

SQL> SELECT name, AVG(score) FROM users WHERE score > 50 GROUP BY name;

mongo> db.users.aggregate(
  [
      { $match : { score : { $gt : 50 } } }
    , { $group : { _id : "$name", avg : { $avg : "$score" } } }
  ]
);
{ "_id" : "lara", "average" : 83 }
{ "_id" : "todd", "average" : 89 }
{ "_id" : "matt", "average" : 95 }

 

ex) 사용자의 이름별로 그룹핑하여 도큐먼트의 건수와 score 필드 평균값 계산

SQL> SELECT name, AVG(score) FROM users WHERE score > 50 GROUP BY name;

mongo> db.users.aggregate(
  [
    { $group : { _id : "$name", counter : { $sum : 1 }, average : { $avg :  "$score" } } }
  ]
);
{ "_id" : "lara", "counter" : 1, "average" : 83 }
{ "_id" : "todd", "counter" : 1, "average" : 89 }
{ "_id" : "matt", "counter" : 1, "average" : 95 }

 

ex) 사용자의 생일에서 연도만 분리해서 도큐먼트 건수 조회 쿼리( 출생 연도별 사용자 수 )

SQL> SELECT SUBSTRING(birthday, 1, 4), COUNT(*) FROM users GROUP BY SUBSTRING(birthday, 1, 4);

mongo> db.users.aggregate(
  [
      { $project : { year : { $substr : [ "$birthdy", 0, 4 ] } } }
    , { $group : { _id : "$year", number : { $sum : 1 } } }
  ]
);

 

ex) 사용자의 이름으로 그룹핑해서 건수가 많은 순서대로 정렬한 다음 상위 20건만 쿼리

SQL> SELECT name, COUNT(*) FROM users GROUP BY name ORDER BY COUNT(*) DESC LIMIT 20;

mongo> db.users.aggregate(
  [
      { $group : { _id : "$name", cnt : { $sum : 1 } } }
    , { $sort : { cnt : -1 } }
    , { $limit : 20 } 
  ]
);

 

ex) 유니크한 사용자 이름의 수 쿼리

SQL> SELECT COUNT(DISTINCT name) FROM users;

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

 

위 예제에서 Aggregate( ) 명령의 두 번째 "$group"에서는 그룹핑 키에 상수로 숫자 "1"이 사용되고 있는데,

 

이는 별도의 그룹핑 키 없이 이전 파이프라인의 모든 결과를 하나의 그룹으로 만드는 효과를 낸다.

 

그래서 이 쿼리는 첫 번째 "$group"에서 유니크한 사용자의 이름 목록을 만들고

 

두 번째 "$group"에서는 유니크한 사용자의 이름 개수를 카운하는 쿼리가 되는것이다.

 

물론 유니크한 값의 건수는 db.users.distinct( ) 명령을 이용할 수도 있지만,

 

Aggregate( ) 명령에서 여러 단계의 처리 과정 중에 유니크한 값의 개수를 확인하는 과정이 필요하기도 하다.

 

그런 경우에는 위와 같이 "$group" 스테이지를 여러 번 실행해야 하므로

 

이런 방식도 기억해두면 나중에 많이 응용할 수 있을 것으로 보인다.

 

ex) 사용자의 로그인 이력 컬렉션에서 전체 50번 이상 로그인했었는데

      2017년 01월 01일 이후에는 한번도 로그인한 적이 없는 사용자의 목록 조회

mongo> db.login_history.aggregate(
  [
      { $group : { _id : "$user_id", count : { $sum : 1 }, last_date : { $max : "$login_date" } } }
    , { $match : { coutn : { $gte : 50 }, last_date : { $lt : ISODate( "2017-01-01T00:00:00" ) } } }
    , { $group : { _id : 1, count : { $sum : 1 } } }
  ]
  , { allowDiskUse : true }
);

 

위의 쿼리는 조금 조건이 복잡하긴 하지만, 마케팅이나 홍보를 위해서 자주 사용되는 쿼리다.

 

즉 2017년 01월 01일 이전까지 사용했지만, 그 이후로는 로그인하고 있지 않은 사용자 중에서

 

조금 자주 사용했던( 즉 50번 이상 로그인했던 ) 사용자만 조사하는 쿼리다.

 

대상자를 조회해서 홍보나 이벤트 메일을 발송하는 방식으로 자주 사용될 수 있다.

 

ex) 필드의 값이 NOT NULL 인지 그리고 필드의 존재 여부를 기준으로 그룹핑

// 예제 데이터
mongo> db.users.insert(
  [
      { "name" : "matt", "phone" : "010-0000-0000" }
    , { "name" : "lara", "phone" : null }
    , { "name" : "todd" }
  ]
);

// 1번예제 : 값이 NULL인지 아닌지 판단
mongo> db.users.aggregate(
  [
    {
      $project : {
          _id : 0
        , name : 1
        , has_phone : { "$cond" : [ { "$ifNull" : [ "$phone", false ] }, true, false ] }
      }
    }
  ]
);
{ "name" : "matt", "has_phone" : true }
{ "name" : "lara", "has_phone" : false }
{ "name" : "todd", "has_phone" : false }

// 2번예제 : 값이 NULL인지 아닌지 그리고 필드가 존재하는지 아닌지 판단
mongo> db.users.aggregate(
  [
    {
      $project : {
          _id : 0
        , name : 1
        , has_phone : {
          $concat : [
              { $cond : [ { $gt : [ "$phone", null ] }, "NOT-NULL", ""] }
            , { $cond : [ { $eq : [ "$phone", null ] }, "NULL", ""] }
            , { $cond : [ { $eq : [ "$phone", undefined ] }, "NOT-EXIST", ""] }
          ]
        }
      }
    }
  ]
);
{ "name" : "matt", "has_phone" : "NOT-NULL" }
{ "name" : "lara", "has_phone" : "NULL" }
{ "name" : "todd", "has_phone" : "NOT-EXIST" }

 

MongoDB 서버는 도큐먼트의 특정 필드 값이 NULL 또는 NOT NULL 일 수도 있지만, 필드 자체가 없을 수도 있다.

 

때로는 필드의 존재 여부에 따라서 비교 방식이 조금 혼란스럽기도 하다.

 

위 예제는 MongoDB에서 필드의 값이 NULL인지 아닌지 판단하거나, 필드의 존재 여부를 판단한느 방법을 보여주고 있다.

 

첫 번째 예제는 $ifNull 연산자를 이용해서 필드의 값이 NULL인지 아닌지만 판단한다.

 

이때 $ifNull 연산자는 필드의 값이 NULL 이거나 필드 자체가 존재하지 않으면 TRUE를 반환한다.

 

하지만 첫 번째 예제에서 사용한 $ifNull은 필드의 값이 NULL이거나 필드 자체가 존재하지 않는 경우를 식별하지 못한다.

 

만약 필드의 값이 NULL인 경우와 필드 자체가 존재하지 않는 경우를 구별하고자 한다면

 

두 번째 예제처럼 "undefined"로 비교해서 체크하면된다.

 

 

이렇게 필드의 값이 NULL인 경우와 필드가 존재하지 않는 경우를 식별해서 값을 구분할 수 만 있다면

 

이 결과 값을 이용해서 그룹핑 작업을 다음과 같이 실행할 수 있다.

 

 

ex) NULL 인지 아닌지만 구분해서 그룹핑

mongo> db.users.aggregate(
  [
    {
      $project : {
          _id : 0
        , name : 1
        , has_phone : { "$cond" : [ { "$ifNull" : [ "phone", false ] }, true, false ] }
      }
    }
    , { $group : { _id : "$has_phone", count : { $sum : 1 } } }
  ]
);
{ "_id" : false, "count" : 2 }
{ "_id" : true, "count" 1 }

// NULL이 아닌 경우 그리고 필드의 존재
mongo> db.users.aggregate(
  [
    {
      $project : {
          _id : 0
        , name : 1
        , has_phone : {
          $concat : [
              { $cond : [ { $gt : [ "$phone", null ] }, "NOT-NULL", "" ] }
            , { $cond : [ { $gt : [ "$phone", null ] }, "NULL", "" ] }
            , { $cond : [ { $gt : [ "$phone", undefined ] }, "NOT-EXIST", "" ] }
          ]
        }
      }
    }
    , { $group : { _id : "$has_phone", count : { $sum : 1 } } }
  ]
);
{ "_id" : "NOT-EXIST", "count" : 1 }
{ "_id" : "NULL", "count" : 1 }
{ "_id" : "NOT-NULL", "count" 1 }