← all posts
DEV 2026.05.02 · 14 min read Intermediate

Elasticsearch 집계는 왜 느리고, 왜 틀릴 수 있는가

Bucket·Metric·Pipeline 3계층 구조와 분산 집계의 2페이즈 실행부터, Terms 오차·fielddata OOM·성능 최적화 전략까지 집계 아키텍처 전체를 추적한다.


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 밖 문서 수)를 확인하면 오차 규모를 가늠할 수 있다.

Terms 집계 오차

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이 오차를 제거하는 대신 치르는 비용을 구체적으로 살펴본다.