부하 테스트
서비스가 얼마나 많은 요청을 얼마나 빨리 버티는지 재는 부하 테스트를 정리한다. 시험 종류와 부하 모델, 평균 대신 백분위수를 봐야 하는 이유, 리틀의 법칙으로 동시 사용자를 계산하는 법, 측정을 속이는 조정된 누락, 임계값과 SLO, 현실적인 시험 데이터, 도구 고르기, 결과에서 병목을 찾는 법까지 숫자로 계산해 가며 본다.
목차 (10)
기능이 맞는지 보는 시험은 소프트웨어 테스트 노트에, 운영 중인 시스템의 지표·로그·추적은 관측성 노트에 있다. 부하 테스트는 그 사이에서 “얼마나 많이, 얼마나 빨리”를 미리 재는 일이다. 용량 계획과 확장 설계는 시스템 설계 노트로 이어진다.
목표와 시험 종류
부하 테스트는 질문을 먼저 정해야 한다. “평소 트래픽에서 p95가 300ms 안인가”, “행사 날 평소의 네 배가 몰려도 죽지 않는가”, “여덟 시간 돌려도 메모리가 새지 않는가”는 서로 다른 시험이다. 질문이 없으면 숫자만 잔뜩 나오고 결론이 없다. k6 문서의 분류를 따르면 다음과 같다. 이름은 업계에 합의된 것이 아니라 팀마다 조금씩 다르다.
- 스모크: 아주 적은 부하로 짧게. 스크립트가 맞게 돌고 기본 응답이 정상인지 확인하는 첫 단계다.
- 부하(평균 부하): 평소 운영 수준을 수십 분 유지한다. 목표(SLO)를 지키는지 보는 기준 시험이다.
- 스트레스: 평소보다 높은 부하에서 성능이 어떻게 무너지는지, 무너진 뒤 회복하는지 본다.
- 스파이크: 몇 분 안에 몇 배로 뛰는 순간을 흉내 낸다. 자동 확장이 따라오는지, 대기열과 타임아웃이 버티는지 본다.
- 소크(내구): 평소 부하를 몇 시간 이상 이어 간다. 메모리 누수, 연결 고갈, 로그·디스크 증가처럼 시간이 지나야 드러나는 문제를 찾는다.
- 브레이크포인트: 깨질 때까지 천천히 올려 시스템의 한계 처리량을 찾는다.
부하 모델: 닫힌 모델과 열린 모델
부하를 거는 방법은 크게 둘이다. 닫힌 모델(closed)은 가상 사용자(VU) 수를 정한다. VU 하나는 요청을 보내고 응답을 받은 뒤, 잠깐 쉬고(생각 시간) 다음 요청을 보낸다. 열린 모델(open)은 도착률을 정한다. “초당 500건”이면 응답이 왔든 말든 2ms마다 새 요청이 출발한다.
차이는 서버가 느려질 때 드러난다. 닫힌 모델의 VU는 응답을 기다리느라 요청을 덜 보내므로, 서버가 느려질수록 부하가 저절로 줄어든다. 응답이 0.2초에서 2초로 느려지면 VU 100명이 내는 처리량은 초당 50건으로 떨어진다. 실제 웹 사용자는 그렇게 행동하지 않는다. 다른 사람의 요청이 느리다고 내가 접속을 미루지는 않는다. 열린 모델에서는 같은 상황에 동시에 처리 중인 요청이 1,000건까지 쌓이고, 이것이 운영에서 실제로 벌어지는 일이다.
Schroeder 등(2006)은 같은 평균 부하라도 두 모델에서 응답 시간이 크게 다르다는 것을 보였다. 인터넷 서비스처럼 모르는 다수가 독립적으로 들어오는 시스템은 열린 모델로, 정해진 수의 작업자·배치 워커·연결 풀이 일하는 시스템은 닫힌 모델로 시험하는 것이 맞다. 로그인한 사용자가 한 세션 안에서 여러 요청을 이어 보내는 경우는 세션 도착은 열린 모델, 세션 안의 흐름은 닫힌 모델인 반쯤 열린 모델이 가깝다.
지연 분포와 백분위수
응답 시간은 정규 분포가 아니다. 대부분은 빠르고, 일부가 캐시 실패·GC 멈춤·잠금 대기·재전송 때문에 훨씬 느리다. 그래서 평균은 대표값이 되지 못한다. 열 개의 표본 42, 38, 51, 45, 40, 47, 39, 44, 620, 43ms에서 중앙값(p50)은 43ms지만 평균은 100.9ms다. 620ms 하나가 평균을 두 배 넘게 끌어올렸고, 평균에 가까운 요청은 하나도 없다.
백분위수 pN은 “요청의 N%가 이 값 이하”라는 뜻이다. 표본을 정렬해 N% 위치의 값을 고른다(가장 가까운 순위 방식). p50은 보통의 경험, p95·p99는 운이 나쁜 사용자의 경험, 최댓값은 최악의 한 번이다. 아래 그림처럼 봉우리가 둘인 분포에서는 평균 46ms가 실제 요청이 거의 없는 골짜기에 떨어진다. p95는 281ms, p99는 391ms로, 느린 봉우리가 존재한다는 사실은 꼬리 백분위수에서만 보인다.
- 꼬리는 생각보다 자주 만난다: 페이지 하나가 백엔드 요청 10개를 기다리면 그중 하나라도 p99 꼬리에 걸릴 확률은 9.6%, 100개면 63%다. 백엔드의 p99가 사용자에게는 중앙값 근처의 경험이 된다(The Tail at Scale).
- 백분위수는 평균 낼 수 없다: 서버 네 대의 p99를 평균한 값은 전체의 p99가 아니다. 1분마다 잰 p99를 한 시간 평균한 값도 마찬가지다. 합칠 때는 원래 표본이나 히스토그램을 합친 뒤 다시 백분위수를 구한다. 그래서 도구들은 HdrHistogram 같은 합칠 수 있는 히스토그램으로 기록한다.
- 표본 수: p99를 말하려면 적어도 수백, p99.9면 수천 개 이상의 표본이 있어야 숫자가 안정된다. 짧은 시험의 p99.9는 몇 개의 요청이 정한 우연한 값이다.
리틀의 법칙: 동시 사용자 계산
리틀의 법칙은 안정된 시스템에서 늘 성립한다. 시스템 안의 평균 개수 L = 도착률 λ × 평균 체류 시간 W. 분포나 스케줄링 방식과 상관없이 맞기 때문에 부하 테스트 계획의 기본 계산이 된다.
- 동시 요청 수: 초당 500건 × 0.2초 = 동시 100건. 스레드 풀·DB 연결 풀·워커 수가 이보다 작으면 나머지는 대기열에서 기다린다.
- VU 수: 사용자가 응답을 받고 1초 생각한 뒤 다음 요청을 보낸다면, 초당 500건을 내는 데 VU 600명이 필요하다. “동시 사용자 1만 명”이라는 요구는 생각 시간을 모르면 처리량으로 바꿀 수 없다.
- 결과 검산: 시험 결과의 처리량 × 평균 응답 시간이 도구가 보고한 동시 요청 수와 맞지 않으면, 도구 쪽(부하 생성기 CPU, 연결 수 제한)이 병목일 가능성이 높다.
조정된 누락: 측정이 거짓말하는 법
Gil Tene가 How NOT to Measure Latency 강연(영상)에서 이름 붙인 조정된 누락(coordinated omission)은 닫힌 루프 측정의 함정이다. 도구가 응답을 기다린 뒤에야 다음 요청을 보내면, 서버가 멈춘 동안 보냈어야 할 요청을 아예 보내지 않는다. 서버가 느려지는 순간 측정도 함께 멈추는 것이다. 서버와 측정 도구가 “조정”해서 나쁜 표본을 “누락”시킨다.
10ms마다 1건, 정상 응답 1ms인 100초 시험에서 서버가 중간에 10초 멈췄다고 하자. 닫힌 루프 도구는 멈춘 동안 요청 1건만 보내고 10초를 기다린다. 기록은 9,001건이고 p99는 1ms다. 보고서만 보면 아무 문제가 없다. 하지만 실제 사용자는 그 10초 동안에도 10ms마다 들어왔고, 그 999건은 최대 10초를 기다렸다. 빠진 요청을 채우면 p95는 5,001ms, p99는 9,001ms다.
- 막는 법 1 — 열린 모델로 부하를 건다: 도착률 실행기(k6의
constant-arrival-rate, Gatling의constantUsersPerSec, wrk2의-R)는 요청을 정해진 시각에 보내고, 지연을 “보내야 했던 시각”부터 잰다. 밀린 요청을 보낼 VU가 모자라면 k6는 빠뜨린 반복 수(dropped_iterations)를 따로 보고한다. - 막는 법 2 — 기록할 때 보정한다: HdrHistogram의 기대 간격 기록은 간격보다 긴 값 하나가 들어오면 그동안 빠진 요청이 겪었을 값(값 − 간격, 값 − 2×간격, …)을 함께 채운다. 위 그림의 보정이 이 방식이다.
- 운영 지표에도 있다: 서버가 처리를 마친 요청만 재는 지표는 대기열에서 기다린 시간을 빼먹는다. 요청을 받은 시각(로드 밸런서·클라이언트)에서 재는 지표와 함께 본다.
임계값과 SLO
부하 테스트는 통과·실패가 기계적으로 정해져야 CI에 넣을 수 있다. 기준은 서비스의 SLO(서비스 수준 목표)에서 가져온다. Google SRE 책의 정의로 SLI는 재는 지표(예: 성공한 요청 중 300ms 안에 끝난 비율), SLO는 그 목표(예: 28일 동안 99%), SLA는 어겼을 때 보상이 걸린 계약이다. 가용성 99.9%라면 30일 동안 허용되는 장애는 43.2분이고, 이것이 오류 예산이다.
k6의 임계값(thresholds)은 이 기준을 스크립트에 적는다. http_req_duration: ['p(95)<300']처럼 지표마다 조건을 달고, 하나라도 어기면 종료 코드가 0이 아니게 되어 파이프라인이 멈춘다. abortOnFail을 켜면 기준을 넘는 순간 시험을 끊어 공유 환경을 오래 괴롭히지 않는다.
- 지연 기준은 성공한 요청으로: 실패한 요청은 대개 빨리 끝나므로, 오류가 늘면 지연 백분위수가 오히려 좋아 보인다. 지연과 오류율을 따로 걸거나, 성공 요청만으로 지연을 잰다.
- 기준선과 비교: 절대 기준 말고도 지난 릴리스 대비 p95가 10% 넘게 나빠지면 실패로 두는 회귀 기준이 유용하다. 같은 환경·같은 데이터로 돌려야 비교가 성립한다.
시험 환경과 데이터의 현실성
부하 테스트의 결론은 시험 환경이 운영을 닮은 만큼만 믿을 수 있다. 흔히 놓치는 차이는 다음과 같다.
- 데이터 분포: 같은 사용자 몇 명, 같은 상품 몇 개만 되풀이하면 캐시·DB 버퍼 풀이 늘 적중한다. 운영에서는 인기 항목이 많이 불리지만 긴 꼬리도 있다(지프 분포). 상품 100만 개 중 인기 1만 개를 캐시해도 적중률은 68%뿐이고, 평균 지연은 시험의 5ms가 아니라 29ms가 된다.
- 데이터 양: 행 1,000개짜리 테이블에서는 인덱스가 없어도 빠르다. 운영 규모의 데이터(또는 익명화한 복제본)를 채워야 실행 계획이 운영과 같아진다.
- 규모를 줄인 환경: 서버 수를 반으로 줄인 환경의 결과를 두 배 한다고 운영이 되지 않는다. DB·캐시·메시지 큐처럼 공유되는 부분은 선형으로 늘지 않는다. 비율로 줄일 때도 병목이 되는 공유 자원은 운영과 같은 크기로 둔다.
- 부하 생성기: 생성기 CPU가 80%를 넘으면 생성기가 병목이 되어 지연이 부풀거나 처리량이 깎인다. 생성기 지표도 함께 모으고, 필요하면 여러 대로 나눈다. 같은 데이터센터 안에서만 쏘면 네트워크 지연과 TLS 연결 비용이 빠진다.
- 외부 의존: 결제·메일·지도 API는 모의 서버로 바꾸되, 실제 응답 시간과 오류율을 흉내 내게 한다. 0ms로 답하는 모의 서버는 연결 풀 고갈을 숨긴다.
- 예열: JIT 컴파일, 연결 풀, 캐시가 차기 전의 처음 몇 분은 따로 보거나 결과에서 뺀다.
- 쏘기 전에 규칙 확인: 부하는 내가 운영하거나 허락받은 대상에만 건다. 클라우드에서 쏠 때는 사업자 정책도 본다. AWS는 정상적인 요청을 대량으로 보내는 네트워크 부하 시험과, 대상을 일부러 무너뜨리려는 플러딩·증폭 같은 DDoS 시험을 구분하고, 후자는 EC2에서 금지하며 따로 정한 DDoS 시뮬레이션 정책을 따르게 한다(Amazon EC2 Testing Policy). 운영 환경이라면 당번·관련 팀에 일정을 미리 알려, 시험 트래픽이 실제 장애로 오인되지 않게 한다.
도구 비교
도구를 고를 때 가장 먼저 볼 것은 스크립트 언어보다 열린 모델을 제대로 내는지다. 2026년 10월 기준 상태는 다음과 같다.
| 도구 | 스크립트 | 특징 | 라이선스·상태 |
|---|---|---|---|
| k6 | JavaScript(TypeScript 가능), Go 엔진 | 시나리오마다 실행기를 고른다: VU 기반(constant-vus, ramping-vus, shared-iterations, per-vu-iterations)과 도착률 기반(constant-arrival-rate, ramping-arrival-rate). 임계값·체크가 내장돼 CI에 넣기 쉽다. | AGPL-3.0, Grafana Labs. 2025년 5월 1.0, 2026년 5월 2.0이 나왔다(GitHub). |
| Gatling | Java·Kotlin·Scala, JavaScript·TypeScript SDK | 주입 프로필이 열린 모델(constantUsersPerSec, rampUsersPerSec)과 닫힌 모델(constantConcurrentUsers)을 명시적으로 나눈다. 비동기 엔진이라 생성기 한 대가 많은 연결을 낸다. HTML 보고서가 자세하다. | 커뮤니티판 Apache-2.0, 상용 Enterprise판 별도. |
| JMeter | GUI로 만든 XML 시험 계획, Groovy | HTTP 밖에도 JDBC·JMS·LDAP 등 프로토콜 플러그인이 많다. 기본 스레드 그룹은 닫힌 모델이고, Open Model Thread Group으로 도착률을 낸다. 스레드 하나가 사용자 하나라 생성기 메모리를 많이 쓴다. | Apache-2.0. 마지막 릴리스는 5.6.3(2024년 1월)이고 저장소는 계속 갱신 중이다. |
| Locust | Python | 사용자 행동을 평범한 Python 클래스로 쓴다. 웹 UI에서 사용자 수를 바꿔 가며 볼 수 있다. 기본은 닫힌 모델이고 wait_time의 constant_throughput으로 사용자별 간격을 맞춘다. 여러 프로세스로 나눠 돌린다. | MIT. 2026년 10월에도 릴리스가 이어진다. |
| wrk · wrk2 | 명령줄, Lua 훅 | 스레드 몇 개로 한 엔드포인트를 최대한 세게 두드리는 마이크로 벤치마크용이다. wrk는 연결 수만 정하는 닫힌 루프라 조정된 누락이 있다. wrk2는 Gil Tene가 고친 갈래로 -R 고정 처리량과 보정된 HdrHistogram 출력을 낸다. | wrk는 변형 Apache-2.0, 마지막 태그 4.2.0. 두 저장소 모두 몇 년째 거의 갱신되지 않는다. |
시나리오 흐름이 복잡하고 CI에 넣을 거라면 k6나 Gatling, 팀이 Python에 익숙하면 Locust, 웹 말고도 여러 프로토콜을 쳐야 하면 JMeter, 엔드포인트 하나의 한계를 빠르게 보려면 wrk2가 맞다. 아래는 열린 모델과 임계값을 함께 쓴 k6 스크립트다.
import http from 'k6/http';
import { check } from 'k6';
export const options = {
scenarios: {
checkout: {
// 열린 모델: 응답과 상관없이 초당 500회 반복을 시작한다
executor: 'constant-arrival-rate',
rate: 500,
timeUnit: '1s',
duration: '10m',
preAllocatedVUs: 200,
maxVUs: 1000,
},
},
thresholds: {
http_req_duration: ['p(95)<300', 'p(99)<800'],
http_req_failed: ['rate<0.01'],
},
};
export default function () {
const response = http.get('https://staging.example.com/api/products');
check(response, { 'status 200': (r) => r.status === 200 });
}결과 분석과 병목 찾기
부하를 올리며 처리량과 지연을 함께 그리면 전형적인 모양이 나온다. 처음에는 부하를 올린 만큼 처리량이 늘고 지연은 그대로다. 어떤 자원이 포화에 가까워지면 처리량은 평평해지고 지연이 가파르게 오른다. 대기열 이론은 이 꺾임을 설명한다. 가장 단순한 M/M/1 대기열의 평균 응답 시간은 서비스 시간 / (1 − 사용률)이어서, 서비스 시간 20ms인 자원은 사용률 50%에서 40ms, 90%에서 200ms가 된다.
- 무릎을 찾는다: 처리량이 더 늘지 않는데 지연만 오르기 시작하는 부하가 그 구성의 실질 용량이다. 브레이크포인트 시험의 결과는 이 지점과 그때의 오류 종류(타임아웃, 503, 연결 거부)다.
- 가장 먼저 포화한 자원을 찾는다: USE 방법대로 CPU·메모리·디스크·네트워크·연결 풀·스레드 풀·잠금마다 사용률(Utilization)·포화(Saturation, 대기열 길이)·오류(Errors)를 본다. CPU가 놀고 있는데 지연이 오르면 DB 연결 풀, 잠금, 외부 API처럼 기다리는 자원이 범인이다.
- 요청 하나를 따라간다: 분산 추적으로 느린 요청이 어느 구간에서 시간을 썼는지 본다. 지표·로그·추적을 모으는 방법은 관측성 노트에 있다.
- 한 번에 하나만 바꾸고 다시 잰다: 병목 하나를 풀면 다음 병목이 나타난다. 결과마다 바꾼 것, 환경, 데이터, 도구 버전을 함께 남겨야 시간이 지나도 비교할 수 있다.
시험 결과는 용량 계획으로 이어진다. 무릎 지점의 처리량에서 여유(예: 사용률 60~70% 목표)를 빼고 예상 최대 트래픽으로 나누면 필요한 서버 수가 나오고, 자동 확장의 기준값도 이 시험에서 정한다.
핵심 자료
| 자료 | 성격 | 한 줄 요지 |
|---|---|---|
| How NOT to Measure Latency (Gil Tene) | 강연 | 평균과 표준편차로 지연을 말하면 안 되는 이유, 조정된 누락이 백분위수를 얼마나 속이는지를 실제 그래프로 보여 준다. |
| Open Versus Closed: A Cautionary Tale (Schroeder 등) | 논문(NSDI 2006) | 같은 부하라도 닫힌 모델과 열린 모델에서 응답 시간과 스케줄링 효과가 크게 다름을 실험과 대기열 모델로 보였다. |
| The Tail at Scale (Dean·Barroso) | 논문(2013) | 요청 하나가 여러 서버를 거치면 드문 꼬리 지연이 흔해진다. 헤지 요청 같은 꼬리 줄이기 기법을 정리했다. |
| k6 문서: Load test types | 문서 | 스모크·평균 부하·스트레스·소크·스파이크·브레이크포인트 시험의 목적과 길이를 한 표로 비교한다. |
| k6 문서: Open and closed models | 문서 | VU 기반 실행기와 도착률 실행기의 차이, 닫힌 모델이 조정된 누락을 일으키는 이유를 설명한다. |
| HdrHistogram | 라이브러리 | 넓은 범위의 값을 정해진 유효 숫자로 담는 히스토그램. 기대 간격으로 조정된 누락을 보정하는 기록 함수가 있다. |
| Google SRE 책: Service Level Objectives | 책 | SLI·SLO·SLA를 구분하고 백분위수로 목표를 세우는 법, 오류 예산의 뜻을 정리한다. |
| The USE Method (Brendan Gregg) | 글 | 모든 자원마다 사용률·포화·오류를 확인해 병목을 체계적으로 찾는 점검표. |
- How NOT to Measure Latency (Gil Tene)
- Open Versus Closed: A Cautionary Tale (Schroeder 등)
- The Tail at Scale (Dean·Barroso)
- k6 문서: Load test types
- k6 문서: Open and closed models
- HdrHistogram
- Google SRE 책: Service Level Objectives
- The USE Method (Brendan Gregg)
- "How NOT to Measure Latency" 강연 영상 (YouTube)
- Little's law (Wikipedia)
- Google SRE 워크북: Implementing SLOs
- Amazon EC2 Testing Policy
- k6 문서
- k6 문서: Executors
- k6 문서: Thresholds
- grafana/k6 (GitHub)
- Gatling 문서
- Gatling 문서: Injection (open·closed workload)
- Apache JMeter
- JMeter 컴포넌트 참조: Open Model Thread Group
- Locust
- Locust 문서: Writing a locustfile (wait_time)
- wg/wrk (GitHub)
- giltene/wrk2 (GitHub)
- HdrHistogram (GitHub)