카테고리 없음

성능 테스트 완벽 가이드 - k6와 Gatling으로 부하 테스트 마스터하기

백엔드 개발자 김승원 2026. 4. 9. 12:15

들어가며

서비스가 트래픽 급증에 버틸 수 있는지, 병목 지점은 어디인지 파악하려면 성능 테스트가 필수입니다. "기능은 잘 동작하는데 동시 사용자 1,000명만 되면 서버가 죽어요"라는 상황을 사전에 방지하려면 체계적인 부하 테스트가 필요합니다. 이번 글에서는 성능 테스트의 유형과 핵심 지표, 그리고 k6Gatling을 활용한 실전 부하 테스트 방법을 다루겠습니다.

1. 성능 테스트 유형

유형 목적 특징
Load Test 예상 트래픽에서의 성능 검증 일정 부하를 일정 시간 동안 유지
Stress Test 한계점 확인 부하를 점진적으로 증가시켜 임계점 발견
Spike Test 급격한 트래픽 대응력 검증 짧은 시간에 급격히 부하 증가 후 감소
Soak Test 장시간 안정성 검증 일정 부하를 수 시간~수 일 유지 (메모리 누수 등 확인)
Breakpoint Test 최대 처리 용량 확인 시스템이 완전히 실패할 때까지 부하 증가

각 테스트의 부하 패턴

Load Test:      ────────────────────────
                   일정 부하 유지

Stress Test:    ──────
                     ──────
                           ──────    점진적 증가
                                ──────

Spike Test:          ████
                ─────    ─────       급격한 피크

Soak Test:      ────────────────────────────────
                   장시간 (4h+) 일정 부하

2. 핵심 성능 지표

반드시 측정해야 할 지표

  • TPS (Transactions Per Second): 초당 처리 가능한 트랜잭션 수. 시스템의 처리량을 나타냅니다.
  • Response Time (응답 시간): 요청부터 응답까지 걸리는 시간
    • Avg: 평균 응답 시간 (이상치에 왜곡될 수 있음)
    • P50 (Median): 50%의 요청이 이 시간 이내
    • P95: 95%의 요청이 이 시간 이내
    • P99: 99%의 요청이 이 시간 이내 (가장 중요한 지표)
  • Error Rate: 전체 요청 중 실패 비율
  • Throughput: 단위 시간당 처리된 데이터량 (bytes/sec)
  • Concurrent Users: 동시 활성 사용자 수

왜 P99가 중요한가?

평균 응답 시간: 200ms  ← 평균은 괜찮아 보이지만...
P50:  150ms    ← 절반의 사용자는 150ms 이내
P95:  500ms    ← 5%의 사용자는 500ms까지
P99:  3000ms   ← 1%의 사용자는 3초 대기!

하루 100만 요청이면 → 1만 명이 3초 이상 대기
이 1%의 사용자가 불만을 제기하고 이탈합니다.

3. k6 - 개발자 친화적 부하 테스트

k6는 Grafana Labs에서 만든 오픈소스 부하 테스트 도구입니다. JavaScript/TypeScript로 테스트 스크립트를 작성하며, Go 기반 엔진으로 높은 성능을 제공합니다.

설치

# macOS
brew install k6

# Docker
docker run --rm -i grafana/k6 run - <script.js

# Linux (apt)
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

기본 스크립트

// load-test.js
import http from 'k6/http';
import { check, sleep } from 'k6';
import { Rate, Trend } from 'k6/metrics';

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

export const options = {
    // 부하 시나리오 설정
    stages: [
        { duration: '1m', target: 50 },   // 1분간 50 VU까지 증가
        { duration: '3m', target: 50 },   // 3분간 50 VU 유지
        { duration: '1m', target: 100 },  // 1분간 100 VU까지 증가
        { duration: '3m', target: 100 },  // 3분간 100 VU 유지
        { duration: '1m', target: 0 },    // 1분간 0으로 감소
    ],
    // 성공 기준 (thresholds)
    thresholds: {
        http_req_duration: ['p(95)<500', 'p(99)<1000'], // P95 < 500ms, P99 < 1s
        http_req_failed: ['rate<0.01'],                  // 에러율 1% 미만
        errors: ['rate<0.05'],                           // 커스텀 에러율 5% 미만
    },
};

const BASE_URL = 'http://localhost:8080';

export default function () {
    // 1. 상품 목록 조회
    const productsRes = http.get(`${BASE_URL}/api/products`);
    check(productsRes, {
        '상품 목록 조회 성공': (r) => r.status === 200,
        '상품 목록 응답 시간 < 300ms': (r) => r.timings.duration < 300,
    });

    sleep(1); // 사용자 행동 시뮬레이션 (생각 시간)

    // 2. 상품 상세 조회
    const productId = Math.floor(Math.random() * 100) + 1;
    const detailRes = http.get(`${BASE_URL}/api/products/${productId}`);
    check(detailRes, {
        '상품 상세 조회 성공': (r) => r.status === 200,
    });

    sleep(2);

    // 3. 주문 생성
    const orderPayload = JSON.stringify({
        userId: `user-${__VU}`,
        items: [
            { productId: productId, quantity: 1 }
        ],
    });

    const orderStart = Date.now();
    const orderRes = http.post(`${BASE_URL}/api/orders`, orderPayload, {
        headers: { 'Content-Type': 'application/json' },
    });
    orderDuration.add(Date.now() - orderStart);

    const orderSuccess = check(orderRes, {
        '주문 생성 성공': (r) => r.status === 201,
        '주문 ID 존재': (r) => JSON.parse(r.body).orderId !== undefined,
    });
    errorRate.add(!orderSuccess);

    sleep(1);
}

k6 실행 및 결과

# 기본 실행
$ k6 run load-test.js

# VU 수와 시간 직접 지정
$ k6 run --vus 100 --duration 5m load-test.js

# 결과 출력 예시
          /\      |‾‾| /‾‾/   /‾‾/
     /\  /  \     |  |/  /   /  /
    /  \/    \    |     (   /   ‾‾\
   /          \   |  |\  \ |  (‾)  |
  / __________ \  |__| \__\ \_____/ .io

  scenarios: (100.00%) 1 scenario, 100 max VUs, 9m30s max duration
           default: Up to 100 VUs for 9m0s

     ✓ 상품 목록 조회 성공
     ✓ 상품 상세 조회 성공
     ✓ 주문 생성 성공
     ✗ 상품 목록 응답 시간 < 300ms
      ↳  92% — ✓ 4600 / ✗ 400

     checks.....................: 97.33%  ✓ 14600  ✗ 400
     data_received..............: 15 MB   28 kB/s
     data_sent..................: 2.1 MB  3.9 kB/s
     errors.....................: 2.00%   ✓ 100    ✗ 4900
     http_req_duration..........: avg=186ms  min=12ms  p(90)=340ms  p(95)=456ms  p(99)=890ms
     http_req_failed............: 0.50%   ✓ 25     ✗ 4975
     http_reqs..................: 15000   27.78/s
     iteration_duration.........: avg=4.2s   min=3.1s  p(90)=5.8s   p(95)=6.2s
     iterations.................: 5000    9.26/s
     order_duration.............: avg=210ms  min=45ms  p(90)=380ms  p(95)=520ms
     vus........................: 1       min=1     max=100
     vus_max....................: 100     min=100   max=100

k6 고급: 시나리오별 분리

// scenarios-test.js
import http from 'k6/http';
import { sleep } from 'k6';

export const options = {
    scenarios: {
        // 시나리오 1: 일반 조회 (읽기 위주)
        browse: {
            executor: 'ramping-vus',
            startVUs: 0,
            stages: [
                { duration: '2m', target: 200 },
                { duration: '5m', target: 200 },
                { duration: '1m', target: 0 },
            ],
            exec: 'browseProducts',
        },
        // 시나리오 2: 주문 생성 (쓰기 위주)
        order: {
            executor: 'constant-arrival-rate',
            rate: 50,           // 초당 50 요청
            timeUnit: '1s',
            duration: '5m',
            preAllocatedVUs: 100,
            maxVUs: 200,
            exec: 'createOrder',
            startTime: '30s',   // 30초 후 시작
        },
        // 시나리오 3: 스파이크 테스트
        spike: {
            executor: 'ramping-arrival-rate',
            startRate: 10,
            timeUnit: '1s',
            stages: [
                { duration: '2m', target: 10 },
                { duration: '10s', target: 500 },  // 스파이크!
                { duration: '2m', target: 10 },
            ],
            preAllocatedVUs: 500,
            exec: 'browseProducts',
            startTime: '3m',
        },
    },
};

export function browseProducts() {
    http.get('http://localhost:8080/api/products');
    sleep(Math.random() * 3);
}

export function createOrder() {
    const payload = JSON.stringify({
        userId: `user-${__VU}`,
        items: [{ productId: 1, quantity: 1 }],
    });
    http.post('http://localhost:8080/api/orders', payload, {
        headers: { 'Content-Type': 'application/json' },
    });
}

4. Gatling - Scala DSL 기반 부하 테스트

Gatling은 Scala DSL로 작성하는 고성능 부하 테스트 도구입니다. JVM 기반이라 Java/Kotlin 생태계와 잘 통합됩니다.

Gradle 설정

plugins {
    id 'io.gatling.gradle' version '3.11.3'
}

dependencies {
    gatlingImplementation 'io.gatling.highcharts:gatling-charts-highcharts:3.11.3'
    gatlingImplementation 'io.gatling:gatling-test-framework:3.11.3'
}

Gatling 시뮬레이션

// src/gatling/scala/simulations/OrderSimulation.scala
package simulations

import io.gatling.core.Predef._
import io.gatling.http.Predef._
import scala.concurrent.duration._

class OrderSimulation extends Simulation {

    val httpProtocol = http
        .baseUrl("http://localhost:8080")
        .acceptHeader("application/json")
        .contentTypeHeader("application/json")

    // 피더: 테스트 데이터 공급
    val userFeeder = csv("users.csv").random
    val productFeeder = (1 to 100).map(i => Map("productId" -> i)).toArray.random

    // 시나리오 정의
    val browseScenario = scenario("상품 조회 시나리오")
        .exec(
            http("상품 목록 조회")
                .get("/api/products")
                .check(status.is(200))
                .check(jsonPath("$[0].id").saveAs("firstProductId"))
        )
        .pause(1, 3)  // 1~3초 대기
        .exec(
            http("상품 상세 조회")
                .get("/api/products/${firstProductId}")
                .check(status.is(200))
        )
        .pause(2, 5)

    val orderScenario = scenario("주문 시나리오")
        .feed(userFeeder)
        .feed(productFeeder)
        .exec(
            http("주문 생성")
                .post("/api/orders")
                .body(StringBody(
                    """{"userId": "${userId}", "items": [{"productId": ${productId}, "quantity": 1}]}"""
                ))
                .check(status.is(201))
                .check(jsonPath("$.orderId").saveAs("orderId"))
        )
        .pause(1)
        .exec(
            http("주문 조회")
                .get("/api/orders/${orderId}")
                .check(status.is(200))
        )

    // 부하 모델
    setUp(
        browseScenario.inject(
            rampUsers(200).during(2.minutes),    // 2분간 200명 주입
            constantUsersPerSec(50).during(5.minutes)  // 5분간 초당 50명
        ),
        orderScenario.inject(
            nothingFor(30.seconds),              // 30초 대기
            rampUsers(100).during(2.minutes),    // 2분간 100명 주입
            constantUsersPerSec(20).during(5.minutes)  // 5분간 초당 20명
        )
    ).protocols(httpProtocol)
     .assertions(
         global.responseTime.percentile3.lt(500),  // P95 < 500ms
         global.responseTime.percentile4.lt(1000), // P99 < 1000ms
         global.successfulRequests.percent.gt(99),  // 성공률 > 99%
         forAll.responseTime.max.lt(5000)           // 최대 응답시간 < 5s
     )
}

Gatling 실행

# Gradle로 실행
$ ./gradlew gatlingRun

# 특정 시뮬레이션만 실행
$ ./gradlew gatlingRun-simulations.OrderSimulation

# 결과 리포트 확인 (HTML)
# build/reports/gatling/{simulation-name}/index.html

5. k6 vs Gatling vs JMeter 비교

항목 k6 Gatling JMeter
언어 JavaScript Scala / Java / Kotlin GUI + XML (Groovy)
엔진 Go JVM (Akka) JVM
리소스 효율 매우 높음 높음 낮음
CI/CD 통합 매우 쉬움 쉬움 (Gradle/Maven) 가능하지만 복잡
리포트 CLI + JSON + Grafana HTML 리포트 (매우 상세) JTL + HTML
학습 곡선 낮음 중간 (Scala) 낮음 (GUI)
프로토콜 HTTP, WebSocket, gRPC HTTP, WebSocket, JMS 거의 모든 프로토콜
분산 테스트 k6 Cloud Gatling Enterprise JMeter Remote
추천 상황 빠른 시작, CI/CD 통합 JVM 팀, 상세 리포트 필요 GUI 선호, 다양한 프로토콜

6. 병목 지점 분석 방법

시스템 레벨 확인

# CPU, 메모리, 디스크 I/O 확인
$ top -b -n 1 | head -20
$ vmstat 1 10
$ iostat -x 1 10

# 네트워크 연결 상태
$ ss -s
# TCP 연결 수, TIME_WAIT 수 확인

# Java 애플리케이션 스레드 덤프
$ jstack <PID> > thread_dump.txt

# Java 힙 메모리
$ jstat -gc <PID> 1000 10

일반적인 병목 지점과 해결 방법

병목 유형 증상 해결 방법
DB 쿼리 응답 시간 급증, DB CPU 100% 쿼리 최적화, 인덱스, 커넥션 풀 조정
커넥션 풀 고갈 대기 시간 증가, Connection timeout 풀 사이즈 증가, 쿼리 시간 단축
스레드 부족 요청 대기열 증가 스레드 풀 조정, 비동기 처리
GC 일시 정지 주기적 응답 지연 GC 튜닝, 힙 사이즈 조정
네트워크 지연 외부 API 호출 시 느림 타임아웃 설정, 서킷 브레이커, 캐싱
파일 디스크립터 제한 Too many open files ulimit 증가

Spring Boot 병목 분석 설정

# application.yml - 성능 테스트용 설정
server:
  tomcat:
    threads:
      max: 200        # 최대 스레드 (기본 200)
      min-spare: 20   # 최소 유휴 스레드
    max-connections: 8192
    accept-count: 100  # 대기 큐

spring:
  datasource:
    hikari:
      maximum-pool-size: 20
      minimum-idle: 5
      connection-timeout: 3000   # 3초
      idle-timeout: 600000      # 10분
      max-lifetime: 1800000     # 30분

logging:
  level:
    com.zaxxer.hikari: DEBUG    # 커넥션 풀 모니터링
    org.hibernate.SQL: DEBUG    # SQL 로깅

7. Grafana 대시보드 연동

k6 테스트 결과를 실시간으로 Grafana에서 모니터링할 수 있습니다.

InfluxDB + Grafana 연동

# docker-compose.yml
services:
  influxdb:
    image: influxdb:1.8
    ports:
      - "8086:8086"
    environment:
      - INFLUXDB_DB=k6

  grafana:
    image: grafana/grafana:latest
    ports:
      - "3000:3000"
    environment:
      - GF_AUTH_ANONYMOUS_ORG_ROLE=Admin
      - GF_AUTH_ANONYMOUS_ENABLED=true
    volumes:
      - grafana-data:/var/lib/grafana

volumes:
  grafana-data:
# k6 실행 시 InfluxDB로 결과 전송
$ k6 run --out influxdb=http://localhost:8086/k6 load-test.js

# Prometheus Remote Write 방식 (권장)
$ K6_PROMETHEUS_RW_SERVER_URL=http://localhost:9090/api/v1/write \
  k6 run --out experimental-prometheus-rw load-test.js

Grafana에서 k6 대시보드 임포트

Grafana 대시보드 ID 2587 (k6 Load Testing Results)을 임포트하면 즉시 사용 가능합니다.

  • VU 수 변화 그래프
  • 요청당 응답 시간 (P50, P95, P99)
  • 초당 요청 수 (RPS)
  • 에러율 추이
  • 데이터 전송량

8. 실전 성능 테스트 체크리스트

  • 테스트 환경: 가능하면 운영과 동일한 스펙 사용. 최소한 운영 대비 비율을 알고 있어야 합니다.
  • 데이터 준비: 실제 운영 데이터와 유사한 규모의 테스트 데이터를 넣어야 합니다. 빈 DB와 100만 건 DB의 성능은 완전히 다릅니다.
  • 워밍업: JVM 기반 애플리케이션은 JIT 컴파일이 완료된 후 측정합니다.
  • 여러 번 반복: 1회 결과로 판단하지 말고 최소 3회 이상 반복하여 편차를 확인합니다.
  • 모니터링 병행: 부하 테스트 중 CPU, 메모리, DB 커넥션, GC 로그를 반드시 모니터링합니다.
  • 기준선 설정: 변경 전/후를 비교할 수 있도록 기준선(Baseline)을 먼저 확보합니다.
  • 결과 문서화: 테스트 조건, 결과, 발견된 병목, 개선 방안을 문서로 남깁니다.

마치며

성능 테스트는 서비스 안정성을 확보하기 위한 핵심 활동입니다. 정리하면 다음과 같습니다.

  • 테스트 유형: Load, Stress, Spike, Soak 등 목적에 맞는 유형을 선택하세요.
  • 핵심 지표: 평균보다 P95, P99 응답 시간이 중요합니다. 에러율도 반드시 확인하세요.
  • k6: JavaScript 기반으로 빠르게 시작하기 좋고, CI/CD 통합이 매우 쉽습니다.
  • Gatling: JVM 팀에 적합하며, HTML 리포트가 상세하고 보기 좋습니다.
  • 병목 분석: DB 쿼리, 커넥션 풀, 스레드, GC 순서로 확인하면 대부분의 병목을 찾을 수 있습니다.
  • Grafana 연동: 실시간 모니터링으로 부하 패턴과 시스템 상태를 동시에 확인하세요.

성능 테스트는 한 번으로 끝나는 것이 아니라, 코드 변경이나 인프라 변경 시마다 반복해야 합니다. CI/CD 파이프라인에 성능 테스트를 포함시켜 성능 회귀를 자동으로 감지하는 것을 목표로 하세요.