- Published on
평균 응답 시간이 거짓말하는 이유 — p50, p95, p99를 제대로 읽는 법
- Authors

- Name
- Youngju Kim
- @fjvbn20031
- 들어가며 — 평균은 80ms인데 왜 느리다는 항의가 들어오는가
- 평균이 감추는 것 — 롱테일 분포의 산술
- p50, p95, p99, p99.9가 각각 답하는 질문
- p99는 "100명 중 1명"이 아니다 — 지연의 곱셈 효과
- 백분위는 평균낼 수 없다 — 그래서 히스토그램이 필요하다
- 프로메테우스 히스토그램 — 버킷 경계와 보간 오차
- SLO를 백분위로 정의할 때의 실무 기준
- 마치며 — 하나의 숫자가 아니라 분포를 보라
들어가며 — 평균은 80ms인데 왜 느리다는 항의가 들어오는가
대시보드의 평균 응답 시간은 80ms입니다. 지난 한 달간 그래프는 평평했고 알림도 울리지 않았습니다. 그런데 고객지원 팀은 "결제 화면이 몇 초씩 멈춘다"는 문의를 매일 받고 있습니다.
두 사실은 모순이 아닙니다. 평균이 원래 그렇게 동작하는 지표이기 때문입니다. 평균은 분포의 모양을 하나의 숫자로 뭉개면서, 정확히 사용자가 화를 내는 구간의 정보를 가장 먼저 버립니다.
이 글은 그 구간을 다시 꺼내는 방법을 다룹니다. 백분위가 무엇을 답하는지, 왜 백분위끼리 평균낼 수 없는지, 그리고 프로메테우스 히스토그램이 실제로 어떤 숫자를 돌려주는지까지 계산 가능한 수준으로 내려가 봅니다.
평균이 감추는 것 — 롱테일 분포의 산술
응답 시간 분포는 정규분포가 아닙니다. 왼쪽에 벽이 있고(0ms보다 빠를 수 없습니다) 오른쪽으로 길게 늘어집니다. GC 정지, 콜드 캐시, 커넥션 풀 대기, 재시도, 시끄러운 이웃 — 느려지는 방법은 많고 빨라지는 방법은 없습니다.
숫자로 보겠습니다. 10,000건의 요청 중 9,900건이 50ms에 끝나고 100건이 3,000ms 걸렸다고 하겠습니다.
# 액세스 로그의 응답 시간(ms) 컬럼만 뽑아 직접 분포를 계산한다
awk '{print $NF}' access.log | sort -n > sorted.txt
wc -l < sorted.txt
# 10000
awk '{s+=$1} END {printf "mean %.1f\n", s/NR}' sorted.txt
# mean 79.5
for n in 5000 9500 9900 9990; do
printf "n=%s\t%s\n" "$n" "$(awk -v r=$n 'NR==r' sorted.txt)"
done
# n=5000 50 <- p50
# n=9500 50 <- p95
# n=9900 50 <- p99 (경계에 걸쳐 있다)
# n=9990 3000 <- p99.9
평균은 79.5ms입니다. 실제로 79.5ms에 응답받은 요청은 단 한 건도 없습니다. 평균은 존재하지 않는 사용자를 묘사합니다.
더 나쁜 것은 민감도입니다. 느린 100건이 3,000ms에서 6,000ms로 두 배 나빠져도 평균은 79.5ms에서 109.5ms로 움직일 뿐입니다. 30ms 상승은 어떤 알림 임계값도 넘지 않습니다. 반면 그 100건을 겪은 사용자에게는 서비스가 완전히 망가진 것으로 보입니다. 평균은 꼬리가 나빠지는 것을 거의 감지하지 못합니다.
p50, p95, p99, p99.9가 각각 답하는 질문
백분위마다 답하는 질문이 다릅니다. 하나만 보는 것은 하나만 보지 않는 것보다 나쁩니다.
| 지표 | 답하는 질문 | 대표적인 오해 |
|---|---|---|
| 평균 | 총 처리 시간을 요청 수로 나누면 얼마인가 | 전형적인 사용자 경험을 나타낸다 |
| p50 | 요청의 절반은 이 시간 안에 끝난다 | 대부분의 요청이 이 정도다 |
| p95 | 스무 번에 한 번 겪는 느림의 크기 | 무시해도 되는 예외값이다 |
| p99 | 백 번에 한 번 겪는 느림의 크기 | 사용자의 1%만 겪는다 |
| p99.9 | 인프라 이상과 GC 정지가 드러나는 구간 | 트래픽이 적어도 의미가 있다 |
실무적인 읽는 법은 이렇습니다.
- p50이 나빠지면 시스템 전체가 느려진 것입니다. 용량 부족이나 공통 의존성의 문제를 의심합니다.
- p50은 그대로인데 p99만 나빠지면 특정 조건에서만 발생하는 문제입니다. 특정 테넌트, 특정 쿼리 경로, 특정 노드, 락 경합, GC를 봅니다.
- p99와 p99.9의 간격이 벌어지면 드물지만 매우 비싼 경로가 생긴 것입니다. 재시도 폭풍이나 타임아웃 설정을 확인합니다.
- p50과 p99가 거의 같으면 분포가 압축된 것입니다. 좋은 신호일 수도 있지만, 앞단에서 타임아웃으로 잘리고 있을 가능성도 함께 봅니다.
p99.9는 트래픽 볼륨이 받쳐줄 때만 의미가 있습니다. 5분에 1,000건이 들어오는 서비스에서 p99.9는 단 한 건의 요청입니다. 한 건짜리 신호에 알림을 걸면 알림은 난수 발생기가 됩니다.
p99는 "100명 중 1명"이 아니다 — 지연의 곱셈 효과
가장 흔하고 가장 비싼 오해입니다. p99가 2초라는 말은 "사용자의 1%가 2초를 겪는다"가 아니라 "요청 100개 중 1개가 2초를 겪는다"입니다. 한 사용자가 여러 요청을 만든다면 체감 확률은 요청 수만큼 곱해집니다.
// 한 화면이 N개의 백엔드 호출을 부를 때
// "적어도 하나가 p99를 넘을" 확률
const p = 0.99
for (const n of [1, 5, 20, 50, 200]) {
const hit = 1 - Math.pow(p, n)
console.log(`${String(n).padStart(3)}회 호출 → ${(hit * 100).toFixed(1)}%`)
}
// 1회 호출 → 1.0%
// 5회 호출 → 4.9%
// 20회 호출 → 18.2%
// 50회 호출 → 39.5%
// 200회 호출 → 86.6%
대시보드 한 화면이 20개의 API를 호출하는 구조라면, 화면을 한 번 열 때 p99 지연을 만날 확률은 18%입니다. 하루에 200번 요청을 만드는 활성 사용자라면 87%가 하루에 한 번 이상 최악 구간을 겪습니다. p99는 소수의 불운한 사용자가 아니라 거의 모든 사용자의 일상입니다.
여기서 두 가지 결론이 나옵니다. 첫째, 팬아웃이 큰 아키텍처일수록 꼬리 지연 관리가 중요합니다. 서비스 하나의 p99를 개선하는 것이 전체 화면의 p50을 개선하는 것보다 효과가 큽니다. 둘째, 클라이언트 측 지표를 따로 봐야 합니다. 서버의 p99가 아니라 화면 완성 시점의 백분위가 사용자가 실제로 겪는 값입니다.
백분위는 평균낼 수 없다 — 그래서 히스토그램이 필요하다
이 절이 이 글에서 가장 실무적으로 중요한 부분입니다. 합계와 개수는 더할 수 있지만 백분위는 더하거나 평균낼 수 없습니다.
인스턴스 두 대를 예로 들겠습니다.
- 서버 A: 요청 9,900건, 전부 50ms → p99(A) = 50ms
- 서버 B: 요청 100건, 전부 3,000ms → p99(B) = 3,000ms
두 p99를 평균내면 1,525ms입니다. 요청 수로 가중 평균하면 79.5ms입니다. 그런데 두 서버의 요청 10,000건을 한 줄로 세워 계산한 진짜 p99는 3,000ms입니다. 세 숫자가 전부 다르고, 앞의 두 개는 아무 의미가 없습니다.
그래서 다음 PromQL은 틀렸습니다.
# 틀림: 인스턴스별 p99를 각각 구한 뒤 평균낸다
avg(
histogram_quantile(0.99, rate(http_request_duration_seconds_bucket{job="checkout-api"}[5m]))
)
올바른 순서는 버킷 카운터를 먼저 합치고, 합쳐진 분포에서 백분위를 계산하는 것입니다. 카운터는 집계 가능하고 백분위는 집계 불가능하기 때문입니다.
# 맞음: le 레이블 기준으로 rate를 먼저 합산한 뒤 백분위를 계산한다
histogram_quantile(
0.99,
sum by (le) (rate(http_request_duration_seconds_bucket{job="checkout-api"}[5m]))
)
서비스별로 나누어 보고 싶다면 sum by 절에 le와 함께 그 레이블을 넣습니다. le는 절대 빠지면 안 됩니다.
# 라우트별 p99 — le 와 route 를 함께 유지한다
histogram_quantile(
0.99,
sum by (le, route) (rate(http_request_duration_seconds_bucket{job="checkout-api"}[5m]))
)
같은 이유로 프로메테우스의 summary 타입은 인스턴스 안에서 이미 백분위를 계산해 내보내기 때문에, 여러 인스턴스나 여러 라우트를 합칠 방법이 없습니다. 단일 프로세스의 로컬 진단에는 쓸 수 있지만 서비스 수준 지표로는 쓸 수 없습니다. 히스토그램은 정확도를 버킷 해상도만큼 포기하는 대신 집계 가능성을 얻습니다. 분산 시스템에서는 거의 항상 후자가 맞는 거래입니다.
프로메테우스 히스토그램 — 버킷 경계와 보간 오차
histogram_quantile이 돌려주는 값은 관측된 실제 값이 아닙니다. 버킷 경계 사이를 직선으로 이은 추정치입니다.
curl -s localhost:8080/metrics | grep '^http_request_duration_seconds_bucket'
# http_request_duration_seconds_bucket{le="0.005"} 0
# http_request_duration_seconds_bucket{le="0.01"} 0
# http_request_duration_seconds_bucket{le="0.025"} 12
# http_request_duration_seconds_bucket{le="0.05"} 4103
# http_request_duration_seconds_bucket{le="0.1"} 8890
# http_request_duration_seconds_bucket{le="0.25"} 9604
# http_request_duration_seconds_bucket{le="0.5"} 9740
# http_request_duration_seconds_bucket{le="1"} 9812
# http_request_duration_seconds_bucket{le="2.5"} 9993
# http_request_duration_seconds_bucket{le="5"} 10000
# http_request_duration_seconds_bucket{le="+Inf"} 10000
p99는 9,900번째 관측값입니다. 이 값은 le=1 버킷(누적 9,812)과 le=2.5 버킷(누적 9,993) 사이에 있습니다. 선형 보간은 이렇게 계산합니다.
python3 - <<'PY'
lo, hi = 1.0, 2.5
c_lo, c_hi = 9812, 9993
target = 0.99 * 10000
est = lo + (target - c_lo) / (c_hi - c_lo) * (hi - lo)
print(f"p99 estimate = {est:.3f}s")
PY
# p99 estimate = 1.729s
1.729초라는 정밀해 보이는 숫자가 나오지만, 이 버킷 안의 181건이 실제로 어디에 있는지는 아무도 모릅니다. 전부 1.05초일 수도 있고 전부 2.4초일 수도 있습니다. 버킷이 넓으면 histogram_quantile의 출력은 소수점 세 자리까지 나오는 추측입니다.
여기서 나오는 실무 규칙이 세 가지입니다.
첫째, SLO 임계값과 정확히 같은 버킷 경계를 반드시 넣습니다. 300ms를 기준으로 삼을 것이라면 le=0.3 버킷이 있어야 합니다. 경계가 있으면 보간 없이 정확한 비율을 셀 수 있습니다.
둘째, 관심 구간에 버킷을 촘촘하게 둡니다. 기본 버킷은 웹 API 기준으로 0.1초 위쪽이 너무 성깁니다.
// prom-client — 서비스의 실제 분포에 맞춰 경계를 직접 정한다
const { Histogram } = require('prom-client')
const httpDuration = new Histogram({
name: 'http_request_duration_seconds',
help: 'HTTP 요청 처리 시간',
labelNames: ['method', 'route', 'code'],
// SLO 임계값 0.3 을 경계로 포함하고, 100ms~1s 구간을 촘촘히 둔다
buckets: [0.01, 0.025, 0.05, 0.075, 0.1, 0.15, 0.2, 0.3, 0.4, 0.6, 0.8, 1, 2, 5, 10],
})
셋째, 최상단 유한 버킷을 실제 타임아웃보다 크게 잡습니다. p99가 마지막 유한 경계를 넘어가면 histogram_quantile은 그 경계값 자체를 돌려주거나(구현에 따라) 상한에 붙어버려서, 얼마나 나쁜지 알 수 없게 됩니다.
버킷 경계를 직접 고르는 일 자체가 부담이라면 프로메테우스의 네이티브 히스토그램을 검토할 만합니다. 지수 간격의 버킷을 자동으로 만들어 주므로 경계 선택 문제와 해상도 문제를 상당 부분 없애줍니다. 다만 저장 포맷과 쿼리 경로, 원격 저장소 호환성이 클래식 히스토그램과 다르므로 스택 전체의 지원 여부를 먼저 확인해야 합니다.
SLO를 백분위로 정의할 때의 실무 기준
여기서 마지막 반전이 있습니다. 지연 SLO를 "p99가 300ms 이하"로 정의하는 것은 실무에서 좋은 선택이 아닙니다.
이유는 앞에서 다 나왔습니다. 백분위는 집계할 수 없고, 시간 축으로도 합칠 수 없습니다. 5분 창의 p99를 30일치 모아 놓아도 30일의 p99가 되지 않습니다. 에러 버짓을 계산할 수도, 번 레이트를 구할 수도 없습니다.
대신 같은 내용을 비율로 표현합니다. "요청의 99%가 300ms 안에 완료된다"는 문장은 임계값을 넘긴 요청의 비율로 직접 계산되고, 이 비율은 더할 수 있습니다.
# 지연 SLI: 300ms 이내에 완료된 요청의 비율
sum(rate(http_request_duration_seconds_bucket{job="checkout-api", le="0.3"}[30d]))
/
sum(rate(http_request_duration_seconds_count{job="checkout-api"}[30d]))
# 그대로 에러 버짓 소진율로 이어진다 (목표 99%, 즉 예산 1%)
(
1 -
sum(rate(http_request_duration_seconds_bucket{job="checkout-api", le="0.3"}[1h]))
/
sum(rate(http_request_duration_seconds_count{job="checkout-api"}[1h]))
) / 0.01
이 형태의 장점은 세 가지입니다. 보간 오차가 없습니다(le=0.3 버킷을 그대로 세기 때문입니다). 어떤 시간 창으로도 다시 계산할 수 있습니다. 그리고 번 레이트 알림에 그대로 꽂힙니다.
기준을 정할 때 참고할 실무 값도 정리해 둡니다.
- 목표 백분위는 사용자 여정의 팬아웃을 고려해 정합니다. 한 화면이 20개를 호출한다면 서비스 하나의 목표는 99%가 아니라 99.9%여야 화면 수준에서 98%가 나옵니다.
- 임계값은 사용자 인지 한계에서 역산합니다. 즉시 반응으로 느끼는 100ms, 흐름이 끊기지 않는 1초, 주의가 이탈하는 10초가 오래된 기준선이고 지금도 유효합니다.
- 측정 창은 28일 또는 30일 롤링으로 두고, 배포 주기보다 길게 잡습니다. 창이 짧으면 배포 한 번의 실수가 곧바로 예산을 다 태웁니다.
- 백분위 대시보드는 계속 봅니다. SLO는 비율로 관리하되, 원인 조사는 p50과 p99, p99.9를 함께 겹쳐 보는 그래프에서 시작합니다.
마치며 — 하나의 숫자가 아니라 분포를 보라
기억할 것은 한 문장입니다. 평균은 분포를 하나의 숫자로 뭉개면서 사용자가 화를 내는 부분을 가장 먼저 버리고, 백분위는 그 부분을 보여주지만 절대 다시 합칠 수 없습니다.
그래서 실무의 순서는 이렇게 됩니다. 히스토그램으로 원본 분포를 보존하고, 버킷 경계에 SLO 임계값을 심어 두고, 백분위는 사람이 보는 대시보드에서만 쓰고, 알림과 SLO는 집계 가능한 비율로 정의합니다. 평균 응답 시간 그래프는 지워도 됩니다. 그 자리에 p50과 p99를 나란히 놓는 것만으로 대부분의 팀은 이미 보이지 않던 사고를 보기 시작합니다.
더 파고들 자료입니다.