이 글에서 얻는 것

  • “부하 테스트를 한다"를 도구 실행이 아니라, 목표 설정 → 시나리오 → 측정 → 병목 분석 → 재검증 루프로 설계할 수 있습니다.
  • p95/p99 레이턴시, 에러율, 포화(saturation)를 함께 보고 “어디가 병목인지"를 분류할 수 있습니다.
  • 테스트가 잘못 설계돼서(캐시 워밍업/데이터/환경) 결과가 왜곡되는 흔한 함정을 피할 수 있습니다.
  • 도구별 특성을 비교하고, 팀 상황에 맞는 도구를 선택할 수 있습니다.

0) 부하 테스트는 ‘튜닝’이 아니라 ‘사실 확인’이다

부하 테스트의 목적은 보통 세 가지입니다.

  • 현재 용량(capacity)에서 SLO를 만족하는가?
  • 어느 지점에서 무너지는가(breakpoint)?
  • 병목이 어디인가(DB/락/커넥션 풀/CPU/GC/네트워크)?

즉, 먼저 “어디까지 되는지"를 알고, 그 다음에 튜닝을 합니다.

“성능 테스트 없이 최적화하는 것은, 지도 없이 길을 찾는 것과 같다.” 감이 아니라 데이터로 의사결정해야 합니다.


1) 테스트 종류를 구분하면 계획이 쉬워진다

종류목적부하 패턴지속 시간주요 관측 지표
Load testSLO 만족 확인예상 피크까지 점진 증가10~30분p95, 에러율, 처리량
Stress test한계점 발견예상 피크의 150~300%10~20분breakpoint, 그레이스풀 저하 여부
Spike test순간 급증 대응0 → 피크 → 0 순간 전환5~10분복구 시간, 오토스케일 반응
Soak test장시간 안정성일반 부하 지속2~24시간메모리 누수, GC 증가, 커넥션 누수
Breakpoint test절대 한계 측정무한 증가실패까지max RPS, 첫 에러 지점
💡 실무에서 자주 빠뜨리는 것:
  
  1. Soak test를 안 한다 → 배포 3일 후 메모리 누수로 장애
  2. Spike test를 안 한다 → 이벤트 오픈 시 서킷브레이커 폭발
  3. Stress test만 한다 → "어디까지 되는지"만 알고 "왜 무너지는지"를 모름

2) 목표(SLI/SLO)를 먼저 고정하라

SLI와 SLO의 관계

SLI (Service Level Indicator): 측정하는 지표
  → 예: "주문 조회 API의 응답 시간"

SLO (Service Level Objective): 목표 수준
  → 예: "p95 < 200ms, 에러율 < 0.1%"

SLA (Service Level Agreement): 고객과의 약속
  → 예: "가용성 99.9%, 위반 시 크레딧 환불"

SLO 설정 프레임워크

대체로 아래 3가지는 필수입니다.

분류SLI (지표)SLO (목표)측정 방법
레이턴시p95/p99 응답 시간p95 < 200ms, p99 < 500ms히스토그램(Prometheus)
가용성성공 요청 비율> 99.9% (= 에러 < 0.1%)rate(http_5xx) / rate(http_total)
처리량초당 요청 수피크 시 500 RPS 유지Counter rate
포화도리소스 사용률CPU < 80%, 커넥션 풀 < 90%Gauge

SLO 예시 (주문 서비스):

주문 조회 (GET /api/orders)
  └─ p95 < 200ms, p99 < 500ms, 5xx < 0.1%

주문 생성 (POST /api/orders)
  └─ p95 < 500ms, p99 < 1s, 5xx < 0.05%

검색 (GET /api/search)
  └─ p95 < 300ms, p99 < 800ms, 5xx < 0.5%

SLO가 없으면 부하 테스트는 “느린데요?“로 끝나기 쉽습니다.


3) 시나리오 설계: 실제 사용자 경로를 모델링

좋은 시나리오의 3요소

요소 1: 현실적인 요청 비율 (Traffic Mix)

실제 서비스 트래픽 분석 결과:
  - 상품 목록 조회: 50%   (GET /products)
  - 상품 상세 조회: 30%   (GET /products/{id})
  - 장바구니 추가:  10%   (POST /cart)
  - 주문 생성:      5%    (POST /orders)
  - 결제:           3%    (POST /payments)
  - 기타:           2%

❌ 안 좋은 시나리오: 모든 API를 균등하게 1:1:1 호출
✅ 좋은 시나리오: 실제 비율을 반영

요소 2: Think Time (사용자 대기 시간)

실제 사용자는 쉬지 않고 요청을 보내지 않습니다.

페이지 열람: 5~15초
장바구니 담기 전 고민: 10~30초
결제 정보 입력: 30~60초

Think Time 없는 테스트 → 실제보다 10~50배 높은 RPS
→ "서버가 1000 RPS도 못 버텨요!" (실제 피크는 100 RPS)

요소 3: 데이터 분포

❌ 모든 요청이 같은 상품 ID 조회 → 캐시 히트 100% (비현실적)
❌ 모든 요청이 다른 상품 ID 조회 → 캐시 히트 0% (비현실적)

✅ 현실적 분포:
  - 인기 상품 20%가 조회의 80%를 차지 (파레토)
  - zipf 분포 또는 실제 로그 기반 분포 사용

시나리오 설계 체크리스트

항목확인위험
API 호출 비율이 현실적인가?균등 분배 → 읽기 부하 과소평가
Think Time이 있는가?없으면 RPS 과대 산정
데이터 분포가 현실적인가?단일 키 → 캐시 히트 100%
DB 데이터 규모가 충분한가?적은 데이터 → 인덱스/플랜 문제 안 드러남
인증/세션이 포함되었는가?미포함 → 인증 병목 놓침
파일 업로드/다운로드 포함?대역폭/I/O 병목 놓침

4) 환경: 프로덕션과 비슷해야 의미가 있다

환경 동등성 체크리스트

요소프로덕션테스트 환경차이 영향
서버 스펙4 vCPU, 16GB2 vCPU, 8GBCPU 병목 2배 빨리 도달
인스턴스 수4대 (LB)1대분산 효과 없음
DB 데이터100만 행1,000행인덱스/플랜 완전히 다름
캐시Redis 3대로컬 캐시캐시 누락 패턴 다름
네트워크AZ 간 1ms로컬 0.1ms레이턴시 과소 평가
커넥션 풀max=50max=10풀 고갈 시점 다름
⚠️ 환경 차이가 불가피할 때의 대응:

1. 차이를 문서에 명시: "테스트 환경은 프로덕션의 1/4 스펙"
2. 결과에 보정 계수를 적용하지 않음: 숫자는 그대로 보고
3. 대신 경향(trend)에 집중: "커넥션 풀이 먼저 포화됨 → 프로덕션도 동일 패턴"
4. 정기적으로 프로덕션 환경 테스트 (카나리아/쉐도우 트래픽)

5) 도구 비교와 선택

5-1) 주요 부하 테스트 도구 비교

도구스크립트 언어분산 실행리소스 효율학습 곡선적합한 상황
k6JavaScript✅ (k6-operator)⭐⭐⭐⭐⭐ (Go)낮음API 테스트, CI 통합
JMeterXML/GUI⭐⭐ (Java)중간복잡한 프로토콜, 비개발자
GatlingScala/Java⭐⭐⭐⭐ (Akka)중간Java/Scala 팀
LocustPython⭐⭐⭐ (gevent)낮음Python 팀, 빠른 프로토타입
ArtilleryYAML/JS✅ (Cloud)⭐⭐⭐ (Node)낮음서버리스 테스트
wrk/heyCLI⭐⭐⭐⭐⭐매우 낮음단순 벤치마크, 빠른 확인

5-2) k6 실전 예시: 전체 시나리오

import http from 'k6/http';
import { check, sleep, group } from 'k6';
import { Rate, Trend } from 'k6/metrics';

// 커스텀 메트릭
const errorRate = new Rate('errors');
const orderDuration = new Trend('order_duration');

export const options = {
  // 단계별 부하 증가 (Load Test)
  stages: [
    { duration: '2m', target: 20 },   // 워밍업
    { duration: '5m', target: 50 },   // 정상 부하
    { duration: '3m', target: 100 },  // 피크 부하
    { duration: '2m', target: 0 },    // 쿨다운
  ],
  
  // SLO 기반 임계값
  thresholds: {
    http_req_failed: ['rate<0.01'],              // 에러율 < 1%
    http_req_duration: ['p(95)<200', 'p(99)<500'], // p95 < 200ms
    'http_req_duration{name:order_create}': ['p(95)<500'], // 주문 생성 별도 SLO
    errors: ['rate<0.05'],                        // 커스텀 에러율 < 5%
  },
};

const BASE_URL = __ENV.BASE_URL || 'https://api.example.com';

// 테스트 데이터 (실제로는 CSV/JSON 파일에서 로드)
const PRODUCT_IDS = [101, 102, 103, 204, 305, 406, 507, 608, 709, 810];

export default function () {
  const headers = {
    'Content-Type': 'application/json',
    'Authorization': `Bearer ${__ENV.TOKEN}`,
  };
  
  // ── 시나리오 1: 상품 목록 조회 (50%) ──
  group('상품 목록 조회', () => {
    const res = http.get(`${BASE_URL}/api/products?page=1&size=20`, {
      headers,
      tags: { name: 'product_list' },
    });
    
    check(res, {
      'status is 200': (r) => r.status === 200,
      'has products': (r) => JSON.parse(r.body).content?.length > 0,
    }) || errorRate.add(1);
  });
  
  sleep(Math.random() * 3 + 2); // Think time: 2~5초
  
  // ── 시나리오 2: 상품 상세 조회 (30%) ──
  if (Math.random() < 0.6) {  // 60% 확률로 상세 조회 진행
    group('상품 상세 조회', () => {
      const productId = PRODUCT_IDS[Math.floor(Math.random() * PRODUCT_IDS.length)];
      const res = http.get(`${BASE_URL}/api/products/${productId}`, {
        headers,
        tags: { name: 'product_detail' },
      });
      
      check(res, {
        'status is 200': (r) => r.status === 200,
      }) || errorRate.add(1);
    });
    
    sleep(Math.random() * 5 + 5); // Think time: 5~10초
  }
  
  // ── 시나리오 3: 주문 생성 (5%) ──
  if (Math.random() < 0.1) {
    group('주문 생성', () => {
      const startTime = Date.now();
      
      const payload = JSON.stringify({
        productId: PRODUCT_IDS[Math.floor(Math.random() * PRODUCT_IDS.length)],
        quantity: Math.ceil(Math.random() * 3),
      });
      
      const res = http.post(`${BASE_URL}/api/orders`, payload, {
        headers,
        tags: { name: 'order_create' },
      });
      
      orderDuration.add(Date.now() - startTime);
      
      check(res, {
        'status is 201': (r) => r.status === 201,
        'has orderId': (r) => JSON.parse(r.body).orderId !== undefined,
      }) || errorRate.add(1);
    });
    
    sleep(Math.random() * 10 + 10); // 결제 후 대기: 10~20초
  }
}

// 테스트 시작 전 실행 (데이터 준비 등)
export function setup() {
  console.log(`Target: ${BASE_URL}`);
  // 사전 조건 확인
  const healthCheck = http.get(`${BASE_URL}/actuator/health`);
  if (healthCheck.status !== 200) {
    throw new Error('서버가 준비되지 않았습니다');
  }
}

// 테스트 종료 후 실행 (정리)
export function teardown(data) {
  console.log('테스트 완료. 결과를 확인하세요.');
}

5-3) k6 CI 통합 (GitHub Actions)

# .github/workflows/load-test.yml
name: Load Test
on:
  schedule:
    - cron: '0 3 * * 1'  # 매주 월요일 새벽 3시
  workflow_dispatch:       # 수동 실행 가능

jobs:
  load-test:
    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 C5AD17C747E3415A3642D57D77C6C491D6AC1D68
          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 Load Test
        env:
          BASE_URL: ${{ secrets.STAGING_URL }}
          TOKEN: ${{ secrets.TEST_TOKEN }}
        run: k6 run --out json=results.json tests/load-test.js
        
      - name: Upload Results
        if: always()
        uses: actions/upload-artifact@v4
        with:
          name: k6-results
          path: results.json
          
      - name: Check Thresholds
        if: failure()
        run: echo "⚠️ 부하 테스트 SLO 위반!" >> $GITHUB_STEP_SUMMARY

6) 측정: 부하 테스트는 ‘관측성 테스트’이기도 하다

6-1) 반드시 함께 봐야 하는 지표

테스트 동안 최소한 아래를 같이 봅니다.

계층지표도구왜 필요한가
애플리케이션p95/p99, 에러율, 스레드 풀/큐Prometheus + Grafana서비스 성능
JVMGC pause, 힙 사용량, 할당률JMX, VisualVM메모리/GC 병목
DB쿼리 시간, 슬로우 로그, 락 대기, 커넥션 수pg_stat_statements, slow_logDB 병목
캐시hit ratio, latency, evictionRedis INFO캐시 효율
인프라CPU/메모리/네트워크/디스크node_exporter리소스 포화
분산 트레이싱느린 요청 경로Jaeger, Tempo근본 원인 파악

6-2) 워밍업 구간 처리

부하 테스트 초반 1~2분은 결과에서 제외해야 합니다.

이유:
1. JVM JIT 컴파일: 인터프리터 → 네이티브 코드 전환 (수십 ms → 수 ms)
2. 캐시 워밍업: 첫 요청은 모두 Cache Miss
3. 커넥션 풀 워밍업: lazy init이면 초반에 커넥션 생성 비용
4. 오토스케일: 반응 시간 30초~3분

k6에서의 처리:
  stages: [
    { duration: '2m', target: 20 },  // ← 워밍업 (결과 분석에서 제외)
    { duration: '5m', target: 50 },  // ← 실제 측정 구간
    ...
  ]

6-3) 결과 해석 패턴

패턴 1: 레이턴시가 서서히 올라감
  → 메모리 누수 또는 GC 빈도 증가
  → 확인: jvm_gc_pause_seconds, jvm_memory_used_bytes 추이

패턴 2: 특정 RPS에서 갑자기 에러 폭증
  → 리소스 포화 (커넥션 풀, 스레드 풀, DB 커넥션)
  → 확인: hikaricp_connections_active == hikaricp_connections_max

패턴 3: 레이턴시가 일정하다가 계단식으로 뜀
  → GC Full GC 발생 또는 오토스케일 전환점
  → 확인: jvm_gc_pause_seconds에서 Major GC 확인

패턴 4: RPS가 올라가도 처리량이 안 늘어남
  → 동기 병목 (락, 동기화, 외부 API 대기)
  → 확인: 스레드 덤프에서 BLOCKED/WAITING 스레드

7) 튜닝 루틴: 원인별로 접근하라

7-1) 병목 유형별 진단과 대응

병목증상진단 방법대응
DB 쿼리p99만 느림, CPU 여유슬로우 로그, EXPLAIN인덱스, 쿼리 최적화
커넥션 풀 고갈갑자기 타임아웃 폭증HikariCP 메트릭풀 크기 + 쿼리 시간 단축
CPU 포화p95/p99 동시 상승CPU 프로파일링 (async-profiler)핫 코드 최적화, 스케일아웃
GC 압박간헐적 레이턴시 스파이크GC 로그, 할당률객체 할당 줄이기, 힙 조정
스레드 블로킹처리량 정체, CPU 여유스레드 덤프 (jstack)비동기화, 타임아웃 설정
외부 API 지연특정 요청만 느림분산 트레이스타임아웃, 서킷브레이커, 캐시
네트워크 대역폭대용량 응답 시 느림네트워크 I/O 모니터링압축, 페이징, CDN

7-2) 커넥션 풀 사이징 공식

풀 크기만 무작정 키우면 안 됩니다. 근거가 있어야 합니다.

기본 공식 (PostgreSQL 가이드):
  pool_size = (core_count * 2) + effective_spindle_count
  
  예: 4 vCPU 서버 → pool_size = (4 * 2) + 1 = 9~10

실무 조정:
  1. 평균 쿼리 시간이 길면 (> 100ms) → 풀 약간 키움
  2. 외부 API 호출이 많으면 → 별도 스레드 풀로 분리
  3. 절대 DB max_connections보다 크면 안 됨
  
  인스턴스 4대 × pool_size 10 = 총 40 커넥션
  DB max_connections = 100 → 나머지 60은 배치/모니터링/마이그레이션용 여유

7-3) 튜닝 원칙: 한 번에 하나만

튜닝 루프:

1. 측정 (Baseline): 현재 성능 기록
2. 가설: "슬로우 쿼리가 p99를 올리고 있다"
3. 변경: 인덱스 하나 추가 (한 가지만!)
4. 재측정: 같은 시나리오, 같은 환경
5. 비교: p99가 내려갔는가?
6. 반복

⚠️ 흔한 실수:
  - 인덱스 추가 + 풀 크기 변경 + GC 옵션 변경을 동시에
  → 뭐가 효과가 있었는지 알 수 없음
  → 오히려 성능이 나빠져도 어디를 되돌려야 할지 모름

8) 부하 테스트 흔한 함정 TOP 10

#함정증상해결
1워밍업 없이 측정초반 레이턴시가 비현실적으로 높음2~3분 워밍업 후 측정
2테스트 클라이언트가 병목RPS가 올라가지 않음분산 실행 또는 서버 스펙 확인
3Think Time 없음비현실적으로 높은 RPS실제 사용자 패턴 반영
4캐시 히트 100%프로덕션보다 훨씬 빠른 결과다양한 키로 분산
5DB 데이터 부족인덱스 스캔이 풀 스캔으로 안 바뀜프로덕션 규모 데이터
6SSL/TLS 미반영실제보다 빠른 결과HTTPS로 테스트
7단일 엔드포인트만 테스트특정 API만 최적화Traffic Mix 반영
8결과를 안 남김이전 대비 비교 불가매번 리포트 저장
9프로덕션 데이터로 테스트개인정보 유출 위험마스킹/합성 데이터
10한 번만 실행우연한 결과에 의존최소 3회 반복, 편차 확인

9) 결과를 남겨라 (다음 테스트를 위해)

부하 테스트는 한 번으로 끝나지 않습니다. 테스트 리포트 템플릿:

# 부하 테스트 리포트

## 개요
- 날짜: 2026-03-23
- 대상: 주문 서비스 v2.1.0
- 테스트 종류: Load Test (피크 100 VU)
- 환경: staging (프로덕션의 1/2 스펙)

## SLO 결과
| 지표 | SLO | 결과 | 판정 |
|------|-----|------|------|
| GET /orders p95 | < 200ms | 185ms | ✅ PASS |
| POST /orders p95 | < 500ms | 620ms | ❌ FAIL |
| 에러율 | < 0.1% | 0.05% | ✅ PASS |

## 병목 분석
- POST /orders가 SLO 초과: HikariCP 커넥션  고갈 확인
- 원인: 재고 확인 쿼리가 테이블 락을 잡고 평균 200ms

## 개선 계획
1. 재고 확인 쿼리에 SELECT FOR UPDATE SKIP LOCKED 적용
2. 커넥션  1015로 증가 (DB max 100, 현재  40)
3. 개선  동일 시나리오로 재테스트

## 환경 상세
- 인스턴스: 2 vCPU, 4GB × 2대
- DB: PostgreSQL 16, 데이터 50만 
- 캐시: Redis 7, 1GB

10) 용량 계획 (Capacity Planning)

부하 테스트 결과를 바탕으로 용량을 계획합니다.

Little’s Law 기반 계산

동시 사용자 = 도착률(RPS) × 평균 체류 시간(초)

예: 100 RPS, 평균 응답 200ms
  → 동시 요청 = 100 × 0.2 = 20

스레드 풀이 20개면 이론상 커버.
실무에서는 2~3배 여유: 40~60개

피크 트래픽 예측

평상시 트래픽: 100 RPS
피크 배율: ×5 (이벤트/프로모션)
안전 마진: ×1.5

필요 용량 = 100 × 5 × 1.5 = 750 RPS

현재 서버 1대 한계 = 300 RPS (Stress Test 결과)
→ 최소 3대 필요 (LB behind)
→ 오토스케일: min=3, max=6, target CPU=70%

운영 체크리스트

테스트 전

  • SLO(p95/에러율/처리량)가 정의되어 있는가?
  • 시나리오에 Think Time과 Traffic Mix가 반영되었는가?
  • 테스트 환경과 프로덕션 차이가 문서화되었는가?
  • DB에 충분한 데이터(프로덕션 규모)가 있는가?
  • 모니터링(Prometheus, 슬로우 로그, GC 로그)이 켜져 있는가?

테스트 중

  • 워밍업 구간(2~3분)을 결과에서 제외하는가?
  • 테스트 클라이언트가 병목이 아닌지 확인했는가?
  • 레이턴시/에러율/리소스를 실시간으로 관찰하고 있는가?

테스트 후

  • 결과 리포트를 작성하고 저장했는가?
  • SLO 위반 항목의 병목 원인을 파악했는가?
  • 개선 후 동일 시나리오로 재테스트 계획이 있는가?
  • 용량 계획에 결과를 반영했는가?

관련 글


연습(추천)

  1. 기본: 핵심 API 3개를 골라 “요청 비율/데이터 분포/캐시 히트율 가정"을 문서로 만들고 k6 스크립트를 작성해보기
  2. 중급: p95/p99가 튀는 구간에서 스레드 덤프/GC 로그/슬로우 로그로 원인을 좁혀보는 연습해보기
  3. 고급: 개선을 하나만 적용(예: 인덱스 추가)한 뒤 같은 시나리오로 재테스트해 “정말 좋아졌는지” A/B 비교해보기
  4. CI 통합: GitHub Actions에 부하 테스트를 주 1회 스케줄로 등록하고, SLO 위반 시 Slack 알림 설정