본문으로 바로가기

MongoDB - 집계

category software engineering/backend 2023. 6. 18. 12:21
728x90

집계

데이터 조회 그 이상이 필요할 때 집계 프레임워크를 통해 콜렉션 내 문서들을 변환하고 결합할 수 있습니다. 일반적으로 여과, 선출, 묶음, 정렬, 제한, 건너뛰기와 같은 구성 요소를 통해 문서의 흐름을 처리하는 파이프라인을 만들 수 있고 몽고디비에서 aggregate() 라는 표현식을 통해 각 연산을 전달할 수 있습니다.

간략하게 어떻게 생긴건지 보면,

> db.articles.aggrecate({"$project": {"author": 1}}, // 각 문서의 author 필드를 추출한다.
...{"$group": {"_id": "$author", "count": {"$sum": 1}}}, // author로 묶고 count에 1을 더한다.
...{"$sort": {"count": -1}}, // 결과를 count기준 내림차순 정렬한다.
...{"$limit": 5}) //처음 다섯 개의 결과 문서로 제한다.

 

1. 파이프라인 연산

1-1. $match는 {$match: {"state": "OR"}} 또는 $gt, $lt, $in 등과 함께 사용되며 선출이나 그룹으로 묶기 전 문서를 걸러내는데 도움을 줍니다. 일반적으로 $match 표현식을 앞쪽에 배치하는게 좋은데 그 이유는 첫째로 불필요한 문서를 재빨리 걷어내 파이프라인에서 수행해야 하는 작업을 가볍게 만들어주고, 둘째로 선출이나 그룹으로 묶기 전 실행하면 쿼리가 인덱스를 이용할 수 있기 때문입니다.

1.2. $project(선출)은 하위 문서에서 필드를 추출하여 필드명을 바꾸고 관심 있는 연산을 수행하는데 도움을 줍니다. 예를 들어 원본 콜렉션에서 각 문서에 대해 "author" 필드 하나를 포함하는 결과 문서를 반환하는 쿼리입니다.

> db.article.aggregate({"$project": {"author": 1, "_id": 0 }})

만약 선출된 필드명을 바꾸고 싶다면, 예를 들어 각 사용자의 "_id" 를 "userId" 로 반환 받는 예시는 다음과 같습니다.

> db.users.aggregate({"$project": {"userId": "$_id", "_id": 0}})

집계 프레임워크에서 알아둬야 할 포인트 중 하나는 "$필드명" 구문인데 필드의 값을 나타냅니다. 예를 들어 "$age"는 숫자의 값, "$tags.3"은 tags 배열의 네 번째 요소를 나타냅니다. 또 하나 알아둬야할 점은 몽고디비는 필드명이 바뀔 때 필드명 이력을 추적하지 않아 인덱스를 사용하기 위해서는 필드 이름을 바꾸기 전에 사용해야합니다.

예를 들어 한가지 예시를 보여드리면, 아래 정렬에 대해서는 필드명 이력을 추적하지 않아 인덱스를 사용할 수 없습니다.

> db.articles.aggregate({"$project": {"새로운필드": "$원본필드"}}, {"$sort": {"새로운필드"}: 1})

 

선출 표현식은 포함, 제외, 필드명을 명시하지만 여러 문자와 변수를 결합할 수 있는 집계에 사용할 수 있는 강력한 표현식이 있습니다. 예를 들어 "salary"와 "bonus" 필드를 합친 "totalPay" 필드를 표현하기 위해, 총액에서 세금 401k를 제한 "finalPay" 또한 아래와 같이 나타낼 수 있습니다.

> db.employees.aggregate({
	"$project": {
    	"totalPay": {
        	"$add": ["$salary", "$bonus"]
        },
        "finalPay": {
        	"$subtract": [{"$add": ["$salary", "$bonus"]}, "$401k"]
        }
    }
})

"$add", "$subtract" 뿐만 아니라 "$multiply", "$divide", "$mod" 또한 사용할 수 있습니다 😉

날짜 표현식

날짜를 이용한 집계도 자주 사용되기에 "$year", "$month", "$week", "$dayOfMonth", "$dayOfWeek", "$dayOfYear", "$hour", "$minute", "$second"와 같은 표현식 셋이 기본적으로 제공됩니다.

종업원이 채용된 달을 조회하고 회사에서 일한 연수를 계산하는 쿼리를 나타내면,

> db.employees.aggregate({
	"$project": {
    	"채용된달": {"$month": "$hireDate"},
        "재임기간": {"$substract": [{"$year": new Date()}, {"$year": "$hireDate"}]}
    }
})

다음과 같이 표현할 수 있습니다. 이제 대충 $aggregate를 어떤 경우에 어떻게 사용할 수 있는지 이해가 됐으니 문자표현식과 논리표현식은 예시를 생략하고 간략하게 어떤 표현식이 있는지만 소개하고 넘어가겠습니다.

문자 표현식: "$substr", "$concat", "$toLowerCase", "$toUpperCase" 모두 자바스크립트에서 자주 사용하던 문법이라 대충 느낌이 옵니다.

논리 표현식은 이해가 갈만한것도 있고 없는것도 있어 간략한 설명을 첨부하자면,

  • "$cmp"는 두 표현식이 같으면 0을 반환하고 첫번째 표현식이 크면 양수를 작으면 음수를 반환합니다.
  • "$strcasecmp"는 대소문자 구분 없이 두 문자열을 비교합니다.
  • "$eq"/"$ne"/"$gt"/"$gte"/"$lt"/"$lte"
  • "$and"/"$or"/"$not"
  • "$cond", "$ifNull"

제어문에 활용할 수 있는 참과 거짓이 결과값으로 나오는 표현식으로 산술 연산자는 숫자가 아닌 값에서는 거부하고, 날짜 연산자는 날짜가 아닌 값에 거부하고, 입력되지 않은 값을 감지하는 등 데이터 셋에 일관성을 지키는데 도움을 줍니다.

위의 표현식들을 통해 교수님이 성적을 매기는데 출석이 10%, 퀴즈가 30%, 시험이 60% 비중으로 매겨지는 규칙을 표현하면 다음과 같이 표현할 수 있습니다.

> db.students.aggregate({
	"$project": {
		"grade": {
        	"$cond": [
            	"$teachersPet",
                100, // 참
                {    // 거짓
                	"$add": [
                    	{"$multiply": [.1, "$출석평균"]},
                        {"$multiply": [.3, "$퀴즈평균"]},
                        {"$multiply", [.6, "$시험평균"]}
                    ]
				}
            ]
        }
	}
})

 

1-3. $group 은 대부분 어떤 표현식인지 예상이 가지만, 몇가지 예시만 살펴보겠습니다.

가장 흔히 예상되는 "$sum", "$avg", "$max", "$min"중 "$sum"의 예시이다. 각 문서에 대한 값을 더한 국가별 수입 충액을 구하기 위한 쿼리입니다.

> db.sales.aggregate({
	"$group": {
    	"_id": "$country",
        "totalRevenue": {"$sum": "$revenue"}
    }
})

뿐만 아니라 "$first", "$last", "$addToSet", "$push" 표현식도 사용 가능한데 뭔지 살펴보면,

"$first"는 예상 할 수 있는것 처럼 그룹에서 발견된 첫 번째 값을 반환하고 나머지 뒤에 값들은 무시한다. 만약 문서가 정렬되어 있지 않다면 "$max"와 "$min"을 사용하여 모든 문서를 살펴보고 가장 크거나 작은 값을 반환해야 하지만, 이미 정렬이 되어 있다면 "$first"와 "$last"를 사용하는게 효율적입니다.

배열에 있어서 "$addToSet" 은 중복된 값을 추가하지 않지만 "$push"는 상관 없이 추가하는 차이가 있습니다.
"$sort", "$limit", "$skip" 은 너무나도 예상 가능해서 살펴보지 않겠고 "$unwind"는 조금 생소해보여서 살펴보겠습니다.

"$unwind"(전개)는 배열을 갖고 있는 필드에서 하위 배열을 독립적인 문서로 변환하는데 사용됩니다. 예를 들어 의견이 달린 블로그를 살펴보겠습니다.

> db.blog.findOne()
{
	"_id": ...,
    "author": "k",
    "post": "Hello World",
    "comments": [{...}, {...}, {...}, ...]
}

> db.blog.aggregate({"$unwind": "$comments"})
{
	"results": [{
    	"_id": ...,
        "author": "k",
        "post": "Hello World",
        "comments": {...}
    },{
    	"_id": ...,
        "author": "k",
        "post": "Hello World",
        "comments": {...}
    }]
}

기존에 하나의 블로그에 달린 댓글들을 하나 하나의 문서로 변환에 성공했습니다. 보통 하위 문서를 "$unwind" 한 다음에 원하는 문서를 "$match" 하는 경우에 유용합니다.

> db.blog.aggregate({"$project": {"comments": $comments}},
   ...{"$unwind": "$comments"},
   ...{"$match": {"comments.author": "Mark"}})

 

2. 맵 리듀스

집계 프레임워크의 쿼리 언어를 이용해서 표현하기에는 너무 복잡한 경우에 맵 리듀스는 문제를 해결할 수 있을 정도로 강력하고 유연합니다. 쿼리 언어로 '자바스크립트'를 사용하기에 복잡한 로직을 임의로 표현할 수 있지만 대신 꽤 느리기 때문에 실시간 데이터 분석에서는 사용해서 안됩니다.

동작에 있어서는 다수의 서버에 걸쳐서 쉽게 병렬화 하여 문제를 해결한 다음, 모든 서버 작업이 끝나면 작업 결과를 모아서 하나의 결과로 다시 합칩니다. 대표적으로 이해를 돕기 위해 몽고디비는 스키마가 가변적인데 콜렉션에서 모든 키를 찾는 예시가 소개 됐습니다.

> map = function () {
	...for(var key in this) {
    	emit(key, {count: 1}; // 나중에 처리할 값을 반환하기 위해 emit 이라는 함수를 제공
  }};
  
> reduce = function(key, emits) {
	...total = 0;
    ...for(var i in emits) {
    	total += emits[i].count;
    }
    return {"count": total};
}

> mr = db.runCommand({"mapreduce": "foo", "map": map, "reduce": reduce})

> db[mr.result].find();
{"_id": "_id", "value": {"count": 6}},
{"_id": "a", "value": {"count": 4}},
{"_id": "b", "value": {"count": 2}},
...

두번째 예시로, reddit과 같은 사이트가 있다고 가정하고 사람들이 'geek', 'politics' 와 같은 특정한 주제와 연관된 태그를 올린다고 가정하고 어떤 주제가 가장 인기 있는지 알아보기 위해서도 사용할 수 있다.

map = function() {
    for(var i in this.tags) {
        var 최신 = 1/(new Date() - this.date);
        var score = 최신 * this.score;
        emit(this.tags[i], {"urls": [this.url], "score": score});
    }
}

reduce = function(key, emits) {
    var total = {urls: [], score: 0};
    for (var i in emits) {
        emits[i].urls.forEach(function(url) {
            total.urls.push(url);
        }
        total.score += emits[i].score;
    }
    return total;
}

 

3. 집계 명령어

복잡한 그룹 묶기는 자바스크립트가 필요하지만 count와 distinct는 프레임워크 없이도 명령어로 간단히 사용할 수 있습니다. "$group" 명령어에서는 reducer 또한 사용할 수 있는데 아래와 같이 사용할 수 있습니다.

> db.runCommand({"group": {
    ..."ns": "stocks", // 어떤 콜렉션에서 group을 수행할지 결정
    ..."key": "day", // 콜렉션 내에서 묶을 키
    ..."$initial": {"time": 0}, // reduce의 초기값
    ..."$reduce": function(doc, prev) { // 우리가 아는 reduce
    	if(doc.time > prev.time) {
            prev.price = doc.price;
            prev.time = doc.time;
        }
    },
    ..."condition": {"day": {"$gt": "2010/09/30"}} // group 명령어가 처리되기 위해 충족되야하는 조건
    ..."finalize": function(prev) { // 클라이언트에 결과를 반환하기 전에 원치 않는 데이터 제거 가능
    	var mostPopular = 0;
        for(i in prev.tags) {
        	if(prev.tags[i] > mostPopular) {
            	prev.tag = i;
                mostPopular = prev.tas[i];
            }
        }
        delete prev.tags
    }
}})

'software engineering > backend' 카테고리의 다른 글

MongoDB - 인덱싱  (0) 2023.06.24
MongoDB - 문서의 갱신/쿼리  (0) 2023.06.24
Locking - Optimistic concurrency control (OCC)  (0) 2022.08.31
Rest? gRPC? GraphQL?  (0) 2022.08.31
ElasticSearch  (0) 2022.08.29