8장. Aggregations — 검색 엔진 위의 분석 엔진
검색이 "어느 문서가 맞는가"를 묻는다면, 집계는 "이 문서들의 분포와 추세는 어떠한가"를 답합니다. Kibana 대시보드의 거의 모든 차트가 이 위에 서 있습니다 — 그리고 그 숫자, 가끔은 근사치입니다.
① 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 트리로 표현된다고 생각하면 됩니다.
집계 전용 요청은 "size": 0으로 시작합니다 — "검색 결과(hits)는 필요 없고 집계만 달라"는 뜻. 먼저 terms bucket이 문서를 카테고리별 버킷으로 나눕니다.
각 버킷 안에 metric 자식을 넣습니다. laptop 버킷의 평균가, phone 버킷의 매출… 버킷마다 독립적으로 계산됩니다.
버킷 안에 또 다른 bucket(date_histogram)을 중첩할 수도 있습니다. "카테고리별 → 월별 매출"의 2단 트리가 완성됩니다.
마지막으로 pipeline 집계가 다른 집계의 출력(월별 매출)을 입력으로 받아 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는 HyperLogLog++ 기반 근사값입니다. 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" }
}
}
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에서 가장 자주 오해되는 부분입니다. 원인은 데이터가 여러 샤드에 분산되어 있다는 사실 그 자체입니다.
- 각 샤드는 자기 데이터에서 상위
shard_size개 버킷만 추려 coordinating node로 보냅니다 - coordinating node가 부분 결과를 합산해 최종 상위
size개를 결정합니다
문제는 어떤 term이 샤드마다 순위가 다를 때 발생합니다. 애니메이션으로 봅시다.
브랜드 Top 5를 요청합니다. 각 샤드는 최종 순위를 모르므로 여유분을 포함해 상위 shard_size(=10)개씩만 보냅니다.
Shard A에서 term X는 카운트 14로 11위 — 전송 목록에서 잘립니다. X가 A에 14건 있다는 사실은 coordinating node에 전달되지 않습니다.
Shard B에서는 X가 카운트 90으로 5위 — 이쪽 목록에는 포함되어 전송됩니다.
coordinating node가 합산한 X의 최종 카운트는 90. 실제로는 104(90+14)인데 샤드 A의 몫이 누락됐습니다. 이것이 terms 집계 근사치 오차의 정체입니다.
대응은 shard_size를 키워 각 샤드가 더 많은 후보를 보내게 하는 것. show_term_doc_count_error: true로 오차 상한(doc_count_error_upper_bound)을 응답에서 확인할 수 있습니다.
"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 커서로 페이지 단위로 순회합니다.
고유값이 수십만 개인 필드를 terms로 집계하면 모든 버킷을 한 번에 메모리에 올려야 합니다. OOM의 지름길이죠.
composite는 키 순으로 정렬된 버킷을 1000개씩 페이지로 반환합니다. 응답 끝의 after_key가 "여기까지 읽었다"는 책갈피입니다.
다음 요청에 "after": {이전 after_key}를 넣으면 이어서 다음 페이지를 받습니다. 7장의 search_after와 같은 커서 패턴입니다.
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(정확한 총 개수 계산 생략). 응답이 빨라지고 작아집니다. 집계만 필요한 쿼리라면 거의 항상 이 둘을 붙이세요.
① terms { "field": "user_id" }처럼 수백만 고유값 필드에 terms 직접 사용 금지 — composite로. ② runtime field 집계는 매칭 문서마다 스크립트 실행 — 자주 쓰면 정식 필드로 승격. ③ 느린 집계는 "profile": true로 단계별 시간부터 확인.
✍️ 이해도 체크
이 장의 원문 문서: chapters/ch08_aggregations.md — percentiles의 TDigest/HDR, bucket_script로 증감률 계산, Top-N 상품 리포트 실전 쿼리가 담겨 있습니다.