k6 + Grafana 연계 및 응용 사용 후기
부하테스트 도구 k6의 실행 메트릭을 InfluxDB에 저장하고, Grafana 대시보드로 실시간 시각화하는 전 과정을 설치부터 응용까지 정리했습니다. 단순한 테스트 결과를 넘어 팀과 공유 가능한 관측 파이프라인을 구축한 실전 후기입니다.
📑 목차
1. 왜 k6와 Grafana 조합인가
서비스 출시 직전이나 대규모 이벤트를 앞두고 "과연 이 API가 초당 몇 건의 요청을 견딜 수 있을까"라는 질문은 피할 수 없습니다. 과거에는 JMeter, Locust, Apache Bench 같은 도구를 사용했지만, 최근 몇 년 사이 k6가 개발자 친화적 문법과 JavaScript 기반 스크립트 덕분에 가장 활발하게 채택되는 부하테스트 도구로 자리잡았습니다. 특히 2021년 Grafana Labs가 k6를 인수한 뒤, k6는 Grafana 생태계의 관측성 도구들과 자연스럽게 맞물리도록 발전해왔습니다.
k6 단독으로도 CLI에서 충분히 좋은 요약 리포트를 제공합니다. 다만 실제 업무에서는 "테스트가 돌아가는 동안 지금 이 순간의 응답 시간과 에러율을 보고 싶다"거나 "오늘 돌린 테스트와 어제 돌린 테스트를 겹쳐보고 싶다"는 니즈가 반드시 생깁니다. 바로 이 지점에서 InfluxDB라는 시계열 데이터베이스에 메트릭을 적재하고, Grafana 대시보드로 실시간·비교 시각화하는 조합이 빛을 발합니다.
이 글은 로컬 맥북 환경에서 Homebrew로 InfluxDB 1.x + Grafana 10.x + k6 0.50.x를 설치하고, 공개된 k6 대시보드 템플릿을 임포트해 실제 로드 테스트가 실시간으로 그려지는 화면까지 만드는 모든 과정을 다룹니다. 중간중간 시행착오와 해결법, 그리고 단순 후기를 넘어 운영 환경에 응용할 수 있는 포인트를 함께 정리합니다.
2. 왜 Grafana를 선택했는가
k6 결과를 볼 수 있는 선택지는 꽤 많습니다. k6 Cloud, Datadog, New Relic, Grafana Cloud, 그리고 CLI 자체 요약까지. 그럼에도 제가 최종적으로 Grafana + InfluxDB 조합을 선택한 데에는 분명한 이유가 있었습니다. 단순히 "유명하니까"가 아니라, 팀의 요구사항과 비용 구조, 그리고 장기적인 확장성을 놓고 저울질한 결과였습니다.
① 완전한 오픈소스 · 비용 0원
상용 APM은 월 수십만 원의 고정비가 들지만, 로컬·사내 서버에 Grafana + InfluxDB를 띄우면 비용이 전혀 들지 않습니다. 부하테스트처럼 주기적으로 대량 메트릭을 쏟아내는 워크로드에서 과금 걱정 없이 데이터를 저장할 수 있다는 점이 결정적이었습니다.
② k6와의 네이티브 연동
k6 CLI는 --out influxdb=... 옵션 하나로 메트릭을 InfluxDB에 스트리밍합니다. 별도의 Exporter, Agent, 커스텀 코드가 필요 없습니다. Grafana Labs가 k6를 인수한 뒤로는 공식 대시보드 템플릿도 꾸준히 유지보수되고 있어 진입 장벽이 매우 낮습니다.
③ 시계열 데이터에 최적화된 시각화
부하테스트 메트릭은 전형적인 시계열 데이터입니다. VUs, 요청률, 응답 시간 p95/p99, 에러율이 시간에 따라 어떻게 변하는지 겹쳐서 봐야 패턴이 드러납니다. Grafana는 시계열 패널, 변수 템플릿, 줌/팬, 드릴다운, 어노테이션 기능이 업계 최고 수준입니다.
④ 한 화면에 여러 출처 합치기
k6의 부하 메트릭만 보는 것이 아니라, 같은 시간대의 서버 CPU, 메모리, DB 쿼리 시간, 애플리케이션 로그를 한 대시보드에 나란히 놓고 비교할 수 있습니다. Prometheus, Loki, Tempo 등 다른 관측 도구와의 통합이 쉬워 "부하가 늘면 어디가 먼저 무너지는가"를 한눈에 파악할 수 있죠.
⑤ 팀 공유와 히스토리 보존
CLI 요약은 휘발됩니다. 테스트를 한 뒤 터미널을 닫으면 결과가 사라지죠. Grafana 대시보드는 URL 하나로 동료에게 공유할 수 있고, InfluxDB에 저장된 원본 메트릭은 언제든 다시 조회할 수 있습니다. "지난주 배포 전후로 응답 시간이 어떻게 달라졌는지" 같은 회고가 가능해집니다.
⑥ 알림과 자동화까지 자연스럽게 확장
Grafana Alerting을 켜면 "p95 응답시간이 500ms를 넘으면 Slack으로 알림" 같은 정책을 패널 기반으로 설정할 수 있습니다. CI 파이프라인에서 k6 테스트를 돌리고 그 결과를 대시보드·알림으로 연결하면 성능 리그레션을 자동으로 잡아내는 체계가 만들어집니다.
⑦ 러닝 커브와 생태계
Grafana는 사실상 관측성 업계의 공용어입니다. 팀원이 새로 들어와도 학습 자료가 풍부하고, 플러그인·커뮤니티 대시보드가 수천 개 공개되어 있습니다. 개인 프로젝트에서 팀 프로젝트로 확장할 때 발생하는 전환 비용이 거의 없다는 점이 큰 매력이었습니다.
3. 아키텍처 한눈에 보기
세 개의 컴포넌트가 각자의 역할을 분담합니다. k6는 부하 생성기, InfluxDB는 시계열 저장소, Grafana는 시각화 레이어입니다. 아래 흐름도를 따라가면 전체 파이프라인이 한눈에 들어옵니다.
flowchart LR
subgraph Client["로컬 워크스테이션"]
K6["k6 CLI
load-test.js"]
end
subgraph Storage["시계열 저장소"]
DB[("InfluxDB 1.x
Port 8086")]
end
subgraph Viz["시각화"]
GF["Grafana 10.x
Port 3000"]
DASH["k6 Load Test
Dashboard"]
end
T["Target API
test-api.k6.io"]
USER["개발자 / QA / PM"]
K6 -->|"HTTP 요청"| T
T -->|"응답"| K6
K6 -->|"메트릭 스트리밍 out influxdb"| DB
GF -->|"InfluxQL 쿼리"| DB
GF --> DASH
DASH -.->|"팀원 URL 공유"| USER
style K6 fill:#1e1b4b,stroke:#a78bfa,color:#f1f5f9
style DB fill:#1e293b,stroke:#22d3ee,color:#f1f5f9
style GF fill:#1e1b4b,stroke:#fb923c,color:#f1f5f9
style DASH fill:#0f172a,stroke:#3fb950,color:#f1f5f9
▲ k6 부하테스트 메트릭이 InfluxDB를 거쳐 Grafana 대시보드로 흘러가는 전체 흐름
k6는 부하를 생성하면서 동시에 http_req_duration, vus, http_reqs, http_req_failed 같은 빌트인 메트릭을 InfluxDB Line Protocol 형태로 스트리밍합니다. Grafana는 InfluxQL 쿼리로 이 메트릭을 꺼내 패널에 그립니다. 중간에 메시지 큐나 배치 잡이 없기 때문에 테스트 중에도 거의 실시간으로 변화를 관찰할 수 있습니다.
4. InfluxDB 설치와 실행
macOS에서는 Homebrew로 몇 줄이면 끝납니다. InfluxDB는 1.x와 2.x의 쿼리 언어(InfluxQL vs Flux)가 크게 달라서, k6 공식 대시보드와 가장 매끄럽게 맞물리는 1.x 버전을 선택했습니다. 최신이 아니어서 불안한 분들도 있겠지만, 부하테스트 저장소 용도로는 1.x가 오히려 안정적이고 쿼리도 직관적입니다.
# Homebrew로 InfluxDB 1.x 설치
brew install influxdb@1
# 서비스 시작 (백그라운드 데몬으로 등록)
brew services start influxdb@1
# 데이터베이스 존재 확인
influx -execute 'SHOW DATABASES'
▲ InfluxDB 1.x 설치 로그 — brew install influxdb@1 실행 결과
설치 중 Homebrew가 /opt/homebrew/etc/influxdb 경로에 기본 설정 파일을 생성합니다. 별도 수정 없이 기본값으로 실행해도 로컬 개발에는 충분합니다. 실제로 저는 추가 튜닝을 하지 않았고, 포트 8086이 비어있는지만 확인했습니다.
▲ brew services로 InfluxDB를 띄운 뒤 SHOW DATABASES로 초기 상태 확인
처음에는 _internal 데이터베이스만 존재합니다. k6 전용 DB는 첫 번째 k6 실행 시 자동으로 만들어지므로, 이 단계에서는 따로 CREATE DATABASE k6 를 실행할 필요가 없습니다. 굳이 미리 만들고 싶다면 아래처럼 한 줄만 쳐도 됩니다.
# k6 전용 데이터베이스 선행 생성 (선택)
influx -execute 'CREATE DATABASE k6'
influx -execute 'SHOW DATABASES'
5. Grafana 설치와 최초 로그인
Grafana 역시 Homebrew 한 줄이면 설치가 끝납니다. 설치가 끝나면 자동으로 로컬 3000번 포트에서 웹 UI가 뜹니다. 초기 계정은 admin / admin 이며, 최초 로그인 직후 비밀번호를 변경하도록 강제됩니다.
# Grafana OSS 설치
brew install grafana
# 백그라운드 서비스로 실행
brew services start grafana
# 브라우저에서 접속
open http://localhost:3000
▲ brew install grafana 실행 로그 — 다운로드와 설정 파일 경로가 출력됨
최초 접속 시 아래처럼 보라색 그라데이션 배경의 로그인 화면이 뜹니다. 이 화면이 떴다면 Grafana 서비스가 정상적으로 기동했다는 신호입니다. 기본 계정 admin/admin으로 들어가면 비밀번호 변경을 요구합니다.
▲ Grafana 최초 로그인 화면 — http://localhost:3000 접속
로그인 직후에는 Welcome 보드가 보입니다. 여기서 Add your first data source와 Create your first dashboard 두 개의 경로가 있는데, 우리는 둘 다 사용합니다. 먼저 데이터소스로 InfluxDB를 연결하고, 그 다음 k6 공식 대시보드 JSON을 임포트하는 순서입니다.
▲ Grafana Welcome 화면 — 데이터소스 연결과 대시보드 생성 경로가 노출됨
6. 데이터소스 연결
왼쪽 사이드바에서 Connections → Data sources로 이동하면 비어있는 목록이 보입니다. 아직 아무것도 연결하지 않았다는 의미입니다. 오른쪽 상단 Add data source 버튼을 누릅니다.
▲ Data sources 초기 화면 — 아직 연결된 데이터소스가 없음
데이터소스 목록에서 Influx를 검색하면 InfluxDB가 한 개 나옵니다. 클릭해서 상세 설정 화면으로 들어갑니다. 여기서 입력해야 할 값은 생각보다 많지 않습니다. URL, Database, Query Language 세 가지만 정확히 맞추면 됩니다.
▲ Add data source — Influx로 검색한 결과 InfluxDB가 노출됨
데이터소스 필수 입력값
- Query language — InfluxQL (Flux 아님)
- URL —
http://localhost:8086 - Database —
k6 - HTTP Method — GET (기본값)
- Min time interval — 10s (k6 기본 flush 주기와 매칭)
▲ InfluxDB 데이터소스 설정 — URL과 Query language 선택
아래쪽 Save & test 버튼을 누르면 Grafana가 실제로 InfluxDB에 쿼리를 쏴봅니다. 녹색 박스로 datasource is working. 0 measurements found가 뜨면 성공입니다. measurement가 0개라고 걱정할 필요는 없습니다. 아직 k6를 돌리지 않았으니 당연한 결과입니다.
▲ Save & test 성공 — datasource is working. 0 measurements found 메시지
7. 대시보드 JSON 임포트
Grafana의 가장 큰 장점 중 하나는 대시보드를 JSON 한 장으로 포터블하게 공유할 수 있다는 점입니다. 이 글에서는 k6의 주요 메트릭을 보여주는 커스텀 대시보드 JSON을 사용합니다. 아래 전체 JSON을 k6-dashboard.json 파일로 저장해두고 Grafana에 업로드하면 됩니다.
왼쪽 사이드바의 Dashboards 메뉴로 이동한 뒤, 오른쪽 상단 New → Import를 선택합니다.
▲ Dashboards 메뉴 우측 New → Import 드롭다운
임포트 화면에서는 JSON 파일을 드래그앤드롭하거나, 클립보드로 붙여넣거나, Grafana.com 공식 대시보드 ID를 입력할 수 있습니다. 저는 커스텀 JSON을 파일로 드롭하는 방식을 선호합니다. 버전 관리에도 유리하고, 다른 팀원과 Git으로 공유하기 편하기 때문입니다.
▲ Import dashboard 화면 — JSON 파일 업로드 영역
k6-dashboard.json 전체 내용
아래는 이번 글에서 실제로 사용한 대시보드 JSON의 전체 내용입니다. Virtual Users, Requests Per Second, Avg Response Time, Error Rate 스탯 패널 네 개와 시계열 차트 패널 여러 개로 구성되어 있습니다. 그대로 복사해서 k6-dashboard.json 이름으로 저장하면 바로 임포트할 수 있습니다.
{
"annotations": { "list": [] },
"editable": true,
"fiscalYearStartMonth": 0,
"graphTooltip": 1,
"links": [],
"panels": [
{
"gridPos": { "h": 4, "w": 6, "x": 0, "y": 0 },
"id": 1,
"title": "Virtual Users",
"type": "stat",
"datasource": { "type": "influxdb", "uid": "${DS}" },
"fieldConfig": {
"defaults": {
"color": { "mode": "thresholds" },
"thresholds": { "steps": [{ "color": "green", "value": null }] },
"unit": "short"
},
"overrides": []
},
"options": { "colorMode": "value", "graphMode": "area", "reduceOptions": { "calcs": ["lastNotNull"] } },
"targets": [{
"query": "SELECT last(\"value\") FROM \"vus\" WHERE $timeFilter GROUP BY time($__interval) fill(null)",
"rawQuery": true,
"refId": "A",
"resultFormat": "time_series"
}]
},
{
"gridPos": { "h": 4, "w": 6, "x": 6, "y": 0 },
"id": 2,
"title": "Requests Per Second",
"type": "stat",
"datasource": { "type": "influxdb", "uid": "${DS}" },
"fieldConfig": {
"defaults": {
"color": { "mode": "thresholds" },
"thresholds": { "steps": [{ "color": "blue", "value": null }] },
"unit": "reqps",
"decimals": 1
},
"overrides": []
},
"options": { "colorMode": "value", "graphMode": "area", "reduceOptions": { "calcs": ["mean"] } },
"targets": [{
"query": "SELECT sum(\"value\") / 10 FROM \"http_reqs\" WHERE $timeFilter GROUP BY time(10s) fill(null)",
"rawQuery": true,
"refId": "A",
"resultFormat": "time_series"
}]
},
{
"gridPos": { "h": 4, "w": 6, "x": 12, "y": 0 },
"id": 3,
"title": "Avg Response Time",
"type": "stat",
"datasource": { "type": "influxdb", "uid": "${DS}" },
"fieldConfig": {
"defaults": {
"color": { "mode": "thresholds" },
"thresholds": { "steps": [
{ "color": "green", "value": null },
{ "color": "yellow", "value": 200 },
{ "color": "red", "value": 500 }
]},
"unit": "ms",
"decimals": 1
},
"overrides": []
},
"options": { "colorMode": "value", "graphMode": "area", "reduceOptions": { "calcs": ["mean"] } },
"targets": [{
"query": "SELECT mean(\"value\") FROM \"http_req_duration\" WHERE $timeFilter GROUP BY time($__interval) fill(null)",
"rawQuery": true,
"refId": "A",
"resultFormat": "time_series"
}]
},
{
"gridPos": { "h": 4, "w": 6, "x": 18, "y": 0 },
"id": 4,
"title": "Error Rate",
"type": "stat",
"datasource": { "type": "influxdb", "uid": "${DS}" },
"fieldConfig": {
"defaults": {
"color": { "mode": "thresholds" },
"thresholds": { "steps": [
{ "color": "green", "value": null },
{ "color": "red", "value": 0.01 }
]},
"unit": "percentunit",
"decimals": 2
},
"overrides": []
},
"options": { "colorMode": "value", "graphMode": "area", "reduceOptions": { "calcs": ["mean"] } },
"targets": [{
"query": "SELECT mean(\"value\") FROM \"http_req_failed\" WHERE $timeFilter GROUP BY time($__interval) fill(null)",
"rawQuery": true,
"refId": "A",
"resultFormat": "time_series"
}]
},
{
"gridPos": { "h": 8, "w": 24, "x": 0, "y": 4 },
"id": 10,
"title": "Virtual Users over Time",
"type": "timeseries",
"datasource": { "type": "influxdb", "uid": "${DS}" },
"fieldConfig": {
"defaults": {
"color": { "mode": "palette-classic" },
"custom": { "fillOpacity": 20, "lineWidth": 2, "spanNulls": true },
"unit": "short"
},
"overrides": []
},
"options": { "legend": { "displayMode": "list" }, "tooltip": { "mode": "multi" } },
"targets": [{
"query": "SELECT last(\"value\") FROM \"vus\" WHERE $timeFilter GROUP BY time($__interval) fill(null)",
"rawQuery": true,
"refId": "A",
"resultFormat": "time_series"
}]
},
{
"gridPos": { "h": 10, "w": 24, "x": 0, "y": 12 },
"id": 11,
"title": "Response Time (ms)",
"type": "timeseries",
"datasource": { "type": "influxdb", "uid": "${DS}" },
"fieldConfig": {
"defaults": {
"color": { "mode": "palette-classic" },
"custom": { "fillOpacity": 10, "lineWidth": 2, "spanNulls": true },
"unit": "ms"
},
"overrides": []
},
"options": { "legend": { "displayMode": "list" }, "tooltip": { "mode": "multi" } },
"targets": [
{
"query": "SELECT mean(\"value\") FROM \"http_req_duration\" WHERE $timeFilter GROUP BY time($__interval) fill(null)",
"rawQuery": true,
"refId": "avg",
"resultFormat": "time_series",
"alias": "avg"
},
{
"query": "SELECT percentile(\"value\", 90) FROM \"http_req_duration\" WHERE $timeFilter GROUP BY time($__interval) fill(null)",
"rawQuery": true,
"refId": "p90",
"resultFormat": "time_series",
"alias": "p90"
},
{
"query": "SELECT percentile(\"value\", 95) FROM \"http_req_duration\" WHERE $timeFilter GROUP BY time($__interval) fill(null)",
"rawQuery": true,
"refId": "p95",
"resultFormat": "time_series",
"alias": "p95"
},
{
"query": "SELECT max(\"value\") FROM \"http_req_duration\" WHERE $timeFilter GROUP BY time($__interval) fill(null)",
"rawQuery": true,
"refId": "max",
"resultFormat": "time_series",
"alias": "max"
}
]
},
{
"gridPos": { "h": 10, "w": 12, "x": 0, "y": 22 },
"id": 12,
"title": "Requests Per Second",
"type": "timeseries",
"datasource": { "type": "influxdb", "uid": "${DS}" },
"fieldConfig": {
"defaults": {
"color": { "fixedColor": "blue", "mode": "fixed" },
"custom": { "fillOpacity": 30, "lineWidth": 2, "spanNulls": true },
"unit": "reqps"
},
"overrides": []
},
"options": { "legend": { "displayMode": "list" }, "tooltip": { "mode": "multi" } },
"targets": [{
"query": "SELECT sum(\"value\") / 10 FROM \"http_reqs\" WHERE $timeFilter GROUP BY time(10s) fill(null)",
"rawQuery": true,
"refId": "A",
"resultFormat": "time_series",
"alias": "RPS"
}]
},
{
"gridPos": { "h": 10, "w": 12, "x": 12, "y": 22 },
"id": 13,
"title": "Data Transfer",
"type": "timeseries",
"datasource": { "type": "influxdb", "uid": "${DS}" },
"fieldConfig": {
"defaults": {
"color": { "mode": "palette-classic" },
"custom": { "fillOpacity": 15, "lineWidth": 2, "spanNulls": true },
"unit": "bytes"
},
"overrides": []
},
"options": { "legend": { "displayMode": "list" }, "tooltip": { "mode": "multi" } },
"targets": [
{
"query": "SELECT mean(\"value\") FROM \"data_received\" WHERE $timeFilter GROUP BY time($__interval) fill(null)",
"rawQuery": true,
"refId": "received",
"resultFormat": "time_series",
"alias": "received"
},
{
"query": "SELECT mean(\"value\") FROM \"data_sent\" WHERE $timeFilter GROUP BY time($__interval) fill(null)",
"rawQuery": true,
"refId": "sent",
"resultFormat": "time_series",
"alias": "sent"
}
]
},
{
"gridPos": { "h": 10, "w": 12, "x": 0, "y": 32 },
"id": 14,
"title": "Response Time Breakdown",
"type": "timeseries",
"datasource": { "type": "influxdb", "uid": "${DS}" },
"fieldConfig": {
"defaults": {
"color": { "mode": "palette-classic" },
"custom": { "fillOpacity": 10, "lineWidth": 1, "spanNulls": true },
"unit": "ms"
},
"overrides": []
},
"options": { "legend": { "displayMode": "list" }, "tooltip": { "mode": "multi" } },
"targets": [
{
"query": "SELECT mean(\"value\") FROM \"http_req_blocked\" WHERE $timeFilter GROUP BY time($__interval) fill(null)",
"rawQuery": true, "refId": "A", "resultFormat": "time_series", "alias": "blocked"
},
{
"query": "SELECT mean(\"value\") FROM \"http_req_connecting\" WHERE $timeFilter GROUP BY time($__interval) fill(null)",
"rawQuery": true, "refId": "B", "resultFormat": "time_series", "alias": "connecting"
},
{
"query": "SELECT mean(\"value\") FROM \"http_req_tls_handshaking\" WHERE $timeFilter GROUP BY time($__interval) fill(null)",
"rawQuery": true, "refId": "C", "resultFormat": "time_series", "alias": "tls_handshaking"
},
{
"query": "SELECT mean(\"value\") FROM \"http_req_sending\" WHERE $timeFilter GROUP BY time($__interval) fill(null)",
"rawQuery": true, "refId": "D", "resultFormat": "time_series", "alias": "sending"
},
{
"query": "SELECT mean(\"value\") FROM \"http_req_waiting\" WHERE $timeFilter GROUP BY time($__interval) fill(null)",
"rawQuery": true, "refId": "E", "resultFormat": "time_series", "alias": "waiting (TTFB)"
},
{
"query": "SELECT mean(\"value\") FROM \"http_req_receiving\" WHERE $timeFilter GROUP BY time($__interval) fill(null)",
"rawQuery": true, "refId": "F", "resultFormat": "time_series", "alias": "receiving"
}
]
},
{
"gridPos": { "h": 10, "w": 12, "x": 12, "y": 32 },
"id": 15,
"title": "Checks & Errors",
"type": "timeseries",
"datasource": { "type": "influxdb", "uid": "${DS}" },
"fieldConfig": {
"defaults": {
"color": { "mode": "palette-classic" },
"custom": { "fillOpacity": 20, "lineWidth": 2, "spanNulls": true },
"unit": "short"
},
"overrides": [
{ "matcher": { "id": "byName", "options": "errors" }, "properties": [{ "id": "color", "value": { "fixedColor": "red", "mode": "fixed" } }] }
]
},
"options": { "legend": { "displayMode": "list" }, "tooltip": { "mode": "multi" } },
"targets": [
{
"query": "SELECT sum(\"value\") FROM \"checks\" WHERE $timeFilter GROUP BY time($__interval) fill(null)",
"rawQuery": true, "refId": "A", "resultFormat": "time_series", "alias": "checks"
},
{
"query": "SELECT sum(\"value\") FROM \"errors\" WHERE $timeFilter GROUP BY time($__interval) fill(null)",
"rawQuery": true, "refId": "B", "resultFormat": "time_series", "alias": "errors"
}
]
},
{
"gridPos": { "h": 8, "w": 24, "x": 0, "y": 42 },
"id": 16,
"title": "Iteration Duration",
"type": "timeseries",
"datasource": { "type": "influxdb", "uid": "${DS}" },
"fieldConfig": {
"defaults": {
"color": { "fixedColor": "purple", "mode": "fixed" },
"custom": { "fillOpacity": 15, "lineWidth": 2, "spanNulls": true },
"unit": "ms"
},
"overrides": []
},
"options": { "legend": { "displayMode": "list" }, "tooltip": { "mode": "multi" } },
"targets": [{
"query": "SELECT mean(\"value\") FROM \"iteration_duration\" WHERE $timeFilter GROUP BY time($__interval) fill(null)",
"rawQuery": true,
"refId": "A",
"resultFormat": "time_series",
"alias": "iteration_duration"
}]
}
],
"refresh": "5s",
"schemaVersion": 39,
"tags": ["k6", "load-testing"],
"templating": {
"list": [
{
"current": {},
"hide": 0,
"includeAll": false,
"label": "Data Source",
"multi": false,
"name": "DS",
"options": [],
"query": "influxdb",
"refresh": 1,
"type": "datasource"
}
]
},
"time": { "from": "now-1h", "to": "now" },
"timepicker": {},
"timezone": "browser",
"title": "k6 Load Test Dashboard",
"uid": "k6-custom-v1",
"version": 1
}
${DS} 변수는 Grafana 임포트 다이얼로그에서 실제 데이터소스 이름을 선택해주는 부분입니다. 드롭다운에서 앞서 생성한 InfluxDB를 선택해야 패널이 정상적으로 렌더링됩니다.
8. k6 스크립트 실행
이제 실제로 부하를 쏠 차례입니다. k6 자체도 Homebrew로 설치할 수 있습니다.
# k6 설치
brew install k6
# 버전 확인
k6 version
그 다음 load-test.js 파일을 하나 만들어 간단한 시나리오를 작성합니다. 아래는 10초 램프업, 20초 유지, 10초 램프다운의 총 40초 시나리오입니다. 공식 테스트 API인 test-api.k6.io를 대상으로 합니다.
import http from 'k6/http';
import { check, sleep } from 'k6';
export const options = {
stages: [
{ duration: '10s', target: 5 }, // 10초 동안 0 → 5 VUs 램프업
{ duration: '20s', target: 10 }, // 20초 동안 5 → 10 VUs 유지
{ duration: '10s', target: 0 }, // 10초 동안 10 → 0 VUs 램프다운
],
thresholds: {
http_req_duration: ['p(95)<500'],
http_req_failed: ['rate<0.01'],
},
};
export default function () {
const res = http.get('https://test-api.k6.io/public/crocodiles/');
check(res, {
'status is 200': (r) => r.status === 200,
'duration < 500ms': (r) => r.timings.duration < 500,
});
sleep(1);
}
이제 k6를 --out influxdb 옵션과 함께 실행합니다. 이 한 줄이 바로 InfluxDB로 메트릭을 흘려보내는 핵심입니다.
# k6 실행 + InfluxDB로 실시간 메트릭 스트리밍
k6 run --out influxdb=http://localhost:8086/k6 load-test.js
▲ k6 run 실행 화면 — InfluxDBv1 출력이 활성화된 상태로 시나리오 진행
화면 상단에 k6의 심볼릭한 ASCII 아트가 뜨고, execution: local, script: load-test.js, output: InfluxDBv1 정보가 표시됩니다. 아래쪽 진행바는 현재 실행 시간과 VUs 상태를 실시간으로 업데이트해줍니다. 이 시점부터 Grafana 대시보드를 새로고침하면 숫자가 움직이기 시작합니다.
9. 실시간 대시보드 분석
Grafana 대시보드를 열고 데이터소스 드롭다운에서 InfluxDB를 선택하면, 아래와 같이 상단 스탯 패널 네 개가 즉시 숫자를 보여줍니다. 테스트가 돌아가는 동안은 30초 간격으로 자동 새로고침이 걸려 숫자와 차트가 꿈틀꿈틀 움직입니다.
▲ k6 Load Test Dashboard 상단 — VUs, RPS, Avg RT, Error Rate 스탯과 시계열
▲ k6 Load Test Dashboard 하단 — RPS, Data Transfer, Response Time Breakdown, Iteration Duration
하단 패널은 더 디테일한 메트릭을 보여줍니다. Response Time Breakdown은 DNS 조회, 연결, TLS 핸드셰이크, 전송, 대기, 수신 각 단계의 소요 시간을 스택 차트로 겹쳐 보여주기 때문에 "실제로 오래 걸리는 지점이 어디인지"를 한 번에 파악할 수 있습니다. 네트워크 지연인지, 서버 처리 시간인지, TLS 설정 문제인지를 따로 분리해서 볼 수 있어 매우 유용합니다.
Data Transfer는 송수신 바이트를 보여주어 대역폭 포화 여부를 확인할 수 있고, Iteration Duration은 한 VU가 시나리오 한 회차를 마치는 데 걸린 시간을 보여줍니다. 이 값이 sleep(1)을 포함해 1초 초반이면 정상, 갑자기 3초 이상으로 튀면 서버가 한계에 부딪혔다는 신호로 해석합니다.
10. 응용 — 임계값·알림·CI 연계
10.1 Threshold로 실패 기준 자동화
k6 스크립트의 thresholds 옵션에 http_req_duration: ['p(95)<500'] 같은 조건을 적어두면, 테스트 종료 시 이 조건을 만족하지 못한 경우 종료 코드 99로 빠져나옵니다. CI 파이프라인에서 이 종료 코드를 보고 빌드 실패로 처리할 수 있습니다. 성능 회귀를 자동으로 잡는 가장 간단한 방법입니다.
10.2 Grafana Alerting으로 Slack 알림
각 패널에서 Edit → Alert 탭으로 들어가면 InfluxQL 쿼리 결과를 기반으로 알림 정책을 만들 수 있습니다. 예를 들어 "5분 평균 p95 응답 시간이 500ms를 초과하면 Slack #alerts 채널에 알림"을 걸면, CI가 아닌 실제 운영 환경에서도 성능 이상을 조기에 포착할 수 있습니다.
10.3 GitHub Actions + k6
name: Load Test
on:
pull_request:
branches: [main]
jobs:
k6:
runs-on: ubuntu-latest
services:
influxdb:
image: influxdb:1.8
ports: ['8086:8086']
steps:
- uses: actions/checkout@v4
- name: Run k6
uses: grafana/k6-action@v0.3.1
with:
filename: load-test.js
flags: --out influxdb=http://localhost:8086/k6
풀 리퀘스트가 열릴 때마다 k6를 돌리고 결과를 InfluxDB에 적재하도록 GitHub Actions를 구성한 예시입니다. 결과는 동일한 Grafana 대시보드에서 시간 범위를 넓혀 조회하거나, PR 별 태그를 붙여 비교할 수 있습니다.
10.4 태그와 변수로 멀티 시나리오 비교
k6 스크립트에서 tags: { env: 'staging', build: '2026-04-14' } 처럼 태그를 붙이면 InfluxDB에 같은 메트릭이 태그별로 구분되어 저장됩니다. Grafana에서는 Dashboard Variables로 이 태그를 드롭다운 변수로 만들어 "staging vs production", "빌드 날짜별" 비교 뷰를 구성할 수 있습니다.
11. 한계와 주의점
12. FAQ
사용할 수는 있지만 k6의 --out influxdb= 옵션은 1.x 프로토콜 기준입니다. 2.x를 쓰려면 xk6-output-influxdb 확장 빌드를 직접 만들어야 합니다. 개인·소규모 팀 환경이라면 1.x로 시작하는 것을 강력히 추천합니다.
됩니다. Grafana Cloud의 k6 통합 기능을 쓰면 InfluxDB 없이 바로 Grafana Cloud로 메트릭을 전송할 수 있습니다. 다만 무료 플랜은 보존 기간과 월간 테스트 횟수에 제한이 있으니, 규모가 커지면 자가호스팅 Grafana + InfluxDB가 비용 면에서 유리합니다.
세 가지를 차례로 점검하세요. 첫째, influx -execute 'SHOW MEASUREMENTS ON k6'로 실제 데이터가 들어왔는지 확인합니다. 둘째, Grafana 데이터소스의 Query language가 InfluxQL로 되어있는지 확인합니다. Flux로 설정되어 있으면 이 대시보드는 동작하지 않습니다. 셋째, 대시보드 상단 Data Source 변수 드롭다운에서 앞서 만든 InfluxDB 이름이 선택되어 있는지 확인합니다.
둘 다 가능합니다. JMeter는 Backend Listener + InfluxDB 플러그인을 통해 메트릭을 쏠 수 있고, Locust는 Prometheus Exporter 또는 InfluxDB Exporter를 붙일 수 있습니다. 다만 개발자 경험(스크립트 문법, 디버깅, CI 통합) 면에서 k6가 가장 매끄러웠습니다. 팀이 이미 JMeter 자산이 많다면 그대로 쓰고 Grafana만 얹는 편이 현실적입니다.
반드시 스테이징 환경 또는 부하테스트 전용 환경에서 먼저 돌리세요. 프로덕션을 대상으로 해야 하는 경우에는 새벽 시간대, 트래픽이 가장 적은 구간을 고르고, VUs를 낮은 수(5~10)에서 점진적으로 올리면서 에러율과 응답 시간을 관찰합니다. 회사 정책에 따라 사전 공지와 모니터링 당직 인원 배치가 필요할 수 있습니다.
마치며
k6 단독으로는 CLI 한 줄짜리 부하테스트 도구였지만, InfluxDB와 Grafana를 붙이는 순간 팀 단위 관측성 파이프라인으로 성격이 완전히 달라졌습니다. 설치부터 대시보드 임포트까지 빠르면 30분, 천천히 해도 1시간이면 끝나는 투자로 얻는 이득치고는 파격적입니다.
특히 Grafana를 선택한 일곱 가지 이유 — 오픈소스 무료, k6와의 네이티브 통합, 시계열 시각화 품질, 멀티 소스 결합, 팀 공유와 히스토리, 알림 자동화, 풍부한 생태계 — 는 시간이 지나도 유효한 가치였습니다. 앞으로 CI/CD에 통합하거나 실서비스 APM과 연결해 나가면서 더 많은 실전 노하우가 쌓이면 후속 글로 공유하겠습니다.
'공부' 카테고리의 다른 글
| 읽는 데 1분 소요는 어떻게 계산할까? N분 소요 기준 정리 (0) | 2026.06.03 |
|---|---|
| MSA란? 채용 공고에서 자주 보이는 마이크로서비스 아키텍처 이해하기 (0) | 2026.06.03 |
| k6 성능 테스트 실 사용 후기 (0) | 2026.04.13 |
| Qdrant와 LLM을 활용한 고객지원 티켓 검색 시스템 구축기 (0) | 2025.10.02 |
| Web Worker로 UI 멈춤 문제 해결하기 (0) | 2025.04.02 |
