Elasticsearch 입문 17편 — Aggregations Bucket (terms·histogram·date_histogram·range·filters·composite)

2026-05-19Elasticsearch 입문에서 운영까지

Elasticsearch 입문 17편 Aggregations Bucket. terms·histogram·date_histogram·range·filters·composite·nested.

📚 Elasticsearch 입문에서 운영까지 · 17편 — Aggregations Bucket (terms·histogram·date_histogram·range·filters·composite)

이 글은 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: 5000010000 ≤ 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만 짜리 필드전체 그룹 을 받으려고 termssize: 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_interval vs calendar_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 이름 오타.
  • 권장 4size: 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대 함정.

※ 이 포스팅은 쿠팡 파트너스 활동의 일환으로, 이에 따른 일정액의 수수료를 제공받습니다.

답글 남기기

error: Content is protected !!