응답 시간 백분위 (p50 / p95 / p99)

목차

모니터링 대시보드를 처음 붙이면 가장 먼저 올리게 되는 게 평균 응답 시간입니다. 숫자 하나로 서비스가 빠른지 느린지 알 수 있으니 직관적이기 때문입니다.

그런데 평균만 보고 있으면 이상한 일이 생깁니다. 대시보드는 계속 초록불인데 사용자 문의는 들어옵니다. 평균이 거짓말을 하는 게 아니라, 평균이라는 지표가 애초에 그 질문에 답할 수 없는 지표이기 때문입니다.

이 글은 p50, p95, p99가 각각 무엇을 재는 지표인지, 그 값이 어떻게 만들어지는지, 그리고 Prometheus로 뽑은 p99가 실제 값과 어떻게 어긋나는지까지 정리합니다.

이 지표들을 실제 장애 상황에서 어떻게 읽었는지는 모니터링 장애 대응 실습 시리즈에 따로 적어뒀습니다.

1. 평균 69.7ms 뒤에 8초가 숨어 있다

어떤 API의 1분간 응답 시간이 이렇게 나왔다고 해봅시다. 요청은 총 1,000건입니다.

응답 시간건수상황
40ms950건캐시에서 바로 응답
120ms40건DB까지 다녀옴
2,100ms9건커넥션 풀에서 대기
8,000ms1건타임아웃 직전

이 데이터로 지표를 뽑으면 이렇게 됩니다.

지표
평균69.7ms
최댓값8,000ms
p5040ms
p9540ms
p99120ms

평균은 69.7ms입니다. 응답 시간을 전부 더해서 1,000으로 나눈 값이니, 8초짜리 요청 한 건이 있어도 950건의 40ms에 묻혀 거의 티가 나지 않습니다. 이 숫자만 보면 조치할 이유가 없습니다.

반대로 최댓값은 8,000ms입니다. 이 숫자만 보면 당장 뛰어가야 할 것 같습니다. 하지만 최댓값은 8초짜리 요청이 한 건인지 500건인지 구분해 주지 않습니다. 어제도 8초, 오늘도 8초라면 그 그래프는 아무것도 알려주지 않는 직선입니다.

평균과 최댓값은 정반대 방향으로 실패하고 있지만 원인은 같습니다. 둘 다 숫자 하나로 분포 전체를 대표하려 했다는 점입니다.

응답 시간은 키나 몸무게처럼 가운데가 볼록한 종 모양으로 퍼지지 않습니다. 대부분 빠른 쪽에 몰려 있고, GC가 돌거나 커넥션을 기다리거나 캐시를 놓친 요청들이 오른쪽으로 길게 꼬리를 만듭니다. 봉우리가 여러 개인 이런 분포에서는 평균이나 표준편차 같은 지표가 실제 모양을 거의 설명하지 못합니다. 그래서 분포를 하나로 뭉개는 대신, 분포의 특정 지점을 직접 짚어보는 방식이 필요합니다.

2. 백분위는 존재가 아니라 빈도를 잰다

백분위(percentile)는 요청들을 빠른 순서대로 줄 세운 다음, 특정 위치에 서 있는 요청의 값을 읽는 방식입니다. p99는 1,000건 중 990번째, p95는 950번째, p50은 500번째에 서 있는 요청의 응답 시간입니다.

앞의 데이터를 줄 세우면 이렇게 됩니다.

빠른 순서로 줄 세운 요청 1,000건
1번 ~ 950번40ms
↑ 500번째(p50) · 950번째(p95)가 여기
951번 ~ 990번120ms
↑ 990번째(p99)가 여기, 이 칸의 마지막 자리
991번 ~ 999번2,100ms
1000번8,000ms

여기서 중요한 건 백분위가 답하는 질문의 성격입니다.

최댓값은 “느린 요청이 있느냐”에 답합니다. 있으면 그 값을 보여주고, 없으면 안 보여줍니다. 백분위는 “느린 요청이 얼마나 흔하냐”에 답합니다. p99가 120ms라는 건 8초짜리 요청이 없다는 뜻이 아니라, 그런 요청이 1%도 안 될 만큼 드물다는 뜻입니다.

이 차이가 실무에서 갈리는 지점입니다. p99가 2초로 올라왔다면 “2초 걸린 요청이 존재한다”가 아니라 “요청 100건 중 1건 가까이가 2초를 넘기고 있다”는 뜻이 됩니다. 앞의 문장으로는 대응 여부를 결정할 수 없지만, 뒤의 문장으로는 결정할 수 있습니다.

다만 이건 요청을 센 값이지 사람을 센 값이 아닙니다. 그 1%가 소수에게 몰린 것인지 모두가 가끔 겪는 것인지는 백분위만 봐서는 알 수 없는데, 둘은 대응이 완전히 다릅니다.

3. 요청 한 건 차이로 p99가 17배 뛴다

p99를 구하는 절차 자체는 앞에서 본 게 전부입니다. 줄 세우고 990번째를 집으면 됩니다. 그런데 이 단순함 때문에 생기는 문제가 있습니다.

앞의 예시에서 990번째에 서 있는 건 120ms짜리 요청이고, 하필 그 칸의 마지막 자리입니다. 바로 뒤 991번부터는 2,100ms가 시작됩니다.

여기서 120ms짜리 요청 한 건이 2,100ms로 느려졌다고 해봅시다. 전체 요청 수는 1,000건 그대로입니다. 2,100ms 요청이 10건이 되면서 990번 자리까지 내려옵니다. p99가 120ms에서 2,100ms가 됩니다. 17.5배입니다.

실제로 나빠진 건 요청 한 건인데 지표는 17배 튀어 오릅니다. 반대로 그 한 건이 사라지면 지표는 다시 120ms로 내려앉습니다.

트래픽이 적은 엔드포인트에서 p99 그래프가 톱니처럼 널뛰는 건 대체로 이 때문입니다. 서비스가 그만큼 불안정한 게 아니라, 990번 자리에 누가 서느냐가 매 순간 바뀌는 것뿐입니다. 분당 요청이 50건인 엔드포인트라면 p99는 사실상 “그 분에 가장 느렸던 한 건”과 같은 값이 됩니다.

990번째를 집는 규칙이 하나가 아니다

여기서 한 가지 더 짚고 갈 게 있습니다. 990번째 요청을 그냥 집는 방식이 있고, “990.01번째”처럼 소수점 자리를 인정해서 990번과 991번 사이를 비례로 섞는 방식이 있습니다.

앞의 방식을 nearest-rank, 뒤의 방식을 선형 보간(linear interpolation, 앞뒤 값 사이를 비례로 채워 계산하는 것)이라고 부릅니다. NumPy나 엑셀의 백분위 함수는 기본적으로 뒤쪽 방식을 씁니다.

같은 데이터에 뒤쪽 방식을 적용하면 990번(120ms)에서 991번(2,100ms) 쪽으로 1%만큼 이동한 139.8ms가 나옵니다.

방식이 데이터의 p99
nearest-rank120ms
선형 보간139.8ms

같은 데이터, 같은 이름의 지표인데 도구에 따라 값이 갈립니다. 어느 한쪽이 틀린 게 아니라 줄 세운 요청을 집는 규칙이 다를 뿐입니다. 다만 서로 다른 도구에서 뽑은 p99를 나란히 놓고 비교할 때는 이 차이를 알고 있어야 합니다.

4. 애초에 잘못 잰 값이라면

여기까지는 데이터가 제대로 모였다는 전제 위에서의 이야기입니다. 그런데 계산이 맞아도 원본 데이터가 이미 왜곡되어 있으면 뒤의 모든 숫자가 무의미해집니다.

멈춘 동안의 요청은 통계에 없다

부하 테스트는 보통 “초당 1,000건씩 보내겠다”처럼 목표 부하를 정해두고 시작합니다. 그런데 도구 중에는 요청을 하나 보내고 응답이 와야 다음 요청을 보내는 방식이 많습니다. 평소에는 목표치가 그럭저럭 맞춰지지만, 서버가 1초 동안 멈춰버리면 이상한 일이 생깁니다.

그 1초 동안 나갔어야 할 1,000건이 아예 발사되지 않습니다. 멈춤이 풀린 뒤에 다시 요청을 보내면 서버는 정상이니 빠르게 응답합니다. 결국 측정 기록에는 “1초 걸린 요청 한 건”만 남습니다. 실제 상황이라면 그 1초 동안 줄을 섰을 1,000건은 통계에 아예 존재하지 않게 됩니다.

실제 사용자는 그 1초를 고스란히 기다렸는데 측정 결과는 멀쩡하게 나옵니다. 이 현상을 coordinated omission이라고 부릅니다. 측정 도구가 서버의 멈춤에 맞춰 요청을 덜 보내면서, 가장 중요한 구간을 스스로 빼먹는다는 뜻입니다.

ScyllaDB가 공개한 사례에서는 보정 전 p99가 249마이크로초로 보이던 것이 보정 후 665밀리초로 나타났습니다. 천 배가 넘는 차이입니다. 부하 테스트 결과가 지나치게 좋게 나온다면 서버가 빠른 게 아니라 측정 도구가 느린 구간을 못 보고 있는 것일 수 있습니다.

이 문제를 염두에 두고 만들어진 도구도 있습니다. wrk2는 요청이 실제로 나간 시각이 아니라 나갔어야 할 시각을 기준으로 지연을 재기 때문에, 멈춤 동안 밀린 시간까지 통계에 들어갑니다.

어디서 재느냐에 따라 값이 달라진다

또 하나는 측정 지점입니다. 서버 애플리케이션 안에서 재는 응답 시간은 요청이 컨트롤러에 도달한 뒤부터 응답을 반환할 때까지입니다. 여기에는 요청이 큐에서 대기한 시간, 네트워크를 오간 시간, 클라이언트가 응답을 처리한 시간이 빠져 있습니다.

Dan Luu가 정리한 측정 사례에서는 같은 요청의 p99가 서버 쪽 계측으로는 약 16ms, 클라이언트 쪽 계측으로는 약 240ms로 나왔습니다. 15배 차이입니다. 서버 지표가 정상인데 사용자 불만이 들어온다면, 지표가 틀린 게 아니라 사용자가 겪는 시간의 절반 이상을 애초에 재고 있지 않은 것일 수 있습니다.

5. 장애는 꼬리에서 먼저 번진다

지표 하나만 보는 대신 여러 개를 나란히 놓으면 문제가 어디까지 퍼졌는지가 보입니다. 앞의 API에서 커넥션 풀 문제가 심해지는 상황을 가정해 보겠습니다.

지표정상 시점악화 시점
p5040ms45ms
p9540ms980ms
p99120ms5,400ms
평균69.7ms310ms

p50은 거의 움직이지 않았습니다. 절반의 사용자는 여전히 쾌적합니다. 평균은 69.7ms에서 310ms로 올랐지만, 임계값을 500ms쯤에 걸어뒀다면 아직 알람이 울리지 않습니다.

반면 p95는 40ms에서 980ms로 뛰었습니다. 20배가 넘습니다. p95 > 300ms 같은 조건을 걸어뒀다면 이 시점에 바로 알 수 있습니다.

이렇게 소수의 요청에만 생기던 문제가 점점 다수로 번지는 경우, 지표는 대체로 p99가 먼저 움직이고 p95가 뒤따르고 p50이 마지막에 움직입니다. p99는 “일부가 아프다”, p95는 “번지는 중이다”, p50은 “이미 전면적이다” 정도로 읽으면 됩니다.

다만 이건 통계적 법칙이 아니라 경험적인 패턴입니다. 특정 노드에서만 GC가 길게 도는 것처럼 일부 요청에만 영향을 주는 문제에서 잘 맞습니다. DB가 통째로 느려지거나 배포 설정이 잘못된 것처럼 모든 요청에 균일하게 영향을 주는 문제에서는 p50이 처음부터 함께 움직이니, “p50이 안 움직였으니 괜찮다”고 읽으면 안 됩니다.

목표치는 몇 번째 지표에 걸어야 하나

Google SRE Book은 지연 시간 목표를 하나로 잡지 말고 여러 지점에 동시에 걸라고 권합니다. “90%는 1ms 이내, 99%는 10ms 이내, 99.9%는 100ms 이내” 같은 식입니다.

목표치를 p50에만 걸면 절반이 무너질 때까지 아무 일도 일어나지 않습니다. 그렇다고 p99에만 거는 것도 안전하지 않습니다. 3절에서 본 것처럼 느린 요청이 1% 경계에 몰려 있으면 한 건 차이로 알람이 울렸다 꺼졌다 합니다.

다만 이건 표본이 적을 때의 이야기입니다. 분당 수만 건이 들어오는 엔드포인트라면 990번 자리가 두터워서 한두 건으로는 잘 흔들리지 않으니 p99에 직접 걸어도 됩니다. 요청이 적거나 응답 시간이 몇 개 값에 뭉쳐 있는 쪽이라면 p95를 알람에 두고 p99는 원인 조사용으로 함께 보는 편이 낫습니다. 정하기 전에 실제 그래프를 며칠 띄워놓고 p99가 평소에도 톱니처럼 널뛰는지 확인하는 게 가장 확실합니다.

6. 꼬리는 곱해서 커진다

내부 서비스의 p99는 그 서비스만의 문제로 끝나지 않습니다.

사용자 요청 하나가 내부적으로 10개 서비스를 호출하는 구조를 생각해 봅시다. 각 서비스가 100번에 1번씩 느려진다면, 사용자 요청 하나는 그 관문을 열 번 연속으로 통과해야 합니다. 열 번 모두 무사히 지날 확률은 90% 정도이고, 뒤집으면 열 번 중 한 번 가까이는 어딘가에서 걸린다는 뜻이 됩니다.

각 팀이 p99라고 부르며 관리하던 경계선이, 사용자 입장에서는 p90 언저리의 흔한 일이 되는 셈입니다. 다만 이건 걸리는 빈도가 바뀐다는 이야기이지, 각 서비스의 p99 값이 그대로 사용자 응답 시간의 p90 값이 된다는 뜻은 아닙니다.

호출 수가 늘수록 걸릴 확률은 더 올라갑니다. 그래서 사용자와 직접 만나지 않는 내부 서비스일수록 p99를 관리해야 합니다.

이 계산은 각 서비스가 서로 무관하게 느려진다고 가정한 값입니다. 실제로는 같은 DB나 네트워크를 공유하니 한꺼번에 느려지는 경우가 많은데, 그러면 걸리는 빈도는 오히려 줄고 대신 한 번 걸릴 때 지연이 겹쳐 크게 터집니다.

인스턴스별 p99를 평균 내면 안 된다

비슷한 착시가 서버를 여러 대 운영할 때도 생깁니다.

100대 중 1대에서만 p99가 10배로 튀었다고 해봅시다. 각 서버의 p99를 모아 평균을 내면 전체 값은 1.1배 정도밖에 움직이지 않습니다. 대시보드는 거의 평평한데, 그 서버로 간 요청의 꼬리 구간은 10배 느린 응답을 받고 있습니다.

백분위는 합계나 요청 수처럼 더하거나 평균 낼 수 있는 종류의 값이 아닙니다. 여러 대의 p99를 알고 싶다면 각자의 p99를 모으는 게 아니라, 원본 분포를 먼저 합친 다음 거기서 다시 990번째를 찾아야 합니다.

7. Prometheus는 어떻게 p99를 만드나

실제 운영에서는 모든 요청의 응답 시간을 저장해두고 정렬할 수 없습니다. 그래서 다른 방식을 씁니다.

Prometheus는 원본 값을 버리고 구간별 개수만 세어 둡니다. “50ms 이하 몇 건, 100ms 이하 몇 건, 250ms 이하 몇 건” 하는 식입니다. 이 구간을 버킷(bucket)이라고 부릅니다. 앞의 데이터를 버킷에 담으면 이렇게 됩니다.

버킷 (이 값 이하)누적 건수
50ms950
100ms950
250ms990
1s990
2.5s999
제한 없음1,000

이 상태에서는 990번째 요청을 직접 집을 수가 없습니다. 원본 값이 없으니까요. 대신 990번째가 들어 있는 버킷을 찾고, 그 버킷 안에서 위치를 추정합니다.

990번째는 100ms에서 250ms 사이 구간에 들어 있습니다. 그런데 하필 그 구간의 맨 마지막 자리입니다. 이 구간에 40건이 있고 990번째가 그중 40번째이기 때문입니다. Prometheus는 구간의 끝인 250ms를 답으로 내놓습니다.

실제 값은 120ms인데 250ms가 나왔습니다. 두 배가 넘게, 정확히는 108% 부풀려진 값입니다.

원인은 Prometheus가 구간 안에 요청들이 고르게 흩어져 있다고 가정하기 때문입니다. 100ms부터 250ms까지 40건이 균등하게 퍼져 있다고 보고 계산하는데, 실제로는 40건 전부가 120ms 근처에 몰려 있습니다. 버킷 하단에 값이 뭉쳐 있을수록 계산 결과는 실제보다 느리게 나옵니다. 추측이 아니라 Prometheus 공식 문서에 “버킷 안의 표본이 균등하게 분포한다고 가정하고 선형 보간한다”고 명시된 동작입니다.

쿼리는 보통 이렇게 씁니다.

histogram_quantile(0.99, sum(rate(http_request_duration_seconds_bucket[5m])) by (le))

안쪽부터 읽으면 됩니다. rate가 최근 5분 동안 각 버킷이 얼마나 늘었는지를 구하고, sum ... by (le)가 같은 경계를 가진 버킷들을 인스턴스 전체에서 먼저 합칩니다. 그렇게 하나로 합쳐진 분포에 histogram_quantile이 0.99를 적용합니다. 첫 번째 인자만 바꾸면 p95든 p50이든 같은 방식으로 나옵니다.

여기서 나오는 값은 실측값이 아니라 버킷 경계로 추정한 값이라는 걸 기억해야 합니다. 오차를 줄이려면 버킷을 촘촘하게 잡아야 하는데, 무작정 늘리면 저장 비용이 커집니다. 현실적인 절충은 목표치 근처에 버킷을 몰아 배치하는 것입니다. p95 목표가 300ms라면 200ms, 250ms, 300ms, 400ms처럼 그 언저리를 잘게 나누고, 5초나 10초처럼 이미 장애인 구간은 성기게 둬도 판단에 지장이 없습니다.

8. 자주 묻는 질문

서버가 여러 대일 때 전체 p99는 어떻게 구하나?

각 서버의 p99를 모아서 평균 내면 안 됩니다.

서버 A의 p99가 100ms, 서버 B의 p99가 900ms라고 해서 전체 p99가 500ms인 건 아닙니다. A로 훨씬 많은 트래픽이 갔다면 전체 p99는 100ms 쪽에 가깝고, 반대라면 900ms 쪽에 가깝습니다. 평균값 500ms는 어느 쪽도 설명하지 못합니다.

Prometheus에서는 앞서 본 쿼리처럼 sum(...) by (le)버킷 카운트를 먼저 합친 다음 histogram_quantile을 적용해야 합니다. 순서를 바꿔서 서버별로 histogram_quantile을 구한 뒤 평균 내면 의미 없는 값이 나옵니다.

트래픽이 적은 엔드포인트도 p99를 봐야 하나?

분당 요청이 100건도 안 된다면 p99는 사실상 그 분에 가장 느렸던 한두 건입니다. 그래프가 계속 튀는데 원인은 매번 다른 요청이라 추적도 안 됩니다.

이런 엔드포인트는 p95까지만 보거나, 집계 구간을 5분이나 15분으로 늘려 표본을 모으는 쪽이 낫습니다. 아니면 백분위 대신 “3초를 넘긴 요청 수”처럼 절대 기준으로 세는 방식이 더 잘 맞습니다. 다만 구간을 늘리면 짧은 스파이크가 묻힌다는 건 감수해야 합니다. 1분 단위로 보면 튀어 보이던 20건이 한 시간 치에 섞이면 상위 1% 근처에도 못 갑니다.

정리

지표읽는 법쓰는 곳
평균분포를 하나로 뭉갠 값추세 확인용, 알람에는 부적합
최댓값가장 느린 한 건최악의 경우 파악, 빈도는 알 수 없음
p50절반의 사용자가 겪는 시간평상시 체감 속도
p95문제가 번지기 시작하는 지점알람 기준선. 트래픽이 적을수록 이쪽이 안전
p99소수가 겪는 최악의 경험원인 조사. 표본이 충분하면 알람에도

그리고 숫자를 믿기 전에 확인할 것들입니다.

  • 측정 도구가 서버가 멈춘 구간의 요청을 빼먹고 있지는 않은가
  • 서버 안쪽만 재고 큐 대기와 네트워크는 빠져 있지 않은가
  • 여러 인스턴스의 백분위를 평균 내고 있지는 않은가
  • 버킷 경계가 목표치 근처에 잡혀 있는가
  • 표본이 백분위를 논할 만큼 충분한가