⚠️

본 콘텐츠의 이미지 및 내용을 무단으로 복제, 배포, 수정하여 사용할 경우 저작권법에 의해 법적 제재를 받을 수 있습니다.

이미지 로딩 중...

MongoDB 집계 파이프라인 고급 완벽 가이드 - 슬라이드 1/7
A

AI Generated

2025. 11. 27. · 5 Views

MongoDB 집계 파이프라인 고급 완벽 가이드

MongoDB의 고급 집계 파이프라인 기능을 초급 개발자도 쉽게 이해할 수 있도록 설명합니다. $lookup, $unwind, $facet, $bucket, $graphLookup 등 실무에서 자주 사용하는 연산자들을 실제 예제와 함께 다룹니다.


목차

  1. $lookup으로 조인하기
  2. $unwind로 배열 펼치기
  3. $facet으로 다중 집계
  4. $bucket으로 구간 분석
  5. $graphLookup 재귀 조회
  6. 집계 파이프라인 최적화

1. $lookup으로 조인하기

김개발 씨는 쇼핑몰 프로젝트를 진행하면서 고민에 빠졌습니다. 주문 정보와 고객 정보가 서로 다른 컬렉션에 있는데, 어떻게 하면 두 데이터를 함께 조회할 수 있을까요?

SQL에서는 JOIN을 쓰면 되는데, NoSQL인 MongoDB에서는 불가능한 걸까요?

$lookup은 MongoDB에서 두 컬렉션을 연결하는 조인 연산자입니다. 마치 도서관에서 책을 빌릴 때, 대출 기록과 회원 정보를 함께 확인하는 것과 같습니다.

이를 활용하면 정규화된 데이터 구조에서도 필요한 정보를 한 번에 가져올 수 있습니다.

다음 코드를 살펴봅시다.

// orders 컬렉션에서 customers 정보를 조인하는 예제
db.orders.aggregate([
  {
    $lookup: {
      from: "customers",        // 조인할 대상 컬렉션
      localField: "customerId", // orders의 연결 필드
      foreignField: "_id",      // customers의 연결 필드
      as: "customerInfo"        // 결과를 담을 배열 필드명
    }
  },
  {
    $project: {
      orderNumber: 1,
      totalAmount: 1,
      customerInfo: { $arrayElemAt: ["$customerInfo", 0] }
    }
  }
])

김개발 씨는 입사 3개월 차 주니어 개발자입니다. 오늘 팀장님께서 새로운 요구사항을 전달해 주셨습니다.

"주문 목록을 보여줄 때 고객 이름도 함께 보여줘야 해요." 문제는 주문 정보는 orders 컬렉션에, 고객 정보는 customers 컬렉션에 따로 저장되어 있다는 것이었습니다. 김개발 씨는 고민에 빠졌습니다.

"MongoDB는 NoSQL이라 조인이 안 된다고 들었는데..." 그때 옆자리 박시니어 씨가 미소를 지으며 말했습니다. "$lookup을 사용하면 돼요.

MongoDB에서도 조인이 가능해요." 그렇다면 $lookup이란 정확히 무엇일까요? 쉽게 비유하자면, $lookup은 마치 백화점의 통합 고객 서비스와 같습니다.

포인트 적립 내역을 조회할 때, 결제 정보와 회원 정보를 함께 보여주죠. 각각 다른 시스템에 저장되어 있지만, 회원번호라는 공통된 키로 연결하여 한눈에 보여주는 것입니다.

$lookup이 없던 시절에는 어떻게 했을까요? 개발자들은 먼저 orders 컬렉션을 조회하고, 각 주문에 대해 customers 컬렉션을 다시 조회해야 했습니다.

이른바 N+1 문제가 발생했죠. 주문이 100개면 101번의 쿼리가 필요했습니다.

성능은 당연히 좋지 않았습니다. 바로 이런 문제를 해결하기 위해 MongoDB 3.2버전부터 $lookup이 도입되었습니다.

$lookup을 사용하면 단 한 번의 쿼리로 두 컬렉션의 데이터를 가져올 수 있습니다. 네트워크 왕복이 줄어들어 성능이 크게 향상됩니다.

무엇보다 코드가 훨씬 깔끔해집니다. 위의 코드를 한 줄씩 살펴보겠습니다.

먼저 from 옵션은 조인할 대상 컬렉션을 지정합니다. 여기서는 customers 컬렉션입니다.

localField는 현재 컬렉션(orders)에서 연결에 사용할 필드입니다. foreignField는 대상 컬렉션에서 매칭할 필드입니다.

마지막으로 as는 조인 결과를 담을 새 필드의 이름입니다. 중요한 점은 $lookup의 결과가 항상 배열로 반환된다는 것입니다.

일대일 관계라 하더라도 배열로 감싸져 있습니다. 따라서 $arrayElemAt을 사용해 첫 번째 요소를 꺼내는 작업이 필요합니다.

실제 현업에서는 어떻게 활용할까요? 전자상거래 플랫폼에서 주문 상세 페이지를 구현한다고 가정해봅시다.

주문 정보뿐만 아니라 고객 정보, 배송 정보, 상품 정보까지 모두 보여줘야 합니다. $lookup을 여러 번 사용하면 한 번의 쿼리로 모든 정보를 가져올 수 있습니다.

하지만 주의할 점도 있습니다. $lookup은 편리하지만 무분별하게 사용하면 성능 문제가 발생할 수 있습니다.

조인 대상 컬렉션이 크다면 인덱스가 반드시 필요합니다. foreignField에 인덱스가 없으면 전체 컬렉션을 스캔하게 됩니다.

다시 김개발 씨의 이야기로 돌아가 봅시다. 박시니어 씨의 조언대로 $lookup을 적용한 김개발 씨는 깔끔하게 요구사항을 해결했습니다.

"MongoDB도 조인이 되는군요!"

실전 팁

💡 - foreignField에는 반드시 인덱스를 생성하세요

  • 조인 결과는 배열이므로 $arrayElemAt이나 $unwind로 처리가 필요합니다
  • 대용량 데이터에서는 $lookup 전에 $match로 데이터를 줄이세요

2. $unwind로 배열 펼치기

김개발 씨가 $lookup을 성공적으로 적용하고 나니, 새로운 고민이 생겼습니다. 한 주문에 여러 상품이 포함되어 있는데, 상품별로 매출을 분석하려면 어떻게 해야 할까요?

배열 안에 들어있는 데이터를 어떻게 꺼내서 처리할 수 있을까요?

$unwind는 배열 필드를 펼쳐서 각 요소를 별도의 문서로 만드는 연산자입니다. 마치 선물 세트를 풀어서 각각의 상품으로 나누는 것과 같습니다.

이를 통해 배열 내부의 데이터를 개별적으로 분석하고 집계할 수 있게 됩니다.

다음 코드를 살펴봅시다.

// 주문의 상품 배열을 펼쳐서 상품별 매출 분석
db.orders.aggregate([
  { $match: { status: "completed" } },
  {
    $unwind: {
      path: "$items",              // 펼칠 배열 필드
      preserveNullAndEmptyArrays: false  // 빈 배열 문서 제외
    }
  },
  {
    $group: {
      _id: "$items.productId",
      totalSales: { $sum: "$items.price" },
      quantity: { $sum: "$items.quantity" }
    }
  },
  { $sort: { totalSales: -1 } }
])

어느 날 팀장님이 새로운 분석 요청을 보내왔습니다. "이번 달 상품별 매출 현황 좀 뽑아줄 수 있어요?" 김개발 씨는 orders 컬렉션을 열어보았습니다.

각 주문 문서에는 items라는 배열이 있었고, 그 안에 여러 상품 정보가 담겨 있었습니다. 문제는 이 배열을 어떻게 분석에 활용하느냐였습니다.

박시니어 씨가 힌트를 주었습니다. "배열을 펼쳐야 해요.

$unwind를 써보세요." $unwind가 하는 일을 비유하자면 이렇습니다. 과일 바구니를 생각해 보세요.

바구니 하나에 사과, 배, 포도가 담겨 있습니다. 이 바구니를 그대로 두면 "바구니 1개"로만 셀 수 있습니다.

하지만 과일을 꺼내서 펼치면 "사과 1개, 배 1개, 포도 1개"로 각각 셀 수 있죠. $unwind가 바로 이 역할을 합니다.

구체적으로 어떻게 동작하는지 살펴보겠습니다. 원본 문서가 이렇게 생겼다고 가정해봅시다.

주문번호 1번에 items 배열로 상품 A, B, C가 들어있습니다. $unwind를 적용하면 이 문서가 세 개로 분리됩니다.

주문번호 1번-상품A, 주문번호 1번-상품B, 주문번호 1번-상품C. 각각이 독립된 문서가 되는 것입니다.

이렇게 펼친 후에는 $group으로 원하는 기준으로 집계할 수 있습니다. 상품별로 그룹핑하면 상품별 매출을, 날짜별로 그룹핑하면 일자별 매출을 구할 수 있습니다.

코드를 자세히 살펴보겠습니다. path 옵션은 펼칠 배열 필드를 지정합니다.

필드명 앞에 달러 기호를 붙여야 합니다. preserveNullAndEmptyArrays 옵션은 배열이 비어있거나 null인 문서를 어떻게 처리할지 결정합니다.

false로 설정하면 해당 문서는 결과에서 제외됩니다. 실무에서 흔히 마주치는 상황이 있습니다.

쇼핑몰에서 "가장 많이 팔린 상품 TOP 10"을 구해야 한다면 어떻게 할까요? 주문 컬렉션에서 $unwind로 상품을 펼치고, 상품별로 그룹핑하여 판매량을 합산하면 됩니다.

정렬과 제한을 추가하면 원하는 순위를 얻을 수 있습니다. 주의해야 할 점도 있습니다.

$unwind는 문서 수를 늘립니다. 주문 10개에 각각 상품이 5개씩 있다면, $unwind 후에는 50개의 문서가 됩니다.

메모리 사용량이 급격히 늘어날 수 있으므로, 가능하면 $match로 필터링을 먼저 하는 것이 좋습니다. 또한 배열이 비어있는 문서의 처리를 신중하게 결정해야 합니다.

preserveNullAndEmptyArrays를 true로 하면 배열이 없는 문서도 유지되지만, 집계 로직이 복잡해질 수 있습니다. 김개발 씨는 $unwind를 활용해 상품별 매출 현황을 깔끔하게 뽑아냈습니다.

팀장님은 만족스러운 표정을 지었습니다. "역시 김개발 씨, 기대를 저버리지 않네요!"

실전 팁

💡 - $unwind 전에 $match로 데이터를 줄이면 성능이 향상됩니다

  • preserveNullAndEmptyArrays 옵션으로 빈 배열 처리 방식을 결정하세요
  • 중첩 배열의 경우 $unwind를 여러 번 적용할 수 있습니다

3. $facet으로 다중 집계

이번에는 더 복잡한 요구사항이 들어왔습니다. 대시보드에 총 주문 수, 카테고리별 매출, 월별 추이를 동시에 보여줘야 합니다.

세 번의 쿼리를 보내야 할까요? 아니면 더 효율적인 방법이 있을까요?

$facet은 하나의 입력 데이터에 여러 개의 파이프라인을 동시에 적용하는 연산자입니다. 마치 뷔페에서 한 번에 여러 종류의 음식을 담는 것과 같습니다.

단 한 번의 쿼리로 다양한 집계 결과를 얻을 수 있어, 대시보드나 리포트 생성에 매우 유용합니다.

다음 코드를 살펴봅시다.

// 대시보드용 다중 집계 쿼리
db.orders.aggregate([
  { $match: { createdAt: { $gte: ISODate("2024-01-01") } } },
  {
    $facet: {
      // 파이프라인 1: 전체 통계
      "summary": [
        { $count: "totalOrders" }
      ],
      // 파이프라인 2: 카테고리별 매출
      "byCategory": [
        { $unwind: "$items" },
        { $group: { _id: "$items.category", revenue: { $sum: "$items.price" } } },
        { $sort: { revenue: -1 } }
      ],
      // 파이프라인 3: 월별 추이
      "byMonth": [
        { $group: { _id: { $month: "$createdAt" }, count: { $sum: 1 } } },
        { $sort: { "_id": 1 } }
      ]
    }
  }
])

김개발 씨에게 새로운 미션이 주어졌습니다. 관리자 대시보드를 만들어야 하는데, 한 화면에 총 주문 수, 카테고리별 매출, 월별 추이를 모두 보여줘야 합니다.

처음에 김개발 씨는 이렇게 생각했습니다. "세 가지 정보니까 쿼리를 세 번 보내면 되겠지." 하지만 박시니어 씨가 고개를 저었습니다.

"네트워크 왕복을 세 번이나 해야 하잖아요. $facet을 쓰면 한 번에 끝나요." $facet이 무엇인지 비유로 설명해 보겠습니다.

회사에서 연말 결산 보고서를 만든다고 상상해보세요. 매출 총액, 부서별 실적, 분기별 추이를 모두 담아야 합니다.

같은 원장 데이터를 세 번 훑어보는 것보다, 한 번 훑으면서 세 가지 관점으로 동시에 집계하는 게 효율적이겠죠. $facet이 바로 이 역할을 합니다.

$facet의 구조는 직관적입니다. $facet 안에 여러 개의 이름-파이프라인 쌍을 정의합니다.

각 파이프라인은 독립적으로 실행되며, 모두 같은 입력 데이터를 받습니다. 결과는 각 파이프라인의 이름을 키로 하는 객체로 반환됩니다.

코드를 살펴보면 세 개의 파이프라인이 정의되어 있습니다. summary 파이프라인은 단순히 문서 수를 셉니다.

byCategory 파이프라인은 상품을 펼치고 카테고리별로 그룹핑합니다. byMonth 파이프라인은 생성 날짜의 월을 추출해 월별로 집계합니다.

세 파이프라인이 병렬로 실행되지는 않습니다. 하지만 데이터를 한 번만 읽어오기 때문에 세 번의 쿼리를 보내는 것보다 훨씬 효율적입니다.

실제 결과는 이런 형태로 반환됩니다. summary 배열에는 전체 주문 수가, byCategory 배열에는 카테고리별 매출이, byMonth 배열에는 월별 추이가 담깁니다.

프론트엔드에서는 이 하나의 응답을 받아 대시보드의 여러 섹션에 데이터를 뿌려주면 됩니다. 주의할 점이 있습니다.

$facet 내의 각 파이프라인 결과는 16MB 제한을 받습니다. MongoDB 문서 크기 제한 때문입니다.

따라서 너무 많은 데이터를 반환하는 파이프라인은 피해야 합니다. 필요하다면 $limit을 사용해 결과 크기를 제한하세요.

또한 $facet 안에서는 $out이나 $merge 같은 출력 스테이지를 사용할 수 없습니다. $facet은 읽기 전용이라고 생각하면 됩니다.

김개발 씨는 $facet을 활용해 대시보드 API를 단 하나로 통합했습니다. 응답 시간이 3분의 1로 줄었고, 코드도 훨씬 깔끔해졌습니다.

"이거 완전 게임 체인저인데요?"

실전 팁

💡 - 각 파이프라인 결과는 16MB 제한이 있으므로 $limit을 적절히 사용하세요

  • $facet 전에 $match로 데이터를 필터링하면 모든 파이프라인이 혜택을 받습니다
  • 페이지네이션이 필요한 목록은 $facet 대신 별도 쿼리를 고려하세요

4. $bucket으로 구간 분석

마케팅팀에서 고객 분석 요청이 들어왔습니다. "구매 금액대별로 고객을 분류해주세요.

0-1만원, 1-5만원, 5-10만원, 10만원 이상으로요." 김개발 씨는 어떻게 하면 이런 구간별 분석을 효율적으로 할 수 있을지 고민했습니다.

$bucket은 데이터를 지정한 구간별로 나누어 집계하는 연산자입니다. 마치 학교에서 점수대별로 학생들을 나누는 것과 같습니다.

가격대별 상품 분포, 연령대별 사용자 분석 등 구간 기반의 분석에 매우 유용합니다.

다음 코드를 살펴봅시다.

// 구매 금액대별 고객 분석
db.orders.aggregate([
  {
    $group: {
      _id: "$customerId",
      totalSpent: { $sum: "$totalAmount" }
    }
  },
  {
    $bucket: {
      groupBy: "$totalSpent",           // 구간 기준 필드
      boundaries: [0, 10000, 50000, 100000, Infinity],  // 구간 경계
      default: "unknown",               // 경계 밖 데이터 처리
      output: {
        count: { $sum: 1 },
        customers: { $push: "$_id" }
      }
    }
  }
])

마케팅팀의 이과장님이 김개발 씨에게 다가왔습니다. "고객 등급을 나누려고 하는데, 구매 금액대별로 분석 좀 해줄 수 있어요?" 김개발 씨는 처음에 $group과 $cond를 복잡하게 조합해야 하나 고민했습니다.

하지만 박시니어 씨가 더 우아한 방법을 알려주었습니다. "$bucket이 딱이에요." $bucket을 쉽게 이해하기 위해 히스토그램을 떠올려 보세요.

학창 시절 수학 시험 점수 분포를 막대그래프로 그려본 적이 있을 겁니다. 0-60점은 몇 명, 60-70점은 몇 명, 이런 식으로요.

$bucket이 바로 이 작업을 자동으로 해줍니다. 데이터를 구간으로 나누고 각 구간에 몇 개가 속하는지 세어주는 것입니다.

$bucket의 핵심 옵션들을 살펴보겠습니다. groupBy는 어떤 필드를 기준으로 구간을 나눌지 지정합니다.

boundaries는 구간의 경계값들을 배열로 정의합니다. [0, 10000, 50000]이면 0-10000, 10000-50000 두 구간이 생깁니다.

경계값은 오름차순으로 정렬되어 있어야 합니다. 중요한 것은 각 구간이 좌측 포함, 우측 미포함 방식이라는 점입니다.

[0, 10000) 구간은 0 이상 10000 미만을 의미합니다. 10000은 다음 구간에 포함됩니다.

default 옵션은 경계 밖의 데이터를 어떻게 처리할지 정합니다. boundaries에서 벗어나는 값이 있으면 이 버킷으로 들어갑니다.

default를 지정하지 않으면 경계 밖 데이터가 있을 때 에러가 발생합니다. output 옵션으로 각 버킷에서 어떤 정보를 집계할지 정의합니다.

단순히 개수만 셀 수도 있고, 평균값이나 목록을 구할 수도 있습니다. 비슷한 연산자로 $bucketAuto도 있습니다.

$bucket은 개발자가 경계값을 직접 지정해야 합니다. 반면 $bucketAuto는 원하는 버킷 개수만 지정하면 MongoDB가 자동으로 균등하게 나눠줍니다.

데이터 분포를 모를 때 유용합니다. 실무에서 $bucket은 다양하게 활용됩니다.

전자상거래에서 가격대별 상품 분포, 핀테크에서 거래금액대별 분석, 교육 플랫폼에서 점수대별 학습자 분포 등이 대표적입니다. 대시보드의 히스토그램 차트 데이터를 뽑을 때 매우 유용합니다.

주의할 점도 있습니다. boundaries 배열은 최소 2개 이상의 값이 있어야 합니다.

그리고 모든 값은 같은 타입이어야 합니다. 숫자와 문자열을 섞으면 에러가 발생합니다.

김개발 씨는 $bucket으로 깔끔하게 고객 등급 분석을 완료했습니다. 이과장님은 결과를 보며 흡족해했습니다.

"이 데이터로 마케팅 전략을 새로 짜볼게요!"

실전 팁

💡 - boundaries는 오름차순으로 정렬되어야 하며 최소 2개 이상 필요합니다

  • default 옵션을 설정하지 않으면 경계 밖 데이터에서 에러가 발생합니다
  • 데이터 분포를 모를 때는 $bucketAuto를 먼저 써서 분포를 파악하세요

5. $graphLookup 재귀 조회

조직도 기능을 개발하던 김개발 씨는 난관에 부딪혔습니다. 특정 팀장 아래의 모든 직원을 조회해야 하는데, 팀장-팀원-파트타임 식으로 계층이 여러 단계입니다.

재귀적으로 모든 하위 직원을 어떻게 한 번에 가져올 수 있을까요?

$graphLookup은 재귀적으로 연결된 데이터를 탐색하는 연산자입니다. 마치 가계도에서 한 사람의 모든 후손을 찾는 것과 같습니다.

조직도, 카테고리 트리, 소셜 네트워크의 친구 관계 등 계층적이거나 그래프 형태의 데이터를 다룰 때 강력한 도구입니다.

다음 코드를 살펴봅시다.

// 특정 매니저 하위의 모든 직원 조회
db.employees.aggregate([
  { $match: { name: "김부장" } },
  {
    $graphLookup: {
      from: "employees",           // 탐색할 컬렉션
      startWith: "$_id",           // 시작점
      connectFromField: "_id",     // 부모 필드
      connectToField: "managerId", // 자식이 참조하는 필드
      as: "subordinates",          // 결과 필드명
      maxDepth: 5,                 // 최대 탐색 깊이
      depthField: "level"          // 깊이 정보 필드
    }
  }
])

HR팀에서 새로운 요청이 들어왔습니다. "김부장님 산하의 모든 직원 명단을 뽑아주세요.

직속 부하뿐만 아니라 그 아래 직원들까지 전부요." 김개발 씨는 머리가 복잡해졌습니다. employees 컬렉션에는 각 직원과 그 상사(managerId)가 기록되어 있습니다.

김부장 직속 부하를 찾는 건 쉽습니다. 하지만 그 부하의 부하, 또 그 부하의 부하까지 재귀적으로 찾으려면 어떻게 해야 할까요?

박시니어 씨가 해결책을 제시했습니다. "$graphLookup이 있잖아요." $graphLookup을 이해하기 위해 가계도를 떠올려 보세요.

할아버지에서 시작해서 모든 자손을 찾는다고 상상해봅시다. 먼저 할아버지의 자녀들을 찾습니다.

그 자녀들의 자녀를 또 찾습니다. 이 과정을 더 이상 자손이 없을 때까지 반복합니다.

$graphLookup이 바로 이 재귀적 탐색을 자동으로 해줍니다. 핵심 옵션들을 살펴보겠습니다.

startWith는 탐색의 시작점입니다. 위 예제에서는 김부장의 _id부터 시작합니다.

connectFromFieldconnectToField는 연결 관계를 정의합니다. "부모의 _id가 자식의 managerId와 같으면 연결"이라는 의미입니다.

maxDepth는 얼마나 깊이 탐색할지 제한합니다. 0이면 직접 연결된 것만, 1이면 한 단계 더, 이런 식입니다.

무한 루프나 성능 문제를 방지하기 위해 적절한 값을 설정해야 합니다. depthField를 지정하면 각 결과에 몇 단계 깊이인지 숫자가 붙습니다.

직속 부하는 0, 그 아래는 1, 이런 식으로요. 조직도를 시각화할 때 유용합니다.

$graphLookup은 다양한 시나리오에서 활용됩니다. 전자상거래의 카테고리 트리 탐색, SNS의 팔로우 체인 분석, 부품 조립품의 BOM(Bill of Materials) 전개 등이 대표적입니다.

특히 "6단계 분리 이론"처럼 두 사용자 간의 연결 경로를 찾는 데도 사용할 수 있습니다. 주의해야 할 점이 있습니다.

$graphLookup은 강력하지만 잘못 사용하면 성능 문제가 생길 수 있습니다. 연결이 많은 노드에서 시작하면 결과가 폭발적으로 늘어날 수 있습니다.

maxDepth로 깊이를 제한하고, restrictSearchWithMatch 옵션으로 탐색 범위를 좁히세요. 또한 순환 참조가 있으면 같은 문서가 여러 번 나타날 수 있습니다.

MongoDB는 자동으로 무한 루프는 방지하지만, 결과에 중복이 포함될 수 있으므로 주의가 필요합니다. 김개발 씨는 $graphLookup으로 조직도 전체를 한 번에 조회하는 API를 만들었습니다.

HR팀 담당자는 감탄했습니다. "와, 이제 버튼 하나로 전체 조직도가 나오네요!"

실전 팁

💡 - maxDepth를 반드시 설정하여 무한 탐색을 방지하세요

  • connectFromField와 connectToField에 인덱스를 생성하면 성능이 향상됩니다
  • restrictSearchWithMatch로 탐색 조건을 추가해 범위를 좁힐 수 있습니다

6. 집계 파이프라인 최적화

김개발 씨가 만든 집계 쿼리가 운영 환경에서 느리다는 보고가 들어왔습니다. 개발 환경에서는 빨랐는데, 데이터가 많아지니 성능 문제가 드러난 것입니다.

어떻게 하면 집계 파이프라인을 최적화할 수 있을까요?

집계 파이프라인 최적화는 쿼리 성능을 극대화하기 위한 전략과 기법들입니다. 마치 요리할 때 재료 손질 순서가 중요하듯, 파이프라인 스테이지의 순서와 구성이 성능에 큰 영향을 미칩니다.

인덱스 활용, 스테이지 순서 최적화, 메모리 관리 등을 이해하면 훨씬 빠른 쿼리를 작성할 수 있습니다.

다음 코드를 살펴봅시다.

// 최적화된 집계 파이프라인 예제
db.orders.aggregate([
  // 1단계: 인덱스를 활용할 수 있는 $match를 최상단에
  { $match: { status: "completed", createdAt: { $gte: ISODate("2024-01-01") } } },

  // 2단계: 필요한 필드만 선택하여 메모리 절약
  { $project: { customerId: 1, items: 1, totalAmount: 1 } },

  // 3단계: 데이터를 줄인 후에 $unwind
  { $unwind: "$items" },

  // 4단계: 집계 수행
  { $group: { _id: "$items.category", revenue: { $sum: "$items.price" } } },

  // 5단계: 정렬과 제한
  { $sort: { revenue: -1 } },
  { $limit: 10 }
], { allowDiskUse: true })  // 메모리 초과 시 디스크 사용 허용

운영팀에서 긴급 연락이 왔습니다. "김개발 씨, 매출 리포트 API가 30초나 걸려요.

타임아웃이 나서 고객들이 불만이에요." 김개발 씨는 당황했습니다. 개발 환경에서는 1초도 안 걸렸는데, 왜 운영에서는 이렇게 느릴까요?

박시니어 씨가 화면을 살펴보더니 고개를 끄덕였습니다. "데이터가 100만 건이 넘으니까요.

파이프라인 최적화가 필요해요." 집계 파이프라인 최적화의 첫 번째 원칙은 $match를 가능한 앞에 배치하는 것입니다. 요리에 비유하면 이렇습니다.

100kg의 감자를 모두 씻고 껍질을 벗긴 다음에 필요한 10kg만 고르는 것과, 먼저 10kg을 고른 다음 씻고 껍질을 벗기는 것, 어느 쪽이 효율적일까요? 당연히 후자입니다.

$match도 마찬가지입니다. $match가 파이프라인 최상단에 있으면 인덱스를 활용할 수 있습니다.

중간에 있으면 이미 인덱스 없이 처리된 데이터에 필터를 적용하는 것이라 효과가 떨어집니다. 두 번째 원칙은 $project로 필드를 제한하는 것입니다.

문서에 필드가 50개인데 실제로 필요한 건 3개라면, 47개의 필드가 메모리를 낭비하고 있는 겁니다. $project나 $addFields로 필요한 필드만 선택하면 메모리 사용량이 크게 줄어듭니다.

세 번째는 $unwind 전에 데이터를 줄이는 것입니다. 앞서 배웠듯이 $unwind는 문서 수를 늘립니다.

10만 개 문서에 각각 10개의 배열 요소가 있다면, $unwind 후에는 100만 개가 됩니다. $match와 $project로 먼저 데이터를 줄인 후에 $unwind를 적용해야 합니다.

네 번째는 인덱스 설계입니다. $match에서 사용하는 필드, $lookup의 foreignField, $graphLookup의 connectToField 등에는 인덱스가 필수입니다.

explain() 메서드로 쿼리 계획을 확인하고, COLLSCAN(컬렉션 스캔)이 보이면 인덱스가 필요하다는 신호입니다. 다섯 번째는 allowDiskUse 옵션입니다.

MongoDB는 기본적으로 각 파이프라인 스테이지에서 100MB 이상의 메모리를 사용하면 에러를 발생시킵니다. allowDiskUse: true를 설정하면 메모리가 부족할 때 임시 파일을 사용합니다.

성능은 떨어지지만 대용량 처리가 가능해집니다. 마지막으로 $limit과 $skip의 위치도 중요합니다.

$sort 후에 $limit을 바로 적용하면 MongoDB가 최적화를 수행합니다. 전체를 정렬하지 않고 상위 N개만 효율적으로 찾아냅니다.

하지만 $skip이 크면 여전히 많은 문서를 건너뛰어야 하므로 주의가 필요합니다. 성능을 측정하는 방법도 알아두면 좋습니다.

**explain("executionStats")**를 사용하면 각 스테이지에서 얼마나 많은 문서가 처리되었는지, 시간은 얼마나 걸렸는지 상세히 볼 수 있습니다. 이 정보를 바탕으로 병목 지점을 찾고 개선할 수 있습니다.

김개발 씨는 파이프라인을 재구성했습니다. $match를 맨 앞으로 옮기고, 필요한 필드만 선택하고, 인덱스를 추가했습니다.

결과는 놀라웠습니다. 30초 걸리던 쿼리가 0.5초로 줄었습니다.

박시니어 씨가 어깨를 두드렸습니다. "좋은 쿼리는 데이터가 커져도 빠르게 유지돼요.

이번 경험을 잊지 마세요."

실전 팁

💡 - $match는 항상 파이프라인 최상단에 배치하고 인덱스를 활용하세요

  • explain("executionStats")로 병목 지점을 파악하고 개선하세요
  • 대용량 처리 시 allowDiskUse: true 옵션을 고려하되, 근본적인 최적화가 우선입니다

이상으로 학습을 마칩니다. 위 내용을 직접 코드로 작성해보면서 익혀보세요!

#MongoDB#Aggregation#Pipeline#lookup#Database#MongoDB,Database,NoSQL

댓글 (0)

댓글을 작성하려면 로그인이 필요합니다.