티스토리 뷰
[Open source contribution] Elasticsearch 오픈소스 기여 경험기 - lasticsearch histogram 집계에서 hard_bounds가 offset을 무시하던 버그 수리
ebson 2026. 9. 30. 15:56Elasticsearch 저장소에 올린 Pull Request 한 건이 2026년 9월 2일 main에 머지됐습니다. Fix hard_bounds ignoring offset in histogram (#158148)이라는 제목의 PR이고, 제품 코드는 세 파일에서 한 줄씩 고친 것이 전부이고 나머지는 테스트와 changelog인 작은 수정입니다. 이 글에서는 그 한 줄이 왜 틀렸는지, 어떻게 확인했는지, 그리고 Elasticsearch의 기여 절차를 따라가면서 알게 된 점을 정리해 보려고 합니다.
histogram 집계의 offset과 hard_bounds
먼저 두 파라미터가 무엇인지 짚고 넘어가겠습니다. histogram 집계는 숫자 필드 값을 일정한 interval 간격의 버킷으로 나눕니다. 공식 문서에는 버킷 키를 계산하는 식이 다음과 같이 적혀 있습니다.
bucket_key = Math.floor((value - offset) / interval) * interval + offset
offset은 버킷 경계를 옮기는 값입니다. 기본값은 0이라 interval이 10이면 버킷이 [0, 10), [10, 20)처럼 만들어지고, offset을 5로 주면 경계가 5만큼 밀려 [5, 15) 같은 버킷이 생깁니다. 문서는 offset을 [0, interval) 범위의 값으로 두도록 안내하고 있습니다.
hard_bounds는 extended_bounds와 짝을 이루는 옵션으로, 문서 표현을 빌리면 히스토그램 버킷의 범위를 제한합니다. min과 max를 주면 그 범위를 벗어난 버킷은 응답에 나오지 않습니다. 열린 범위의 데이터처럼 버킷이 아주 많이 생길 수 있는 경우에 쓰라고 소개돼 있습니다.
두 옵션을 함께 쓰면 자연스럽게 이런 기대를 하게 됩니다. hard_bounds의 min/max는 응답에 실제로 찍히는 버킷 키, 즉 offset까지 더한 값을 기준으로 적용될 것이라는 기대입니다.
경계 비교에서 빠져 있던 offset
숫자 필드용 histogram 집계는 :server 모듈의 NumericHistogramAggregator가 수집합니다. 값을 읽을 때마다 Math.floor((value - offset) / interval)로 버킷 인덱스를 구하고, addKey()에서 hard_bounds를 검사한 뒤 버킷에 문서를 넣습니다. 수정 전 코드는 다음과 같았습니다.
private void addKey(double key, int doc, long owningBucketOrd, LeafBucketCollector sub) throws IOException {
if (hardBounds == null || hardBounds.contain(key * interval)) {
여기서 key는 버킷 인덱스이고, 응답으로 나가는 버킷 키는 공통 부모인 AbstractHistogramAggregator가 결과를 만들 때 roundKey * interval + offset으로 계산합니다. 그런데 hard_bounds 비교에는 key * interval만 들어가 있어서 + offset 항이 빠져 있었습니다. offset이 0이면 두 값이 같으니 아무 문제가 없지만, offset을 주는 순간 경계 비교가 정확히 offset만큼 어긋납니다.
어긋남이 어떤 결과를 내는지는 숫자로 따져 보면 분명해집니다. interval이 5, offset이 3, hard_bounds가 [-2, 10]이고 값이 -5, 0, 5, 10, 15인 문서 다섯 건이 있다고 해 보겠습니다. 각 값의 버킷 키는 식대로 -7, -2, 3, 8, 13이 되고, 범위 안에 드는 키는 -2, 3, 8 세 개입니다. 하지만 수정 전 코드가 비교하던 값은 + offset이 빠진 -10, -5, 0, 5, 10이었습니다. 그래서 키 -2 버킷은 -5로 비교되어 min 아래로 판정돼 사라지고, 범위를 넘는 키 13 버킷은 10으로 비교되어 max 안쪽으로 판정돼 응답에 남습니다. 경계에 걸친 버킷은 빠지고, 범위 밖 버킷은 들어오는 셈입니다.
옆에 있던 올바른 구현
이 식이 의도된 것인지 실수인지를 판단할 때 기준이 된 것은 같은 패키지의 DateHistogramAggregator였습니다. 날짜 히스토그램은 addRoundedValue()에서 반올림(rounding)을 마친 값, 곧 버킷 키로 쓰이는 값을 그대로 hardBounds.contain(...)에 넘깁니다. 같은 hard_bounds 옵션을 두고 날짜 쪽은 실제 버킷 키로 비교하고 숫자 쪽만 offset이 빠진 값으로 비교하고 있었으니, 숫자 쪽을 날짜 쪽에 맞추는 방향이 자연스럽다고 판단했습니다. 몇 달 전 date_histogram과 hard_bounds 조합의 다른 버그를 고친 PR(#148765)도 머지된 적이 있었습니다.
같은 식이 다른 곳에도 있는지 찾아보니 :x-pack:plugin:analytics 모듈에 두 군데가 더 있었습니다. histogram 필드 타입을 집계하는 HistoBackedHistogramAggregator와 exponential_histogram 필드를 집계하는 ExponentialHistogramBackedHistogramAggregator입니다. 둘 다 AbstractHistogramAggregator를 상속하고 같은 offset을 받으며, hard_bounds 검사도 똑같이 key * interval로 하고 있었습니다. "histogram의 hard_bounds가 offset을 무시한다"는 하나의 결함이라 세 곳을 한 PR에서 함께 고치기로 했습니다.
이슈를 먼저 열고 PR로 연결하기
PR을 올리기 전에 #158146으로 이슈를 먼저 열었습니다. 재현 절차는 값 15인 double 문서 하나를 interval: 5, offset: 3, hard_bounds: {min: -2, max: 10}으로 집계하면 max를 넘는 키 13 버킷이 나온다는 것이었습니다. PR 본문 끝에 Closes #158146을 적어 두었고, PR이 머지되면서 이슈도 함께 닫혔습니다.
실제 수정은 세 파일 모두 같은 모양입니다.
if (hardBounds == null || hardBounds.contain(key * interval + offset)) {
offset을 지정하지 않은 기존 쿼리는 + 0이라 산술적으로 결과가 똑같고, 날짜 히스토그램 경로는 건드리지 않았습니다. PR 본문에도 바뀌지 않는 부분으로 이 두 가지를 밝혀 두었습니다.
테스트는 세 집계기마다 하나씩
새 테스트 클래스를 만들지는 않았습니다. 이미 있던 NumericHistogramAggregatorTests, HistoBackedHistogramAggregatorTests, ExponentialHistogramBackedHistogramAggregatorTests에 testHardBoundsWithOffset 메서드를 하나씩 추가했습니다. 세 테스트 모두 앞에서 계산해 본 것과 같은 조건, 즉 값 -5, 0, 5, 10, 15에 interval=5, offset=3, hard_bounds=[-2, 10]을 주고 버킷 키가 정확히 -2, 3, 8 세 개이며 각 버킷의 doc_count가 1인지 확인합니다.
exponential_histogram 테스트에서는 값 하나만 가진 히스토그램을 다섯 개 만들었는데, 최솟값과 최댓값이 같아져 버킷 중심이 그 값으로 고정되기 때문입니다. 세 테스트 모두 수정 전 코드에서는 실패하고 수정 후에는 통과하는 것을 확인했습니다.
검증 범위를 어디까지로 잡을지도 PR 본문에 그대로 적었습니다. 변경이 닿는 두 모듈에 대해 ./gradlew :server:precommit :x-pack:plugin:analytics:precommit을 돌려 통과를 확인했습니다. precommit은 포맷·명명 규칙 위반을 검사만 하고, 포맷을 실제로 고쳐 주는 것은 spotlessApply입니다. 저장소 전체 ./gradlew check는 여러 시간이 걸리고 Docker가 필요해서 로컬에서는 돌리지 않았고, 그 부분은 CI에 맡긴다고 밝혀 두었습니다.
YAML REST 테스트를 추가하지 않은 이유도 함께 적었습니다. 저장소 안내는 통합 수준 검증에 YAML REST 테스트를 권하는 편이지만, 이번처럼 응답 결과가 바뀌는 수정을 REST 테스트로 고정하려면 버전이 섞인 클러스터에서 옛 노드가 옛 결과를 돌려주는 경우를 걸러야 합니다. 그렇게 하려면 새 NodeFeature를 추가해야 해서 이 PR의 범위를 넘는다고 판단했습니다.
changelog와 머지
docs/changelog/158148.yaml 파일도 추가했습니다. area: Aggregations, type: bug에 연결 이슈 158146을 적은 파일인데, 이름이 PR 번호라 PR을 연 뒤에 만들었습니다. 다만 CONTRIBUTING.md에는 changelog 항목이 자동으로 만들어지므로 외부 기여자가 직접 작성할 필요는 없고, 리뷰어가 요청할 때만 손대면 된다고 적혀 있습니다.
리뷰어 승인 후 squash 머지되어 main에는 Fix hard_bounds ignoring offset in histogram (#158148) 한 커밋이 남았습니다. 버전 라벨은 v9.6.0이었고, 백포트 여부는 Elastic 쪽이 판단하는 부분이라 따로 요청하지 않았습니다.
돌아보며
이번 수정에서 가장 오래 남는 것은 "비교하는 값과 보여 주는 값이 같은가"라는 질문입니다. 버킷 인덱스, 인덱스에 간격을 곱한 값, 거기에 offset까지 더한 버킷 키는 offset이 0일 때 뒤의 둘이 구분되지 않습니다. 기존 testHardBounds도 offset을 쓰지 않았기 때문에 이 차이가 드러날 기회가 없었습니다. 기본값에서 두 값이 우연히 같아지는 자리에서는 테스트가 그 차이를 잡아 주지 못한다는 점을 이번에 직접 겪으며 배웠습니다.
또 하나는 같은 저장소 안의 형제 구현이 가장 믿을 만한 근거가 된다는 점입니다. DateHistogramAggregator가 이미 실제 버킷 키로 비교하고 있었기 때문에 "어느 쪽이 맞는가"를 길게 논증할 필요가 없었고, 같은 식이 복사된 x-pack의 두 집계기도 같은 방식으로 찾을 수 있었습니다. 다음 기여에서도 고칠 지점을 찾으면 먼저 옆에 같은 일을 하는 코드가 어떻게 되어 있는지부터 살펴보려고 합니다.
'OPEN SOURCE' 카테고리의 다른 글
- Total
- Today
- Yesterday
- Eager Initialization
- mybatis
- DB 인덱스 성능
- 스레드 생명주기
- Redis 성능 개선
- 동시성처리
- 캐시와 인덱스
- Cache Aside
- 백엔드 성능
- InterruptedException
- DB 트랜잭션
- 캐시 장애
- 트랜잭션 관리
- Redis 캐시 전략
- 백엔드 성능 튜닝
- Enum 기반 싱글톤
- Spring Batch
- TTL 설계
- 백엔드 성능 설계
- 트래픽 처리
- Redis vs DB
- Cache Penetration
- Cache Avalanche
- 백엔드 아키텍처
- Double-Checked Locking
- Java Performance
- spring batch 5
- 캐시 성능 비교
- Hot Key 문제
- Initialization-on-Demand Holder Idiom
| 일 | 월 | 화 | 수 | 목 | 금 | 토 |
|---|---|---|---|---|---|---|
| 1 | 2 | 3 | ||||
| 4 | 5 | 6 | 7 | 8 | 9 | 10 |
| 11 | 12 | 13 | 14 | 15 | 16 | 17 |
| 18 | 19 | 20 | 21 | 22 | 23 | 24 |
| 25 | 26 | 27 | 28 | 29 | 30 | 31 |

