k6와 AWS로 수백 대에서 부하 보내기
JMeter를 쓰다가 k6에서 더 많은 부하를 보낼 수 있었던 경험을 바탕으로, AWS CLI와 SSH로 수백 대를 실행하는 구성을 살펴봅니다. 1~3시간짜리 시험에 드는 서버 비용과 전송료도 계산합니다.
그동안 부하테스트는 JMeter로 해왔습니다. k6를 써보니 같은 설정에서도 훨씬 많은 부하를 보낼 수 있었습니다. 수백 대에서 부하를 만들어야 할 때는 이 차이가 꽤 큽니다. 같은 목표를 더 적은 서버로 만들 수 있으니까요.
대규모 부하테스트를 1년에 한 번 정도 하고, 실제 시험은 1~3시간이면 끝나는 상황을 생각해 봅시다. 필요한 부하는 크지만 서버를 쓰는 시간은 짧습니다. 그 몇 시간을 위해 서버를 계속 유지하기보다는, 시험할 때만 AWS에서 빌리는 편이 잘 맞습니다.
구성도 복잡할 필요가 없습니다. AWS CLI로 EC2를 만들고, 각 서버에 SSH로 k6 실행 명령을 보냅니다. 시험이 끝나면 결과를 모으고 서버를 종료합니다. 상용 도구의 라이선스 비용 없이 큰 부하를 만들 수 있고, 비용은 주로 빌린 서버와 네트워크 사용량에서 나옵니다.
서버마다 k6를 따로 실행합니다
작업용 컴퓨터에서 AWS CLI로 서버를 생성하고 IP 목록을 가져옵니다. k6가 설치된 같은 이미지를 쓰면 서버마다 설치할 필요도 없습니다. 각 서버에 스크립트와 설정을 전달한 뒤 SSH로 실행 명령을 보내면 됩니다.
시험 중에는 각 서버의 k6가 대상 서비스에 직접 요청을 보냅니다. 작업용 컴퓨터를 통해 요청이 지나가지 않으므로, 이 컴퓨터가 수십만 건의 HTTP 요청을 처리할 필요는 없습니다.
SSH로 실행했다고 해서 연결을 3시간 내내 열어 둘 필요는 없습니다. 서버에서 프로세스를 관리하는 systemd에 k6 실행을 맡기면, SSH 연결을 닫아도 시험은 계속됩니다. 중간에 시험을 멈춰야 할 때는 각 서버에 중단 명령을 보내야 하고요.
상시 운영하는 중앙 제어 서버는 없어도, 이번 시험에 어떤 서버가 참여하는지는 알고 있어야 합니다. 생성할 때 실행 ID를 태그로 붙이고 서버 목록을 보관하면, 명령을 못 받은 서버를 찾거나 시험이 끝난 뒤 정리할 때도 같은 목록을 쓸 수 있습니다. 지표를 한곳에 모을 저장소는 별도로 둘 수 있습니다.
JMeter로도 이 구성은 가능합니다. JMeter 운영 지침에는 여러 서버에서 CLI로 독립 실행한 뒤 결과를 합치는 방법이 나옵니다. AWS에서 잠깐 서버를 빌리는 방식에 k6가 꼭 필요한 것은 아닙니다. k6를 쓰면서 기대하는 이점은 한 대에서 만들 수 있는 부하입니다.
한 대에서 얼마나 보낼 수 있나
k6는 Go로 작성된 엔진에서 JavaScript 시나리오를 실행합니다. 같은 설정에서 JMeter보다 많은 부하를 보낼 수 있었지만, 그 차이를 Go와 Java의 차이만으로 설명하기는 어렵습니다. JMeter의 GUI나 응답을 저장하는 리스너를 켜 두었는지, 두 스크립트가 같은 응답 검증을 했는지에 따라서도 결과가 달라집니다. 비교할 때는 JMeter도 CLI로 실행하고, 같은 요청과 검증을 수행하게 맞춰야 합니다.
서버 수를 정하려면 한 대에서 실제 시나리오를 먼저 실행해 봅니다. 요청률을 올리면서 실제로 보낸 요청 수와 CPU, 메모리, 네트워크 사용량을 함께 확인합니다. CPU가 남아 있어도 네트워크가 먼저 한도에 닿을 수 있으니, 최대 요청 수만 보는 것보다는 시험 시간 동안 유지할 수 있는 요청률을 잡는 편이 낫습니다.
예를 들어 한 대에서 초당 1,000건을 꾸준히 보낼 수 있고, 전체 목표가 초당 300,000건이라면 300대가 필요합니다.
1
2
3
필요한 서버 수 = 올림(전체 목표 요청률 ÷ 1대의 지속 가능한 요청률)
= 올림(300,000 ÷ 1,000)
= 300대
1,000건은 계산을 위한 예시입니다. 실제 대수는 한 대에서 확인한 값으로 정해야 합니다. 아래 비용 표에 쓰는 c6i.large 역시 가격 계산용 사양이며, 이 요청률을 측정한 서버는 아닙니다.
가상 사용자(VU) 수와 요청률도 구별해야 합니다. VU는 스크립트를 실행하는 주체이고, 한 번 실행하면서 여러 요청을 보내거나 응답을 기다릴 수 있습니다. VU 1,000개를 설정했다고 초당 1,000건을 보내는 것은 아닙니다.
같은 스크립트를 300번 실행하면
서버를 늘릴 때는 스크립트에 적은 숫자가 전체 목표인지, 한 대의 목표인지부터 정해야 합니다. 전체 초당 6,000회를 보낼 스크립트를 세 대에 그대로 복사하면 각 서버가 6,000회씩 실행합니다. 합계는 18,000회가 됩니다.
각 서버의 요청률을 2,000회로 바꿔도 되지만, k6의 실행 구간 옵션인 execution-segment를 쓸 수도 있습니다. 스크립트에는 전체 목표를 두고, 실행할 때 서버마다 맡을 구간을 지정합니다.
예시에서는 검색 API 하나를 호출하겠습니다. rate는 초당 반복을 시작하는 횟수입니다. 아래 코드는 반복마다 HTTP 요청을 한 번 보내고 리다이렉트도 따라가지 않아서, 계획한 반복 수와 요청 수가 같습니다.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
import http from 'k6/http';
import { check } from 'k6';
export const options = {
discardResponseBodies: true,
scenarios: {
search: {
executor: 'constant-arrival-rate',
rate: Number(__ENV.TOTAL_RATE || 6000),
timeUnit: '1s',
duration: __ENV.DURATION || '2h',
preAllocatedVUs: Number(__ENV.VU_BUDGET || 3000),
maxVUs: Number(__ENV.MAX_VUS || 6000),
gracefulStop: '30s',
},
},
tags: { run_id: __ENV.RUN_ID, loadgen: __ENV.NODE_ID },
};
export default function () {
const response = http.get(`${__ENV.BASE_URL}/api/search?q=load-test`, {
redirects: 0,
timeout: '10s',
tags: { name: 'search' },
});
check(response, { 'HTTP 200': (r) => r.status === 200 });
}
export function handleSummary(data) {
return { 'summary.json': JSON.stringify(data, null, 2) };
}
응답이 느려져도 정한 빈도로 실행을 시작하려고 하는 constant-arrival-rate를 썼습니다. 응답을 기다리는 VU가 늘어나면 더 많은 VU가 필요하고, 준비한 VU를 모두 쓰면 목표 횟수를 채우지 못합니다. 위 VU 수는 설명용 값이므로 실제 시나리오에 맞춰 잡습니다. 이 예시는 상태 코드만 확인하지만, 로그인 토큰을 꺼내거나 본문을 검증해야 하는 요청에서는 본문도 받아야 합니다.
세 서버가 전체 실행을 나눠 맡도록, 각각 다른 구간과 같은 구간 목록을 줍니다. 대상 URL과 실행 ID 같은 환경 변수는 각 서버에 준비해 둔 상태입니다.
1
2
3
4
5
6
7
8
9
10
11
# 서버 A
k6 run --execution-segment '0:1/3' \
--execution-segment-sequence '0,1/3,2/3,1' scenario.js
# 서버 B
k6 run --execution-segment '1/3:2/3' \
--execution-segment-sequence '0,1/3,2/3,1' scenario.js
# 서버 C
k6 run --execution-segment '2/3:1' \
--execution-segment-sequence '0,1/3,2/3,1' scenario.js
300대도 같은 방식입니다. 전체를 300구간으로 나누고 서버마다 하나씩 배정합니다. 전체 목표를 초당 300,000회로 잡으면 각 서버가 1,000회를 맡습니다. 이때 스크립트의 rate까지 다시 300으로 나누면 부하를 두 번 나누게 되므로, rate에는 전체 목표를 그대로 둡니다.
계정이나 주문처럼 상태를 바꾸는 데이터도 서버별로 나눠야 합니다. 실행 구간을 지정해도 각 프로세스가 읽는 계정 파일까지 자동으로 나눠 주지는 않습니다. 모든 서버가 같은 파일의 첫 줄부터 읽으면 같은 계정으로 동시에 로그인하거나 같은 주문을 변경할 수 있습니다.
시작 시각은 따로 맞춥니다
SSH 명령을 보낸 즉시 실행하면 첫 서버가 요청을 보내는 동안 마지막 서버는 아직 명령을 기다릴 수 있습니다. 수백 대를 차례로 실행하다 보면, 초반 부하가 서버 대수에 따라 천천히 올라가게 됩니다.
각 서버에 명령을 미리 보내고 공통 시작 시각까지 기다리게 하면 이 간격을 줄일 수 있습니다. 서버에서 실행할 작은 스크립트가 시작 시각을 기다렸다가 k6를 켜는 방식입니다. 실행 구간 옵션은 부하를 나누는 역할만 하므로 이 대기는 별도로 넣습니다.
그림에서는 명령을 받은 시각이 달라도 모두 60초에 시작합니다. 서버 시계를 맞추고 스크립트와 입력 파일을 미리 전달해 둬야 가능한 흐름입니다. k6 초기화에 걸리는 시간까지 같아지는 것은 아니므로, 시작 뒤에는 실제 요청률이 올라오는 구간을 확인합니다.
시작 시각이 다 됐는데 준비하지 못한 서버가 있다면 전체 시작을 미루는 편이 낫습니다. 실행 중 한 대가 빠져도 다른 서버가 그 부하를 자동으로 가져가지는 않습니다. 결과를 읽을 때도 예정했던 300대보다 실제 참여한 대수와 발생한 요청률을 봐야 합니다.
서버를 빌리는 단계에서 막히는 경우도 있습니다. c6i.large 300대는 총 600 vCPU이고, EC2 온디맨드 한도는 vCPU 수로 적용됩니다. 시험 당일 수백 대를 만들기 전에 계정 한도와 서브넷의 남은 IP를 확인해 두는 것이 좋습니다.
300대를 두 시간 쓰면 얼마일까요?
서울 리전의 Linux c6i.large를 300대 빌려 두 시간 시험한다고 해보겠습니다. 준비와 결과 회수, 종료에 총 30분을 더 잡으면 서버는 2.5시간 살아 있습니다. 이 조건에서 EC2, 공인 IPv4, 디스크 비용을 합치면 $76.51입니다.
Linux 온디맨드는 최소 60초 이후 초 단위로 과금합니다. 두 시간 시험을 위해 하루치 서버 비용을 낼 필요는 없습니다. 대신 요청을 보내지 않고 준비하거나 결과를 회수하는 시간에도 과금된다는 점은 계산에 넣어야 합니다.
2026-10-04에 확인한 서울 리전 공식 단가로 계산했습니다. 실제 청구액이나 성능 측정 결과는 아닙니다.
| 항목 | 단가 또는 가정 |
|---|---|
Linux c6i.large 온디맨드 |
1대에 $0.096/시간 |
| 공인 IPv4 | 1개에 $0.005/시간, 서버마다 1개 |
| gp3 디스크 | $0.0912/GB·월, 서버마다 8GB |
| 디스크의 월 환산 | 30일, 720시간 |
| 준비·결과 회수·종료 시간 | 시험 시간에 총 30분 추가 |
단가는 AWS의 EC2 공개 요금 데이터, EBS 공개 요금 데이터, 공인 IPv4 요금표를 썼습니다. 디스크 비용은 EBS 과금 예시처럼 할당한 용량과 사용 시간으로 계산합니다. gp3 기본 성능을 쓰고, 할인·크레딧·무료 사용량·세금은 넣지 않았습니다.
1
2
3
자원 비용 = 대수 × (시험 시간 + 0.5시간)
× (EC2 시간당 단가 + IPv4 시간당 단가
+ 디스크 8GB × gp3 월 단가 ÷ 720시간)
100대부터 500대까지 같은 조건으로 계산하면 다음과 같습니다. 모든 행에 준비와 정리 30분을 더했습니다.
| 대수 | 시험 시간 | 비용 (USD) |
|---|---|---|
| 100대 | 1시간 | 15.30 |
| 100대 | 2시간 | 25.50 |
| 100대 | 3시간 | 35.70 |
| 300대 | 1시간 | 45.91 |
| 300대 | 2시간 | 76.51 |
| 300대 | 3시간 | 107.11 |
| 500대 | 1시간 | 76.51 |
| 500대 | 2시간 | 127.52 |
| 500대 | 3시간 | 178.52 |
두 시간 시험의 $76.51 중 EC2가 $72.00, IPv4가 $3.75, 디스크가 $0.76입니다. 300대라는 대수에 비해 비용이 작게 나오는 이유는 2.5시간만 쓰기 때문입니다. 준비 연습이나 재시험을 한다면 그만큼 다시 더하면 됩니다.
이 표는 부하발생기 자원 비용입니다. 시험 대상의 서버·DB·로드밸런서·WAF, 지표와 로그 저장, AMI 스냅샷, 운영 인력, 전송료는 포함하지 않았습니다. 시험이 끝나면 디스크와 공인 IP도 정리하는 조건입니다.
서버보다 전송료가 더 나올 수도 있습니다
300대가 초당 1,000건씩 두 시간 요청하면 21억 6,000만 건입니다. 요청 한 건에서 AWS 밖으로 나가는 양이 평균 1KiB라면 송신량은 약 2,059.94GB가 됩니다. 서버 비용은 작아도 네트워크 사용량은 작지 않습니다.
1
2
3
외부 송신량 = 300대 × 초당 1,000건 × 7,200초 × 1,024바이트
= 2,211,840,000,000바이트
≈ 2,059.94GB
1KiB는 1,024바이트이며, 계산의 GB는 1,073,741,824바이트로 환산했습니다. 서울에서 인터넷으로 보내는 첫 유료 10TB 구간은 공식 전송 요금 데이터 기준 $0.126/GB입니다. 월 무료 송신 100GB를 이미 사용했고, 아래 전송량 전체에 첫 유료 구간 단가를 적용할 수 있다고 가정하겠습니다.
평균 송신량을 1KiB와 4KiB로 바꿔 보면 차이가 꽤 큽니다. 마지막 행은 앞의 자원 비용 $76.51까지 더한 값입니다.
| 항목 | 1KiB/회 | 4KiB/회 |
|---|---|---|
| 송신량 (GB) |
2059.94 | 8239.75 |
| 송신료 (USD) |
259.55 | 1038.21 |
| 합계 (USD) |
336.06 | 1114.72 |
서버는 같은 300대인데, 요청 한 건의 송신량이 네 배가 되면 송신료도 네 배가 됩니다. 여기서 송신량은 JSON 본문 크기만 뜻하지 않습니다. 요청 헤더와 본문, 연결과 암호화에 쓰는 바이트까지 포함해 실제 네트워크에서 나간 양으로 잡아야 합니다.
대상이 큰 응답을 돌려주는 경우에는 방향도 구별합니다. AWS에 있는 부하발생기가 받는 응답은 수신 트래픽입니다. 이 응답에 부하발생기 쪽 인터넷 송신료를 곱하지는 않지만, 대상 서비스 쪽에서는 송신 비용이 생길 수 있습니다.
같은 AWS 안에 있어도 사설 IP, 가용 영역, 리전, NAT Gateway와 로드밸런서 경로에 따라 비용이 달라집니다. 공인 IP 없이 사설 서버에서 인터넷으로 요청한다면 NAT의 시간·처리량 요금도 더해야 합니다. 반대로 비용을 아끼려고 내부 경로로 바꾸면 외부 방화벽이나 인터넷 진입 경로가 시험에서 빠질 수 있습니다. 어디까지 시험할 것인지에 맞춰 경로를 정해야 합니다. 경로에 따른 측정 차이는 k6와 APM의 응답시간을 비교한 글에서도 다뤘습니다.
LoadRunner와 비교할 때 남는 일
오픈 소스 k6를 EC2에서 직접 실행하면 k6 상용 서비스 사용료는 없습니다. LoadRunner와 비교하려면 필요한 프로토콜과 VU 수, 보유한 라이선스와 계약 조건을 함께 봐야 합니다. 같은 조건의 견적이 없으므로 절감률을 숫자로 제시하기는 어렵지만, 적어도 이 구성의 AWS 비용은 위처럼 계산할 수 있습니다.
다만 LoadRunner Professional의 프로토콜 지원과 분석 기능까지 필요한 상황이라면 직접 준비할 일이 더 많아집니다. HTTP 요청을 수백 대에서 실행하는 것만으로 같은 기능을 갖추지는 못합니다. 이번 구성에서도 서버 준비, 실행 확인, 결과 수집과 집계는 직접 해야 합니다.
서버별 요청 수는 더하면 되지만, p95를 평균 내서 전체 p95로 쓸 수는 없습니다. p95는 응답시간을 정렬했을 때 95% 지점의 값이므로 전체 요청을 모은 분포에서 다시 구해야 합니다. 각 서버의 summary.json은 실행 여부와 오류를 확인하는 데 쓰고, 전체 p95는 원본 표본이나 합칠 수 있는 히스토그램으로 구합니다. k6의 Prometheus 출력을 쓸 때도 노드별 백분위수의 평균과 전체 분포에서 구한 값을 구별해야 합니다. 응답시간을 합쳐 읽을 때 생기는 문제는 평균 응답시간 글에서 더 다뤘습니다.
JMeter 대신 k6로 한 대에서 더 많은 요청을 보낼 수 있다면 필요한 EC2 대수는 줄어듭니다. EC2와 IP 비용도 함께 내려가겠죠. 하지만 전체 송신량과 경로가 같다면 전송료는 그대로입니다.