Elasticsearch 집계는 왜 느리고, 왜 틀릴 수 있는가
Bucket·Metric·Pipeline 3계층 구조와 분산 집계의 2페이즈 실행부터, Terms 오차·fielddata OOM·성능 최적화 전략까지 집계 아키텍처 전체를 추적한다.
- 01 Elasticsearch는 어떻게 분산 검색을 완성하는가
- 02 Elasticsearch는 왜 역색인인가
- 03 Elasticsearch는 어떻게 텍스트를 이해하는가
- 04 Elasticsearch는 왜 같은 조건도 다르게 처리하는가
- 05 Elasticsearch 집계는 왜 느리고, 왜 틀릴 수 있는가
- 06 Elasticsearch 운영의 모든 결정은 하나의 균형에서 나온다
- 07 Spring + Elasticsearch, 어디서 무엇이 깨지는가
Elasticsearch 집계는 대시보드, 통계 리포트, 실시간 분석의 핵심이다. 그런데 “카테고리별 상위 10개”를 Terms 집계로 구하면 결과가 틀릴 수 있고, text 필드 하나에 집계를 걸었다가 노드가 OOM으로 죽기도 한다. 왜 이런 일이 벌어지는가?
집계 3계층 — 무엇을 나누고, 무엇을 계산하고, 무엇을 가공하는가
Elasticsearch 집계는 세 계층으로 구성된다.
Bucket 집계는 문서를 기준으로 그룹을 분류한다. terms는 필드 값별로, date_histogram은 시간 구간별로, range는 명시적 범위별로 버킷을 만든다. 각 버킷은 doc_count와 하위 집계 결과를 담는다.
Metric 집계는 버킷 내 문서 집합에서 수치를 계산한다. avg, sum, min, max는 단일 수치, percentiles는 분위수, cardinality는 HyperLogLog++로 고유값 수를 추정한다.
Pipeline 집계는 다른 집계의 결과를 입력으로 받아 가공한다. moving_avg, cumulative_sum, derivative가 여기 속한다. Date Histogram으로 월별 합계를 먼저 구한 뒤 cumulative_sum을 적용하는 식의 2단계 구조다.
중첩 집계는 terms(category) → date_histogram(month) → sum(amount) 순서로 계층적으로 실행된다. 버킷 수는 레벨마다 곱해진다 — 카테고리 5개 × 월 12개 = 60 버킷. 중첩이 깊어질수록 버킷이 폭발적으로 증가하고, 모든 버킷은 JVM 힙에 상주한다.
분산 집계의 2페이즈와 Terms 오차
집계 요청이 들어오면 Coordinator가 모든 샤드에 브로드캐스트한다. 각 샤드는 자신의 문서에서 부분 집계를 계산하고(Phase 1), Coordinator가 부분 결과를 병합한다(Phase 2).
sum, avg, min, max는 이 과정에서 오차가 없다. sum은 부분 합산을 더하면 되고, avg는 가중 평균으로 정확히 재구성된다.
문제는 Terms 집계다.
설정: size=3 (상위 3개 카테고리), 3개 샤드
실제 전체 카운트:
keyboard: 120개 (1위)
mouse: 90개 (2위)
monitor: 60개 (3위)
Shard 0의 문서 분포:
mouse:50, headset:15, keyboard:20, monitor:10, ...
Shard 0 top-3 반환:
mouse:50, headset:15, keyboard:20
→ monitor:10은 4위라 제외!
Coordinator 병합 결과:
monitor: 0(Shard0 누락) + 30 + 20 = 50 ← 실제 60인데 과소 계산
각 샤드는 로컬 상위 shard_size개만 반환한다. 어떤 샤드에서 하위에 밀린 버킷은 Coordinator가 볼 수 없어 과소 계산된다.
shard_size를 늘리면 각 샤드가 더 많은 버킷을 반환해 누락이 줄어든다. ES 기본값은 max(10, size × 1.5)다. 정확도가 중요한 경우에는 전체 카테고리 수만큼 설정하거나, 오차가 없는 Composite Aggregation으로 전환해야 한다. Composite는 페이지네이션 방식으로 모든 버킷을 순서대로 반환한다 — 느리지만 정확하다.
응답의 doc_count_error_upper_bound(각 버킷의 오차 상한)와 sum_other_doc_count(top-N 밖 문서 수)를 확인하면 오차 규모를 가늠할 수 있다.
order: _count(기본값)일 때 오차가 가장 작다. order: avg_price처럼 메트릭 기준으로 정렬하면 오차가 더 커진다 — 각 샤드의 로컬 평균과 글로벌 평균이 다르기 때문이다.
fielddata와 OOM — text 필드 집계의 함정
keyword, 숫자, 날짜 필드는 doc_values로 저장된다. 디스크 기반(mmap)이라 집계 시 힙을 거의 쓰지 않는다.
text 필드는 다르다. 형태소 분석된 역색인은 doc_values가 없다. text 필드에 집계를 걸면 fielddata 메커니즘이 동작한다 — 역색인 전체를 역방향으로 변환해 JVM 힙에 올린다.
100만 문서 × 평균 10개 토큰 × 8바이트/토큰 ≈ 800MB 힙 점유
이 상태가 유지되면 GC 압박이 쌓이고, 다른 작업이 밀린다. 더 심하면 OOM으로 노드가 죽는다.
ES는 Circuit Breaker로 이를 막는다. fielddata 로딩 전에 예상 크기를 추정해 indices.breaker.fielddata.limit(기본 힙의 40%)를 초과하면 요청을 거부한다. OOM 대신 CircuitBreakingException(HTTP 429)을 반환하고 노드는 살아남는다.
대용량 중첩 집계의 버킷 폭발도 같은 위험이다. Request Breaker가 버킷 생성 중 메모리를 추적하고, search.max_buckets(기본 65,536)를 초과하면 오류를 반환한다.
해결책은 단순하다 — text 필드 집계는 .keyword 서브필드로 대체하라. keyword는 doc_values 기반이라 힙 영향이 없다.
트레이드오프 — 정확도, 메모리, 속도
Terms shard_size: 높이면 정확도가 오르지만 네트워크·메모리 비용이 증가한다. 대시보드는 기본값으로 충분하고, 정산 리포트는 Composite Aggregation을 고려하라.
fielddata 캐시: indices.fielddata.cache.size: 20%로 제한하라. 크게 열어두면 힙이 잠식된다.
중첩 집계: 3단계 이상 중첩은 버킷 수를 사전에 계산하라. 카테고리 수 × 날짜 수 × 상태 수가 힙에 올라간다.
calendar_interval vs fixed_interval: 월별 분석에는 반드시 calendar_interval: "month"를 써야 한다. fixed_interval: "30d"는 2월(28~29일)을 무시해 버킷 경계가 어긋난다.
타임존: time_zone: "Asia/Seoul"을 항상 명시하라. 없으면 UTC 기준으로 버킷이 나뉘어 한국 시간 자정이 두 버킷에 걸린다.
성능 최적화 — 캐시를 이해하면 5초가 50ms가 된다
집계 성능을 끌어올리는 핵심 기법 세 가지:
Filter Aggregation + Query Cache: query 컨텍스트의 필터는 캐시되지 않지만, aggs 안의 filter 집계는 비트셋을 Filter Cache에 저장한다. 동일 필터 반복 요청 시 필터링 비용이 수 μs로 줄어든다. 여러 카테고리를 비교하는 집계라면 filters 집계로 단일 요청에 묶어라.
eager_global_ordinals: Terms 집계는 내부적으로 keyword 고유값을 정수 ID로 매핑한 Global Ordinals를 사용한다. 기본 설정에서는 첫 집계 요청 시 구축되어 느리고, refresh 후 무효화된다. eager_global_ordinals: true로 설정하면 refresh 시 미리 구축해 집계 요청 시 항상 준비된 상태가 된다.
Transform API: 같은 통계를 반복 조회하는 대시보드라면 Transform으로 사전 집계 인덱스를 만들어라. 원본 수백만 건을 매번 집계하는 대신 수천 건의 집계 결과 인덱스를 조회한다. 1~10분 지연을 허용할 수 있으면 가장 강력한 최적화다.
정리
- Elasticsearch 집계는 Bucket(그룹 분류) → Metric(수치 계산) → Pipeline(결과 가공) 3계층으로 동작한다.
- 분산 집계는 2페이즈(샤드별 부분 계산 + Coordinator 병합)로 실행되며, Terms 집계는
shard_size설정에 따라 오차가 발생할 수 있다.doc_count_error_upper_bound로 오차 규모를 확인하라. - text 필드 집계는 fielddata를 힙에 올려 OOM을 유발한다.
.keyword서브필드와 doc_values로 대체하고, Circuit Breaker 설정(indices.breaker.fielddata.limit: 40%)으로 방어선을 쳐라. - 성능은 Filter Aggregation 캐시 →
eager_global_ordinals→ Transform 사전 집계 순서로 점진적으로 최적화할 수 있다.
다음 글에서는 Terms 집계 오차를 수치로 추적하고, Composite Aggregation이 오차를 제거하는 대신 치르는 비용을 구체적으로 살펴본다.