신규 기능을 오픈하거나 대규모 이벤트를 앞두고 내 백엔드 API가 과연 몇 명의 동시 접속자를 견딜 수 있는지 궁금했던 적이 있을 것이다. 다만 정확한 한계 수치나 성능 병목 지점을 모른 채 서비스부터 오픈했다가, 트래픽이 몰려 서버가 다운되고 피를 보는 경우가 많다.
이번에는 가벼우면서도 강력한 부하 테스트 도구인 k6가 정확히 무엇이고, 왜 필요한지, 그리고 어떻게 실제 API 시나리오에 적용하는지 완벽하게 정리해서 소개하겠다.
부하 테스트(Load Testing)는 개발한 시스템이 예상되는 트래픽 수준에서 의도대로 동작하는지 검증하는 필수 절차다. 과거에는 Apache JMeter나 Locust 같은 도구를 많이 사용했다. 하지만 JMeter는 GUI 중심이라 тяжело 메모리를 많이 먹고, Locust는 Python 기반이라 고성능 트래픽 생성을 위해 복잡한 분산 환경을 꾸려야 했다.
k6는 Go 언어로 작성된 고성능 부하 테스트 도구다. 테스트 시나리오는 개발자들에게 친숙한 JavaScript(ES6)로 작성하지만, 내부 실행 엔진은 Go의 고성능 스레드 모델을 활용한다. 덕분에 단일 개발 PC에서도 매우 적은 리소스로 수만 명의 가상 사용자(VU, Virtual Users)를 스위칭하며 트래픽을 만들어낼 수 있다.
| 비교 항목 | k6 | JMeter | Locust |
|---|---|---|---|
| 개발 언어 / 스크립트 | Go / JavaScript | Java / GUI 및 XML | Python / Python |
| 리소스 사용량 | 매우 낮음 (고성능) | 높음 (Java JVM 오버헤드) | 보통 (Python 스레드 한계) |
| CI/CD 연동성 | 매우 우수 (CLI 완벽 지원) | 보통 (XML 설정 복잡) | 우수 |
| 학습 곡선 | 낮음 (JS 문법 사용) | 보통 (GUI 메뉴 익숙 필요) | 낮음 |
k6는 각 운영체제별 패키지 매니저를 이용하면 몇 초 만에 설치할 수 있다. 윈도우, 맥, 리눅스 환경에 맞춰 명령어를 실행해 보자.
macOS의 경우 Homebrew를 사용하여 설치한다.
brew install k6Windows 환경에서는 Choco나 Winget 명령어를 사용할 수 있다.
winget install k6 --source winget설치가 완료되면 simple-test.js 파일을 만들고 기본적인 HTTP GET 요청을 보내는 시나리오를 작성할 수 있다. k6는 HTTP 요청 함수와 테스트 검증을 위한 check 기능을 기본 제공한다.
단순히 단일 URL에 요청을 쏟아붓는 무지성 테스트는 실제 사용자 동작을 반영하지 못한다. 실제 서비스처럼 단계적으로 동시 접속자 수를 늘려가고(Ramp-up), 응답 성공 여부와 응답 시간(Latency) 목표치를 설정해야 제대로 된 결과를 얻을 수 있다.
✗ 사용자의 행동 패턴이나 대기 시간 없이 단순 요청만 계속 보내면, 실제 서비스 환경과 동떨어진 결과가 나오거나 단순 네트워크 병목만 확인하게 된다.
import http from 'k6/http';
export default function () {
// ✗ 인증 헤더나 응답 상태 확인(Check) 없이 단순 GET 요청만 반복
// ✗ 응답 시간이 200ms 이하인지 등의 임계값(Thresholds) 설정 부재
http.get('https://api.example.com/v1/products');
}
✓ 단계적 가상 사용자 증가(Stages), 응답 데이터 검증(Check), 성능 조건(Thresholds), 사용자 대기시간(sleep)을 모두 반영한 스크립트다.
import http from 'k6/http';
import { check, sleep } from 'k6';
// 1. 부하 조건 및 테스트 통과 기준(Thresholds) 설정
export const options = {
stages: [
{ duration: '30s', target: 20 }, // 30초 동안 가상 사용자 20명까지 증가
{ duration: '1m', target: 20 }, // 1분 동안 20명 유전 유지
{ duration: '10s', target: 0 }, // 10초 동안 0명으로 감소
],
thresholds: {
http_req_failed: ['rate<0.01'], // 에러율 1% 미만이어야 성공
http_req_duration: ['p(95)<500'], // 95%의 요청이 500ms 이내에 응답해야 성공
},
};
export default function () {
const url = 'https://api.example.com/v1/products';
const params = {
headers: {
'Content-Type': 'application/json',
'Authorization': 'Bearer YOUR_TEST_TOKEN',
},
};
// 2. HTTP GET 요청 수행
const res = http.get(url, params);
// 3. 응답 검증
check(res, {
'status is 200': (r) => r.status === 200,
'response has data': (r) => r.json().length > 0,
});
// 4. 실제 사용자처럼 1초 대기 (Think Time)
sleep(1);
}
k6 run test-script.js테스트가 종료되면 터미널에 요약 리포트가 출력된다. 주요하게 봐야 할 지표는 다음과 같다.
✓ status is 200
✓ response has data
http_req_failed................: 0.00% ✓ 0 out of 1200
http_req_duration..............: avg=120ms min=80ms med=110ms max=450ms p(90)=180ms p(95)=210ms ✓
http_reqs......................: 1200 19.8/s
vus............................: 1 min=1 max=20http_req_duration 항목에서 p(95) 수치가 210ms로 설정했던 목표치(500ms)를 만족하여 초록색 체크표시가 출력된다. 만약 DB 쿼리 병목이나 커넥션 풀 부족이 발생했다면, 이 단계에서 p(95) 수치가 급증하거나 http_req_failed 수치가 올라가며 경고가 표시된다.
부하 테스트를 진행할 때 개발자들이 흔히 범하는 몇 가지 실수가 있다. 이 부분만 주의해도 훨씬 정확한 데이터를 얻을 수 있다.
- 운영(Production) 환경에서 직접 테스트 수행: 충분한 모니터링이나 격리 없이 운영 DB가 연결된 서버에 부하를 보내면 실제 유저 데이터가 오염되거나 서비스 불능 상태가 될 수 있다. 가능하면 운영과 동일한 스펙의 Staging 환경에서 테스트해야 한다.
- Think Time(sleep) 누락: 반복문 안에서 sleep() 없이 계속 요청을 날리면, 실제 유저 행동 양상과 달라진다. 사용자가 화면을 조회하고 다음 버튼을 누르기까지의 대기 시간을 반영해야 정확한 동시 접속자 수 수치가 계산된다.
- 부하 생성기(클라이언트) 스펙 부족: 부하를 주는 내 로컬 PC의 CPU나 네트워크 대역폭이 100%에 도달하면, 대상 서버는 여유가 있는데도 응답이 지연되는 것으로 잘못 측정될 수 있다. 테스트 중 내 로컬 리소스 사용량도 함께 점검해야 한다.
백엔드 API 성능 병목 추적은 안정적인 서비스 운영을 위한 필수 과정이다. 감으로 '이 정도면 되겠지' 하고 넘어가기보다, 직접 지표를 측정하고 수치화하는 습관이 모여서 견고한 백엔드 시스템을 만든다는 점을 잊지 말자. 이 글의 k6 테스트 스크립트 예제를 참고해 내 서비스의 핵심 API에 부하 테스트를 적용해 보면, 숨겨진 DB 쿼리 병목이나 세션 블로킹 문제를 사전에 발견하고 해결할 수 있을 것이다.