[MongoDB in Action] CH6. 집계

이 내용은 몽고디비 인 액션 2nd Edition 책을 읽고 개인적으로 정리한 내용입니다.

MongoDB 집계 프레임워크(aggregation framework)를 사용해서 보다 복잡한 쿼리를 작성해본다.

집계 프레임워크 개요

집계 프레임워크에 대한 호출은 파이프라인의 각 단계에서의 출력이 다음 단계로의 입력으로 제공되는 파이프라인, 즉 aggregation pipeline 을 정의한다.

  • $project : 출력 도큐먼트상에 배치할 필드를 지정
  • $match : 처리될 도큐먼트를 선택하는 것. find()와 비슷한 역할을 수행한다.
  • $limit : 다음 단계에 전달될 도큐먼트의 수를 제한한다.
  • $skip : 지정된 수의 도큐먼트를 건너뛴다.
  • $unwind : 배열을 확장하여 각 배열 항목에 대해 하나의 출력 도큐먼트를 생성한다.
  • $group : 지정된 키로 도큐먼트를 그룹화한다.
  • $sort : 도큐먼트를 정렬한다.
  • $geoNear : 지리 공간위치 근처의 도큐먼트를 선택한다.
  • $out : 파이프라인의 결과를 컬렉션에 쓴다.
  • $redact : 특정 데이터에 대한 접근을 제어한다.

집계 파이프라인은 다음과 같이 정의할 수 있다. 람다와 비슷하게 chaining 형식으로 호출하는 것을 알 수 있다.

db.products.aggregate([{$match: ...}, {$group: ...}, {$sort: ...}])

e-commerce 집계 예제

책에서는 이커머스 모델로, products, reviews, categories, orders 및 users 컬렉션으로 집계를 어떻게 하는지 예시를 든다.

여기서는 각 연산이 어떻게 수행되는지만 정리한다.

$group

아래는 product_id 기준으로 review 컬렉션에서 카운트를 하는 쿼리이다.

> product = db.product.findOne({'slug': 'wheel-barrow-9092'})
{ _id: ObjectId("4c4b1476238d3b4dd5003981"),
  slug: 'wheel-barrow-9092',
  sku: '9092',
  name: 'Extra Large Wheel Barrow',
  description: 'Heavy duty wheel barrow...',
  details:
   { weight: 47,
     weight_units: 'lbs',
     model_num: 4039283402,
     manufacturer: 'Acme',
     color: 'Green' },
  total_reviews: 4,
  average_review: 4.5,
  pricing: { retail: 589700, sale: 489700 },
  price_history:
   [ { retail: 529700,
       sale: 429700,
       start: 2010-05-01T00:00:00.000Z,
       end: 2010-05-08T00:00:00.000Z },
     { retail: 529700,
       sale: 529700,
       start: 2010-05-09T00:00:00.000Z,
       end: 2010-05-16T00:00:00.000Z } ],
  category_ids:
   [ ObjectId("6a5b1476238d3b4dd5000048"),
     ObjectId("6a5b1476238d3b4dd5000049") ],
  main_cat_id: ObjectId("6a5b1476238d3b4dd5000048"),
  tags: [ 'tools', 'gardening', 'soil' ] }
> reviews_count = db.reviews.count({'product_id': product['_id']})
3

이것을 product_id로 그룹핑을 해서, count를 집계할 수 있다. 주의할점은, 입력도큐먼트 필드에 $ 기호가 앞에와서 지정되어야 한다는 것이다.

$group연산자는 평균, 최소 및 최대뿐만 아니라 합계 등을 포함한 다양한 집계 결과를 계산할 수 있다.

db.reviews.aggregate([{$group: {_id:'$product_id', count:{$sum:1}}}])
> [ { _id: ObjectId("4c4b1476238d3b4dd5003982"), count: 2 },
  { _id: ObjectId("4c4b1476238d3b4dd5003981"), count: 3 } ]

$match 연산을 먼저 수행해서, (1) product _id 에 맞는 연산을 찾고, (2) 그에 따라 $group을 한뒤 (3) next 연산으로 하나의 도큐먼트만 반환한다.

$group 앞에 $match를 두는 것은 중요하다. 당연하듯이, 필터링을 먼저하고 grouping을 해야되기 때문이다.

ratingSummary = db.reviews.aggregate([{$match: {product_id: product['_id']}}, {$group: {_id: '$product_id', count:{$sum:1}}}]).next();
{ _id: ObjectId("4c4b1476238d3b4dd5003981"), count: 3 }

$avg

$group 파이프라인에, {$avg} 옵션을 추가해서 평균을 추출할 수 있다.

ratingSummary  = db.reviews.aggregate([
    {$match : {'product_id': product['_id']}},
    {$group : { _id:'$product_id',
        average:{$avg:'$rating'},
        count: {$sum:1}}}
]).next();
// Results
{ _id: ObjectId("4c4b1476238d3b4dd5003981"),
  average: 4.333333333333333,
  count: 3 }

이렇게 집계된 결과를 배열로 변환할수도 있는데, .toArray() 함수를 쓰면 된다.

컬렉션 조인

몽고DB는 자동 조인을 허용하지 않지만, SQL 조인과 동등한 기능을 제공하는 데 사용할 수 있는 몇 가지 옵션이 있다.

forEach 함수를 사용해서 집계 명령에서 반환된 커서를 처리하고 pseudo-join을 사용해서 이름을 추가하는 것이다.

// "join" main category summary with categories
db.mainCategorySummary.remove({});

db.products.aggregate([
        {$group : { _id:'$main_cat_id',
            count:{$sum:1}}}
    ]).forEach(function(doc){
        var category = db.categories.findOne({_id:doc._id});
        if (category !== null) {
            doc.category_name = category.name;
        }
        else {
            doc.category_name = 'not found';
        }
        db.mainCategorySummary.insert(doc);
    })

// findOne on mainCategorySummary

db.mainCategorySummary.findOne()

///*  Expected results
//
// {
// "_id" : ObjectId("6a5b1476238d3b4dd5000048"),
// "count" : 2,
// "category_name" : "Gardening Tools"
// }
//
// */

pseudo join은 느려질 수 있다. 커서를 조인을 위해 사용할 때 주의해야 한다.

$outproject

위에서 뽑은 mainCategorySummary 컬렉션을 insert 하려면, 위에서 뽑은 결과를 변수에 저장한뒤, insert 해줘야 한다.

$out 연산을 사용하면 파이프라인의 출력을 자동으로 컬렉션에 저장할 수 있다. 컬렉션이 존재하지 않으면 컬렉션을 생성하고, 컬렉션이 존재하면 컬렉션을 완전히 대체한다. 😑

db.products.aggregate([
    {$group : { _id:'$category_ids',
        count:{$sum:1}}},
    {$out : 'countsByCategory'} // 파이프라인 결과를 컬렉션에 저장
]);

$unwind

이 연산자를 사용하면 배열을 확장해서 모든 입력 도큐먼트 배열 항목에 대해 하나의 출력 도큐먼트를 생성할 수 있다.

// FASTER JOIN - $UNWIND
db.products.aggregate([
    {$project : {category_ids:1}},
    {$unwind : '$category_ids'},
    {$group : { _id:'$category_ids',
        count:{$sum:1}}},
    {$out : 'countsByCategory'}
]);

$unwind 는 배열에 많은 수의 항목이 포함되어 있으면 결국 파이프라인의 다음 단계로 거대한 결과가 전달 될 것이다. (주의하자)

이렇게 집계 파이프라인을 구축하면 파이프라인을 쉽게 개발, 반복, 테스트할 수 있으며 이해하기가 훨씬 쉬어진다. (다만 오타가 안나게끔 해야할것 같다 ㅋㅋ)

집계 파이프라인 연산자

aggregate으로 파이프라인을 만드는 것과, 그냥 db.findOne() 과 같이 메서드를 작성하는 것 모두 동일하다. 표현하기 쉬운걸로 골라서 쓰면 될 것 같다.

  • $project : 출력 도큐먼트상에 배치할 필드를 지정
  • $match : 처리될 도큐먼트를 선택하는 것. find()와 비슷한 역할을 수행한다.
  • $limit : 다음 단계에 전달될 도큐먼트의 수를 제한한다.
  • $skip : 지정된 수의 도큐먼트를 건너뛴다.
  • $unwind : 배열을 확장하여 각 배열 항목에 대해 하나의 출력 도큐먼트를 생성한다.
  • $group : 지정된 키로 도큐먼트를 그룹화한다.
  • $sort : 도큐먼트를 정렬한다.
  • $geoNear : 지리 공간위치 근처의 도큐먼트를 선택한다.
  • $out : 파이프라인의 결과를 컬렉션에 쓴다.
  • $redact : 특정 데이터에 대한 접근을 제어한다.

도큐먼트 재구성

MongoDB 집계 파이프라인은 도큐먼트를 변형하여 출력 도큐먼트를 생성하는 데 사용할 수 있는 많은 함수를 포함하고 있다.

db.users.aggregate([
    {$match: {username: 'kbanker'}},
    {$project: {name: {first:'$first_name',
                       last:'$last_name'}}
    }
]);

위와 같이, $project 를 써서, 다른 필드 값으로 변형할 수 있다. 이렇게 필드 값을 조작할 수 있는 함수들이 여러개가 있다.

문자열 함수

  • $concat : 두 개 이상의 문자열을 단일 문자열로 연결한다.
  • $strcasecmp : 대/소문자를 구분하지 않는 문자열 비교를 하며, 숫자를 반환한다.
  • $substr : 문자열의 부분 문자열을 만든다.
  • $toLower : 문자열을 모두 소문자로 변환한다.
  • $toUpper : 문자열을 모두 대문자로 변환한다.
db.users.aggregate([
    {$match: {username: 'kbanker'}},
    {$project:
    {name: {$concat:['$first_name', ' ', '$last_name']},    // 1
        firstInitial: {$substr: ['$first_name',0,1]},          // 2
        usernameUpperCase: {$toUpper: '$username'}             // 3
    }
    }
]);

//    /*  Expected results
//
//     { "_id" : ObjectId("4c4b1476238d3b4dd5000001"),
//       "name" : "Kyle Banker",
//       "firstInitial" : "K",
//       "usernameUpperCase" : "KBANKER" }
//
//
//     */

산술 함수

  • $add : 배열 번호를 추가한다.
  • $divide : 첫 번째 숫자를 두 번째 숫자로 나눈다.
  • $mod : 첫 번째 숫자의 나머지를 두 번째 숫자로 나눈다.
  • $multiply : 숫자 배열을 곱한다.
  • $subtract : 첫 번쨰 숫자에서 두 번째 숫자를 뺀다.

날짜/시간 함수

논리함수

ne, lt, gt 과 같은 것들..

집합함수

집합함수로는 두 배열의 내용을 비교할 수 있다. 집합 연산자를 사용하면 두 배열을 비교해서 그 배열이 정확히 같은지, 어떤 요소를 공통적으로 가지고 있는지, 또는 어떤 요소가 다른 요소인지를 확인할 수 있다.

기타함수

$size : 배열의 크기를 반환한다. (부하가 좀 있을 듯..?)

집계 파이프라인 성능에 대한 이해

집계 파이프라인의 성능에 중요한 영향을 미칠 수 있는 몇 가지 주요 고려사항은 다음과 같다.

  • 가능한 한 빨리 도큐먼트의 수와 크기를 줄인다.
  • 인덱스는 $match$sort 작업에서만 사용할 수 있고, 이러한 작업을 크게 가속화할 수 있다.
  • $match 또는 $sort이외의 연산자를 파이프라인에서 사용한 후에는 인덱스를 사용할 수 없다.
  • sharding을 사용하는 경우 $match$project 연산자는 개별 샤드에서 실행된다. 다른 연산자를 사용하면 남아 있는 파이프라인이 프라이머리 샤드에서 실행된다.

집계 파이프라인 옵션

aggregate() 함수에 파이프라인과 별도로, option 을 파라미터로 넘길 수 있다.

  • explain() : 파이프라인을 실행하고 오직 파이프라인 프로세스 세부 정보만 반환한다.
  • allowDiskUse : 중간결과를 위해 디스크를 사용한다.
  • cursor : 초기 배치 크기를 지정한다.
{explain: true, allowDiskUse: true, cursor: {batchSize:n}}

explain()

aggregate 에서의 explain 과 find 에서의 explain은 약간씩 다르지만 비슷하다. aggregate에서의 explain은 파이프라인의 각 단계가 호출되므로 각 연산에 대한 결과를 받게된다. (stages에서 배열의 객체로 표현된다.)

allowDiskUse

MongoDB는 100MB의 램제한을 가진다. 따라서 집계 파이프라인이 수백만건의 도큐먼트를 처리하다가 100MB를 초과했다면 에러가 난다. 이 때, MongoDB가 중간저장을 위해 디스크를 사용할 수 있게 해주는 옵션이다.

cursor

MongoDB v2.6부터 Mongo 셸을 통해 액세스하는 경우 기본값은 커서를 반환하는 것이다. 그러나 프로그램에서 파이프라인을 실행하는 경우, 여전히 16MB로 제한된 단일 도큐먼트만 반환한다. 따라서 옵션에 cursor를 넣어서 cursor를 반환할 수 있다.

커서의 목적은 많은 양의 데이터를 스트리밍할 수 있게 해주는 것이다.

기타 집계 기능

집계 파이프라인 외에도 다른 방식으로 집계가 가능하다.

.count() 와 .distinct()

db.reviews.count({'product_id': ''})

맵리듀스

전체 프로세스를 정의할 때 자바스크립트를 사용할 수 있다. 이는 많은 유연성을 제공하지만, 집계프레임워크보다 훨씬 느리다. 또한 복잡하고 직관적이지 않다.

db.orders.aggregate([
    {"$match": {"purchase_data":
    {"$gte" : new Date(2010, 0, 1)}}},
    {"$group": {
        "_id": {"year" : {"$year" :"$purchase_data"},
            "month" : {"$month" : "$purchase_data"}},
        "count": {"$sum":1},
        "total": {"$sum":"$sub_total"}}},
    {"$sort": {"_id":-1}}
]);

위의 집계파이프라인을 map-reduce 패턴으로 자바스크립트로 짜면 아래와 같다. 먼저 map 함수이다. map은 그룹화 중인 키를 정의하고, 계산에 필요한 모든 데이터를 패키지화 한다.

map = function() {
    var shipping_month = (this.purchase_data.getMonth()+1) +
        '-' + this.purchase_data.getFullYear();

    var tmpItems = 0;
    this.line_items.forEach(function(item) {
        tmpItems += item.quantity;
    });

    emit(shipping_month, {order_total: this.sub_total, items_total: tmpItems});
};

emit() 함수의 첫 번째 인수는 그룹화 기준의 키이고, 두 번째 인수는 값을 줄이는 도큐먼트다.

그리고 reduce 함수는 위의서 map으로 도출된 값을 수집 하고 압축한다.

reduce = function(key, values) {
    var result = { order_total: 0, items_total: 0 };
    values.forEach(function(value){
        result.order_total += value.order_total;
        result.items_total += value.items_total;
    });
    return ( result );
};

이렇게 정의된 두개의 메서드를 mapReduce 메서드에 전달하면 된다.

filter = {purchase_data: {$gte: new Date(2010, 0, 1)}};
db.orders.mapReduce(map, reduce, {query: filter, out: 'totals'});

여기서 중요한 점은 map 함수가 주어진 키에 대해 단일 결과만을 생성하면 리듀스 단계를 건너뛰기 때문에, 따로 값 출력 구조를 변경할 수가 없다.

댓글남기기