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

12편까지 만든 API가 실제 트래픽을 버틸 수 있는지 확인한다. k6는 JavaScript로 시나리오를 작성하는 부하 테스트 도구다.

왜 k6인가

부하 테스트 도구는 JMeter, Locust, Gatling 등 여러 가지가 있다.

도구스크립트 언어특징
JMeterXML / GUI오래됐고 복잡함. GUI 위주
LocustPythonPython 친숙하면 좋지만 성능 한계 있음
GatlingScala고성능이지만 진입 장벽 높음
k6JavaScript코드 기반, 가볍고 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네트워크 제외한 순수 서버 처리 시간서버 시간
checkscheck() 통과율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가 실패 처리 → 배포 자동 차단

테스트 종류 정리

종류VUsduration목적
Smoke1~210~30s배포 직후 기본 동작 확인
Load예상 최대치5~30m정상 트래픽에서 성능 측정
Stress최대치 초과30m+한계점과 장애 지점 파악
Soak보통 수준수 시간메모리 누수, 커넥션 고갈 등 장기 안정성
Spike0 → 폭발 → 0수 분갑작스러운 트래픽 급증 대응

정리

배포
│
▼
smoke test — 기본 동작 확인 (1 VU, 30s)
│
▼
load test — 예상 트래픽 성능 측정 (20 VU, 5m)
│
▼
threshold 통과?
├─ Yes → 배포 완료
└─ No  → CI/CD 차단 → 원인 파악 (인덱스? 커넥션 풀? 쿼리?)

11편에서 배운 인덱스와 커넥션 풀, 12편의 비동기 처리가 실제로 얼마나 효과가 있는지 k6로 before/after를 비교하면 수치로 확인할 수 있다.