Python FastAPI 입문(/12)
Python/FastAPI부하 테스트 - k6
k6로 FastAPI 서버의 실제 트래픽 내성을 검증한다. smoke/load/stress 시나리오 작성부터 CI/CD 통합까지.
2026-04-22
10 min read
#Python#FastAPI#k6#부하 테스트#성능#CI/CD#smoke test#load test
Python FastAPI 입문시리즈 목차
1부하 테스트 - k62Python FastAPI 입문 시리즈 - 시작하기 전에3FastAPI 구조 - 라우터, 3계층 아키텍처4Pydantic과 의존성 주입 - FastAPI의 핵심 두 가지5REST API 만들기 - Router, Service, Repository6SQLAlchemy와 데이터베이스 연동7FastAPI 테스트 - 단위 테스트, 통합 테스트, E2E 테스트, TDD8JWT 인증과 bcrypt 비밀번호 암호화9환경변수 관리 - pydantic-settings와 .env10에러 핸들링과 미들웨어11DB 커넥션 풀, 트랜잭션, 인덱스12비동기 처리 - async/await와 비동기 SQLAlchemy
12편까지 만든 API가 실제 트래픽을 버틸 수 있는지 확인한다. k6는 JavaScript로 시나리오를 작성하는 부하 테스트 도구다.
왜 k6인가
부하 테스트 도구는 JMeter, Locust, Gatling 등 여러 가지가 있다.
| 도구 | 스크립트 언어 | 특징 |
|---|---|---|
| JMeter | XML / GUI | 오래됐고 복잡함. GUI 위주 |
| Locust | Python | Python 친숙하면 좋지만 성능 한계 있음 |
| Gatling | Scala | 고성능이지만 진입 장벽 높음 |
| k6 | JavaScript | 코드 기반, 가볍고 CI/CD 통합 쉬움 |
k6를 쓰는 이유는 세 가지다.
- JavaScript로 작성 — 별도 언어를 배울 필요 없다. ES6 문법으로 그냥 쓰면 된다.
- Go로 구동 — 스크립트는 JS로 작성하지만 실행 엔진은 Go다. 단일 머신에서도 수천 명의 가상 유저를 돌릴 수 있다.
- 코드로 관리 — 테스트 스크립트를 앱 코드와 함께 Git으로 버전 관리한다. CI/CD에 끼워 넣기도 쉽다.
설치
# macOS
brew install k6
# Windows
winget install k6
# Linux (Ubuntu/Debian)
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
# Docker
docker run --rm -i grafana/k6 run - <script.js
스크립트 기본 구조
// tests/load/smoke.js
import http from 'k6/http';
import { check, sleep } from 'k6';
// 1. 테스트 옵션
export const options = {
vus: 10, // Virtual Users — 동시 가상 유저 수
duration: '30s', // 테스트 실행 시간
};
// 2. 기본 함수 — VU마다 반복 실행
export default function () {
const res = http.get('http://localhost:8000/users/1');
// 3. 응답 검증
check(res, {
'status is 200': (r) => r.status === 200,
'response time < 500ms': (r) => r.timings.duration < 500,
});
sleep(1); // 요청 간 1초 대기 (실제 유저 행동 모방)
}
실행:
k6 run tests/load/smoke.js
주요 개념
VU (Virtual User)
가상 유저. 각 VU는 독립적으로 default function을 반복 실행한다.
vus: 50 → 50명이 동시에 API를 두드린다고 상상하면 됨
stages — 트래픽 패턴 정의
실제 트래픽은 갑자기 오지 않는다. 서서히 증가했다가 유지되다 감소한다.
export const options = {
stages: [
{ duration: '1m', target: 20 }, // 0 → 20명으로 증가 (Ramp-up)
{ duration: '3m', target: 20 }, // 20명 유지 (Steady state)
{ duration: '1m', target: 100 }, // 20 → 100명으로 급증 (Stress)
{ duration: '3m', target: 100 }, // 100명 유지
{ duration: '1m', target: 0 }, // 0명으로 감소 (Ramp-down)
],
};
thresholds — 통과/실패 기준
테스트가 성공인지 실패인지 기준을 코드로 정의한다.
export const options = {
thresholds: {
http_req_duration: ['p(95)<500'], // 95%의 요청이 500ms 이내
http_req_failed: ['rate<0.01'], // 실패율 1% 미만
'http_req_duration{name:login}': ['p(99)<1000'], // 로그인 API만 별도 기준
},
};
threshold를 넘기면 k6가 오류 코드를 반환하며 종료한다. CI/CD에서 자동으로 배포를 막을 수 있다.
FastAPI 서버에 적용하기
디렉토리 구조
tests/
└── load/
├── smoke.js ← 기본 동작 확인 (소수 유저)
├── load.js ← 일반 부하 테스트
├── stress.js ← 한계 테스트
└── soak.js ← 장시간 안정성 테스트
smoke.js — 배포 직후 기본 동작 확인
// tests/load/smoke.js
// 목적: 배포 직후 API가 정상 동작하는지 빠르게 확인
// 유저 수가 적어서 부하를 주지 않음
import http from 'k6/http';
import { check, sleep } from 'k6';
const BASE_URL = __ENV.BASE_URL || 'http://localhost:8000';
export const options = {
vus: 1,
duration: '10s',
thresholds: {
http_req_failed: ['rate<0.01'],
http_req_duration: ['p(95)<200'],
},
};
export default function () {
const res = http.get(`${BASE_URL}/users/1`);
check(res, { 'status 200': (r) => r.status === 200 });
sleep(1);
}
load.js — 일반 부하 테스트 (인증 포함)
// tests/load/load.js
// 목적: 예상 트래픽 수준에서 성능 측정
import http from 'k6/http';
import { check, group, sleep } from 'k6';
const BASE_URL = __ENV.BASE_URL || 'http://localhost:8000';
export const options = {
stages: [
{ duration: '1m', target: 20 },
{ duration: '3m', target: 20 },
{ duration: '1m', target: 0 },
],
thresholds: {
http_req_duration: ['p(95)<500'],
http_req_failed: ['rate<0.01'],
},
};
// setup() — 테스트 시작 전 1회 실행. 반환값이 default function에 data로 전달됨
export function setup() {
const loginRes = http.post(
`${BASE_URL}/auth/login`,
JSON.stringify({ email: 'test@example.com', password: 'password123' }),
{ headers: { 'Content-Type': 'application/json' } }
);
check(loginRes, { 'login success': (r) => r.status === 200 });
const token = loginRes.json('access_token');
return { token }; // 모든 VU에 전달
}
export default function (data) {
const headers = {
'Content-Type': 'application/json',
Authorization: `Bearer ${data.token}`,
};
// group() — 관련 요청을 묶어서 결과에서 구분 가능
group('user APIs', () => {
const meRes = http.get(`${BASE_URL}/users/me`, { headers });
check(meRes, { 'get me 200': (r) => r.status === 200 });
});
group('post APIs', () => {
const listRes = http.get(`${BASE_URL}/posts`, { headers });
check(listRes, { 'list posts 200': (r) => r.status === 200 });
});
sleep(1);
}
stress.js — 한계점 찾기
// tests/load/stress.js
// 목적: 서버가 어디서 무너지는지 확인
import http from 'k6/http';
import { check, sleep } from 'k6';
const BASE_URL = __ENV.BASE_URL || 'http://localhost:8000';
export const options = {
stages: [
{ duration: '2m', target: 50 }, // 정상 부하
{ duration: '2m', target: 100 }, // 증가
{ duration: '2m', target: 200 }, // 고부하
{ duration: '2m', target: 300 }, // 한계 탐색
{ duration: '2m', target: 0 }, // 회복 확인
],
};
export default function () {
const res = http.get(`${BASE_URL}/users/1`);
check(res, { 'status 200': (r) => r.status === 200 });
sleep(0.5);
}
결과 읽는 법
k6 run tests/load/load.js 실행 결과:
✓ status 200
checks.........................: 100.00% ✓ 3600 ✗ 0
data_received..................: 2.4 MB 80 kB/s
data_sent......................: 456 kB 15 kB/s
http_req_blocked...............: avg=1.2ms min=1µs med=3µs max=1.5s
http_req_connecting............: avg=600µs min=0s med=0s max=756ms
✓ http_req_duration..............: avg=45ms min=3ms med=38ms max=812ms
{ expected_response:true }...: avg=45ms min=3ms med=38ms max=812ms
✓ http_req_failed................: 0.00% ✓ 0 ✗ 3600
http_req_receiving.............: avg=180µs min=20µs med=100µs max=3ms
http_req_sending...............: avg=23µs min=8µs med=18µs max=620µs
http_req_waiting...............: avg=44ms min=3ms med=37ms max=811ms
http_reqs......................: 3600 120/s
iteration_duration.............: avg=1.04s min=1s med=1.04s max=1.81s
iterations.....................: 3600 120/s
vus............................: 20 min=1 max=20
vus_max........................: 20 min=20 max=20
| 지표 | 의미 | 확인 포인트 |
|---|---|---|
http_req_duration | 전체 응답시간 | p(95), p(99) 값 |
http_req_failed | 실패율 | 0%에 가까워야 함 |
http_reqs | 초당 처리 요청 수 (RPS) | 높을수록 좋음 |
http_req_waiting | 네트워크 제외한 순수 서버 처리 시간 | 서버 시간 |
checks | check() 통과율 | 100%가 목표 |
p(95)는 95번째 백분위수다. 100개 요청이 있으면 느린 순서로 5개는 무시하고, 나머지 95개 중 가장 느린 값이다. 극단적으로 느린 소수를 제외하고 "대부분의 유저가 경험하는 응답속도"를 나타낸다.
환경변수로 대상 서버 교체
# 로컬
k6 run tests/load/load.js
# 스테이징 서버
BASE_URL=https://staging.api.com k6 run tests/load/load.js
# 프로덕션 (주의)
BASE_URL=https://api.com k6 run --vus 5 tests/load/smoke.js
CI/CD 통합 (GitHub Actions)
# .github/workflows/load-test.yml
name: Load Test
on:
push:
branches: [main]
jobs:
load-test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Install k6
run: |
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 smoke test
run: k6 run tests/load/smoke.js
env:
BASE_URL: ${{ secrets.STAGING_URL }}
threshold를 넘기면 k6가 오류 코드를 반환하며 종료 → GitHub Actions가 실패 처리 → 배포 자동 차단
테스트 종류 정리
| 종류 | VUs | duration | 목적 |
|---|---|---|---|
| Smoke | 1~2 | 10~30s | 배포 직후 기본 동작 확인 |
| Load | 예상 최대치 | 5~30m | 정상 트래픽에서 성능 측정 |
| Stress | 최대치 초과 | 30m+ | 한계점과 장애 지점 파악 |
| Soak | 보통 수준 | 수 시간 | 메모리 누수, 커넥션 고갈 등 장기 안정성 |
| Spike | 0 → 폭발 → 0 | 수 분 | 갑작스러운 트래픽 급증 대응 |
정리
배포
│
▼
smoke test — 기본 동작 확인 (1 VU, 30s)
│
▼
load test — 예상 트래픽 성능 측정 (20 VU, 5m)
│
▼
threshold 통과?
├─ Yes → 배포 완료
└─ No → CI/CD 차단 → 원인 파악 (인덱스? 커넥션 풀? 쿼리?)
11편에서 배운 인덱스와 커넥션 풀, 12편의 비동기 처리가 실제로 얼마나 효과가 있는지 k6로 before/after를 비교하면 수치로 확인할 수 있다.