TT Lab
시작하기
배우기 러닝패스 코스

부하 테스트

평균은 꼬리가 나빠지는 것을 감지하지 못한다

TT Lab 에서 이어서 보기

한 줄 요약

같은 로그를 보고도 평균만 보는 팀은 롤백을 결정하고, 중앙값만 보는 팀은 성공 사례로 발표하고, p99 만 보는 팀은 인시던트를 연다. 셋 다 같은 데이터를 봤다.

왜 이게 필요했나

요청 10,000건 중 9,900건이 50ms, 100건이 3,000ms 인 서비스를 보자. 평균은 79.5ms 다. 실제로 79.5ms 에 응답받은 요청은 단 한 건도 없다. 평균은 존재하지 않는 사용자를 묘사한다. p50 도 p95 도 p99 도 전부 50ms 이고, p99.9 만 3,000ms 다.

더 나쁜 것은 민감도다. 느린 100건이 3,000 → 6,000ms 로 두 배 나빠져도 평균은 79.5 → 109.5ms 밖에 움직이지 않는다. 평균은 꼬리가 나빠지는 것을 거의 감지하지 못한다.

실제 롤아웃 전후 숫자를 보면 결론이 갈리는 이유가 분명해진다. 평균 112 → 122ms(+9%), p50 99 → 54ms(−46%), p95 224 → 454ms(+103%), p99 309 → 678ms(+119%). 평균만 보면 소폭 악화, 중앙값만 보면 큰 개선, p95/p99 를 보면 심각한 회귀다.

어떻게 동작하나

p99 를 "1%의 불운한 사용자"로 이해하면 우선순위를 잘못 잡는다. 꼬리는 증폭된다. 요청을 n번 할 때 최소 한 번 p99 구간을 만날 확률은 1 − 0.99^n 이다.

호출 횟수 p99 를 만날 확률
1회 1.0%
5회 4.9%
20회 18.2%
50회 39.5%
200회 86.6%

대시보드 한 화면이 20개의 API 를 호출한다면 화면을 한 번 열 때 p99 지연을 만날 확률은 18% 다. 하루 200번 요청하는 활성 사용자라면 87% 가 하루에 한 번 이상 최악 구간을 겪는다. p99 는 소수의 불운한 사용자가 아니라 거의 모든 사용자의 일상이다. 그래서 한 화면이 20개를 호출한다면 서비스 하나의 목표는 99% 가 아니라 99.9% 여야 화면 수준에서 98% 가 나온다.

백분위는 합칠 수도 없다. 서버 A(9,900건 전부 50ms)의 p99 는 50, 서버 B(100건 전부 3,000ms)의 p99 는 3,000 이다. 단순 평균은 1,525ms, 요청 수 가중 평균은 79.5ms, 진짜 통합 p99 는 3,000ms 다. 세 숫자가 전부 다르고 앞의 두 개는 아무 의미가 없다.

현장에서 만나는 모습

부하 테스트 보고서에서 가장 자주 보이는 결함은 조건이 없다는 것이다. RPS 와 p95 만 적혀 있고 동시성도, 총 요청 수도, 언제 쟀는지도 없다. 그런 숫자는 다음 달에 비교할 수 없으므로 기준선이 아니다.

두 번째는 한 번만 재는 것이다. 같은 조건으로 세 번 돌리면 변동폭이 보이고, 그 변동폭보다 작은 차이는 개선이라고 부를 수 없다.

백분위를 제대로 저장하는 법

p99 를 합칠 수 없다는 문제는 저장 방식을 바꾸면 대부분 사라진다. 열쇠는 백분위 값이 아니라 분포를 저장하는 것이다. 히스토그램은 "0–10ms 에 몇 건, 10–25ms 에 몇 건" 처럼 버킷별 건수를 담으므로, 인스턴스 여럿의 버킷을 그냥 더하면 전체 분포가 된다. 그 합쳐진 분포에서 백분위를 구하면 그것이 진짜 값이다. 시간 축으로도 마찬가지라, 5분 단위 히스토그램을 하루치 더하면 그날의 p99 가 나온다.

대신 히스토그램에는 고유한 오차가 있다. 백분위가 어느 버킷 안에 있는지까지만 알 수 있고, 그 안에서의 정확한 위치는 보간으로 추정한다. 그래서 버킷 경계를 목표치 근처에 촘촘히 두는 것이 정확도를 좌우한다. 목표가 300ms 인데 경계가 100ms 다음에 1초라면, 그 사이의 모든 값이 한 칸에 뭉쳐 p99 를 판정할 수 없다. 반대로 아무도 안 보는 10초 위쪽은 성기게 둬도 손해가 없다.

그리고 최댓값은 히스토그램으로 못 구한다. 마지막 버킷은 "1초 이상" 처럼 열려 있어서, 그 안에 1.1초가 있는지 30초가 있는지 구별되지 않는다. 가장 느린 요청이 얼마였는지가 중요한 조사에서는 최댓값을 따로 지표로 내보내거나, 느린 요청 자체를 로그와 트레이스에서 찾아야 한다.

무엇을 재는지가 숫자보다 먼저다

백분위를 올바로 읽어도, 애초에 잘못된 조건에서 잰 숫자면 아무 소용이 없다. 부하 시험이 현실과 어긋나는 자리는 대개 정해져 있다.

워밍업을 안 한다. JIT 컴파일, 커넥션 풀 채우기, 캐시 예열, 그리고 자동 확장이 반응하기까지의 시간. 시작 직후 30초는 정상 상태가 아니므로 그 구간을 집계에 넣으면 p99 가 실제보다 훨씬 나쁘게 나온다. 반대로 워밍업이 지나치게 길어도 문제다. 실제 사용자는 배포 직후에도 요청을 보내므로, 그 구간의 성능은 따로 재서 알고 있어야 한다.

데이터가 너무 깨끗하다. 모든 요청이 같은 사용자, 같은 상품을 조회하면 첫 요청 뒤로는 전부 캐시에서 나온다. 처리량 숫자는 아름답지만 현실에는 없는 값이다. 반대로 매번 완전히 무작위인 키를 쓰면 캐시 적중률이 0이 되어 지나치게 비관적이다. 실제 접근 분포는 대개 소수의 항목에 몰려 있으므로, 그 편중을 흉내 내야 한다.

한 곳만 때린다. 엔드포인트 하나에 부하를 몰면 그 경로의 병목만 보인다. 현실에서는 여러 기능이 같은 데이터베이스와 같은 스레드 풀을 나눠 쓰므로, 혼자서는 멀쩡하던 것이 함께 돌 때 무너진다. 시나리오는 실제 트래픽 비율대로 섞어야 한다.

부하 도구가 먼저 지친다. 클라이언트 쪽 CPU 가 포화되거나 포트가 고갈되면 서버가 아니라 도구가 한계다. 이때 나오는 지연 증가는 서버의 성질이 아니다. 부하 도구가 도는 장비의 자원도 함께 기록해야 이 착각을 피할 수 있다.

마지막으로, 결과를 남기는 형식도 정해 두는 편이 좋다. 부하, 지속 시간, 총 요청 수, 오류율, p50/p95/p99, 그리고 그때 서버의 CPU·메모리·커넥션 수. 이 조합이 없으면 다음 달에 같은 시험을 돌려도 비교할 수 없고, 비교할 수 없는 숫자는 기준선이 아니라 그냥 그날의 기록입니다.

다음 실습에서 할 것

nginx 컨테이너를 부하 대상으로 띄우고 hey 로 스모크·워밍업·본 측정을 나눠 실행합니다. 도구 출력에서 RPS 와 p50/p95/p99 와 에러율을 직접 뽑아 JSON 으로 정리하고, 평균 대비 p95 배수를 계산한 뒤, 세 번 반복해 변동폭까지 포함한 기준선을 확정합니다.