P50, P95, P99를 이해하는 법


p50, p95, p99를 이해하는 법

p50, p95, p99는 분포에서 특정 백분위 지점의 값이다. 지연(latency), 응답 시간, 큐(queue) 대기 시간, DB 쿼리(query) 시간, 외부 API 호출 시간처럼 "시간이 얼마나 걸렸는가"를 볼 때 자주 쓴다.

목표는 단순히 용어를 외우는 게 아니다. 숫자의 뜻을 정확히 잡고, 더 나아가 "이 지표로 진짜 판단해도 되는가?"까지 물어보는 것이다.


0. 한 장 요약

요청 시간을 작은 순서대로 정렬했다고 하자.

[10ms, 11ms, 11ms, 12ms, ... 80ms, 120ms, 300ms, 900ms]

각 지표의 뜻은 다음과 같다.

지표실무 감각
p50전체 요청 중 50%가 이 값 이하보통 사용자는 얼마나 빠른가
p95전체 요청 중 95%가 이 값 이하대부분의 사용자는 괜찮은가
p99전체 요청 중 99%가 이 값 이하일부 사용자가 심하게 느린가
max가장 느린 단일 요청이상하게 튄 요청이 있는가

예를 들어 p95 = 300ms라면:

요청의 95%는 300ms 이하로 끝났고, 나머지 5%는 300ms보다 오래 걸렸다.

p99 = 900ms라면:

요청의 99%는 900ms 이하로 끝났고, 가장 느린 1%는 900ms보다 더 오래 걸렸다.

한 줄로 줄이면 이렇다.

p50 = 보통 얼마나 빠른가
p95 = 대부분의 사용자가 괜찮은가
p99 = 일부 사용자가 고통받고 있는가

주의

이 글만 보면 바보가 될 수 있습니다.


1. 평균이 아니라 백분위를 보는 이유

평균 응답 시간은 실무에서 자주 사람을 속인다.

요청 10개가 있다고 하자.

10, 10, 10, 10, 10, 10, 10, 10, 10, 1000 ms

평균은 다음과 같다.

(10 * 9 + 1000) / 10 = 109ms

평균만 보면 "응답시간 109ms면 괜찮네?"라고 생각할 수 있다. 하지만 실제로는 10명 중 1명이 1초를 기다렸다.

반대로 이런 경우를 보자.

100, 100, 100, 100, 100, 100, 100, 100, 100, 110 ms

평균은 101ms다. 앞의 109ms와 큰 차이가 없어 보인다. 하지만 사용자 경험은 완전히 다르다.

첫 번째 시스템은 대부분 빠르지만 가끔 크게 튄다. 두 번째 시스템은 모든 요청이 거의 비슷하게 끝난다.

그래서 응답 시간은 평균보다 분포가 중요하다.


2. p50: 일반적인 사용자 경험

p50은 중앙값(median)이다.

p50 = median

전체 요청의 절반은 p50보다 빠르고, 절반은 p50보다 느리다.

예를 들어:

GET /products 응답 시간 p50 = 40ms

이면 "보통 요청은 40ms 정도로 빠르다"고 볼 수 있다.

p50은 시스템의 기본 체력을 보여준다.

상황해석
p50 낮고 p99 높음일반 경로는 빠르지만 일부 조건에서 꼬리가 튄다
p50 높고 p99도 높음전반적으로 느리다
p50 높고 p99와 차이가 작음모든 요청이 균일하게 느리다
p50 낮고 p95/p99도 낮음안정적으로 빠르다

p50은 일반적인 코드 경로가 무거운지, 캐시(cache)가 잘 먹는지, DB 인덱스가 잘 타는지 볼 때 유용하다. 하지만 p50만 보면 장애를 놓친다.


3. p95: 대부분의 사용자 경험

p95는 실무에서 가장 많이 보는 지표 중 하나다.

p95 = 상위 5% 느린 요청을 제외했을 때의 경계값

예를 들어:

GET /products p95 = 250ms

이면 요청 100개 중 95개는 250ms 안에 끝났다는 뜻이다.

p95는 "대부분의 사용자가 괜찮게 쓰고 있는가?"를 판단하기 좋다. 그래서 SLO도 보통 이런 식으로 잡는다.

전체 요청의 95%는 300ms 이내에 응답해야 한다.

즉 p95가 300ms를 넘기 시작하면, 일부 이상값(outlier)이 튄다를 넘어 제품 경험의 대부분을 지키지 못하고 있다는 신호가 된다.

p95는 극단적으로 튄 요청에 p99보다 덜 흔들리면서도, 평균보다 훨씬 사용자 경험을 잘 반영한다. 그래서 요청 수가 아주 많지 않은 API에서는 p99보다 p95가 더 안정적인 신호일 때도 많다.


4. p99: 꼬리 지연

p99는 상위 1% 느린 요청의 경계다.

p99 = 요청 100개 중 99번째로 빠른 요청의 시간

예를 들어:

POST /checkout p99 = 2.5s

이면 요청 100개 중 99개는 2.5초 이내에 응답했고, 가장 느린 1개는 그보다 더 오래 걸렸다는 뜻이다.

p99는 꼬리 지연(tail latency)을 보는 지표다. 꼬리 지연은 분포의 오른쪽 꼬리다.

대부분 요청: 30~80ms
일부 요청: 300ms
극소수 요청: 3s, 10s, timeout

이런 시스템은 p50이 멀쩡하고 평균도 그럭저럭일 수 있다. 하지만 p99는 크게 망가진다.

실무에서는 이 꼬리가 중요하다. 사용자는 "평균 시스템"을 경험하지 않고, 자기가 걸린 그 요청 하나를 경험하기 때문이다.

특히 로그인, 검색, 피드 로딩, 주문 생성, 결제 승인 같은 핵심 플로우에서는 p99가 중요하다.


5. 백분위는 어떻게 계산하는가

가장 단순한 방식은 이렇다.

  1. 측정값을 작은 순서대로 정렬한다.
  2. 원하는 백분위의 위치를 구한다.
  3. 그 위치의 값을 읽는다.

예를 들어 요청 시간이 10개 있다고 하자.

[10, 11, 12, 13, 14, 15, 16, 17, 18, 1000] ms

p50, 즉 중앙값

p50은 중앙값이다. 값이 홀수 개면 가운데 값을 고르면 된다.

[10, 11, 12, 13, 14]
              ^
             p50 = 12

값이 짝수 개면 가운데 두 값의 평균을 쓰는 경우가 많다.

[10, 11, 12, 13, 14, 15]
          ^   ^
       가운데 두 값 = 12, 13

p50 = (12 + 13) / 2 = 12.5

다만 관측 도구에서는 "실제 관측된 값 중 하나"를 고르는 방식도 흔하다. 그래서 도구마다 p50이 아주 조금 다를 수 있다.

p95, p99

p95와 p99도 원리는 같다. 정렬된 값에서 95%, 99% 지점의 값을 찾는다.

가장 이해하기 쉬운 방식은 nearest-rank다. 샘플 수가 $N$개이고 원하는 백분위가 $P$라면 위치는 대략:

$$ \lceil P \times N \rceil $$

여기서 $P$는 p95라면 0.95, p99라면 0.99로 넣는다.

예를 들어 샘플이 100개라면:

p50 위치 = ceil(0.50 * 100) = 50번째
p95 위치 = ceil(0.95 * 100) = 95번째
p99 위치 = ceil(0.99 * 100) = 99번째

샘플이 1,000개라면:

p50 위치 = 500번째
p95 위치 = 950번째
p99 위치 = 990번째

여기서 중요한 점이 있다. 도구마다 백분위 계산 방식은 조금씩 다르다.

  • nearest-rank: 가장 가까운 순위의 실제 값을 고른다.
  • interpolation: 두 값 사이를 보간한다.
  • histogram: 버킷(bucket) 분포로 근사한다.
  • summary/distribution: 수집 클라이언트나 백엔드가 자체 알고리즘으로 근사한다.

그래서 같은 데이터라도 Prometheus histogram, Datadog distribution, OpenTelemetry 지표가 완전히 같은 p99를 내놓는다고 가정하면 안 된다. 하지만 해석의 핵심은 같다.

p50/p95/p99는 평균이 아니라, 정렬된 분포의 특정 지점을 보는 숫자다.


6. 숫자로 보는 예시

요청 100개의 응답 시간이 다음과 같다고 하자.

90개: 50ms
5개: 200ms
4개: 800ms
1개: 5000ms

작은 순서대로 정렬하면:

1~90번째   = 50ms
91~95번째  = 200ms
96~99번째  = 800ms
100번째    = 5000ms

nearest-rank 방식으로 보면 대략 다음과 같다.

지표의미
p5050ms절반 이상은 매우 빠름
p95200ms95%까지는 괜찮음
p99800ms상위 1% 직전까지는 800ms
max5000ms최악 요청은 5초

평균은:

(90*50 + 5*200 + 4*800 + 1*5000) / 100 = 137ms

평균 137ms만 보면 좋아 보인다. 하지만 실제로는 어떤 사용자는 5초를 기다렸다. 서비스에 따라 이건 장애일 수 있다.


7. p50, p95, p99를 같이 봐야 하는 이유

하나만 보면 오해한다.

케이스 A

p50 = 30ms
p95 = 50ms
p99 = 80ms

좋은 상태다. 빠르고 안정적이다.

케이스 B

p50 = 30ms
p95 = 80ms
p99 = 2000ms

일반 요청은 빠르지만 꼬리가 심하다.

가능한 원인:

  • 일부 요청에서 DB 락(lock)이 걸림
  • 일부 요청에서 외부 API 호출이 오래 걸림
  • GC pause 발생
  • 커넥션 풀(connection pool) 고갈
  • 특정 샤드(shard), 테넌트(tenant), 상품군만 느림
  • 차가운 캐시
  • 큰 요청/응답 본문
  • N+1 쿼리
  • 재시도(retry) 폭주
  • 큐 대기 증가

이 경우 평균 최적화가 아니라 꼬리 지연의 원인을 분석해야 한다.

케이스 C

p50 = 300ms
p95 = 400ms
p99 = 500ms

꼬리는 크지 않지만 전반적으로 느리다.

가능한 원인:

  • 기본 코드 경로가 무거움
  • 모든 요청이 DB를 많이 침
  • 캐시 없음
  • 네트워크 홉(network hop)이 많음
  • 직렬화/역직렬화 비용이 큼
  • 모든 요청에서 불필요한 join, aggregation, validation 발생

이 경우에는 꼬리보다 기본 경로를 줄여야 한다.

케이스 D

p50 = 40ms
p95 = 1000ms
p99 = 3000ms

실무에서 매우 흔한 문제다.

절반은 빠르게 끝나지만, 느린 쪽 꼬리가 너무 두껍다.

이 케이스는 단순히 "가끔 느리다"가 아니다. p95가 1초라는 말은 관측 구간 안에서 요청의 5%가 1초보다 오래 걸렸다는 뜻이다. 15분 구간에 요청이 100,000개라면 5,000개가 1초를 넘은 것이다. 사용자 입장에서는 이미 눈에 띄는 품질 문제일 수 있다.

p50이 40ms라고 해서 무조건 "평소엔 빠르다"고 말할 수도 없다. 상태 확인 요청이나 캐시 히트가 섞여 p50을 낮춰 보이게 만들 수 있고, 핵심 API 기준으로 40ms가 충분히 빠른지도 제품/SLO에 따라 다르다.

이때는 "서버가 느리다"로 뭉뚱그리기보다, p50을 만드는 요청과 p95/p99를 만드는 요청이 어떤 조건에서 갈라지는지 찾아야 한다.


8. p99가 드러내는 약한 고리

p50은 정상 경로(happy path)를 보여준다. p99는 예외적인 경로를 드러낸다.

p99가 튄다는 건 보통 이런 뜻이다.

시스템은 정상적으로 빠른데, 일부 요청이 특정 조건에서 병목을 만난다.

대표적인 원인을 보자.

8.1 DB 쿼리 편차

같은 API라도 파라미터에 따라 쿼리 비용이 달라진다.

GET /orders?userId=123

대부분 유저는 주문이 10개인데, 특정 유저는 주문이 100,000개일 수 있다. 그러면 p50은 20ms인데 p99는 2초가 될 수 있다.

이 경우 해결은 단순히 서버를 늘리는 게 아니다.

  • 페이지네이션(pagination)
  • 인덱스
  • covering index
  • 쿼리 플랜(query plan) 확인
  • 큰 테넌트 분리
  • 카디널리티(cardinality) 높은 조건 추가
  • 집계(aggregation) 비동기화
  • 읽기 모델(read model) 분리

이런 방향으로 봐야 한다.

8.2 락 경합

락 대기는 p99가 튀는 대표 원인이다. 특히 주문, 재고, 포인트, 쿠폰, 정산 쪽에서 자주 나온다.

UPDATE inventory
SET stock = stock - 1
WHERE product_id = ?;

인기 상품 하나에 요청이 몰리면 로우 락(row lock) 대기가 발생한다. p50은 멀쩡한데 p99는 급격히 증가한다.

이 경우 CPU를 늘려도 해결이 안 된다. 대기 중인 건 CPU가 아니라 락이기 때문이다.

해결 방향:

  • 핫 로우(hot row) 분산
  • 낙관적 락(optimistic locking)
  • 재고 차감 큐
  • 선점/예약 모델
  • 멱등성 키(idempotency key)
  • 재시도 정책 제한
  • 결제와 재고 트랜잭션 경계 재설계

8.3 커넥션 풀 고갈

p95/p99가 갑자기 튀는데 CPU는 남아 있다면 풀 고갈을 의심해야 한다.

DB 커넥션 풀 max = 20
동시 요청 = 200
각 요청이 DB 커넥션을 오래 잡음

그러면 일부 요청은 DB 커넥션을 얻기 위해 기다린다. 실제 쿼리 시간은 20ms인데 API 응답 시간은 2초가 될 수 있다.

중요한 건 작업 실행 시간과 리소스를 얻기 위해 기다린 시간을 분리해서 보는 것이다.

요청 응답 시간
= 큐 대기
+ 커넥션 대기
+ db query
+ app logic
+ network

이걸 쪼개지 않으면 "DB가 느린가?"라고 착각한다.

8.4 런타임 일시정지

JVM, Go, Node.js 모두 일시정지(pause) 이슈가 있다.

JVM이면:

  • GC pause
  • allocation spike
  • large object allocation
  • old gen pressure

Node.js면:

  • event loop blocking
  • sync JSON parse/stringify
  • CPU-bound 작업
  • 큰 요청/응답 본문 처리

Go면:

  • GC pressure
  • 고루틴(goroutine) 폭증
  • 락 경합
  • network timeout accumulation

p99가 튀는 시스템은 애플리케이션 코드뿐 아니라 런타임 일시정지도 봐야 한다.

8.5 외부 API 의존성

외부 API는 p99 악화의 흔한 원인이다.

내 API p50 = 50ms
외부 PG API p99 = 2s

외부 호출을 요청 처리 경로에 동기적으로 넣으면 꼬리 지연이 전염된다.

해결 방향:

  • 타임아웃(timeout) 명확화
  • 회로 차단기(circuit breaker)
  • 폴백(fallback)
  • 비동기(async) 처리
  • 웹훅(webhook) 기반 최종 확정
  • 사용자 응답과 후속 처리 분리
  • 재시도 예산 제한
  • hedged request는 신중히 사용

9. p99는 합성된다

분산 시스템에서 꼬리 지연은 더 무섭다.[1]

한 화면을 그리기 위해 내부 API 10개를 호출한다고 하자. 각 API가 99% 확률로 빠르더라도, 전체 화면 입장에서는 그중 하나라도 느리면 사용자는 느리게 느낀다.

간단히 계산하면:

$$ 0.99^{10} \approx 0.904 $$

즉 10개 API가 모두 빠를 확률은 약 90.4%다. 반대로 말하면 약 9.6%의 요청에서는 적어도 하나의 API가 꼬리에 걸릴 수 있다.

20개면:

$$ 0.99^{20} \approx 0.818 $$

거의 5명 중 1명이 느린 응답을 경험할 수 있다.

이게 꼬리 지연 증폭(tail latency amplification)이다.

그래서 MSA, BFF, 검색, 추천, 피드처럼 팬아웃(fan-out)이 큰 구조에서는 p99가 더 중요해진다.

실무에서의 의미:

  • 팬아웃 개수를 줄인다.
  • 병렬 호출을 해도 가장 느린 의존성이 전체 응답 시간을 결정한다.
  • 캐시와 읽기 모델이 중요해진다.
  • 핵심 경로를 줄인다.
  • 화면 단위 p95/p99를 따로 본다.
  • 내부 API별 p99만 보면 UX를 놓친다.

10. p99만 최적화하면 비용이 폭증한다

p99는 중요하지만 무조건 낮추는 게 정답은 아니다.

예를 들어:

p50 = 50ms
p95 = 150ms
p99 = 800ms

여기서 p99를 200ms로 낮추려면 비용이 많이 들 수 있다.

  • 더 큰 인스턴스(instance)
  • 더 많은 복제본(replica)
  • 더 공격적인 캐시
  • 사전 계산(precompute)
  • 비동기 아키텍처
  • DB 스키마 변경
  • 큐 도입
  • 샤드 분리
  • 외부 의존성 제거

그래서 p99 최적화는 항상 비즈니스 중요도와 연결해야 한다.

기능p99 중요도
결제 승인매우 높음
로그인높음
상품 상세높음
검색높음
추천 영역 로딩중간, 폴백 가능
관리자 페이지 CSV 다운로드낮음
내부 배치 대시보드낮음

모든 API에 같은 p99 SLO를 걸면 운영비가 터진다. 반대로 결제/로그인 같은 핵심 기능을 "평균 300ms라 괜찮음"으로 보면 사고가 난다.


11. 전체 응답 시간만 보면 안 된다

전체 API 응답 시간 p95/p99는 거의 항상 섞여 있다.

예를 들어 전체 트래픽이 이렇다고 하자.

GET /health        70%
GET /products      20%
POST /checkout      1%
POST /login         2%
기타                7%

전체 p95가 좋아도 /checkout은 느릴 수 있다. 상태 확인 요청이나 가벼운 API가 전체 분포를 희석하기 때문이다.

실무에서는 최소한 다음 차원으로 잘라 본다.

  • API 경로 / 라우트 템플릿(route template)
  • method
  • status code
  • 리전(region)
  • 테넌트
  • 앱 버전
  • DB 복제본
  • 캐시 hit/miss
  • 성공/실패
  • 의존성
  • 요청/응답 본문 크기 구간
  • 콜드 스타트(cold start) 여부
  • 재시도 여부

특히 응답 시간은 성공 요청과 실패 요청을 섞으면 안 된다.

정상 요청: 500ms
인증 실패: 5ms

이걸 섞으면 평균과 p50이 좋아 보인다. 하지만 실제 정상 사용자는 느리다.


12. 히스토그램 버킷이 중요하다

p50/p95/p99는 보통 원본 샘플 전체를 저장해서 계산하지 않는다. 대부분의 관측 시스템은 히스토그램(histogram)이나 summary 형태로 근사 계산한다.

예를 들어 응답 시간 버킷을 이렇게 둔다.

<= 10ms
<= 25ms
<= 50ms
<= 100ms
<= 250ms
<= 500ms
<= 1s
<= 2.5s
<= 5s
<= 10s

요청이 들어올 때마다 해당 버킷 카운트를 증가시킨다. 나중에 버킷 분포를 보고 p95/p99를 추정한다.

문제는 버킷이 너무 거칠면 p99가 부정확해진다는 점이다.

<= 100ms
<= 1s
<= 10s

버킷이 이렇게만 있으면 p95가 300ms인지 900ms인지 구분하기 어렵다. 둘 다 <= 1s 버킷에 들어가기 때문이다.

그래서 응답 시간 SLO 근처에는 버킷을 촘촘하게 둬야 한다.

목표가 300ms라면:

100ms
150ms
200ms
250ms
300ms
400ms
500ms
750ms
1s

이런 식이 더 좋다.

핵심:

버킷은 내가 판단하고 싶은 임계값 주변에 촘촘해야 한다.


13. 짧은 관측 구간의 p99는 흔들린다

p99는 샘플 수가 적으면 신뢰도가 낮다.

1분에 요청이 50개뿐인 API의 p99를 본다고 하자.

50개 요청에서 p99 = 거의 max

요청 수가 적은 API에서 1분 단위 p99는 요동친다.

대략적으로:

요청 수p99 해석
10개거의 의미 없음
100개가장 느린 1개 수준
1,000개어느 정도 의미 있음
10,000개 이상꽤 안정적

요청 수가 적은 API는 다음 중 하나가 필요하다.

  • p95를 본다.
  • 관측 구간을 늘린다.
  • 비슷한 API끼리 묶는다.
  • 합성 점검(synthetic check)을 따로 둔다.
  • 응답 시간 백분위 대신 타임아웃/에러 기준 SLO를 둔다.

14. p99와 max는 다르다

p99 = 상위 1% 경계
max = 진짜 최악의 단일 요청

max는 너무 민감하다. 네트워크 한 번 튀거나, 클라이언트가 연결을 오래 붙잡거나, 로그 flush가 한 번 밀리면 max가 폭발한다.

그래서 max는 디버깅 신호로는 유용하지만 SLO로 쓰기엔 별로다.

p99 = 500ms
max = 30s

이 경우 30초짜리 요청이 실제 문제일 수도 있지만, 한 번 튄 요청일 수도 있다.

반대로:

p99 = 5s
max = 30s

이건 구조적 문제일 가능성이 높다. 상위 1% 전체가 이미 느리기 때문이다.


15. p99가 튀었을 때 보는 순서

p99가 올랐다고 바로 "서버 느림"이라고 하면 안 된다. 보통은 다음 순서로 본다.

15.1 트래픽

RPS 증가?
특정 API만 증가?
특정 테넌트만 증가?
batch/job 트래픽 유입?

트래픽이 늘면서 p99가 튀면 포화(saturation) 가능성이 있다.

15.2 에러율

5xx 증가?
timeout 증가?
429 증가?
DB error 증가?

응답 시간과 에러가 같이 오르면 장애 가능성이 높다.

15.3 포화

CPU
memory
GC
스레드 풀(thread pool)
event loop lag
DB 커넥션 풀
HTTP 커넥션 풀
큐 깊이(queue depth)
disk IO
network IO

p99는 보통 리소스 포화와 강하게 연결된다. 특히 다음 조합은 위험하다.

큐 깊이 증가 + p99 증가
커넥션 대기 증가 + p99 증가
CPU 100% 근접 + p99 증가
GC pause 증가 + p99 증가

Little's Law 관점에서도 큐가 차면 잔류 시간(residence time)이 늘어난다.[2]

15.4 의존성 응답 시간

내 서비스가 느린 게 아니라 의존성이 느린 경우도 많다.

봐야 할 것:

DB 쿼리 p95/p99
Redis p95/p99
외부 API p95/p99
메시지 브로커 발행 시간
오브젝트 스토리지 응답 시간

내 API p99가 2초인데 DB p99가 1.8초면 거의 답이 나온다.

15.5 라우트별 분해

전체 p99가 아니라 라우트별로 봐야 한다.

GET /products
GET /products/{id}
POST /orders
POST /payments/confirm
GET /me

전체 p99 상승은 특정 API 하나가 만들 수 있다.

15.6 성공/실패 분리

status=2xx
status=4xx
status=5xx

실패 요청이 타임아웃으로 느리게 실패하면 p99를 올린다. 반대로 인증 실패처럼 빠른 실패가 많으면 p50을 낮춰 보이게 한다.


16. 좋은 대시보드 구성

API 기준이라면 최소한 이 정도를 같이 본다.

Traffic
- RPS
- concurrent requests

Errors
- 4xx rate
- 5xx rate
- timeout rate

Latency
- p50
- p95
- p99
- max optionally

Saturation
- CPU
- memory
- GC pause
- thread pool active/queued
- DB connection pool active/wait
- queue depth
- event loop lag

Dependencies
- DB query 시간 p95/p99
- Redis 응답 시간 p95/p99
- 외부 API 응답 시간 p95/p99

전체 그래프는 알람용이다. 진단은 라우트별, 의존성별, 테넌트별 상세 화면에서 한다.


17. 알람은 p99만으로 걸면 피곤하다

p99는 변동이 크다(noisy). 특히 트래픽이 적은 서비스에서는 오탐이 많다.

알람은 보통 이런 식이 낫다.

p95 > 500ms for 5 minutes
AND request_count > 1000

또는:

p99 > 2s for 10 minutes
AND error_rate > 1%

단순히:

p99 > 1s

로 알람을 걸면 새벽에 계속 깨게 된다.

좋은 알람 조건은 보통 세 가지를 같이 본다.

영향도: request_count가 충분한가?
지속성: 몇 분 이상 지속되는가?
심각도: 응답 시간/에러가 비즈니스 기준을 넘었는가?

18. 흔한 오해

오해 1. p99가 1초면 99%가 1초 걸린다?

아니다.

p99 = 1s

는 99% 요청이 1초 이하라는 뜻이다. 대부분은 50ms일 수도 있다.

오해 2. p50이 좋으면 서비스가 빠르다?

반만 맞다. p50은 "보통은 빠르다"는 뜻이지 "안정적이다"는 뜻이 아니다.

오해 3. p99는 항상 봐야 한다?

중요하지만 샘플 수가 적으면 의미가 약하다. 트래픽이 적은 API는 p99보다 p95, max, timeout count, 트레이스 샘플을 보는 게 더 낫다.

오해 4. 평균은 필요 없다?

평균도 의미는 있다. 평균은 비용 계산, 전체 리소스 사용량, 처리 용량 추정에는 쓸 수 있다.

하지만 사용자 경험과 장애 진단에서는 백분위가 더 강하다.

오해 5. p99를 낮추려면 서버를 늘리면 된다?

아니다.

p99 원인이 CPU 포화면 서버 증설이 먹힌다. 하지만 원인이 DB 락, slow query, 외부 API, 커넥션 풀 대기, GC pause, 핫 키(hot key), 재시도 폭주, 순차 팬아웃이면 서버만 늘려도 별 효과가 없거나 오히려 악화될 수 있다.


19. 숫자 너머를 봐야 할 지점

p50/p95/p99를 아는 것과 이 숫자로 제대로 판단하는 것은 다르다. "p99가 몇 ms인가?"에서 멈추지 않고, 그 p99가 어떤 워크로드, 관측 구간, 라벨, 측정 방식에서 나온 숫자인지 확인해야 한다.

19.1 p99는 워크로드와 관측 구간의 함수다

다음 두 문장은 같은 말이 아니다.

GET /products p99 = 800ms
GET /products p99 = 800ms, last 5m, 30k requests, only 2xx, cache_hit=false

첫 번째는 숫자다. 두 번째는 판단 가능한 관측값이다.

p99를 볼 때 최소한 같이 붙어야 하는 맥락:

  • 기간: last 1m, 5m, 1h 중 무엇인가
  • 샘플 수: p99를 볼 만큼 충분한가
  • 트래픽 수준: 평소 부하인가, 피크 부하인가
  • 라우트/메서드: 어떤 API인가
  • status: 성공 요청만인가, 실패 요청도 섞였는가
  • 의존성: 내부 처리 시간인가, 외부 호출까지 포함인가
  • 캐시: hit와 miss가 섞였는가

맥락 없는 p99는 숫자처럼 보이지만, 사실은 질문에 가깝다.

19.2 백분위는 함부로 평균 내면 안 된다

리전별 p99가 있다고 하자.

ap-northeast-2 p99 = 200ms
us-east-1      p99 = 900ms

이 둘의 평균을 내서 "전체 p99 = 550ms"라고 하면 틀린 해석이다. 백분위는 이미 분포를 압축한 값이라, 압축된 백분위끼리 다시 평균 내도 전체 분포의 백분위가 되지 않는다.

전체 p99를 구하려면 원본 샘플을 합치거나, 병합 가능한 histogram/distribution을 합쳐서 다시 계산해야 한다.

이 함정은 대시보드에서 자주 나온다.

service별 p99 평균
pod별 p99 평균
region별 p99 평균
API별 p99 평균

대부분의 경우 이런 그래프는 보기 좋지만 의사결정에는 위험하다. 먼저 물어야 할 질문은 "이 p99는 어떤 분포에서 계산된 p99인가?"이다.

19.3 조정된 누락(coordinated omission)을 조심해야 한다

부하 테스트에서 흔한 함정이다.

클라이언트가 요청 하나를 보내고 응답을 기다린 뒤 다음 요청을 보내는 방식이라고 하자. 서버가 3초 멈추면, 그 3초 동안 클라이언트도 같이 멈춰서 새 요청을 보내지 않는다.

그러면 실제 프로덕션에서는 큐가 쌓였을 상황인데, 테스트 결과에는 "3초짜리 느린 요청 하나"만 기록된다. 꼬리 지연이 과소 측정된다.

이 문제가 흔히 조정된 누락(coordinated omission)이라고 불린다.

좋은 부하 테스트는 "응답이 왔으니 다음 요청"이 아니라, 가능한 한 예정된 도착률(arrival rate)을 기준으로 요청을 넣고 지연을 측정해야 한다.

비슷한 이유로 다음 질문이 중요하다.

응답 시간은 클라이언트가 요청을 보내려던 시점부터 재는가?
실제로 socket write가 시작된 시점부터 재는가?
큐 대기도 포함하는가?
타임아웃 난 요청도 응답 시간 분포에 들어가는가?

측정 방식이 꼬이면 p99는 좋아 보이는데 사용자는 느린 이상한 대시보드가 나온다.

19.4 p99는 처리량과 같이 말해야 한다

다음 문장은 반쪽짜리다.

p99 응답 시간 = 20ms

진짜 질문은 이쪽에 가깝다.

p99 20ms 이하를 유지하면서 몇 RPS까지 버티는가?

응답 시간은 부하가 낮을 때는 거의 누구나 좋다. 성능의 본질은 부하가 올라가도 꼬리를 통제할 수 있는가다.

그래서 벤치마크나 시스템 비교에서는 다음처럼 말하는 편이 낫다.

10k RPS에서 p99 < 50ms
30k RPS부터 p99가 500ms로 튐
CPU 70%까지는 p99 안정, 85%부터 큐 대기 증가

이 관점은 앞 글의 성능(Performance envelope)과 이어진다. 성능은 단일 숫자가 아니라 응답 시간 분포, 처리량(throughput) 천장, 용량, 코디네이션 비용이 함께 만드는 영역이다.

19.5 핵심 경로에 응답 시간 예산을 배분해야 한다

화면 하나가 내부 API 8개와 DB 3번, Redis 2번, 외부 API 1번을 지난다고 하자. 각 의존성이 "p99 300ms라 괜찮다"고 말해도 전체 화면은 괜찮지 않을 수 있다.

API별 p99를 보는 데서 멈추지 않고, 핵심 경로 전체에 응답 시간 예산을 배분해야 한다.

예를 들어 결제 플로우(checkout flow)의 목표가:

checkout p95 < 1s
checkout p99 < 2.5s

라면 각 단계에도 예산이 있어야 한다.

cart validation       100ms
coupon calculation    150ms
inventory reservation 300ms
payment authorization 1500ms
order persistence     200ms
fallback/render       250ms

그리고 이 예산을 넘는 의존성에는 타임아웃, 폴백, 비동기 경계, 회로 차단기, 캐시, 읽기 모델 같은 설계 결정을 붙인다.

p99는 대시보드 숫자가 아니라 아키텍처 경계를 정하는 입력이다.


20. 데이터 시스템 컴포넌트를 볼 때

데이터 시스템 컴포넌트를 평가할 때도 평균 응답 시간보다 분포가 중요하다.

Redis, DB, 큐, 외부 API, 오브젝트 스토리지를 비교할 때는 최소한 다음을 같이 본다.

p50 응답 시간
p95 응답 시간
p99 응답 시간
throughput under p99 constraint
tail behavior under saturation

"초당 10만 요청 처리 가능"보다 더 실무적인 질문은 이거다.

p99 10ms 이하를 유지하면서 초당 몇 요청까지 받을 수 있나?

이게 진짜 성능이다. 앞의 글에서 성능을 응답 시간 분포, 처리량 천장, 용량, 코디네이션 비용으로 나눈 이유도 여기에 있다.


21. 마지막 요약

p50 = 기본 성능
p95 = 제품 경험
p99 = 안정성과 병목의 신호

p50/p95/p99는 단순한 관측 숫자가 아니다. 분포를 기준으로 정상 경로와 병목 경로를 분리해서 보는 렌즈다.

평균은 시스템을 부드럽게 보이게 만든다. 백분위는 어디가 실제로 아픈지 보여준다.

백분위는 하나의 숫자가 아니라 설계 질문이다.

무슨 워크로드에서 나온 p99인가?
샘플 수는 충분한가?
꼬리는 어디서 만들어지는가?
핵심 경로 전체 예산은 맞는가?
p99를 낮추는 비용은 비즈니스 가치와 맞는가?

1

Dean & Barroso의 "The Tail at Scale"은 팬아웃 시스템에서 꼬리 지연이 어떻게 사용자 경험을 지배하는지 설명하는 대표적인 글이다. 본문으로 돌아가기 ↑

2

Little's Law는 $L = \lambda W$로 표현된다. 시스템 안의 평균 요청 수($L$)는 도착률($\lambda$)과 평균 잔류 시간($W$)의 곱이다. 큐가 길어지면 요청이 시스템 안에 머무는 시간도 늘어난다. 본문으로 돌아가기 ↑