
성능 문제는 사용자가 느끼기 전에 발견하기 어렵다. 로컬에서는 빠른데 스테이징에서 느리고, 스테이징에서는 괜찮은데 프로덕션에서만 터지는 일은 흔하다. 그래서 부하 테스트는 여전히 가장 비싼 보험 중 하나다. 잘못하면 시간만 쓰고 결론은 “뭔가 느림”으로 끝나지만, 잘하면 배포 전에 회귀를 잡고, 용량 계획과 SLO 논의의 근거가 된다.
이 글은 Grafana k6를 기준으로, 스크립트 작성부터 시나리오 설계, 지표 읽기, CI에 게이트로 묶는 방법까지 한 흐름으로 정리한다. 특정 클라우드 벤더에 종속되지 않으려는 팀에도 맞도록 구성했다.
왜 k6인가
부하 도구는 JMeter, Gatling, Locust 등 선택지가 많다. k6가 자주 선택되는 이유는 대략 다음과 같다.
- 코드로 시나리오를 쓴다(JavaScript 호환 문법). Git으로 버전 관리하고 리뷰하기 쉽다.
- CLI가 단순하고 CI에 붙이기 좋다.
- Grafana 스택과의 연계, 클라우드 실행(k6 Cloud) 등 운영 경로가 정리되어 있다.
반면 브라우저 렌더링이나 복잡한 GUI 시나리오는 다른 도구가 나을 수 있다. k6는 주로 HTTP/gRPC 등 프로토콜 레벨 부하에 강하다.
최소 스크립트와 실행
아래는 개념을 보여주는 예시다. 실제 URL·헤더·페이로드는 서비스에 맞게 바꾼다.
import http from "k6/http";
import { check, sleep } from "k6";
export const options = {
vus: 10,
duration: "30s",
};
export default function () {
const res = http.get("https://example.com/api/health");
check(res, {
"status is 200": (r) => r.status === 200,
});
sleep(1);
}실행은 k6 run script.js 한 줄로 끝난다. 여기서 중요한 것은 “돌아간다”가 아니라 “무엇을 측정하는가”다.
부하 모델: VU, RPS, 스테이지
용어를 정확히 쓰자.
- VU(Virtual User): 동시에 시나리오를 수행하는 가상 사용자 수다. 생각보다 “동시 접속자”와 일대일로 대응하지 않는다. 사용자 한 명이 짧은 요청을 자주 보내면 VU는 적어도 RPS는 높을 수 있다.
- RPS(Requests Per Second): 초당 요청 수. 시스템 처리량의 핵심 지표 중 하나다.
- 스테이지(ramp-up/ramp-down): VU나 목표 RPS를 시간에 따라 올리고 내리는 구간.
잘못된 부하 테스트의 대표는 “그냥 VU 1000으로 때려본다”다. 현실 트래픽 패턴과 다르면, 병목이 엉뚱한 곳으로 보이거나 반대로 숨는다.
현실적인 패턴 예시
- 점진적 증가: 낮은 부하에서 시작해 단계적으로 올린다. 어디서 응답 시간이 꺾이는지 본다.
- 스파이크: 짧은 시간에 급증하는 트래픽(이벤트, 뉴스 알림)을 흉내 낸다.
- 스트레스: 한계까지 밀어 어디가 먼저 무너지는지 확인한다. 한계치 자체가 문서화된다.
k6에서는 scenarios로 여러 패턴을 동시에 돌릴 수 있다. 팀은 “우리 서비스의 피크 시간대 그래프”를 대략이라도 그려 두고 시나리오에 반영해야 한다.
임계값(thresholds)과 SLO 연결
부하 테스트의 산출물은 그래프가 아니라 합격/불합격 기준이어야 한다. k6의 thresholds는 그 역할을 한다.
export const options = {
thresholds: {
http_req_duration: ["p(95)<500"],
http_req_failed: ["rate<0.01"],
},
};여기서 p95 지연 500ms 미만 같은 숫자는 어디서 오는가? 하늘에서 떨어지지 않는다. 제품의 SLO(예: “핵심 API p95 300ms”)에서 완화된 버전을 두거나, 스테이징 환경 한계를 반영해 조정한다. 중요한 것은 이 숫자를 서비스 오너와 합의했다는 사실이다.
check()와 임계값의 차이
check()는 요청 단위 검증이다. 비즈니스적으로 “장바구니 합계가 맞는가” 같은 것도 넣을 수 있다.thresholds는 전체 집계에 대한 패스 조건이다.
둘 다 없으면 “높은 RPS인데 전부 500 에러” 같은 상황을 놓칠 수 있다.
데이터 준비와 테스트 격리
부하 테스트는 프로덕션에서 함부로 돌리면 안 된다는 말이 먼저다. 데이터가 오염되거나, 결제·알림이 실제로 발송될 수 있다.
권장 패턴은 다음과 같다.
- 전용 스테이징에 최대한 프로덕션과 비슷한 데이터 볼륨(익명화)을 둔다.
- idempotency key와 테스트 전용 플래그로 부작용을 차단한다.
- 캐시를 비운 상태와 캐시가 찬 상태를 둘 다 본다. 결과가 크게 다를 수 있다.
메트릭 해석: 무엇을 봐야 하는가
k6 기본 메트릭 중에서 특히 자주 보는 것은 다음과 같다.
- httpreqduration: 지연 분포. p95, p99를 집중적으로 본다.
- httpreqfailed: 실패율. 타임아웃·5xx·4xx를 구분해 원인을 좁힌다.
- iterations: 시나리오 반복 횟수. 스크립트 로직이 의도대로인지 확인한다.
- vus / vus_max: 동시성 수준.
여기에 애플리케이션 메트릭(CPU, GC, DB 커넥션 풀 대기, 쿼리 지연)을 함께 봐야 병목이 어디인지 알 수 있다. k6만 보면 “느리다”밖에 안 나온다.
병목 후보 체크리스트
- 애플리케이션 CPU 100%: 코드 최적화 또는 인스턴스 수평 확장.
- DB CPU 또는 디스크 I/O: 쿼리·인덱스·연결 풀.
- 네트워크 RTT: 외부 API 의존. 타임아웃·캐시·회로 차단기.
- 락 경합: DB 행 락, Redis 단일 키, 메시지 큐 소비 지연.
gRPC·WebSocket
HTTP 외 프로토콜을 쓴다면 별도 모듈·스크립트 패턴이 필요하다. 핵심은 동일하다. 동시성 모델, 페이로드 크기, 스트림 특성을 반영해 시나리오를 나눈다.
GitHub Actions에 붙이기 — CI 게이트
“매 PR마다 풀 부하”는 비용과 안정성 문제로 어렵다. 현실적인 타협은 다음과 같다.
- 스모크 수준: 짧은 시간, 적은 VU로 회귀만 감지한다.
- 메인 브랜치 야간: 긴 시나리오는 스케줄 워크플로에서 돌린다.
- 릴리즈 직전: 수동 승인 후 강한 부하.
워크플로 개념
k6 run이 exit code 0이면 통과, 임계값 실패 시 비제로로 실패하게 한다.- 스테이징 URL은 시크릿에 두고, 필요하면 VPN이나 허용 IP를 맞춘다.
- 결과를 아티팩트로 저장하거나 Grafana에 푸시한다.
YAML 예시는 환경마다 다르므로, 여기서는 단계만 적는다.
- k6 바이너리 설치(또는 컨테이너)
- 스크립트 체크아웃
k6 run+ 임계값- 실패 시 PR 코멘트 또는 Slack 알림
이렇게 해야 “어제까지 됐는데 오늘 느려짐”을 코드 변경과 연결할 수 있다.
성능 테스트 조직 문화
도구만으로는 부족하다. 다음이 있어야 한다.
- 성능 예산(예: 목록 API 한 페이지당 p95 400ms)
- 환경 동등성(스테이징이 프로덕션과 얼마나 비슷한지 문서화)
- 회귀 대응 프로세스(실패한 PR을 누가, 어떤 기준으로 머지할지)
k6는 이런 문화를 코드와 숫자로 고정하는 역할을 한다.
흔한 실수
- 캐시 히트율 100%인 부하: 현실이 아니다.
- think time 없음: 사용자가 페이지를 읽는 시간 없이 API만 미친 듯이 호출한다.
- 클라이언트 한 대에서만: 네트워크 대역·파일 디스크립터 한계로 가짜 상한이 생긴다. 필요하면 분산 실행을 검토한다.
- 데이터 의존 순서 무시: 주문→결제처럼 상태가 있는 시나리오를 한 요청으로 때우면 실패율만 올라간다.
장기 운영: 결과를 축적하라
한 번 찍고 끝내면 부하 테스트는 금방 썩는다. 같은 시나리오를 주기적으로 돌리고, 커밋 해시·환경 버전·DB 사이즈를 메타데이터로 남겨라. 그래프가 “예전보다 느려졌다”를 증명할 수 있다.
정리
k6는 학습 곡선이 비교적 완만하고, 코드 리뷰 가능한 부하 시나리오를 만든다는 점에서 팀 도구로 잘 맞는다. 핵심은 도구가 아니라 부하 모델과 임계값을 비즈니스와 합의하는 일이다. 그 합의가 있으면 CI 게이트는 자연스럽게 생기고, 성능 이슈는 “누군가의 감”이 아니라 계약이 된다.
서비스가 커질수록 성능은 기능과 같다. 사용자는 changelog를 읽지 않는다. 체감 속도로 평가한다. k6는 그 체감을 배포 전에 재현하게 돕는 도구다. 오늘 스테이징에서 한 번, 임계값을 적어 보고 실패해 보아라. 실패가 보이면 그건 좋은 신호다. 프로덕션에서 터지기 전에 터진 것이니까.
마지막으로, 부하 테스트 결과는 회의 자료 한 장으로 남기길 권한다. 그래프 스크린샷, 임계값 표, 환경 스펙(DB 사이즈, 인스턴스 타입), 실행한 k6 버전까지. 3개월 뒤의 성능 이슈는 거의 항상 “그때와 지금이 다른가?”에서 시작하니까, 기록이 곧 방패다.
부록: 스크립트 품질을 올리는 작은 습관
- 시나리오 파일을 도메인별로 분리하고 공통 헬퍼를 둔다.
- 태그로 엔드포인트 그룹을 나눠 리포트에서 비교한다.
- 환경 변수로 베이스 URL·토큰을 주입하고, 기본값을 프로덕션에 두지 않는다.
이 세 가지만 지켜도, 6개월 뒤의 자신이 고마워할 것이다.
시나리오(scenarios)로 여러 패턴 동시에 돌리기
export const options 안에 scenarios를 정의하면, 한 번의 k6 run으로 서로 다른 트래픽 패턴을 병렬에 가깝게 섞을 수 있다. 예를 들어 “일반 조회 API”와 “무거운 리포트 API”를 동시에 때려 리소스 경합을 본다. 실제 피크 시간에는 사용자 행동이 단일 패턴이 아니기 때문이다.
설계할 때는 각 시나리오에 명확한 exec 함수를 나누고, 공통 헬퍼는 import로 묶는다. 이렇게 해야 스크립트가 길어져도 리뷰 가능한 구조를 유지한다.
// 개념 예시 — 실제 옵션 키는 k6 문서와 버전을 확인할 것
export const options = {
scenarios: {
browse: {
executor: "ramping-vus",
startVUs: 0,
stages: [
{ duration: "2m", target: 50 },
{ duration: "5m", target: 50 },
{ duration: "2m", target: 0 },
],
exec: "browseFlow",
},
spike: {
executor: "constant-vus",
vus: 200,
duration: "1m",
startTime: "3m",
exec: "spikeFlow",
},
},
};
export function browseFlow() {
/* ... */
}
export function spikeFlow() {
/* ... */
}startTime으로 시나리오 간 시간 순서를 주면, “점진적 증가 후 갑자기 스파이크” 같은 현실적인 시퀀스를 연출할 수 있다.
목표 RPS를 쓰는 경우: arrival-rate 실행기
VU 기반 모델은 “사용자 동시성”에 직관적이지만, 목표 처리량을 고정하고 싶을 때는 constant-arrival-rate 같은 실행기를 쓴다. 예를 들어 “초당 500건의 주문 생성 요청을 유지할 수 있는가?”처럼 비즈니스 KPI와 직결된 질문에 맞춘다.
이때 주의할 점은, 시스템이 목표 RPS를 감당하지 못하면 iteration이 밀리고 VU가 부족하다는 신호가 뜬다는 것이다. 그래서 VU 상한을 넉넉히 잡거나, 목표 RPS를 단계적으로 올린다.
커스텀 메트릭과 비즈니스 검증
HTTP 지연만으로는 부족할 때가 있다. 예를 들어 결제 API는 200이어도 응답 본문의 status: "failed"일 수 있다. 이때 Counter나 Rate를 정의해 비즈니스 실패율을 따로 본다.
import { Rate } from "k6/metrics";
const bizFail = new Rate("biz_payment_failed");
export default function () {
const res = http.post(/* ... */);
const body = res.json();
bizFail.add(body.ok !== true);
}그리고 thresholds에 biz_payment_failed를 걸어 “HTTP는 성공인데 장사는 실패”를 잡는다.
소켓 테스트: 분산 실행과 한계
한 대의 k6 프로세스는 네트워크·CPU 한계로 RPS 상한이 있다. 더 높은 부하가 필요하면 k6 분산 실행(execution segments)이나 클라우드 실행을 검토한다. 이때는 동일 시나리오 버전을 고정하고, 클럭 동기화 이슈를 의식해야 한다.
반대로 “우리 회사 노트북 한 대로 프로덕션과 같은 수준”을 기대하면 안 된다. 부하 생성기 자체가 병목이 되면 수치가 왜곡된다. CPU 사용률을 모니터링하라.
소크 테스트(Soak): 오래 눌러보기
짧은 스파이크만 돌리면 메모리 누수, 커넥션 풀 고갈, 디스크 채움 같은 느린 문제를 놓친다. 몇 시간 이상 일정한 부하를 유지하는 소크 테스트는 별도 일정으로 잡는 것이 좋다. 특히 캐시 TTL, 배치 작업, 로그 로테이션과 겹치는 시간대를 고른다.
GitHub Actions 예시 스케치
실제 워크플로는 조직의 러너·시크릿 정책에 맞춰야 한다. 아래는 개념을 잡기 위한 스케치다.
# .github/workflows/k6-smoke.yml (개념)
# on:
# pull_request:
# branches: [main]
# jobs:
# k6:
# runs-on: ubuntu-latest
# steps:
# - uses: actions/checkout@v4
# - name: Install k6
# run: |
# sudo gpg -k
# sudo gpg --no-default-keyring --keyring /usr/share/keyrings/k6-archive-keyring.gpg --keyserver hkp://keyserver.ubuntu.com:80 --recv-keys C5AD17C747E3415A3642D57D77C6C491D6AC1D69
# echo "deb [signed-by=/usr/share/keyrings/k6-archive-keyring.gpg] https://dl.k6.io/deb stable main" | sudo tee /etc/apt/sources.list.d/k6.list
# sudo apt-get update
# sudo apt-get install k6
# - name: Run k6 smoke
# env:
# BASE_URL: ${{ secrets.STAGING_BASE_URL }}
# run: k6 run ./load/smoke.js시크릿 이름, k6 설치 방식, 캐시는 팀 표준에 맞게 조정한다. 중요한 것은 PR마다 동일한 스테이징을 바라보게 하고, 임계값 실패 시 워크플로가 빨간불을 켠다는 점이다.
트레이스·로그와 상관관계
가능하다면 k6 실행 ID를 헤더로 넘기고, 애플리케이션 로그·분산 트레이스(OpenTelemetry 등)에 같은 ID를 남긴다. 그러면 “느린 요청” 하나를 골라 DB 쿼리·외부 API·큐 지연까지 한 줄로 따라갈 수 있다. 부하 테스트는 관측 가능성과 세트일 때 가장 강해진다.
비용과 윤리
스테이징이든 어디든, 타사에 피해를 주는 부하를 절대 보내지 말 것. 허용 IP, WAF, rate limit을 먼저 확인한다. 사내 규정에 부하 테스트 승인 절차가 있다면 따른다.
장애 후 재현
프로덕션 장애가 났을 때, 같은 트래픽 패턴을 k6로 재현하면 수정이 빨라진다. 이때 “그때의 페이로드 분포”를 대략이라도 복원하는 것이 핵심이다. 로그 샘플링이나 대표 트레이스를 모아 시나리오를 버전 관리하라.
결과 해석: 숫자 뒤에 숨은 이야기
같은 p95 지표라도 요청 분포가 다르면 의미가 달라진다. 소수의 극단적 아웃라이어가 p95를 밀어 올린 것인지, 전체가 고르게 나빠진 것인지 확인하라. 전자라면 특정 쿼리·특정 테넌트 문제일 수 있고, 후자라면 전체 용량 문제일 가능성이 크다.
히스토그램을 Grafana에 보낼 수 있다면, 시간대별로 겹쳐 보는 것이 좋다. 배포 직후에만 지연이 튀었다면, 캐시 콜드 스타트, JIT 컴파일, 커넥션 풀 워밍업 같은 일시적 이유일 수 있다.
부하 테스트 전에 확인하는 체크리스트
- 대상 환경이 프로덕션 데이터를 오염시키지 않는가
- 알림·결제·SMS 같은 부작용 경로가 차단됐는가
- 동시성이 실제와 비슷한가 (think time, 시나리오 다양성)
- 임계값이 SLO와 연결돼 있는가
- 실패 시 롤백·원인 분석 담당이 정해져 있는가
체크리스트가 형식이 아니라 회의록 한 줄이라도 남으면, 부하 테스트는 팀의 자산이 된다.
또 한 가지, 지역(latency) 차이를 기억하라. 부하 생성기가 서울에 있고 API가 버지니아에 있으면 RTT만으로도 p95가 커진다. CI 러너 위치와 스테이징 리전을 맞추거나, 결과를 비교할 때 항상 같은 네트워크 경로를 전제로 문서화하라.
마지막으로
부하 테스트는 한 번의 이벤트가 아니라 제품의 지속적인 질문이다. “우리는 얼마나 버틸 수 있는가?”에 대한 답은 코드 한 줄이 아니라, 시나리오·임계값·관측이 합쳐졌을 때 비로소 신뢰를 얻는다. k6는 그 답을 자동화하기 좋은 도구다. 이 글이 스테이징에서의 첫 thresholds 실패를, 프로덕션의 미래의 성공으로 바꾸는 데 도움이 되기를 바란다.