P50, P95, P99를 이해하는 법


p50, p95, p99를 이해하는 법

p50, p95, p99는 분포에서 특정 백분위 지점의 값이다. latency, 응답 시간, 큐 대기 시간, 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은 중앙값이다.

p50 = median

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

예를 들어:

GET /products 응답 시간 p50 = 40ms

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

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

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

p50은 일반적인 코드 경로가 무거운지, 캐시가 잘 먹는지, 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: 수집 client나 backend가 자체 알고리즘으로 근사함

그래서 같은 데이터라도 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, 상품군만 느림
  • 차가운 cache
  • 큰 요청/응답 본문
  • N+1 query
  • retry 폭주
  • queue 대기 증가

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

케이스 C

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

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

가능한 원인:

  • 기본 코드 경로가 무거움
  • 모든 요청이 DB를 많이 침
  • 캐시 없음
  • network hop이 많음
  • serialization/deserialization 비용이 큼
  • 모든 요청에서 불필요한 join, aggregation, validation 발생

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

케이스 D

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

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

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

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

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

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


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

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

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

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

대표적인 원인을 보자.

8.1 DB query 편차

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

GET /orders?userId=123

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

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

  • pagination
  • index
  • covering index
  • query plan 확인
  • 큰 tenant 분리
  • cardinality 높은 조건 추가
  • aggregation 비동기화
  • read model 분리

이런 방향으로 봐야 한다.

8.2 Lock contention

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

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

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

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

해결 방향:

  • hot row 분산
  • optimistic locking
  • 재고 차감 queue
  • 선점/예약 모델
  • idempotency key
  • retry 정책 제한
  • 결제와 재고 트랜잭션 경계 재설계

8.3 Connection pool 고갈

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

DB connection pool max = 20
동시 요청 = 200
각 요청이 DB connection을 오래 잡음

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

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

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

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

8.4 Runtime pause

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 폭증
  • lock contention
  • network timeout accumulation

p99가 튀는 시스템은 application code뿐 아니라 runtime pause도 봐야 한다.

8.5 외부 API 의존성

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

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

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

해결 방향:

  • timeout 명확화
  • circuit breaker
  • fallback
  • async 처리
  • webhook 기반 최종 확정
  • 사용자 응답과 후속 처리 분리
  • retry 예산 제한
  • hedged request는 신중히 사용

9. p99는 합성된다

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

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

간단히 계산하면:

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

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

20개면:

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

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

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

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

실무에서의 의미:

  • fan-out 개수를 줄인다
  • 병렬 호출을 해도 가장 느린 의존성이 전체 응답 시간을 결정한다
  • cache/read model이 중요해진다
  • 핵심 경로를 줄인다
  • 화면 단위 p95/p99를 따로 본다
  • 내부 API별 p99만 보면 UX를 놓친다

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

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

예를 들어:

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

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

  • 더 큰 instance
  • 더 많은 replica
  • 더 공격적인 cache
  • precompute
  • async architecture
  • DB schema 변경
  • queue 도입
  • shard 분리
  • 외부 의존성 제거

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

기능p99 중요도
결제 승인매우 높음
로그인높음
상품 상세높음
검색높음
추천 영역 로딩중간, fallback 가능
관리자 페이지 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
  • tenant
  • app version
  • DB replica
  • cache hit/miss
  • success/failure
  • 의존성
  • 요청/응답 본문 크기 구간
  • cold start 여부
  • retry 여부

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

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

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


12. Histogram bucket이 중요하다

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

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

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

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

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

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

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

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

목표가 300ms라면:

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

이런 식이 더 좋다.

핵심:

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


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를 따로 둔다
  • 응답 시간 백분위 대신 timeout/error 기준 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만 증가?
특정 tenant만 증가?
batch/job 트래픽 유입?

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

15.2 Error rate

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

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

15.3 Saturation

CPU
memory
GC
thread pool
event loop lag
DB connection pool
HTTP connection pool
queue depth
disk IO
network IO

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

queue depth 증가 + p99 증가
connection wait 증가 + p99 증가
CPU 100% 근접 + p99 증가
GC pause 증가 + p99 증가

Little's Law 관점에서도 queue가 차면 residence time이 늘어난다.[2]

15.4 의존성 응답 시간

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

봐야 할 것:

DB query p95/p99
Redis p95/p99
external API p95/p99
message broker publish 시간
object storage 응답 시간

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

15.5 Route별 분해

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

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

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

15.6 성공/실패 분리

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

실패 요청이 timeout으로 느리게 실패하면 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

전체 그래프는 알람용이다. 진단은 route별, 의존성별, tenant별 상세 화면에서 한다.


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가 충분한가?
지속성: 몇 분 이상 지속되는가?
심각도: 응답 시간/error가 비즈니스 기준을 넘었는가?

18. 흔한 오해

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

아니다.

p99 = 1s

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

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

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

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

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

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

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

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

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

아니다.

p99 원인이 CPU saturation이면 서버 증설이 먹힌다. 하지만 원인이 DB lock, slow query, 외부 API, connection pool wait, GC pause, hot key, retry 폭주, sequential fan-out이면 서버만 늘려도 별 효과가 없거나 오히려 악화될 수 있다.


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

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

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를 볼 만큼 충분한가
  • 트래픽 수준: 평소 부하인가, peak 부하인가
  • route/method: 어떤 API인가
  • status: 성공 요청만인가, 실패 요청도 섞였는가
  • 의존성: 내부 처리 시간인가, 외부 호출까지 포함인가
  • cache: hit와 miss가 섞였는가

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

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

region별 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을 조심해야 한다

load test에서 흔한 함정이다.

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

그러면 실제 production에서는 queue가 쌓였을 상황인데, test 결과에는 "3초짜리 느린 요청 하나"만 기록된다. 꼬리 지연이 과소 측정된다.

이 문제가 흔히 coordinated omission이라고 불린다.

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

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

응답 시간은 client가 요청을 보내려던 시점부터 재는가?
실제로 socket write가 시작된 시점부터 재는가?
큐 대기도 포함하는가?
timeout 난 요청도 응답 시간 분포에 들어가는가?

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

19.4 p99는 throughput과 같이 말해야 한다

다음 문장은 반쪽짜리다.

p99 응답 시간 = 20ms

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

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

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

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

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

이 관점은 앞 글의 Performance envelope와 이어진다. 성능은 단일 숫자가 아니라 응답 시간 분포, throughput 천장, capacity, coordination cost가 함께 만드는 영역이다.

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

그리고 이 예산을 넘는 의존성에는 timeout, fallback, async boundary, circuit breaker, cache, read model 같은 설계 결정을 붙인다.

p99는 대시보드 숫자가 아니라 architecture boundary를 정하는 입력이다.


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

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

Redis, DB, queue, external API, object storage를 비교할 때는 최소한 다음을 같이 본다.

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

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

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

이게 진짜 성능이다. 앞의 글에서 Performance envelope를 응답 시간 분포, throughput 천장, capacity, coordination cost로 나눈 이유도 여기에 있다.


21. 마지막 요약

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

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

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

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

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

1

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

2

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