K6 부하테스트 스크립트 작성법

예진욱·2024년 12월 1일

Monitoring, Load Test

목록 보기
2/4
post-thumbnail

K6 부하테스트, Grafana (+ influx DB) 모니터링, Prometheus 인스턴스 메트릭 수집

위 링크에서 K6 부하테스트 및 Grafana / Prometheus / InfluxDB 모니터링 환경 구축을 언급했다.

이에 이어서 K6 스크립트 작성법에 알아보고자 한다.

K6?

먼저 K6 란 Grafana 에 소속된 부하테스트 도구로써 Grafana 와의 호환성이 뛰어나다.
Grafana 사용자 수가 많은 만큼, 오픈소스도 잘 되어있고 K6 또한 많은 기능들을 지원한다.

또한 Go 언어의 코루틴 기반으로 작성되어 있어,
가상 사용자 (vUser) 수를 대폭 늘릴 수 있다는 것이 가장 큰 장점이다.
JMeter 나 Ngrinder 에서 Heap 메모리 제한으로 인해 가상 사용자 수 (VUs) 최대 값은 수천을 을 넘기기 힘들고, OOM 도 자주 발생한다.
가상 사용자 하나 당 쓰레드 생성으로 인한 메모리 한계 때문인데, 이게 과연 부하 테스트가 맞는지는 의문이다.
K6 는 최대 30,000 ~ 40,000 개의 가상 사용자 수를 지정해도 충분해, 진정한 의미로 부하 테스트가 가능하다.

JMeter vs K6 를 비교한 Grafana 블로그에서 비교 분석글을 확인할 수 있다.

코루틴에 대한 자세한 내용은 아래 포스팅에서 확인 가능하다.






스크립트 작성법

K6 의 테스트 스크립트는 Javascript 로 작성한다.
런타임에는 Javascript Interpreter 를 사용해 Go 엔진으로 실행된다고 보면 된다.
개발자라면 한 번쯤은 다뤄보았을 언어이기도 하고, K6 스크립트 메서드가 직관적이라 어려울 것은 없다.

그래도 어떠한 기능들을 지원하는지는 알아야 하니 자세하게 살펴보자.


1. K6 스크립트 LifeCycle

기본적인 구조는 아래와 같다.

import http from 'k6/http';
import { sleep } from 'k6';

export let options = {
  vus: 10, // 가상 사용자 수
  duration: '30s', // 테스트 실행 시간
};

export function setup() {
  // setup code
  return {
    initData: 'initial setup data'
  }
}

export default function (data) {
  let res = http.get('https://test-api.com');
  console.log(`Response time: ${res.timings.duration}ms`);
  sleep(1);
}

export function teardown(data) {
  // teardown code
}

기본적으로는 테스트 스크립트에 대한 options 를 지정하고, 아래와 같은 lifeCycle 을 가져간다.

  • function setup() 으로 테스트 실행 전 데이터를 정의하고,
  • default function (data) 에서는 실제 테스트할 스크립트를 지정한다.
    data 는 setup 에서 정의한 data 를 의미한다.
  • teardown 에서는 테스트 종료 후 정리 작업을 진행한다.
    마찬가지로 data 는 setup 에서 return 한 객체를 의미한다.
    일반적으로 테스트 중에 저장된 데이터를 삭제하는 로직이 들어간다.

추가적으로,

  • options 객체에서는 가상 사용자 수(vus)와 테스트 시간, Tag 등 다양한 설정을 할 수 있다.
  • sleep(1)은 1명의 가상 사용자가 요청을 마치고 1초간 쉬는 걸 의미한다.
    이렇게 하면 부하를 연속해서 주지 않고 약간의 간격을 줄 수 있다.
    Java 와는 다르게 ms 단위가 아닌 s 단위임에 주의하자.

2. 옵션 설정

options 설정을 통해 부하 테스트의 스케줄을 세부적으로 지정할 수 있다.

2-1. Stages : 단계별 부하를 설정

stages 는 테스트 부하가 주입되는 단계를 설정할 수 있다.
일반적으로 테스트는 RampUp --> Load --> RampBackDown 의 순서로 수행이 된다.

export let options = {
  stages: [
    { duration: '10s', target: 20 }, // 10초 동안 가상 사용자를 20명까지 증가
    { duration: '20s', target: 50 },  // 1분 동안 가상 사용자를 50명으로 유지
    { duration: '10s', target: 0 },  // 30초 동안 가상 사용자를 0명으로 감소
  ],
};

위 예제에서는 RampUp으로 20유저를 10초간 생성한다.
그리고 Load는 50 유저를 20초간 수행하고,
마지막으로 RampBackDown으로 사용자를 10초동안 0으로 만든다.

2-2. Tags : 스크립트에 특정 태그를 붙이기

스크립트 자체에 Tag 를 붙여 해당 테스트 결과는 해당 Tag 를 붙여서 출력하도록 설정할 수 있다.

export let options = {
  tags: { test_name: "test-script-1" }, // 태그 추가
};

2-3. Thresholds: 테스트 중 특정 성능 목표를 설정

thresholds 옵션을 사용하면 테스트 완료 후, 설정된 성능 목표가 충족되었는지 확인할 수 있다.
이를 통해 부하 테스트의 성공 여부를 자동으로 판단할 수 있다.

export let options = {
  thresholds: {
    http_req_duration: ['p(95)<500'], // 95%의 요청이 500ms 이하이어야 함
  },
};

2-4. Summary Trend Stats: 테스트가 종료된 후, 요약 보고서에 포함할 통계의 종류를 설정

summaryTrendStats 옵션을 사용하면 요약 보고서에서 원하는 통계 정보만을 선택적으로 확인할 수 있다.

export let options = {
  summaryTrendStats: ['avg', 'p(95)', 'max'],
};

3. HTTP 요청 메서드

# GET 요청 예시
export default function () {
  let url = 'https://test-api.com/resource';
  let res = http.get(url);
  console.log(`Status code: ${res.status}`);
  console.log(`Response body: ${res.body}`);
}
# POST 요청 예시
export default function () {
  let url = 'https://test-api.com/resource';
  let payload = JSON.stringify({ name: 'John Doe', age: 30 });
  let params = {
    headers: {
      'Content-Type': 'application/json',
    },
  };

  let res = http.post(url, payload, params);
  console.log(`Status code: ${res.status}`);
}

4. Check & Metrics

부하 테스트를 하다 보면 단순히 요청을 보내는 것 외에도, 요청이 성공했는지 여부를 확인하고 싶은 경우가 많다.
이럴 때 check 메서드를 사용해 볼 수 있다.

import { check } from 'k6';

export default function () {
  let res = http.get('https://test-api.com');
  check(res, {
    'status is 200': (r) => r.status === 200,
    'response time < 500ms': (r) => r.timings.duration < 500,
  });
}

위 스크립트에서 check 메서드는 응답 상태 코드가 200인지, 그리고 응답 시간이 500ms 이하인지 확인한다.
이렇게 조건을 걸어두면, 부하 테스트 후에 얼마나 많은 요청이 성공했는지 쉽게 알 수 있다.

5. Group 지정

하나의 테스트 스크립트 안에서도 특정 API 마다, 혹은 특정 로직 별로 구분하고 싶을 수가 있다.
이럴 때 사용되는 것이 Group 이다.

자세한 옵션은 아래에서 확인 가능하다.
grafana k6 - Tags and Groups document

export default function (data) {
  group('POST api/books', function () {
	// API 테스트 또는 로직 테스트 작성...
  }
  group('Review Create And Update', function () {
	// API 테스트 또는 로직 테스트 작성...
  }
}

이렇게 하면 HTTP 결과가 POST api/books, Review Create And Update 라눈 두개의 Group 으로 나뉘게 된다.
Console 의 결과창에는 자세한 정보가 나오지 않아, 저장한 DB 에서 조회하거나 DB 에 연동된 시각화 도구에서 확인이 가능하다.
나의 경우엔 InfluxDB 에 저장 후 Grafana DashBoard 와 연동했다.

6. 외부에서 변수 지정

k6 를 수행하는 명령어는 k6 run your-path/script.js 와 같은 형태이다.
다만 수행할때마다 script 를 일일이 바꾸기 힘드므로 특정 값을 변수로 지정해 수행할 때 외부에서 지정한 값 또는 기본값으로 수행하게 할 수 있다.

외부에서 변수를 주입해 수행하는 예시)

STAGE1_DURATION=5s
STAGE1_TARGET=5000
STAGE2_DURATION=20s
STAGE2_TARGET=10000
STAGE3_DURATION=5s
STAGE3_TARGET=0
k6 run --env STAGE1_DURATION=$STAGE1_DURATION --env STAGE1_TARGET=$STAGE1_TARGET --env STAGE2_DURATION=$STAGE2_DURATION --env STAGE2_TARGET=$STAGE2_TARGET --env STAGE3_DURATION=$STAGE3_DURATION --env STAGE3_TARGET=$STAGE3_TARGET

k6 script 예시)

// 외부 환경변수로부터 stages 값 주입
const stage1_duration = __ENV.STAGE1_DURATION || '10s';
const stage1_target = Number(__ENV.STAGE1_TARGET || 1);
const stage2_duration = __ENV.STAGE2_DURATION || '110s';
const stage2_target = Number(__ENV.STAGE2_TARGET || 1);
const stage3_duration = __ENV.STAGE3_DURATION || '10s';
const stage3_target = Number(__ENV.STAGE3_TARGET || 0);

// 테스트 설정
export let options = {
  stages: [
    { duration: stage1_duration, target: stage1_target }, 
    { duration: stage2_duration, target: stage2_target }, 
    { duration: stage3_duration, target: stage3_target },  
  ],
  ...
};





복합 예시

위 설명한 내용을 기반으로 복합적인 스크립트 작성 예시를 들어본다.

import http from 'k6/http';
import { sleep, check, group } from 'k6';

// 테스트 설정
import http from 'k6/http';
import { sleep, check, group } from 'k6';

// 테스트 설정
export let options = {
  stages: [
    { duration: '10s', target: 20 }, // 10초 동안 가상 사용자를 20명까지 증가
    { duration: '20s', target: 50 }, // 20초 동안 가상 사용자를 50명으로 유지
    { duration: '10s', target: 0 },  // 10초 동안 가상 사용자를 0명으로 감소
  ],
  tags: {                            // 태그 추가
    team : 'server3',
    test_name: 'test-script-2' 
  }, 
  thresholds: {
    http_req_duration: ['p(95)<100'], // 95%의 요청이 100ms 이하이어야 함
  },
};

// setup 함수 - 테스트 실행 전 초기화 작업
export function setup() {
  console.log('Setup: Initializing test setup...');

  // 공통으로 사용할 헤더 초기화
  let headers = {
    'accept': '*/*',
    'Content-Type': 'application/json',
  };

  // 필요한 데이터나 환경 초기화 등 설정
  return {
    initData: 'initial setup data', // 필요 시 데이터를 반환하여 main 함수에 전달
    commonHeaders: headers          // 헤더를 반환하여 main 함수에서 사용
  };
}

// main 함수 - 실제 테스트가 수행되는 부분
export default function (data) {
  let url = 'http://host.docker.internal:8080/api/books';
  let bookId;

  group('POST api/books', function () {
    // __VU: 현재 가상 사용자 ID, __ITER: 해당 VU의 반복 횟수
    let payload = JSON.stringify({
        name: `The Lord of the Rings VU${__VU} ITER${__ITER + 1}`, // VU ID와 반복 횟수를 조합하여 고유한 값으로 변경
        category: 'Fantasy',
        author: {
        name: 'JinUk Ye',
        biography: 'English writer and philologist'
        }
    });

    // POST 요청을 보낸다.
    let res = http.post(url, payload, { headers: data.commonHeaders });

    // POST 요청 응답 검증
    check(res, {
        'is POST status 200 or 201': (r) => r.status === 200 || r.status === 201,   // 상태 코드가 200 또는 201인지 확인
    });

    console.log(`POST Status code: ${res.status}`);

    // POST 응답에서 생성된 ID를 추출한다.
    bookId = res.json().id;
    
  });

  sleep(0.1);       // POST 저장 후 100ms 후에 GET 조회

  // GET 요청 그룹
  group('GET /api/books', function () {
    // 책 ID로 GET 요청을 보낸다.
    if (bookId) {
      let getUrl = `${url}/${bookId}`;
      let getRes = http.get(getUrl, { headers: data.commonHeaders });

      // GET 요청 응답 검증
      check(getRes, {
        'is GET status 200': (r) => r.status === 200, // 상태 코드가 200인지 확인
      });

      console.log(`GET ${getUrl} Status code: ${getRes.status}`);
    } else {
      console.error('No book ID returned from POST request.');
    }
  });

  sleep(0.1);
}

// teardown 함수 - 테스트 종료 후 정리 작업
export function teardown(data) {
  console.log('Teardown: Cleaning up after test...');
  // 테스트가 끝난 후 필요한 정리 작업 수행
}

host.docker.internal 은 내가 임의로 환경 테스트 중인 WSL 에서 docker 외부의 localhost 에 요청을 보내기 위해 작성한 것이니 무시해도 된다.

위와 같이 script 를 작성하고,
K6 부하테스트, Grafana (+ influx DB) 모니터링, Prometheus 인스턴스 메트릭 수집
에서 설정한 것을 기반으로 아래와 같이 k6 스크립트를 실행해보았다.

위와 같이 통합 모니터링 환경을 설정한 것이 아니라면, 그냥 로컬에서 k6 run test-script.js 와 같이 실행해도 무방하다.


docker run --rm --network monitoring_network \
  -v ${docker 외부에서 마운트할 디렉토리}/load-test/${팀명}:/scripts grafana/k6:0.55.0 run \
  --out influxdb=http://influxdb:8086/metrics \
  /scripts/test-script.js

아래와 같은 결과가 콘솔에 출력된다.
50 명의 가상유저(VUs) 로 실행되었고,
모든 유저들이 40초 동안 테스트들이 5016 번을 테스트했음을 알린다.
checks 에는 5016 * 2 = 10034 번의 검증이 통과했고, (각 테스트마다 check 가 2개 있으므로)
이외 나머지 http 관련 값들이 출력된다.


         /\      Grafana   /‾‾/  
    /\  /  \     |\  __   /  /   
   /  \/    \    | |/ /  /   ‾‾\ 
  /          \   |   (  |  (‾)  |
 / __________ \  |_|\_\  \_____/ 

     execution: local
        script: /scripts/test-script.js
        output: InfluxDBv1 (http://influxdb:8086)

     scenarios: (100.00%) 1 scenario, 50 max VUs, 1m10s max duration (incl. graceful stop):
              * default: Up to 50 looping VUs for 40s over 3 stages (gracefulRampDown: 30s, gracefulStop: 30s)
              

# 테스트 진행..
.
.
.

time="2024-12-01T07:11:36Z" level=info msg="Teardown: Cleaning up after test..." source=console

     ✓ is POST status 200 or 201
     ✓ is GET status 200

     checks.........................: 100.00% 10034 out of 10034
     data_received..................: 3.2 MB  79 kB/s
     data_sent......................: 2.3 MB  57 kB/s
     http_req_blocked...............: avg=40.02µs  min=1.7µs    med=4.66µs   max=16.68ms  p(90)=8.02µs   p(95)=10.81µs 
     http_req_connecting............: avg=32.39µs  min=0s       med=0s       max=16.57ms  p(90)=0s       p(95)=0s      
   ✓ http_req_duration..............: avg=3.76ms   min=1.25ms   med=2.83ms   max=33.92ms  p(90)=7.14ms   p(95)=9.45ms  
       { expected_response:true }...: avg=3.76ms   min=1.25ms   med=2.83ms   max=33.92ms  p(90)=7.14ms   p(95)=9.45ms  
     http_req_failed................: 0.00%   0 out of 10034
     http_req_receiving.............: avg=47.23µs  min=12.33µs  med=36.47µs  max=1.72ms   p(90)=74.5µs   p(95)=95.79µs 
     http_req_sending...............: avg=21.8µs   min=4.2µs    med=13.99µs  max=3.3ms    p(90)=37.17µs  p(95)=51.51µs 
     http_req_tls_handshaking.......: avg=0s       min=0s       med=0s       max=0s       p(90)=0s       p(95)=0s      
     http_req_waiting...............: avg=3.69ms   min=1.22ms   med=2.78ms   max=33.7ms   p(90)=7.05ms   p(95)=9.35ms  
     http_reqs......................: 10034   249.732516/s
     iteration_duration.............: avg=209.16ms min=203.57ms med=207.23ms max=243.99ms p(90)=216.19ms p(95)=220.55ms
     iterations.....................: 5017    124.866258/s
     vus............................: 1       min=1              max=49
     vus_max........................: 50      min=50             max=50

이전에 내가 포스팅했던 모니터링 환경을 구축했다면, Grafana 에서 같이 모니터링해보자.

아래는 위 부하테스트를 두 건 (정상 1 건, 에러 발생 1 건) 결과를 Grafana DashBoard 로 모니터링한 결과를 캡처한 예시이다.


내친 김에 VUs (가상 사용자 수) 를 대폭 늘려보자.
JMeter 나 Ngrinder 에서는 상상도 못했던 10,000 으로 화끈하게 테스트해보자.

  stages: [
    { duration: '10s', target: 4000 }, // 10초 동안 가상 사용자를 4,000명까지 증가
    { duration: '20s', target: 10000 }, // 20초 동안 가상 사용자를 1,000명으로 유지
    { duration: '10s', target: 0 },  // 10초 동안 가상 사용자를 0명으로 감소
  ]

결론적으로 40초동안 12만 건 이상의 HTTP 요청을 보냈는데,
아래를 보면 이제 슬슬 지연되는 것이 확인된다.
내 로컬PC 에서 Spring Boot 인스턴스를 띄우고 k6 를 구동해 실제 서버 스펙보다는 떨어진다는 것을 참고하자.






HTTP/HTTPS 외 다른 프토토콜 지원

K6 는 기본적으로 HTTP/HTTPS 기반이기 때문에, Kafka / RabbitMQ 와의 직접적인 부하는 지원하지 않는다.
다만 k6-plugin-kafka 또는 k6-plugin-amqp 등의 플러그인을 사용하면 사용이 가능하다.

관련해 추가로 작성한 포스트

profile
Spring 백엔드 개발자

0개의 댓글