Elasticsearch 입문 17편 Aggregations Bucket. terms·histogram·date_histogram·range·filters·composite·nested.
이 글은 Elasticsearch 입문에서 운영까지 시리즈 38편 중 17편이에요. 16편 Metric Aggregations 에서 avg·sum·stats·percentiles·cardinality 로 "한 줄짜리 숫자 결과" 를 뽑는 자리까지 봤어요. 이번 편은 그 한 단계 위 — "데이터를 먼저 그룹으로 묶고, 그룹마다 숫자를 뽑는" Bucket Aggregations 자리예요.
이 시리즈는 Elasticsearch 공식 docs(elastic.co/docs) Bucket aggregations 챕터를 참고해 한국어 학습 노트로 풀어쓴 자료예요.
Kibana Dev Tools 에서 직접 한두 번 돌려 보면 buckets 응답 구조가 머리에 훨씬 잘 박혀요.
Bucket Aggregation 이란
Bucket Aggregation = "문서를 일정 기준으로 그룹(bucket) 으로 묶어 주는 집계" 예요. SQL 의 GROUP BY 와 거의 같은 자리고, 결과는 "bucket 한 칸마다 doc_count 가 들어 있는 배열" 형태로 떨어져요.
Metric Aggregation 이 "전체 문서에 대해 숫자 한 줄" 을 뽑았다면, Bucket 은 "카테고리별·시간대별·가격대별로 묶어서 각 칸마다 다시 Metric 을 돌릴 수 있게" 해 줘요. 그래서 거의 모든 실전 대시보드 쿼리는 Bucket → 그 안에 sub-aggregation 으로 Metric 이라는 두 층 구조로 짭니다.
기본 모양은 이런 식이에요.
GET /orders/_search
{
"size": 0,
"aggs": {
"by_category": {
"terms": { "field": "category.keyword", "size": 10 },
"aggs": {
"total_revenue": { "sum": { "field": "amount" } }
}
}
}
}
size: 0 으로 검색 결과 hits 는 비우고, aggs 결과만 받는 게 표준 패턴이에요. 카테고리별 매출 같은 자리에 가장 흔하게 나오는 형태입니다.
응답은 aggregations.by_category.buckets 배열에 카테고리마다 한 칸 씩 들어와요. 칸 안에 key, doc_count, 그리고 sub-aggregation 결과 total_revenue.value 가 같이 떨어집니다.
terms — 가장 자주 쓰이는 그룹화
terms 집계는 "keyword·numeric·boolean 같은 값별로 묶어 주는" 가장 흔한 bucket 이에요. 카테고리·태그·도시·상태 코드 같은 유한한 값 을 그룹 키로 잡을 때 거의 모든 상황에 들어와요.
GET /products/_search
{
"size": 0,
"aggs": {
"top_brands": {
"terms": {
"field": "brand.keyword",
"size": 20,
"order": { "_count": "desc" }
}
}
}
}
여기서 한 가지 함정이 있어요. size 기본값이 10 이에요. 카테고리가 100개인데 size 를 명시 안 하면 상위 10개 만 나와요. 나머지는 sum_other_doc_count 에 합산돼 들어가는데, "왜 우리 카테고리가 빠졌지" 라는 사고는 거의 다 이 자리에서 발생합니다.
또 하나 — terms 집계는 분산 클러스터 특성상 정확도가 100% 가 아니에요. 각 샤드에서 shard_size (기본값 size * 1.5 + 10) 만큼 상위 후보를 모아 코디네이터 노드가 다시 합치는데, 카디널리티(서로 다른 값 수) 가 매우 클 때 는 진짜 1등이 빠질 수 있어요. 응답의 doc_count_error_upper_bound 값으로 오차 상한 을 알 수 있고, 정확도가 더 필요하면 shard_size 를 명시적으로 키워요.
"top_brands": {
"terms": {
"field": "brand.keyword",
"size": 20,
"shard_size": 200
}
}
order 옵션도 자주 등장해요. 기본은 _count 내림차순(많이 등장한 순) 이지만, sub-aggregation 결과 기준으로 정렬 도 가능해요. "매출 합계 기준 상위 10 브랜드" 같은 자리.
"top_brands": {
"terms": {
"field": "brand.keyword",
"size": 10,
"order": { "total_revenue": "desc" }
},
"aggs": {
"total_revenue": { "sum": { "field": "amount" } }
}
}
마지막으로 include / exclude 로 정규식 기반 필터를 걸 수 있어요. "S 로 시작하는 브랜드만", "테스트 데이터 제외" 같은 자리.
histogram + date_histogram — 숫자와 날짜 구간
histogram 은 숫자 필드를 일정 간격으로 묶어 주는 집계예요. 가격대별 상품 수, 나이대별 가입자 같은 자리.
GET /products/_search
{
"size": 0,
"aggs": {
"price_range": {
"histogram": {
"field": "price",
"interval": 10000,
"min_doc_count": 1
}
}
}
}
interval: 10000 이면 0~10000, 10000~20000, 20000~30000 식으로 자동 분할. min_doc_count 를 1 이상으로 잡으면 문서 없는 빈 칸은 응답에서 생략 돼서 트래픽이 줄어요.
date_histogram 은 날짜 필드용 histogram 이에요. 시계열 대시보드의 절대 다수가 여기를 거쳐 가요.
GET /logs/_search
{
"size": 0,
"aggs": {
"events_per_day": {
"date_histogram": {
"field": "@timestamp",
"fixed_interval": "1d",
"time_zone": "Asia/Seoul",
"format": "yyyy-MM-dd"
}
}
}
}
여기서 fixed_interval vs calendar_interval 구분이 핵심이에요.
- fixed_interval — 항상 같은 길이의 간격.
1d= 정확히 24시간,1h= 정확히 60분. DST 무관·달력 무관. - calendar_interval — 달력 기준 간격.
1M= 달마다 일수 다름(28~31),1y= 윤년 고려,1w= 월요일~일요일 한 주.
"한 달 단위 매출" 같이 사람 달력으로 묶고 싶으면 calendar_interval: "1M", "24시간 단위 균등 슬롯" 이면 fixed_interval: "1d". 두 옵션을 동시에 쓰면 에러가 떠요.
time_zone 도 거의 필수예요. 기본은 UTC 라서 한국 데이터를 그냥 1d 로 묶으면 오전 9시에 날짜가 바뀌어 보이는 사고가 납니다. Asia/Seoul 을 명시하면 한국 자정 기준 으로 정확히 잘려요. 23편(Bulk·Ingest) 에서 다룬 timestamp 표준화 와 함께 묶어 봐 두면 머리에 박혀요.
range / date_range / ip_range — 명시적 구간
histogram 이 균등 간격 이었다면, range 는 내가 원하는 구간을 직접 지정 하는 집계예요. "무료·1만원 이하·1만원~5만원·5만원 초과" 같이 불균등 구간 이 필요할 때 들어와요.
GET /products/_search
{
"size": 0,
"aggs": {
"price_band": {
"range": {
"field": "price",
"ranges": [
{ "key": "free", "to": 1 },
{ "key": "cheap", "from": 1, "to": 10000 },
{ "key": "mid", "from": 10000, "to": 50000 },
{ "key": "premium", "from": 50000 }
]
}
}
}
}
from 은 포함, to 는 비포함이에요. 즉 from: 10000, to: 50000 은 10000 ≤ x < 50000 자리. key 를 명시하면 응답에서 내가 정한 이름 으로 떨어져서 프런트에서 매핑하기 좋아요.
date_range 는 날짜용. from: "now-7d/d", to: "now/d" 같은 날짜 수식 을 그대로 박을 수 있어 "지난 7일·이번 달" 류 대시보드에 자주 등장해요.
ip_range 는 IPv4·IPv6 자리. "내부망·외부망" 처럼 CIDR 으로 구간 나누고 싶을 때 들어와요. 거의 보안·로그 자리에서만 보입니다.
filters / filter — 임의 필터를 buckets 로
filters (복수) 는 "내가 정한 임의의 필터마다 한 bucket 을 만들어 주는" 집계예요. range 가 연속 값 구간 자리라면, filters 는 완전히 자유로운 조건 자리.
GET /orders/_search
{
"size": 0,
"aggs": {
"by_status": {
"filters": {
"filters": {
"paid": { "term": { "status": "paid" } },
"refunded": { "term": { "status": "refunded" } },
"cancelled":{ "term": { "status": "cancelled" } },
"vip": { "bool": { "must": [
{ "term": { "tier": "vip" } },
{ "range": { "amount": { "gte": 100000 } } }
]}}
}
}
}
}
}
vip 처럼 bool 쿼리를 통째로 박을 수도 있어요. 대시보드 카드 자리 — "오늘 결제·환불·취소·VIP 결제" 4개 KPI 를 한 번 호출로 끝낼 때 황금. "각 카드마다 따로 검색 4번" 보다 압도적으로 빠릅니다.
단수 filter 집계는 "이 필터 조건 안에 들어오는 문서들로 한 bucket 만 만들고, 그 안에 다시 sub-aggregation" 자리. 쿼리 자체가 변하지 않아야 하는 "기본 검색 결과는 그대로 두고, 한쪽 카드만 다른 조건으로" 같은 상황.
함정 한 가지 — filters 의 filter 갯수가 수십 개 를 넘기면 쿼리 한 번이 클러스터를 폭주 시킬 수 있어요. 각 filter 가 사실상 개별 검색 이라서. 대시보드 카드 수는 10개 안쪽 으로 자제하는 게 운영 가이드.
composite — 페이징 가능한 multi-source 집계
terms·histogram·date_histogram 같은 일반 bucket 집계 의 약점은 페이징이 없다 는 거예요. 카디널리티가 100만인 필드를 전부 가져오고 싶다 — 그러면 한 번에 다 받아야 하는데 메모리·네트워크가 터집니다.
composite 집계는 이 자리를 해결해요. "여러 source 를 조합해서 만든 키마다 bucket 을 만들되, after_key 로 다음 페이지를 끊어 받을 수 있게" 해 줍니다.
GET /logs/_search
{
"size": 0,
"aggs": {
"all_combinations": {
"composite": {
"size": 1000,
"sources": [
{ "host": { "terms": { "field": "host.keyword" } } },
{ "day": { "date_histogram": { "field": "@timestamp", "calendar_interval": "1d" } } },
{ "status":{ "terms": { "field": "status" } } }
]
}
}
}
}
응답에 after_key 가 같이 떨어져요. 다음 페이지는 그 after_key 를 그대로 composite.after 에 박아 두 번째 요청을 보내요.
"composite": {
"size": 1000,
"sources": [ ... ],
"after": { "host": "web-03", "day": 1700000000000, "status": 200 }
}
이 패턴이 "매일 자정에 어제치 로그를 ETL 로 추출" 같은 배치 job 자리의 사실상 표준이에요. deep pagination 폭망 사고를 거의 다 막아 줍니다.
주의 — composite 는 모든 결과를 정렬 순회 하는 성격이라 terms 처럼 정확도 오차가 0 이지만, order 옵션이 자유롭지 않고 _key 정렬만 가능해요. "매출 기준 정렬 상위 N" 자리에는 안 어울리고, 그 자리는 terms + sub-aggregation order 로 가요.
nested aggregation — 중첩 객체 집계
nested 필드(8편 Mapping Deep 참조) 에 데이터를 박았으면, 집계도 nested aggregation 으로 한 번 부모-자식 경계를 넘는 단계 를 거쳐야 해요. 그러지 않으면 "한 주문 안의 여러 상품 라인" 같은 자리가 상위 문서 단위로만 잡혀서 결과가 어긋납니다.
GET /orders/_search
{
"size": 0,
"aggs": {
"items_agg": {
"nested": { "path": "items" },
"aggs": {
"by_product": {
"terms": { "field": "items.product_id.keyword", "size": 20 },
"aggs": {
"qty_sum": { "sum": { "field": "items.quantity" } }
}
}
}
}
}
}
nested.path 로 어느 nested 필드 안으로 들어갈지 지정하고, 그 안의 sub-aggregation 들이 line item 단위로 돌아요. e-commerce 장바구니·주문 라인 분석 자리에서 거의 항상 만나는 형태.
반대로 nested 안에서 다시 부모 문서 단위로 돌아 나오고 싶다 — 그러면 reverse_nested 를 써요. "이 상품이 들어간 주문 수" 같은 자리.
자주 만나는 사고
사고 1 — terms size=10 기본값으로 인한 카테고리 누락
원인 — terms 집계에서 size 를 명시 안 하면 상위 10개 만 응답돼요. 카테고리가 100개인데 "왜 우리 카테고리가 안 보이지" 라는 사고가 발생.
해결 — size 를 원하는 상위 N 으로 명시. 카디널리티가 크면 shard_size 도 같이 명시해서 정확도 확보. "전수가 필요한 자리" 면 terms 가 아니라 composite 로 페이징해서 다 받아요.
사고 2 — composite 안 쓰고 deep pagination
원인 — 카디널리티 100만 짜리 필드 의 전체 그룹 을 받으려고 terms 에 size: 1000000 을 박으면 코디네이터 노드 메모리가 폭주. 클러스터 OOM 까지 갈 수 있어요.
해결 — 전수 추출 은 무조건 composite + after_key 페이징. ETL·리포트 배치 자리는 page size 1,000~10,000 정도 가 거친 가이드.
사고 3 — date_histogram time_zone 누락
원인 — 한국 데이터를 time_zone 없이 fixed_interval: "1d" 로 묶으면 UTC 자정 기준 으로 잘려서 한국 시각 오전 9시에 날짜가 바뀌어 보임. 일·주·월 단위 리포트가 다 어긋남.
해결 — time_zone: "Asia/Seoul" 명시. fixed vs calendar 도 의도에 맞게 골라요. "사람 달력 한 달" 은 calendar_interval: "1M" 이어야 함.
사고 4 — filters 너무 많아 폭주
원인 — 대시보드 카드 50개 를 한 번에 filters 집계로 받으려고 함. 각 filter 가 사실상 개별 검색 이라 한 쿼리가 클러스터 CPU 의 절반 을 먹는 사고.
해결 — 카드 수 10개 안쪽 으로 줄이거나, 카드별로 캐시 가능한 별도 쿼리 로 쪼개요. 대시보드 새로고침 주기 도 15초 → 60초 로 늘리는 게 같이 들어가는 표준 처방.
사고 5 — cardinality 폭증으로 메모리 터짐
원인 — terms 집계의 field 가 user_id, session_id 처럼 사실상 unique 한 값 이면 bucket 수가 폭증해서 circuit breaker 가 작동하고 쿼리가 실패.
해결 — 고-카디널리티 필드는 terms 금지. 유저별 그룹화 가 필요하면 composite 페이징. 단순 서로 다른 값 수 만 알고 싶으면 16편의 cardinality metric 을 써요.
사고 6 — nested 경계 안 넘고 그냥 terms
원인 — items 가 nested 필드인데 그냥 terms: { "field": "items.product_id" } 를 쓰면, 상위 문서 단위로 잡혀서 한 주문에 같은 상품 3개 가 1번만 카운트됨.
해결 — nested.path: "items" 로 한 단계 들어간 다음 그 안에서 terms. 상품 단위 결과가 정확히 나와요. 반대 방향은 reverse_nested.
사고 7 — order: { sub_agg: desc } 에서 sub-aggregation 빠뜨림
원인 — terms.order 에 존재하지 않는 sub-aggregation 이름 을 넣어서 parsing error 가 떨어져요. 보통 "매출 기준 정렬" 을 짜다가 sub-aggregation 정의를 빼먹는 자리.
해결 — order 에 쓴 이름이 같은 terms 의 aggs 블록 안에 정의 돼 있는지 다시 확인. 또는 _key, _count 같은 내장 키 로 정렬.
운영 권장 패턴 4가지
대시보드 표준 패턴은 size: 0 + Bucket + sub-aggregation Metric 입니다. 검색 결과 hits 는 다 비우고, 시각화에 쓸 집계만 받아요. 페이로드가 압도적으로 작아요.
전수가 필요한 자리는 무조건 composite 로 페이징하고, 일반 상위 N 자리는 terms + size + shard_size 명시. 두 자리를 섞지 마세요.
date_histogram 은 항상 time_zone 명시 + calendar/fixed 명시적 선택. 빈 칸 응답을 줄이려면 min_doc_count: 1 을 같이.
nested 필드 집계는 무조건 nested aggregation 으로 경계 넘기. 잘못된 결과를 캐시까지 태우면 정말 골치 아파져요.
시험 직전 한 번 더 — 압축 노트
- Bucket Aggregation = SQL
GROUP BY자리. 문서를 그룹으로 묶고, 각 bucket 마다 sub-aggregation Metric 을 돌릴 수 있음. - terms = keyword·numeric·boolean 값별 그룹.
size기본 10 — 명시 안 하면 누락 사고.shard_size로 정확도 보강. - histogram = 숫자 균등 간격, date_histogram = 날짜용.
fixed_intervalvscalendar_interval, time_zone 필수. - range / date_range / ip_range = 명시적 구간.
from포함,to비포함.key로 이름 박기. - filters = 임의 bool 쿼리마다 bucket. 대시보드 카드 자리. 갯수 10개 안쪽 권장.
- composite = multi-source +
after_key페이징. 전수 추출 자리 표준. _key 정렬만 가능. - nested aggregation = nested 필드 경계 넘기. 부모로 돌아 나오려면 reverse_nested.
- 사고 7개 — size=10 누락, deep pagination, time_zone 누락, filters 폭주, 고-카디널리티 OOM, nested 미경유, sub-agg 이름 오타.
- 권장 4 —
size: 0+ Bucket + Metric, 전수는 composite, date_histogram 항상 time_zone, nested 는 무조건 경계 넘기. - 응답의
doc_count_error_upper_bound= terms 오차 상한. 0 이 아니면 shard_size 키워서 재시도.
시리즈 다른 편
- 이전 글 = 16편 Aggregations Metric — avg·sum·stats·percentiles·cardinality
- 다음 글 = 18편 Aggregations Pipeline — bucket_script·moving_avg·cumulative_sum
- 8편 = Mapping Deep — Static·Dynamic·Multi-field·Runtime (nested 필드)
- 14편 = Term-level Query — terms 집계와 매핑 짝꿍
- 19편 = Search Features — search_after·scroll (composite 와 비교)
- 22편 = Vector Search·RAG — kNN 과 집계 결합
- 33편 = Kibana·ELK — 집계 결과를 시각화
한 줄 정리 — Bucket Aggregations = 문서를 그룹으로 묶는 자리. terms·histogram·date_histogram·range·filters·composite·nested 7개로 SQL GROUP BY 자리의 거의 모든 케이스가 잡힘. size 기본 10·time_zone 누락·deep pagination·nested 미경유 가 4대 함정.