들어가며
k6 예제를 검색하면 대부분 이런 형태입니다.
export const options = {
stages: [
{ duration: '1m', target: 1000 },
{ duration: '2m', target: 3000 },
{ duration: '1m', target: 0 },
],
};
export default function () {
http.get(`${API_URL}/endpoint`);
sleep(1);
}stages만 쓰면 k6는 ramping-vus executor로 동작합니다. 저도 별생각 없이 이 형태로 API 부하테스트를 돌리고 있었습니다. VU를 3,000까지 올렸고, 에러율도 낮았고, 임계값도 통과했습니다.
그런데 결과를 정리하다 이런 질문이 남았습니다.
“그래서 이 API가 몇 TPS까지 버티는 건데?”
VU 3,000을 넣었다는 사실만으로는 이 질문에 답할 수 없었습니다. 이 글은 그 질문 때문에 ramping-vus에서 ramping-arrival-rate로 넘어간 과정과, 실제로 써보며 알게 된 동작 방식을 정리한 글입니다.
1. ramping-vus로는 TPS를 지정할 수 없다
k6 문서는 VU 스케줄링 방식을 open model과 closed model로 구분합니다.
ramping-vus는 closed model입니다. 이전 iteration이 끝나야 다음 iteration이 시작되는 구조라, 새 요청의 도착 비율이 iteration 소요 시간에 묶여 있습니다.
“the start or arrival rate of new VU iterations is tightly coupled with the iteration duration”
즉 closed model에서 TPS는 내가 지정하는 값이 아니라 결과로 나오는 값입니다.
TPS ≈ VU 수 ÷ (응답시간 + sleep)
문서의 예시가 직관적입니다. VU 1개로 1분간 돌리는데 요청 하나가 6초 걸리면, 1분 동안 완료되는 iteration은 10개입니다. 내가 정한 건 “VU 1개”뿐이고, 실제 초당 요청 수는 서버 응답 시간이 결정했습니다.
문제: 서버가 느려지면 부하도 같이 줄어든다
여기서 진짜 문제가 생깁니다. 스트레스 테스트는 서버를 한계까지 밀어붙이는 게 목적인데, closed model은 서버가 느려지는 순간 부하도 같이 줄어듭니다.
VU들이 응답을 기다리느라 루프가 늦게 돌고, 그만큼 새 요청이 덜 들어갑니다. 문서는 이걸 이렇게 표현합니다.
“when the target system is stressed and starts to respond more slowly, a closed model load test will wait, resulting in increased iteration durations and a tapering off of the arrival rate.”
부하 생성기가 알아서 브레이크를 밟아주는 자기제한(self-limiting) 구조입니다. 그래서 결과 그래프는 이렇게 나옵니다.
- 응답 시간은 늘어남
- 그런데 에러율은 안 올라감
- TPS는 어느 지점부터 평평해짐
이걸 보고 “한계에 도달했지만 그래도 버티고 있다”고 읽으면 착시입니다. 실제로는 서버가 버틴 게 아니라 부하를 덜 넣은 것에 가깝습니다. 실제 트래픽은 서버가 느려진다고 해서 사용자들이 요청을 줄여주지 않습니다. 이 간극이 부하테스트에서 흔히 말하는 coordinated omission입니다.
임계값도 근사치였다
이때 쓰고 있던 threshold도 다시 보게 됐습니다.
thresholds: {
http_reqs: ['rate>=400'],
}이 400이라는 숫자는 “VU 몇 개 정도면 대충 이만큼 나오겠지”에서 나온 값이었습니다. 우리가 실제로 검증해야 할 목표 TPS와는 아무 관련이 없는, VU 기반 근사치였던 겁니다. TPS를 지정할 수 없으니 임계값도 추정에서 시작할 수밖에 없었습니다.
2. ramping-arrival-rate: 요청 비율을 직접 지정한다
ramping-arrival-rate는 open model executor입니다. iteration 시작을 iteration 소요 시간에서 분리합니다.
“The open model decouples VU iterations from the iteration duration. The response times of the target system no longer influence the load on the target system.”
서버가 느려지든 말든 지정한 비율로 계속 요청을 밀어 넣습니다. 그래서 “목표 TPS를 진짜로 버티는가”를 정직하게 검증할 수 있습니다. 한계를 넘으면 응답 시간이 튀고 에러가 나고 VU가 폭증하는 형태로, 숨지 않고 드러납니다.
옵션
| 옵션 | 필수 | 설명 | 기본값 |
|---|---|---|---|
stages | O | 목표 iteration 비율까지 오르내릴 구간 배열 | [] |
preAllocatedVUs | O | 테스트 시작 전 미리 확보할 VU 수 | — |
startRate | 시작 시점의 timeUnit당 iteration 수 | 0 | |
timeUnit | startRate와 stages.target에 적용할 시간 단위 | 1s | |
maxVUs | 테스트 중 허용할 최대 VU 수 | 미지정 시 preAllocatedVUs와 동일 |
여기서 핵심은 target이 VU 수가 아니라 iteration 비율이라는 점입니다. timeUnit: '1s'이면 target: 1200은 곧 1,200 rps입니다. 검증하려는 목표 TPS를 그대로 숫자로 적을 수 있습니다.
바꾼 스크립트
export const options = {
discardResponseBodies: true,
scenarios: {
api_load: {
executor: 'ramping-arrival-rate',
startRate: 100,
timeUnit: '1s',
preAllocatedVUs: 50,
maxVUs: 3000,
stages: [
{ duration: '1m', target: 500 }, // 500 rps까지 상승
{ duration: '3m', target: 500 }, // 500 rps 유지
{ duration: '1m', target: 1200 }, // 1,200 rps까지 상승
{ duration: '3m', target: 1200 }, // 1,200 rps 유지
{ duration: '1m', target: 0 },
],
},
},
thresholds: {
http_req_failed: ['rate<0.01'],
http_req_duration: ['p(95)<500'],
dropped_iterations: ['count<100'],
},
};
export default function () {
http.get(`${API_URL}/endpoint`);
// sleep 없음
}stages의 숫자가 곧 목표 TPS이므로, 임계값도 “몇 VU면 대충 이 정도”가 아니라 “목표 TPS에서 p95가 500ms 안에 들어오는가”로 바뀝니다. 검증 문장이 훨씬 단단해졌습니다.
3. 쓰면서 알게 된 것들
문서에는 한 줄로만 적혀 있거나, 직접 돌려봐야 체감되는 것들이 있었습니다.
sleep()을 넣으면 안 된다
closed model에서는 sleep()이 사용자 think time을 흉내 내는 필수 요소였습니다. open model에서는 정반대입니다. 문서도 명시합니다.
“the arrival-rate executors already pace the iteration rate through the
rateandtimeUnitproperties.”
요청 간격은 executor가 이미 맞춰주고 있습니다. 여기에 sleep(1)을 넣으면 요청 비율은 그대로인데 iteration 하나가 붙잡고 있는 시간만 1초 늘어납니다. 결과적으로 같은 rate를 유지하는 데 필요한 VU 수만 몇 배로 부풀어 오릅니다. 부하 자체는 그대로인데 부하 생성기 리소스만 낭비되고, VU 수치를 해석할 때도 방해가 됩니다.
preAllocatedVUs는 필수, maxVUs는 사실상 필수
preAllocatedVUs는 required 옵션이라 빼면 시나리오가 시작조차 하지 않습니다. VU 생성은 비싼 작업이라 미리 확보해두는 개념입니다.
문제는 maxVUs입니다. 이건 optional이지만, 생략하면 기본값이 preAllocatedVUs와 같아집니다. 즉 VU 풀이 초기값에서 한 발짝도 못 늘어납니다.
이 상태에서 서버가 느려지면 어떻게 될까요. executor는 rate를 유지하려고 새 iteration을 시작하려는데 가용 VU가 없습니다. 그래서 그 iteration들은 실행되지 못하고 dropped_iterations로 집계됩니다.
여기서 중요한 건 dropped_iterations는 서버가 아니라 부하 생성기 쪽 문제라는 점입니다. 이게 쌓였다는 건 목표 부하를 실제로 넣지 못했다는 뜻이라, 그 구간의 테스트 결과는 신뢰할 수 없습니다. 그래서 저는 아예 threshold로 걸어두고 있습니다.
thresholds: {
dropped_iterations: ['count<100'],
}필요한 VU는 목표 TPS가 아니라 rate × 응답시간으로 정해진다
처음엔 1,200 rps를 넣으려면 VU도 그만큼 있어야 하는 줄 알았습니다. 아닙니다. Little’s Law가 그대로 적용됩니다.
필요 VU ≈ rate × 응답시간
응답이 십수 ms대로 빠르면 1,200 rps여도 1200 × 0.016 ≈ 20, VU 20개 정도로 충분합니다. 실제로 정상 구간에서 k6가 쓰는 VU는 놀랄 만큼 적습니다.
그러니 maxVUs는 “정상일 때 필요한 수”가 아니라 “응답이 느려졌을 때 필요한 수” 를 기준으로 잡아야 합니다. 같은 1,200 rps라도 응답이 1초로 늘어나면 필요 VU는 1,200개가 됩니다.
VU 급증은 그 자체로 백엔드 지연 신호다
위 식을 뒤집으면 유용한 모니터링 지표가 나옵니다. rate는 executor가 고정해주므로, VU 수의 변화는 곧 응답 시간의 변화입니다.
- 정상 구간: VU 20~30에서 평탄
- 장애 구간: VU가 수백~수천으로 급격히 상승
closed model에서는 VU가 내가 정한 입력값이라 아무 정보도 주지 않지만, open model에서는 VU가 출력값이 됩니다. 그래프에서 VU 라인이 꺾여 올라가는 지점이 곧 백엔드가 무너지기 시작한 지점입니다. 저는 이제 응답 시간 그래프보다 VU 그래프를 먼저 봅니다.
4. VU 급증은 “재시도”가 아니다
장애 구간에서 VU가 치솟고 DB 커넥션이 수천 개까지 올라가는 걸 보면 “k6가 실패한 요청을 재시도하면서 증폭시킨 것 아니냐”는 얘기가 나옵니다. 실제로 테스트 결과를 공유했을 때 가장 먼저 받은 질문이기도 했습니다.
k6는 실패한 요청을 자동으로 재시도하지 않습니다. 스크립트에 명시적인 retry 로직을 넣지 않았다면 재시도는 일어나지 않습니다.
그럼 왜 늘어날까요. executor는 rate를 지키려고 매초 새 iteration을 계속 띄웁니다. 응답이 느려지면 먼저 쏜 iteration들이 아직 안 끝난 상태에서 새 iteration이 계속 얹힙니다. 동시에 떠 있는 iteration 개수, 즉 VU만 불어나는 겁니다.
rate 500/s, iteration 50ms → 필요 VU 500 × 0.05 = 25
rate 500/s, iteration 5s → 필요 VU 500 × 5 = 2,500
같은 500 rps인데 필요 VU는 100배가 됩니다.
그리고 서버 입장에서 이건 재시도보다 오히려 나쁩니다. 재시도는 “같은 요청이 다시 오는 것”이지만, 이 경우는 “아직 안 끝난 요청 + 계속 새로 들어오는 요청”이 동시에 쌓이는 구조입니다. 동시 커넥션과 자원 점유량이 실제로 곱해서 늘어납니다. 장애 구간에서 DB 커넥션이 5,000개 이상으로 튀었던 것도 이 메커니즘으로 설명됩니다.
다만 이건 스크립트에 retry 로직이 없다는 전제입니다. 실패 시 재호출하는 코드가 들어 있다면 진짜 재시도가 섞인 것이므로, 원인을 단정하기 전에 스크립트부터 확인하는 게 맞습니다.
5. 그럼 ramping-vus는 언제 쓰나
closed model이 틀린 방식이라는 얘기는 아닙니다. 검증 대상이 다를 뿐입니다.
| API | Front | |
|---|---|---|
| 관심사 | 백엔드 처리량 (TPS) | 앞단 동시 수용력 (CCU) |
| 병목 | 애플리케이션 / DB | ingress / LB / 커넥션 |
| executor | ramping-arrival-rate | ramping-vus |
| 지정하는 값 | 목표 TPS | 목표 동시 접속자 수 |
Front는 “초당 몇 건을 처리하는가”보다 “동시에 몇 개의 연결이 물려 있어도 버티는가”가 중요하다고 생각합니다. VU는 곧 동시 연결이므로, CCU 목표를 VU 수에 그대로 매핑하는 ramping-vus가 오히려 자연스럽습니다.
단, sleep 값 조정이 필수다
여기서 앞의 공식이 다시 발목을 잡습니다.
TPS ≈ VU ÷ (응답시간 + sleep)
CCU 5,000을 검증하려고 VU를 5,000으로 맞췄는데 스크립트에 sleep(0.5)가 그대로 남아 있으면,
5,000 ÷ (0.5 + 0.1~0.3) ≈ 6,250 ~ 8,300 TPS
CCU를 테스트한다면서 API 한계(약 2,000 TPS)를 서너 배 초과하는 부하가 들어갑니다. 그러면 CCU 수용력이 아니라 백엔드 한계를 측정하게 되고, 결과는 “5,000 CCU를 못 버틴다”로 잘못 결론납니다.
그래서 sleep을 실제 사용자 체류 시간에 가깝게 조정했습니다.
export default function () {
http.get(`${FRONT_URL}/`);
sleep(10); // 실사용 체류 시간 기준
}VU 5,000 ÷ (10 + 0.2) ≈ 490 TPS
VU 10,000 ÷ (10 + 0.2) ≈ 980 TPS
이제 VU 목표가 백엔드를 과부하시키지 않으면서 동시 연결 수용력만 검증하는 지점에 놓입니다. closed model에서 sleep은 스타일이 아니라 부하량을 결정하는 파라미터입니다.
정리
- 검증 대상부터 정한다. TPS를 보고 싶은가, 동시 연결 수를 보고 싶은가. executor 선택은 여기서 갈린다.
- TPS가 관심사라면
ramping-arrival-rate. closed model은 서버가 느려질 때 부하도 같이 줄어들어, 한계 지점을 실제보다 관대하게 보여준다. **ramping-arrival-rate를 쓸 땐**sleep()을 빼고,maxVUs를 응답 지연 상황 기준으로 넉넉히 잡고,dropped_iterations를 threshold로 감시한다.**ramping-vus를 쓸 땐**sleep값부터 확인한다. VU 수만 맞추고 sleep을 방치하면 의도한 것과 전혀 다른 TPS가 들어간다.- VU 그래프를 지표로 읽는다. open model에서 VU 급증은 재시도가 아니라 “안 끝난 요청이 쌓이고 있다”는 신호이고, 실제 장애 시점보다 먼저 보인다.
VU 숫자를 키우는 것과 부하를 키우는 것은 다릅니다. 그 둘이 언제 어긋나는지를 알고 나서야, 테스트 결과에 “몇 TPS까지 버팁니다”라고 답할 수 있게 됐습니다.
댓글 (0)