Micrometer 3편. Timer 가 내부적으로 담는 count·totalTime·max, 평균 latency 만으로 부족한 이유, client-side percentile(publishPercentiles)과 histogram(publishPercentileHistogram) 의 차이, 분산 환경에서 percentile 을 산술평균하면 통계적으로 틀리는 이유, SLO boundaries(serviceLevelObjectives)와 Prometheus histogram_quantile 활용까지.
이 글은 Micrometer 입문에서 운영까지 시리즈 3편이에요. 2편에서 Counter·Gauge·Timer·DistributionSummary 각각의 동작 원리와 언제 어느 타입을 쓰는지 살펴봤어요. 2편 마지막에 Timer 를 소개하면서 "percentile 과 histogram 설정은 3편에서 자세히 다뤄요"라고 예고했는데, 이제 그 자리예요. 이번 글은 latency 를 제대로 재는 법 — Timer 가 안에서 뭘 하고 있는지부터 p95·p99 가 왜 필요한지, 분산 환경에서 percentile 을 합산하면 왜 틀리는지, histogram 은 그 문제를 어떻게 해결하는지까지 깊게 파고들어요.
이 시리즈는 Micrometer 공식 문서, Spring Boot Actuator 공식 가이드, 여러 observability 학습 자료 등 공개 자료를 참고해 한국어 학습 노트로 풀어쓴 자료입니다.
실제로 publishPercentiles 와 publishPercentileHistogram 을 둘 다 켜 놓고 /actuator/prometheus 응답을 직접 비교해 보면 이번 글이 훨씬 빠르게 이해될 거예요.
이번 글의 범위
이번 글은 Timer 의 분포 측정 기능에 집중해요.
| 자리 | 내용 |
|---|---|
| Timer 내부 구조 | count · totalTime · max. 평균만으로 부족한 이유 |
| percentile 개념 | p95/p99 가 왜 평균보다 신뢰할 수 있나 |
| 두 가지 설정 | publishPercentiles (client-side) vs publishPercentileHistogram (histogram 버킷) |
| 집계 불가 함정 | 인스턴스별 p99 를 평균내면 왜 틀리나 |
| SLO boundaries | serviceLevelObjectives 로 관심 구간 버킷 추가 |
| Prometheus 연동 | histogram_quantile() 로 정확한 p99 계산 |
| 버킷 범위 튜닝 | minimumExpectedValue · maximumExpectedValue |
Timer가 안에 담는 것
Timer 를 한 번 record() 할 때마다 내부적으로 세 가지 값이 동시에 갱신돼요.
count (실행 횟수) · totalTime (누적 실행 시간, 나노초) · max (최근 구간 최댓값)
count 와 totalTime 이 있으니 평균 latency 는 totalTime / count 로 계산할 수 있어요. Prometheus 에서는 메트릭 이름에 _count 와 _sum suffix 가 붙어서 나와요. 예를 들어 orders_processing_time_seconds_count 와 orders_processing_time_seconds_sum 이렇게요.
// Timer 기본 생성
Timer timer = Timer.builder("orders.processing.time")
.tag("type", "online")
.description("Order processing latency")
.register(registry);
// record() 한 번마다 count +1, totalTime += 소요시간, max 갱신
timer.record(() -> processOrder(request));
그러면 이 세 가지 값만 가지고도 충분할까요? 그렇지 않아요. 평균이 거짓말을 하는 상황이 있거든요.
평균 latency 만으로 부족한 이유
100개 요청 중 99개가 10ms 에 끝나고, 1개가 9,910ms 걸렸다고 해요. 평균은 (99 * 10 + 9910) / 100 = 108.1ms 예요. 대시보드에서 "평균 응답 100ms 내외"로 보이겠지만, 실제로 사용자 한 명은 10초 가까이 기다린 거죠. 평균은 이 극단값을 완벽하게 숨겨요.
요청 분포:
10ms ██████████████████████████████████ 99개
...
9910ms █ 1개
평균: 108ms ← "정상처럼 보임"
p99: 9910ms ← "99번째 사람이 경험하는 현실"
p99(99th percentile) 는 "100개 요청 중 99번째로 느린 값"이에요. 99명의 사용자가 실제로 경험한 최대치라고 볼 수 있어요. 사용자 경험 관점에서는 평균보다 훨씬 진실에 가까운 숫자예요.
percentile을 왜 따로 봐야 하나
평균이 위험한 이유는 분포가 비대칭일 때 특히 심해요. 웹 서비스 latency 는 대부분 비대칭이에요. 빠른 요청이 대다수지만, 가끔 DB 락이나 GC 때문에 튀는 값이 나오거든요. 그 "가끔"이 평균에 녹아들어 정상처럼 보이게 만들어요.
percentile 을 이해하는 가장 쉬운 방법은 성적 백분위예요. 수능 백분위 99 이면 상위 1% 라는 뜻이잖아요. latency 에서 p99 는 반대로 "하위 1% 에 해당하는 느린 요청"을 뜻해요. p95 는 95% 가 이보다 빠르다는 뜻이고요.
운영에서 주로 보는 percentile 은 세 가지예요.
| percentile | 의미 | 언제 쓰나 |
|---|---|---|
| p50 | 중간값 — 절반이 이보다 빠름 | 일반 사용자 대표 경험 |
| p95 | 상위 5% 느린 구간의 경계 | 느린 요청 존재 여부 확인 |
| p99 | 상위 1% 느린 구간의 경계 | SLO 기준선, 극단값 탐지 |
여기서 실무에서 자주 쓰이는 기준이 있어요. "API p99 < 500ms" 같은 식으로 SLO(Service Level Objective) 를 정의할 때 percentile 을 써요. "평균 응답 < 200ms"는 의미가 약한 SLO 예요 — 극단값을 숨기거든요.
client-side percentile vs histogram
Micrometer 에서 percentile 을 얻는 방법이 두 가지예요. 겉보기엔 비슷해 보이지만 동작 방식이 완전히 달라요.
publishPercentiles — 앱 안에서 계산
Timer timer = Timer.builder("orders.processing.time")
.tag("type", "online")
.publishPercentiles(0.5, 0.95, 0.99) // p50, p95, p99
.register(registry);
이렇게 하면 Micrometer 가 앱 내부에서 HDR Histogram 알고리즘으로 percentile 을 직접 계산해요. Prometheus 에서 긁으면 이런 식으로 보여요.
orders_processing_time_seconds{quantile="0.5"} 0.0124
orders_processing_time_seconds{quantile="0.95"} 0.3821
orders_processing_time_seconds{quantile="0.99"} 0.9441
장점은 정확도예요. 앱 내부에서 모든 측정값을 가지고 있으니 정밀한 percentile 을 뽑아낼 수 있어요. 단점도 있어요. 이 값은 집계할 수 없어요. 이게 왜 문제인지는 다음 섹션에서 자세히 다뤄요.
publishPercentileHistogram — 버킷 노출
Timer timer = Timer.builder("orders.processing.time")
.tag("type", "online")
.publishPercentileHistogram() // histogram 버킷 노출
.register(registry);
이렇게 하면 앱이 미리 정해진 버킷 경계별로 "이 버킷 이하인 요청이 몇 개"를 카운팅해서 노출해요. Prometheus 에서 긁으면 이런 식이에요.
orders_processing_time_seconds_bucket{le="0.001"} 42
orders_processing_time_seconds_bucket{le="0.005"} 1280
orders_processing_time_seconds_bucket{le="0.01"} 3841
orders_processing_time_seconds_bucket{le="0.025"} 5612
orders_processing_time_seconds_bucket{le="0.05"} 6890
...
orders_processing_time_seconds_bucket{le="+Inf"} 7210
le 는 "less than or equal" 이에요. le="0.01" 버킷이 3841 이라면 10ms 이하인 요청이 3841개라는 뜻이에요. percentile 값 자체는 Prometheus 가 histogram_quantile() 함수로 이 버킷들에서 계산해요. 앱이 percentile 을 직접 계산하지 않는다는 게 핵심이에요.
이 두 설정은 동시에 켤 수 있어요.
Timer timer = Timer.builder("orders.processing.time")
.publishPercentiles(0.5, 0.95, 0.99) // client-side: 정확한 값
.publishPercentileHistogram() // histogram: Prometheus 에서 집계 가능
.register(registry);
개발·디버깅 시에는 publishPercentiles 로 즉시 값을 확인하고, 프로덕션 다중 인스턴스 환경에서는 publishPercentileHistogram 으로 정확한 집계를 보는 방식으로 쓸 수 있어요.
percentile은 합산할 수 없다
이게 이번 글에서 가장 중요한 포인트예요.
인스턴스 3대가 각각 p99 = 100ms, 200ms, 900ms 를 리포트했을 때, 이 셋을 산술평균(400ms)하면 "전체 시스템의 p99" 가 되지 않아요. 통계적으로 아무 의미가 없는 숫자예요.
분산 환경에서 정확한 p99 는 각 인스턴스의 측정값 분포를 합쳐서 처음부터 다시 계산해야 해요. histogram 버킷 카운트는 더하기가 가능하기 때문에, Prometheus 가 여러 인스턴스의 버킷을 합산한 다음 histogram_quantile() 로 계산하면 정확한 전체 p99 가 나와요.
왜 percentile 을 합산하면 안 될까요? 수학적인 이유가 있어요.
인스턴스 A: 요청 100개, p99 = 100ms
→ 99번째로 느린 요청이 100ms 라는 뜻
인스턴스 B: 요청 100개, p99 = 200ms
→ 99번째로 느린 요청이 200ms 라는 뜻
인스턴스 C: 요청 100개, p99 = 900ms
→ 99번째로 느린 요청이 900ms 라는 뜻
전체 합산 (A+B+C = 300개 요청):
p99 = 300 * 0.99 = 297번째로 느린 요청의 latency
= ??? ← A, B, C 의 p99 를 더하거나 평균낸 값으로는 알 수 없음
300개 요청 중 297번째로 느린 요청이 어느 인스턴스 어느 요청인지는 각 인스턴스의 측정값 전체를 합쳐봐야 알 수 있어요. 인스턴스별로 "99번째 하나"만 뽑아 두면 나머지 맥락이 사라지거든요.
반면 histogram 버킷은 달라요. "10ms 이하 요청 수"는 더할 수 있어요. A 에서 50개, B 에서 40개, C 에서 30개라면 합산하면 120개예요. Prometheus 는 이렇게 버킷을 더한 다음 histogram_quantile() 로 분위수를 역산해요.
SLO boundaries 설정
serviceLevelObjectives 는 특정 latency 임계값을 버킷에 정확히 추가해 주는 기능이에요.
Timer timer = Timer.builder("orders.processing.time")
.tag("type", "online")
.publishPercentileHistogram()
.serviceLevelObjectives(
Duration.ofMillis(50),
Duration.ofMillis(100),
Duration.ofMillis(300),
Duration.ofMillis(1000)
)
.register(registry);
이렇게 하면 50ms, 100ms, 300ms, 1000ms 에 정확히 버킷 경계가 생겨요. Prometheus 에서 "100ms 이내 처리된 요청 비율"을 쿼리할 때 이 버킷이 있으면 정확히 뽑을 수 있어요.
왜 이 설정이 필요할까요? publishPercentileHistogram() 만 켜면 Micrometer 가 자동으로 버킷 경계를 여러 개 생성해요. 하지만 그 경계가 1ms, 5ms, 10ms, 25ms, 50ms, 75ms, 100ms, 250ms, 500ms ... 식의 로그 스케일 이에요. 만약 SLO 가 "120ms 이내"라면 100ms 와 250ms 버킷 사이에 끼어서 정확한 비율을 쿼리하기 어려워요.
serviceLevelObjectives 로 관심 임계값을 명시하면 그 경계에 버킷이 정확히 생겨요. SLO 위반율 계산이 쉬워지는 이유예요.
Prometheus 쿼리 — 100ms 이내 처리율
orders_processing_time_seconds_bucket{le="0.1"} / orders_processing_time_seconds_count
→ 이 쿼리가 정확하게 동작하려면 le="0.1" 버킷이 존재해야 함
→ serviceLevelObjectives(Duration.ofMillis(100)) 가 그 버킷을 보장
Prometheus에서 histogram_quantile
histogram 버킷이 Prometheus 에 수집되면, PromQL 의 histogram_quantile() 함수로 percentile 을 계산해요. 수집된 histogram 에서 histogram_quantile() 로 p99 를 뽑는 건 Prometheus/PromQL 쪽 일이고, 그건 Grafana 시리즈 2편 에서 자세히 다뤘어요. 여기서는 Micrometer 와 연결되는 부분만 짚을게요.
-- p99 latency (인스턴스 합산)
histogram_quantile(0.99,
sum(rate(orders_processing_time_seconds_bucket[5m])) by (le)
)
이 쿼리의 핵심은 sum(...) by (le) 예요. 여러 인스턴스의 버킷을 le 값(버킷 경계) 별로 합산한 다음 histogram_quantile() 에 넘기면, 앞서 얘기한 percentile 합산 문제 없이 전체 시스템의 정확한 p99 가 나와요.
-- 인스턴스별로 따로 보고 싶을 때
histogram_quantile(0.99,
rate(orders_processing_time_seconds_bucket[5m])
)
태그(instance 등)로 쪼개지 않고 sum 으로 합산하는 게 핵심이에요. 각 인스턴스별 p99 를 따로 보다가 "평균"을 내면 앞서 말한 함정에 그대로 빠져요.
Micrometer 쪽에서 해야 할 일은 publishPercentileHistogram() 을 켜는 것뿐이에요. percentile 계산은 Prometheus 가 담당하고, 시각화는 Grafana 가 담당해요. 역할 분리가 명확해요.
min/max expected value 튜닝
histogram 버킷을 생성할 때 "어느 범위의 latency 를 버킷으로 커버할 것인가"를 설정하는 옵션이에요.
Timer timer = Timer.builder("orders.processing.time")
.publishPercentileHistogram()
.minimumExpectedValue(Duration.ofMillis(1)) // 1ms 이하는 버킷 불필요
.maximumExpectedValue(Duration.ofSeconds(10)) // 10초 이상은 버킷 불필요
.register(registry);
기본값으로 두면 Micrometer 가 매우 넓은 범위에 걸쳐 버킷을 자동 생성해요. 문제는 버킷 수가 많아질수록 Prometheus 의 메모리 사용량과 시계열 수(cardinality) 가 늘어난다는 거예요.
예를 들어 주문 처리 API 의 latency 가 현실적으로 1ms ~ 5초 사이라는 걸 안다면, 그 범위 밖의 버킷은 의미가 없어요. minimumExpectedValue 와 maximumExpectedValue 로 범위를 좁히면 버킷 수가 줄고, Prometheus 메모리 사용량이 내려가요.
기본 설정 시:
자동 생성 버킷 경계: 0.001s, 0.005s, 0.01s ... 30s, 60s
(매우 넓은 범위)
튜닝 후:
minimumExpectedValue = 1ms, maximumExpectedValue = 10s
생성 버킷 경계: 0.001s, 0.005s ... 10s
(불필요한 상한 버킷 제거)
serviceLevelObjectives 와 함께 쓸 때는, SLO 경계 값들이 minimumExpectedValue ~ maximumExpectedValue 범위 안에 있어야 해요. 범위 밖이면 버킷이 생기지 않아요.
함정 정리
사고 1: publishPercentiles 만 믿고 다중 인스턴스 운영
publishPercentiles 로 노출된 quantile 레이블 값들을 Prometheus 에서 avg() 로 합산해요.
원인 — 설정이 간단하고 값이 바로 보이니 집계도 그냥 평균내면 되겠지 싶어요.
해결 — publishPercentiles 는 단일 인스턴스에서만 정확해요. 다중 인스턴스 환경에서는 publishPercentileHistogram() + Prometheus histogram_quantile() 조합을 써야 정확한 전체 p99 가 나와요.
사고 2: 평균 latency 로 SLO 정의
"API 평균 응답 < 200ms" 를 SLO 로 정의하고 모니터링해요.
원인 — 평균이 직관적이고 쉬워 보여요.
해결 — 평균은 극단값을 숨겨요. SLO 는 p95 또는 p99 기준으로 정의하는 게 사용자 경험을 더 잘 반영해요. 예: "p99 < 500ms".
사고 3: publishPercentileHistogram 없이 histogram_quantile 쿼리
Prometheus 에서 histogram_quantile() 을 쓰려는데 쿼리 결과가 비어 있어요.
원인 — publishPercentiles 만 켜 두면 quantile 레이블 타임시리즈만 생기고, _bucket 시리즈가 생기지 않아요.
해결 — publishPercentileHistogram() 을 Timer 빌더에 추가해야 _bucket 시리즈가 생겨요. histogram_quantile() 은 _bucket 이 있어야 동작해요.
사고 4: SLO 버킷 경계가 범위 밖
serviceLevelObjectives(Duration.ofMillis(200)) 를 설정했는데 Prometheus 에서 le="0.2" 버킷을 못 찾아요.
원인 — maximumExpectedValue 를 낮게 설정해서 200ms 가 범위 밖이 됐거나, publishPercentileHistogram() 을 켜지 않아서예요.
해결 — serviceLevelObjectives 경계값은 minimumExpectedValue ~ maximumExpectedValue 범위 안에 있어야 해요. publishPercentileHistogram() 과 함께 쓰는 것도 확인하세요.
사고 5: max 값을 p99 처럼 사용
Prometheus 에서 Timer 의 _max 시리즈를 보고 "이게 가장 느린 요청이구나"라고 생각해요.
원인 — Timer 가 max 를 제공하니까 그게 p100 처럼 보여요.
해결 — Micrometer 의 max 는 최근 수집 구간(보통 60초) 의 최댓값이에요. 영구적인 전체 최댓값이 아니에요. 다음 수집 구간이 지나면 리셋돼요. 실제 최악의 사용자 경험을 추적하려면 p99 또는 p99.9 percentile 이 맞아요.
사고 6: 버킷 수 폭증으로 Prometheus 메모리 이슈
publishPercentileHistogram() 을 켰더니 Prometheus 메모리 사용량이 갑자기 늘었어요.
원인 — histogram 버킷 수가 많아지면 시계열(time series) 수가 그만큼 늘어요. Timer 마다 수십 개의 _bucket 시계열이 생기거든요. Timer 가 여러 태그 조합을 가지면 곱으로 늘어요.
해결 — minimumExpectedValue · maximumExpectedValue 로 버킷 범위를 줄여요. 태그 cardinality 도 점검해야 해요.
사고 7: publishPercentiles 의 percentile 값이 고정되어 보임
같은 시간대에 요청이 폭증했는데 quantile="0.99" 값이 변화가 없어요.
원인 — publishPercentiles 는 앱 시작 이후 누적 측정값 기반으로 계산해요. 최근 1분 급등이 누적 분포에 희석될 수 있어요.
해결 — 최근 구간의 percentile 변화를 보려면 Prometheus 쪽에서 histogram_quantile(0.99, rate(..._bucket[5m])) 처럼 rate() 와 함께 쓰면 돼요. 이때 publishPercentileHistogram() 이 켜져 있어야 해요.
사고 8: Timer 에 직접 percentile 쿼리 없이 평균만 그래프화
Grafana 대시보드에 rate(orders_processing_time_seconds_sum[5m]) / rate(orders_processing_time_seconds_count[5m]) 만 박아요.
원인 — 설정이 간단하고 이 쿼리로도 숫자가 나오니까요.
해결 — 이 쿼리는 평균 latency 예요. p99 를 보려면 publishPercentileHistogram() 을 켜고 Prometheus 에서 histogram_quantile() 을 써야 해요.
사고 9: 인스턴스별 p99 를 Grafana 에서 평균내기
Grafana 에서 avg(orders_processing_time_seconds{quantile="0.99"}) 쿼리를 써요.
원인 — 인스턴스가 여러 개라 평균내야 전체 수치가 나올 것 같아요.
해결 — percentile 의 평균은 통계적으로 아무 의미가 없어요. publishPercentileHistogram() 으로 바꾸고 histogram_quantile(0.99, sum(rate(..._bucket[5m])) by (le)) 로 합산해야 정확해요.
사고 10: minimumExpectedValue 를 0 또는 매우 작게 설정
minimumExpectedValue(Duration.ofNanos(1)) 처럼 설정해요.
원인 — "작게 잡을수록 더 세밀한 버킷이 생기겠지"라고 생각해요.
해결 — 범위가 넓어질수록 로그 스케일 버킷 수가 폭증해요. 실제 latency 분포를 먼저 확인하고 현실적인 최솟값을 설정하는 게 맞아요. 1ms 이하 응답이 사실상 없는 API 라면 minimumExpectedValue(Duration.ofMillis(1)) 로 충분해요.
운영 권장 패턴
Pattern 1: 다중 인스턴스 환경 기본 설정
Timer apiTimer = Timer.builder("api.latency")
.tag("endpoint", "/api/orders")
.publishPercentileHistogram() // Prometheus 집계 가능 histogram
.serviceLevelObjectives(
Duration.ofMillis(50),
Duration.ofMillis(100),
Duration.ofMillis(300),
Duration.ofMillis(1000)
)
.minimumExpectedValue(Duration.ofMillis(1))
.maximumExpectedValue(Duration.ofSeconds(30))
.register(registry);
프로덕션 다중 인스턴스 환경의 기본값이에요. publishPercentiles 는 쓰지 않고 histogram 만 노출하고, Prometheus 가 p99 계산을 담당하게 해요.
Pattern 2: 개발 환경 — 빠른 percentile 확인
Timer devTimer = Timer.builder("api.latency")
.publishPercentiles(0.5, 0.95, 0.99) // 개발 시 즉시 값 확인
.register(registry);
단일 인스턴스 로컬 개발 환경에서는 publishPercentiles 로 바로 값을 볼 수 있어요. /actuator/prometheus 에서 quantile 레이블로 즉시 확인돼요.
Pattern 3: SLO 위반율 추적
Timer sloTimer = Timer.builder("checkout.latency")
.publishPercentileHistogram()
.serviceLevelObjectives(Duration.ofMillis(200)) // SLO 기준: 200ms
.register(registry);
-- Prometheus: 200ms SLO 위반율
1 - (
rate(checkout_latency_seconds_bucket{le="0.2"}[5m])
/
rate(checkout_latency_seconds_count[5m])
)
SLO 경계를 버킷으로 박아 두면 "얼마나 많은 요청이 SLO 를 위반했냐"를 바로 쿼리할 수 있어요.
Pattern 4: 중요도별 Timer 설정 분리
// 핵심 결제 API — 세밀한 버킷, 좁은 범위
Timer paymentTimer = Timer.builder("payment.latency")
.publishPercentileHistogram()
.serviceLevelObjectives(
Duration.ofMillis(100), Duration.ofMillis(200), Duration.ofMillis(500)
)
.minimumExpectedValue(Duration.ofMillis(1))
.maximumExpectedValue(Duration.ofSeconds(5))
.register(registry);
// 내부 배치 작업 — 넓은 범위, 느슨한 버킷
Timer batchTimer = Timer.builder("batch.settlement.time")
.publishPercentileHistogram()
.minimumExpectedValue(Duration.ofSeconds(1))
.maximumExpectedValue(Duration.ofMinutes(30))
.register(registry);
API 성격에 따라 범위를 다르게 설정하면 버킷 수를 최적화할 수 있어요. 빠른 API 는 ms 범위, 배치는 분 범위로요.
Pattern 5: @Timed 어노테이션으로 선언적 설정
@Timed(
value = "orders.create.time",
percentiles = {0.5, 0.95, 0.99}, // publishPercentiles 에 해당
histogram = true // publishPercentileHistogram 에 해당
)
@PostMapping("/orders")
public ResponseEntity<Order> createOrder(@RequestBody CreateOrderRequest req) {
return ResponseEntity.ok(orderService.create(req));
}
@Timed(histogram = true) 가 publishPercentileHistogram() 을 켜는 것과 동일해요. 선언형으로 깔끔하게 쓸 수 있지만, serviceLevelObjectives 나 minimumExpectedValue 같은 세밀한 설정은 어노테이션으로 지정이 어려워서 복잡한 요구사항에서는 빌더 패턴이 나아요.
Pattern 6: 이미 존재하는 percentile 값 로그로 검증
// 단위 테스트나 스모크 테스트에서 Timer 설정 검증
@Test
void timerShouldExposePercentileHistogram() {
SimpleMeterRegistry registry = new SimpleMeterRegistry();
Timer timer = Timer.builder("test.latency")
.publishPercentileHistogram()
.register(registry);
timer.record(Duration.ofMillis(50));
timer.record(Duration.ofMillis(150));
timer.record(Duration.ofMillis(400));
// _bucket 시리즈가 생겼는지 확인
assertThat(registry.find("test.latency").timer()).isNotNull();
// Prometheus 포맷으로 직렬화해서 _bucket 존재 확인 가능
}
publishPercentileHistogram() 이 실제로 적용됐는지 테스트에서 확인해 두면, 배포 후 Prometheus 쿼리가 빈 결과를 리포트하는 상황을 미리 잡을 수 있어요.
시험 직전 한 번 더 — Timer/percentile 압축 노트
Timer 내부 구조
record()한 번 → count +1, totalTime += 소요시간(나노초), max 갱신_count,_sum,_max가 Prometheus 에 각각 시계열로 노출max는 최근 수집 구간 최댓값이고, 구간이 지나면 리셋됨 (전체 누적 최댓값 아님)
왜 평균이 부족한가
- 비대칭 분포 — 느린 요청 소수가 평균에 희석됨
- 극단값 99개가 1개 느린 요청을 숨길 수 있음
- SLO 는 p95/p99 기준이 사용자 경험을 더 정확히 반영
client-side percentile (publishPercentiles)
- 앱 내부에서 HDR Histogram 알고리즘으로 계산
- Prometheus 에
{quantile="0.99"}레이블 타임시리즈로 노출 - 장점: 정확. 단점: 집계 불가 — 여러 인스턴스 p99 를 합치거나 평균낼 수 없음
- 단일 인스턴스 개발 환경에 적합
histogram 버킷 (publishPercentileHistogram)
- 앱이 버킷 경계별 카운트를 노출 (
_bucket시리즈,le레이블) - percentile 계산은 Prometheus
histogram_quantile()이 담당 - 장점: 집계 가능 —
sum(rate(..._bucket[5m])) by (le)로 여러 인스턴스 합산 - 단점: 버킷 정밀도 한계, 버킷 수 만큼 시계열 증가
percentile 합산 불가 핵심
- 인스턴스별 p99 를 산술평균 → 통계적으로 무의미
- histogram 버킷 카운트는 더하기 가능 → Prometheus 합산 후
histogram_quantile()이 정확 - 다중 인스턴스 운영에서는 반드시
publishPercentileHistogram()사용
SLO boundaries
serviceLevelObjectives(Duration.ofMillis(100), ...)로 관심 임계값에 버킷 추가- 기본 자동 버킷(로그 스케일)이 SLO 경계와 맞지 않을 때 필수
minimumExpectedValue~maximumExpectedValue범위 안에 있어야 버킷 생성
Prometheus histogram_quantile
histogram_quantile(0.99, sum(rate(timer_seconds_bucket[5m])) by (le))sum(...) by (le)가 인스턴스 합산의 핵심 — by (le) 를 빠뜨리면 안 됨_bucket시리즈가 없으면 동작 안 함 →publishPercentileHistogram()필수
min/max expected value
minimumExpectedValue·maximumExpectedValue로 버킷 생성 범위 제한- 범위를 좁히면 불필요한 버킷 제거 → 시계열 수 감소 → Prometheus 메모리 절약
- SLO 경계값은 이 범위 안에 있어야 버킷이 생김
흔한 사고 목록
publishPercentiles값을 다중 인스턴스에서avg()→ 통계적으로 틀림- 평균 latency SLO 정의 → 극단값 은폐
publishPercentileHistogram()없이histogram_quantile()쿼리 → 빈 결과- SLO 버킷이
maximumExpectedValue밖 → 버킷 미생성 - Timer
max를 p100 처럼 사용 → 최근 구간 리셋 특성 놓침 - histogram 켜고 버킷 수 폭증 → Prometheus OOM
공식 문서: Micrometer 공식 docs 에서 Timer 옵션 전체와 histogram 설정 레퍼런스를 확인할 수 있어요.
시리즈 다른 편 (앞뒤 글 모음)
이전 글:
다음 글: