K6 기반 부하 테스트 플랫폼을 GitOps-ready 구조로 확장하기
일회성 k6 스크립트를 웹 실행 플랫폼과 메트릭 파이프라인, GKE를 향한 GitOps-ready 배포 구조로 키워 간 기록. 검증한 것과 하지 않은 것을 나눠 적었다.
K6 기반 부하 테스트 플랫폼을 GitOps-ready 구조로 확장하기
부하 테스트를 할 일이 계속 생겼는데, 매번 조건이 달랐다. 지난번엔 VU를 몇으로 돌렸더라? 그때 결과는 어디 적어놨지? 테스트는 분명 돌렸는데 결과는 문서 어딘가에, 조건은 내 기억 속에 흩어져 있었다. 같은 조건으로 다시 돌려서 비교하고 싶은데 "같은 조건"이 뭐였는지부터 찾아야 하는 상황이 반복되니, 안 되겠다 싶었다.
k6 run script.js 한 줄이면 시작할 수 있는 게 부하 테스트다. 문제는 두 번째 실행부터다. 반복하고, 비교하고, 팀원이 같은 조건으로 재현하고, 장애 상황까지 안전하게 검증하려면 스크립트 하나로는 안 된다.
그래서 질문을 하나 던졌다.
설정, 실행, 이력, 메트릭, 배포 경로를 하나의 플랫폼으로 묶을 수 있을까?
이 글은 그 질문에 답해 간 기록이다. 미리 말해두자면 완성품 자랑이 아니다. GKE/ArgoCD 실환경 동기화처럼 아직 검증하지 않은 부분은 검증하지 않았다고 그대로 적었다.
스크립트에서 플랫폼으로
처음에는 k6 스크립트를 직접 짜서 돌리는 걸로 충분해 보였다. 그런데 "반복 가능한 성능 검증"을 하려고 하니 문제가 줄줄이 나왔다.
가장 아픈 건 재현성이었다. 누가 어떤 VU 수, duration, endpoint, method, body로 테스트했는지 기록되지 않으면 결과 비교 자체가 안 된다. 지난 테스트의 평균 응답 시간을 다시 보려면 그때 터미널 출력을 캡처해 둔 사람을 찾아야 했다.
그다음은 타겟 문제다. 실제 운영 API에 바로 부하를 주기 전에, 플랫폼 자체가 제대로 도는지 확인할 샌드박스가 필요했다. 외부 API 상태가 불안정하면 "타겟이 느린 건지, 내 파이프라인이 잘못된 건지"를 구분할 수 없다. 이걸 구분 못 하는 부하 테스트 도구는 결과를 믿을 수가 없다.
마지막으로 배포. 로컬 Docker에서만 도는 장난감이 아니라, Kubernetes 환경으로 옮길 수 있는 경계와 설정 구조를 처음부터 갖추고 싶었다.
그래서 목표를 이렇게 잡았다. k6를 실행하는 도구가 아니라, 부하 테스트 실행을 제품처럼 다루는 플랫폼.
전체 그림
컴포넌트는 다섯 개로 나눴다.
Control Panel → K6 Runner → Target Server
│ │
▼ ▼
PostgreSQL InfluxDB
| 컴포넌트 | 기술 | 역할 |
|---|---|---|
| Control Panel | Next.js | 테스트 설정 UI, API, 이력 조회 |
| K6 Runner | Express + TypeScript + xk6 | k6 스크립트 생성과 프로세스 실행 |
| Mock Server | NestJS | 성능/장애 시나리오를 가진 샌드박스 타겟 |
| PostgreSQL | PostgreSQL 16 | 테스트 실행 이력과 요약 결과 저장 |
| InfluxDB | InfluxDB 3 | k6 시계열 메트릭 저장과 조회 |
여기서 제일 중요한 결정은 저장소를 둘로 나눈 것이다.
테스트 목록, 실행 상태, 시작/종료 시간, 요청 조건 — 이건 관계형 데이터다. 반면 http_req_duration, vus, data_received 같은 값은 시간 축을 따라 초 단위로 계속 쌓이는 메트릭이다. 성격이 다른 두 데이터를 한 저장소에 억지로 밀어 넣기보다 조회 패턴대로 갈랐다. PostgreSQL은 "나중에 사람이 다시 보는 이력", InfluxDB는 "실행 중에 쌓이는 시계열". 나누고 나니 양쪽 스키마가 훨씬 단순해졌다.
로컬에서 먼저 다 돌게 만들기
클라우드 배포보다 먼저 잡은 건 로컬 재현성이었다.
make local-setup
make local-up
make local-health
이 세 줄로 Control Panel, K6 Runner, Mock Server, PostgreSQL, InfluxDB가 한 번에 뜬다. 각 서비스에 healthcheck를 달았고, 포트는 .env로 조정한다.
외부 타겟 의존성을 없애는 게 생각보다 중요했다. 그래서 Mock Server를 아예 별도 서비스로 뒀다. /success, /performance/slow, /performance/timeout, /performance/variable-latency, /chaos/random 같은 엔드포인트로 성공, 지연, 타임아웃, 랜덤 에러를 내 마음대로 만들 수 있다. 실서비스에서 503이나 timeout을 "일부러" 만들기는 어렵다. Mock Server 안에서는 그게 그냥 된다.
K6 Runner → Mock Server → InfluxDB → Control Panel 경로가 정상이면, 최소한 실행부터 수집, 조회까지의 파이프라인은 살아 있다고 판단할 수 있다.
테스트 실행 플로우
사용자 입력
↓
Control Panel UI
↓
/api/k6/run
↓
K6 Runner /api
↓
임시 k6 스크립트 생성
↓
k6 프로세스 실행
↓
InfluxDB 메트릭 적재
↓
Control Panel completion callback
↓
PostgreSQL 결과 저장
UI는 scenario, VU 수, duration, target URL, method, body 같은 조건을 구조화된 payload로 만들기만 한다. 실제 스크립트 생성과 프로세스 실행은 전부 Runner 몫이다. 이 경계를 그어두니 UI는 실행 엔진의 세부 구현을 몰라도 됐다.
작지만 넣어두길 잘했다 싶은 안전장치도 있다. 사용자가 target URL을 직접 지정할 수 있는 도구라서, metadata endpoint, localhost, private IP 같은 내부 네트워크 대상은 차단했다. 임의 URL로 요청을 쏠 수 있는 플랫폼은 그 자체로 SSRF 도구가 될 수 있기 때문이다.
Runner는 요청을 받으면 고유한 testId를 만들고, 스크립트를 /tmp/k6-test-{testId}.js로 저장한 뒤 child process로 k6 run을 실행한다. 이때 testId를 k6 tag로 함께 심는다. 나중에 InfluxDB에서 특정 테스트의 메트릭만 골라내는 키가 이거다. PostgreSQL의 실행 이력과 InfluxDB의 시계열을 억지로 조인하지 않고 느슨하게 연결하는 장치다.
프로세스 lifecycle도 플랫폼의 일부다
부하 테스트 실행은 HTTP 요청 하나로 끝나는 일이 아니다. 별도 프로세스가 뜨고, stderr가 쏟아지고, dashboard 포트가 열리고, 강제 종료도 일어난다. 그래서 Runner에 프로세스 관리 계층을 따로 뒀다. k6 binary 존재 확인, InfluxDB output 인자 구성, spawn, stderr에서 connection refused 같은 의미 있는 오류 감지, 서버 종료 시 활성 프로세스 정리까지.
고백하자면, 이걸 만들고 한동안은 잘 돌아가는 줄 알았다. 나중에 Linux 컨테이너에서 /proc을 열어 실제로 검증해 보니 구멍이 여럿 나왔다. Node.js ChildProcess의 killed는 "죽었다"가 아니라 "signal을 보냈다"는 뜻이었고, 그 오해 하나 때문에 SIGKILL fallback이 통째로 건너뛰어지고 있었다. 이 삽질은 분량이 길어서 별도 글로 정리할 생각이다.
솔직하게: p95는 아직 없다
Control Panel은 InfluxDB 3 SQL API로 http_req_duration, http_reqs, vus, http_req_failed 같은 값을 조회한다. 그런데 현재 쿼리는 평균, 최소, 최대, count/rate까지만 만든다. p95/p99는 계산하지 않는다.
웃긴 건 PostgreSQL 스키마에는 p95/p99 필드가 있다는 거다. 자리만 파놓고 값은 안 채우는 상태. 그래서 이 플랫폼을 "퍼센타일 분석 지원"이라고 소개하면 그건 과장이다. 지금 확실하게 말할 수 있는 건 "이력은 PostgreSQL에, 실행 중 메트릭은 InfluxDB에 저장하고 조회할 수 있다"까지다. percentile 계산과 threshold 기반 pass/fail 판정은 다음 개선 목록 맨 위에 올려뒀다.
GitOps-ready: push가 곧바로 배포가 되면 안 된다
로컬이 다 돌고 나니 다음 질문은 배포였다. 이 구조를 Kubernetes로 옮긴다면 어떤 선언형 경계가 필요할까?
Helm chart에는 세 서비스의 image, replica, resource, HPA 옵션을 분리해 뒀고, GKE values에는 Artifact Registry, 외부 PostgreSQL/InfluxDB endpoint, External Secrets 경로, NetworkPolicy, Workload Identity annotation까지 표현했다. ArgoCD Application은 이 chart를 source로 보고 automated sync, prune, self-heal을 켠 상태다.
CD 흐름에서 고집한 게 하나 있다.
main push
↓
GitHub Actions image build/push
↓
Artifact Registry에 image tag push
↓
Helm values-gke-dev.yaml image tag 업데이트 PR 생성
↓
PR merge 후 main의 desired state 변경
↓
ArgoCD가 변경된 desired state를 GKE에 동기화
push가 곧바로 클러스터를 바꾸는 구조로 만들지 않았다. 이미지 빌드와 desired state 변경을 분리하고, values 업데이트를 자동 PR로 남긴다. 어떤 이미지 태그가 어떤 커밋으로 배포 의도에 반영됐는지 Git history에 남기고 싶었기 때문이다. GitOps에서 내가 제일 중요하게 보는 건 배포 자동화가 아니라 이 감사 가능성이다.
왜 GKE였나
GKE, EKS, ECS Fargate를 놓고 비교했다. EKS는 데모 규모에서 control plane 고정비가 아깝고, ECS Fargate는 싸지만 Helm, ArgoCD, RBAC, Workload Identity 같은 Kubernetes-native 운영 경험을 보여주기 어렵다. GKE는 초기 credit과 간헐 운영 전략을 전제로 비용 리스크를 낮추면서 Kubernetes 운영 서사를 만들기 좋았다.
셋 다 기술적으로는 가능하다. 그래서 "뭐가 가능한가"가 아니라 "이 프로젝트의 목적에 뭐가 맞는가"로 물었고, 실행 환경은 GKE로 잡고 AWS는 비교 문서와 Terraform 맥락의 설계 대안으로 남겼다. 비용도 아키텍처 결정의 일부라는 걸 이때 체감했다.
검증한 것과 안 한 것
이 글에서 제일 지키고 싶은 경계가 이거다.
로컬에서 통과한 것: config/env consistency 테스트, hardcoded secret 검사, Docker reproducible build 검사, CI workflow 검사, Kubernetes manifest coverage 검사, demo dry-run, Helm lint/template, control-panel build, k6-runner lint/test/build, mock-server test/build, AWS Terraform validate/plan.
반면 GKE credentials 획득, kubectl로 node/pod 확인, ArgoCD app sync, GCP budget alert 생성, cluster start/stop 스크립트의 실제 반영 — 이건 전부 실제 클라우드 접근이 있어야 하는 런타임 검증 항목으로 남아 있다. 그래서 이 프로젝트를 "GKE에서 운영했다"라고 말하지 않는다. 정확한 표현은 이거다.
로컬 실행과 저장소 산출물 기준으로 실행, 관측, 배포 경계를 구성했고, GKE/ArgoCD로 동기화할 수 있는 GitOps-ready 경로를 준비했다. 클라우드 런타임 검증은 별도 체크리스트로 분리했다.
검증 안 한 걸 검증했다고 쓰는 순간, 검증한 것까지 의심받는다. 기술 글이든 이력서든 똑같다.
만들면서 배운 것
부하 테스트의 본체는 스크립트가 아니라 실행 전후의 맥락이었다. 실행 조건, 실행자, 결과, 메트릭, 실패 원인이 함께 남지 않으면 테스트는 일회성 이벤트로 끝난다. 플랫폼화라는 건 결국 그 맥락을 보존하는 일이었다.
그리고 Mock Server는 부가 기능이 아니었다. 파이프라인 문제와 타겟 문제를 분리해 주는 게 이 친구라서, 없었으면 디버깅 때마다 "누구 탓이지"부터 시작했을 거다.
다음 단계
percentile 계산과 threshold 기반 pass/fail 판정, 여러 Runner가 테스트를 나눠 실행하는 분산 구조, 테스트 결과 리포트 자동 생성, 그리고 실제 GKE 런타임 증빙 수집이 남아 있다.
이 중 프로세스 lifecycle 검증은 이후 실제로 진행했다. SIGTERM을 무시하는 프로세스 100회, 정상 실행 1,000회 soak까지. 그 삽질기는 따로 쓴다.
부하 테스트를 스크립트에서 플랫폼으로 키운다는 건 결국 이런 일이었다. 테스트를 한 번 실행하는 게 아니라, 테스트가 반복되고 비교되고 운영될 수 있는 시스템을 만드는 것.
Evidence checklist
글에서 말한 내용의 저장소 근거다.
- Core architecture:
README.md:7-32,README.md:147-195 - Local stack:
docker-compose.yml:1-180,README.md:41-70 - UI payload:
apps/control-panel/components/TestController.tsx:239-260 - Control Panel start API/history:
apps/control-panel/app/api/k6/run/route.ts:63-141 - Completion callback:
apps/control-panel/app/api/tests/complete/route.ts:21-54 - Persistence:
apps/control-panel/lib/test-run-persistence.ts:85-152 - PostgreSQL schema:
services/postgres/init.sql:8-45 - Metrics queries:
apps/control-panel/lib/k6-metrics.ts:83-143 - Metrics percentile limit:
apps/control-panel/lib/k6-metrics.ts:162-169 - Runner execution:
apps/k6-runner-v2/src/modules/test/test.service.ts:57-237 - Process spawning/InfluxDB output:
apps/k6-runner-v2/src/modules/process-manager/process-manager.service.ts:57-105 - Runner health/shutdown:
apps/k6-runner-v2/src/app.ts:47-128,apps/k6-runner-v2/src/app.ts:154-189 - Mock server modules:
apps/mock-server/src/app.module.ts:1-21,README.md:123-145 - Helm/GKE values:
helm/k6-platform/values.yaml:1-141,helm/k6-platform/values-gke-dev.yaml:1-65 - ArgoCD application:
argocd/applications/k6-platform.yaml:10-29 - CI/CD workflows:
.github/workflows/ci.yml:12-104,.github/workflows/cd.yml:23-121 - GKE decision:
docs/architecture/adr-001-why-gke.md:7-80 - Cost/tradeoff:
docs/architecture/cost-comparison.md:12-58,docs/architecture/multi-cloud-tradeoffs.md:17-63 - Verification limits:
docs/runbook/demo-gitops-runbook.md:20-27,docs/runbook/demo-gitops-runbook.md:225-229,docs/work-plan-gke-migration-implementation-status.md:73-107
댓글 0
아직 댓글이 없습니다. 첫 번째 댓글을 작성해보세요!