Micrometer 입문 3편 — Timer·percentile·histogram·SLO 깊이

2026-05-25Micrometer 입문에서 운영까지

Micrometer 3편. Timer 가 내부적으로 담는 count·totalTime·max, 평균 latency 만으로 부족한 이유, client-side percentile(publishPercentiles)과 histogram(publishPercentileHistogram) 의 차이, 분산 환경에서 percentile 을 산술평균하면 통계적으로 틀리는 이유, SLO boundaries(serviceLevelObjectives)와 Prometheus histogram_quantile 활용까지.

📚 Micrometer 입문에서 운영까지 · 3편 — Timer·percentile·histogram·SLO 깊이

이 글은 Micrometer 입문에서 운영까지 시리즈 3편이에요. 2편에서 Counter·Gauge·Timer·DistributionSummary 각각의 동작 원리와 언제 어느 타입을 쓰는지 살펴봤어요. 2편 마지막에 Timer 를 소개하면서 "percentile 과 histogram 설정은 3편에서 자세히 다뤄요"라고 예고했는데, 이제 그 자리예요. 이번 글은 latency 를 제대로 재는 법 — Timer 가 안에서 뭘 하고 있는지부터 p95·p99 가 왜 필요한지, 분산 환경에서 percentile 을 합산하면 왜 틀리는지, histogram 은 그 문제를 어떻게 해결하는지까지 깊게 파고들어요.

📚 학습 노트

이 시리즈는 Micrometer 공식 문서, Spring Boot Actuator 공식 가이드, 여러 observability 학습 자료 등 공개 자료를 참고해 한국어 학습 노트로 풀어쓴 자료입니다.

실제로 publishPercentilespublishPercentileHistogram 을 둘 다 켜 놓고 /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 (최근 구간 최댓값)

counttotalTime 이 있으니 평균 latency 는 totalTime / count 로 계산할 수 있어요. Prometheus 에서는 메트릭 이름에 _count_sum suffix 가 붙어서 나와요. 예를 들어 orders_processing_time_seconds_countorders_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은 합산할 수 없다

이게 이번 글에서 가장 중요한 포인트예요.

⚠️ 핵심 함정 — 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초 사이라는 걸 안다면, 그 범위 밖의 버킷은 의미가 없어요. minimumExpectedValuemaximumExpectedValue 로 범위를 좁히면 버킷 수가 줄고, 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() 을 켜는 것과 동일해요. 선언형으로 깔끔하게 쓸 수 있지만, serviceLevelObjectivesminimumExpectedValue 같은 세밀한 설정은 어노테이션으로 지정이 어려워서 복잡한 요구사항에서는 빌더 패턴이 나아요.

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 설정 레퍼런스를 확인할 수 있어요.

시리즈 다른 편 (앞뒤 글 모음)

이전 글:

다음 글:

답글 남기기

error: Content is protected !!