DB Study

8장. Aggregations — 검색 엔진 위의 분석 엔진

검색이 "어느 문서가 맞는가"를 묻는다면, 집계는 "이 문서들의 분포와 추세는 어떠한가"를 답합니다. Kibana 대시보드의 거의 모든 차트가 이 위에 서 있습니다 — 그리고 그 숫자, 가끔은 근사치입니다.

중급 ⏱ 약 28분 🎬 애니메이션 3개 선수 지식: 7장 Query DSL
🧭
이 장에서 배우는 것

① Metric / Bucket / Pipeline — 집계의 세 종류와 트리 구조 ② terms 집계가 샤드별 부분 집계를 합치는 과정과 근사치 오차의 원인 ③ 시계열의 핵심 date_histogram과 pipeline 집계 ④ 대량 버킷을 안전하게 순회하는 composite

8.1 집계의 세 종류와 트리 구조

종류산출물
Metric숫자 (또는 통계 객체)avg, sum, cardinality, percentiles, top_hits
Bucket문서들의 그룹(버킷)terms, range, date_histogram, filters
Pipeline다른 집계의 출력을 입력으로derivative, cumulative_sum, moving_fn, bucket_script

핵심은 bucket 안에 자식 집계를 중첩해 트리를 만들 수 있다는 점입니다. SQL의 GROUP BY + 집계 함수 + 윈도우 함수가 하나의 JSON 트리로 표현된다고 생각하면 됩니다.

Search 요청 "size": 0, "aggs": … 🪣 by_category (terms) laptop · phone · tablet 📏 avg_price (avg) 📏 revenue (sum) 🪣 by_month (date_histogram) calendar_interval: 1M 📏 monthly_rev (sum) 🔁 3m_moving_avg moving_fn(window: 3) 결과: 카테고리별 → [평균가, 매출, 월별 매출 + 3개월 이동평균] 트리 그대로 반환

8.2 Metric — 숫자를 뽑는 집계

GET /sales/_search
{
  "size": 0,
  "aggs": {
    "avg_price":     { "avg": { "field": "price" } },
    "total_revenue": { "sum": { "field": "price" } },
    "price_stats":   { "stats": { "field": "price" } },   // count·min·max·avg·sum 한 번에
    "unique_users":  { "cardinality": { "field": "user_id" } }
  }
}
⚠️
cardinality는 정확한 수가 아니다

cardinalityHyperLogLog++ 기반 근사값입니다. precision_threshold(기본 3000, 최대 40000) 이하에서는 정확하고, 그 이상은 약 1~2% 오차가 납니다. 값을 키우면 정확도↑ 메모리↑ (대략 threshold × 8바이트). 정확한 distinct count가 반드시 필요하다면 ES는 적합한 도구가 아닙니다.

percentiles(TDigest 기반 근사)로 p50/p95/p99 지연시간을, top_hits로 각 버킷의 대표 문서를 뽑을 수 있습니다. top_hits는 SQL의 ROW_NUMBER() OVER (PARTITION BY …)에 가장 가까운 도구입니다.

8.3 Bucket — 문서를 그룹으로 나누는 집계

가장 자주 쓰는 둘만 확실히 잡으면 됩니다: 그룹핑의 terms, 시계열의 date_histogram.

// 브랜드별 Top 10 — 매출순 정렬은 자식 metric으로
"by_brand": {
  "terms": {
    "field": "brand",
    "size":  10,
    "order": { "total_revenue": "desc" }
  },
  "aggs": {
    "total_revenue": { "sum": { "field": "price" } }
  }
}
// 일별 매출 그래프 — 시계열의 표준 형태
"sales_over_time": {
  "date_histogram": {
    "field": "ordered_at",
    "calendar_interval": "1d",
    "time_zone": "Asia/Seoul",       // 기본 UTC! 반드시 명시
    "min_doc_count": 0,               // 데이터 없는 날도 0으로
    "extended_bounds": { "min": "2026-01-01", "max": "2026-06-30" }
  }
}
🕐
time_zone 누락 — 한국 대시보드의 단골 버그

date_histogram의 기본은 UTC입니다. KST 자정 직전 주문이 "어제" 버킷으로 밀려 보이는 버그의 원인. 한국 기준 일별 차트라면 항상 time_zone: "Asia/Seoul"을 명시하세요.

그 밖에: range(사용자 정의 구간), histogram(숫자 균등 구간), filters(이름 붙은 필터 버킷), nested(nested 필드 진입용) 등이 있습니다.

8.4 terms 집계는 왜 부정확할 수 있는가 — 분산의 대가

공식 문서가 직접 경고합니다: "the doc_count values for a terms aggregation may be approximate." ES에서 가장 자주 오해되는 부분입니다. 원인은 데이터가 여러 샤드에 분산되어 있다는 사실 그 자체입니다.

  1. 각 샤드는 자기 데이터에서 상위 shard_size개 버킷만 추려 coordinating node로 보냅니다
  2. coordinating node가 부분 결과를 합산해 최종 상위 size개를 결정합니다

문제는 어떤 term이 샤드마다 순위가 다를 때 발생합니다. 애니메이션으로 봅시다.

terms(brand, size=5) · shard_size=10 Shard A — 로컬 카운트 1위 a:100 · 2위 b:90 · … · 10위 j:15 11위 X:14 — 잘림! 상위 10개(shard_size)만 전송 Shard B — 로컬 카운트 1위 k:200 · 2위 l:150 · 3위 m:120 5위 X:90 — 포함 ✓ 상위 10개 안에 X가 있음 coordinating node 부분 집계 합산 → 최종 Top 5 X의 최종 카운트 = 90 … 실제는 90 + 14 = 104. 샤드 A 몫이 증발! ❌ ✅ shard_size를 키우면 X가 샤드 A에서도 전송됨 → 104로 정확해짐
"by_brand": {
  "terms": {
    "field":      "brand",
    "size":       10,
    "shard_size": 100,                 // 기본 size*1.5+10 → 키우면 정확도↑
    "show_term_doc_count_error": true   // 버킷별 오차 상한 포함
  }
}
// 응답: "doc_count_error_upper_bound": 14  ← 0이면 신뢰 가능
💡
얼마나 심각하게 받아들여야 하나

대시보드의 "Top 10 보여주기"는 기본값으로 충분합니다. 반면 보고서·정산처럼 숫자가 정확해야 하는 곳은 오차 상한을 확인하고, 필요하면 composite로 모든 버킷을 순회하세요. 고유값이 수만 개 이상인 필드일수록 오차 위험이 커집니다.

8.5 composite — 버킷의 페이지네이션

terms는 모든 버킷을 한 번에 계산해 반환하므로, 고유값이 수십만 개인 필드(예: user_id)에 쓰면 메모리가 폭발합니다. 이때의 답이 composite — 정렬된 버킷을 after_key 커서로 페이지 단위로 순회합니다.

전체 버킷 (수십만 개, 키 순 정렬) buckets 1 ~ 1000 buckets 1001 ~ 2000 buckets 2001 ~ … terms로 한 번에? 전 버킷을 메모리에! 고카디널리티 필드 → OOM 위험 ❌ composite(size=1000) → page 1 after_key: {"category":"laptop"} "after": {"category":"laptop"} → page 2 after_key: {"category":"wear"} ✅ after_key가 없어질 때까지 반복 모든 버킷을 누락·중복 없이 순회 — ETL·전체 덤프에 적합

제약도 있습니다: composite 안에서는 자식 metric 기준 order를 쓸 수 없고(키 순서 고정), 한 요청에 composite 하나만 넣을 수 있습니다.

8.6 Pipeline — 시계열 분석의 마무리

pipeline 집계는 다른 집계의 결과를 입력으로 받습니다. 시계열 대시보드의 "전일 대비 증감", "7일 이동평균"이 전부 이것입니다. 실전 예제로 마무리합니다.

GET /sales/_search
{
  "size": 0,
  "track_total_hits": false,
  "query": {
    "bool": {
      "filter": [                              // 7장: 점수 불필요 → filter (캐시)
        { "term":  { "status": "completed" } },
        { "range": { "ordered_at": { "gte": "now-90d/d" } } }
      ]
    }
  },
  "aggs": {
    "daily_sales": {
      "date_histogram": {
        "field": "ordered_at",
        "calendar_interval": "1d",
        "time_zone": "Asia/Seoul",
        "min_doc_count": 0
      },
      "aggs": {
        "revenue": { "sum": { "field": "price" } },
        "7d_moving_avg": {
          "moving_fn": {                       // moving_avg는 deprecated
            "buckets_path": "revenue",
            "window": 7,
            "script": "MovingFunctions.unweightedAvg(values)"
          }
        },
        "revenue_diff": {
          "derivative": { "buckets_path": "revenue" }   // 전일 대비 증감
        }
      }
    }
  }
}
💡
집계 전용 쿼리의 표준 헤더

"size": 0(hits 생략) + "track_total_hits": false(정확한 총 개수 계산 생략). 응답이 빨라지고 작아집니다. 집계만 필요한 쿼리라면 거의 항상 이 둘을 붙이세요.

⚠️
운영 주의 3가지

terms { "field": "user_id" }처럼 수백만 고유값 필드에 terms 직접 사용 금지 — composite로. ② runtime field 집계는 매칭 문서마다 스크립트 실행 — 자주 쓰면 정식 필드로 승격. ③ 느린 집계는 "profile": true로 단계별 시간부터 확인.

✍️ 이해도 체크

terms 집계의 doc_count가 실제보다 작게 나올 수 있는 근본 원인은?
✅ 분산 환경에서 각 샤드는 로컬 상위 shard_size개 버킷만 보냅니다. 어떤 term이 샤드 A에서는 잘리고(11위) 샤드 B에서는 포함(5위)되면, A의 카운트가 누락된 채 합산됩니다. shard_size를 키우거나 composite로 전체 순회하면 완화됩니다. (참고: HLL은 cardinality 집계 이야기입니다.)
user_id처럼 고유값이 수십만 개인 필드의 모든 버킷별 합계를 정확히 뽑아야 한다. 올바른 접근은?
✅ terms의 size를 키우면 모든 샤드가 거대한 부분 결과를 전송해 coordinating node 메모리가 폭발합니다. composite는 정렬된 버킷을 페이지 단위로 결정적으로 순회하므로 메모리가 일정하고 결과도 정확합니다. cardinality는 "고유값 개수"만 셀 뿐 버킷별 합계와는 무관합니다.
📚
원문으로 더 깊이

이 장의 원문 문서: chapters/ch08_aggregations.md — percentiles의 TDigest/HDR, bucket_script로 증감률 계산, Top-N 상품 리포트 실전 쿼리가 담겨 있습니다.