
개발 환경에서 완벽하게 동작하던 API가 운영 환경에서 무너지는 이유는 단순합니다. 개발 중에는 동시 사용자가 1명이지만, 실제 서비스는 수천 명의 사용자가 스레드, DB 커넥션, 메모리를 두고 치열하게 경쟁하기 때문입니다.
시스템이 어디까지 버티는지 확인하는 부하 테스트와 시스템 내부 상황을 숫자로 들여다보는 모니터링의 핵심을 정리합니다.
💡 비유로 이해하기
부하 테스트 도구는 환자에게 운동을 시키는 '러닝머신'이고, 모니터링은 그동안 심박수와 혈압을 재는 '센서'입니다. 러닝머신 속도(부하)만 봐서는 환자의 상태를 정확히 알 수 없으므로, 두 지표는 항상 짝을 지어 분석해야 합니다.
성능을 분석할 때 평균(Average)만 보면 절대로 안 됩니다. 예를 들어 응답 시간이 [100ms, 100ms, 100ms, 100ms, 5000ms]라면 평균은 1,080ms(약 1초)이지만, 실제로는 5명 중 1명의 사용자가 5초라는 치명적인 지연을 겪은 것입니다. 이 때문에 실무에서는 백분위수를 기준으로 삼습니다.
목적에 따라 부하를 주는 패턴을 다르게 설정해야 합니다. 입문 단계에서는 Smoke → Load → Stress 순서로 흐름을 파악하는 것이 좋습니다.
| 항목 | K6 (추천) | nGrinder |
|---|---|---|
| 개발 및 관리 | Grafana Labs (활발히 개발 중) | 네이버 (2025년 9월 GitHub 아카이브 전환) |
| 인터페이스 | CLI 중심, 경량화 | 웹 GUI 중심, 편리한 접근성 |
| 스크립트 언어 | JavaScript | Groovy / Jython |
| CI/CD 연동 | 매우 적합 (코드로 관리 가능) | 상대적으로 연동 불편 |
| 시각화 | Prometheus, Grafana 연동 강력 | 자체 웹 리포트 내장 |
⚠️ 선택 가이드: nGrinder는 국내 자료가 많고 GUI가 편리하지만, 현재 공식 오픈소스 유지보수가 중단(Read-only)되었습니다. 따라서 신규 프로젝트나 미래 경쟁력을 위해서는 K6를 메인으로 학습하는 것을 강력히 권장합니다.
💡 단계별 부하 제어(Stress Test) 및 자동 검증(Thresholds) 설정
import http from 'k6/http';
import { check } from 'k6';
export const options = {
// 1. 단계별 부하 설정 (Stages)
stages: [
{ duration: '1m', target: 50 }, // 1분 동안 VU 50명까지 증가
{ duration: '2m', target: 50 }, // 2분간 50명 유지
{ duration: '1m', target: 200 }, // 1분 동안 VU 200명까지 급증 (Stress)
{ duration: '1m', target: 0 }, // 종료 시 점진적 감소
],
// 2. 자동 검증 기준 (Thresholds) -> 기준 미달 시 CI/CD 파이프라인에서 에러 발생
thresholds: {
http_req_duration: ['p(95)<200'], // 95%의 요청이 200ms 이내여야 통과
http_req_failed: ['rate<0.01'], // 에러율이 1% 미만이어야 통과
},
};
export default function () {
const res = http.get('http://localhost:8080/api/products');
// 응답 검증 (Assertion)
check(res, { 'status is 200': (r) => r.status === 200 });
}
밖에서 주는 부하(K6)에 대응해 서버 안의 지표를 수집하려면 Spring Boot 환경에서 다음 조합이 표준으로 사용됩니다.
Spring Boot Actuator: 애플리케이션의 내부 메트릭 엔드포인트를 노출합니다.
Micrometer: 자바 애플리케이션의 메트릭 수집 표준 인터페이스(SLF4J와 유사한 역할)를 제공하여 다차원 태그 데이터를 분류합니다.
# application.yml 설정 예시
management:
endpoints:
web:
exposure:
include: health, info, prometheus, metrics
이렇게 노출된 /actuator/prometheus 데이터를 Prometheus가 주기적으로 수집(Scraping)하고, Grafana 대시보드를 통해 시각화합니다.
CPU 사용률: 100%에 근접하면 연산 과다 또는 비효율 로직(CPU 바운드). 한가한데 지연이 생기면 I/O 또는 락 대기 의심.
JVM Heap 메모리: GC 후에도 사용량이 계속 우상향한다면 메모리 누수(Memory Leak) 상태입니다.
GC Pause: GC가 발생하는 순간 애플리케이션이 멈춥니다(Stop-the-world). 응답 시간이 튀는 구간과 GC 발생 시점이 겹치는지 대조해야 합니다.
Tomcat 스레드 풀: Worker 스레드가 모두 사용 중(busy)이면 새로운 요청은 톰캣 큐에서 무한 대기하게 됩니다.
HikariCP (DB 커넥션 풀): 커넥션 풀이 고갈되어 pending 상태의 커넥션이 쌓이면 쿼리 성능 최적화나 풀 크기 조정이 필요합니다.
외부 증상(부하 테스트 결과)과 내부 지표(서버 메트릭)를 매핑하여 원인을 찾아내는 사고 흐름입니다.
증상: 평균 응답 시간은 90ms로 쾌적하지만, p99 지표는 1,800ms로 급증함. 에러는 없음.
내부 관찰: Grafana 확인 결과 응답 시간이 튀는 주기와 GC Pause(Stop-the-world) 발생 주기가 완벽히 일치함.
조치: Heap 크기 및 GC 알고리즘(G1GC 등) 변경, 코드 레벨에서 불필요한 단기 객체 생성 로직 제거.
증상: 가상 사용자를 50명에서 100명으로 늘렸으나 TPS는 80에서 고정되고, p95 응답 시간만 2배로 증가함.
내부 관찰: 서버 CPU 사용률은 40%로 매우 한가함. 하지만 HikariCP의 pending connection(대기 수)이 지속해서 쌓임.
추론: CPU 연산 문제가 아닌 DB 커넥션 풀 병목. 요청들이 DB 접근 권한을 얻기 위해 줄 서 있는 상태.
조치: DB 커넥션 풀 확장, 쿼리 인덱싱, N+1 문제 해결 및 슬로우 쿼리 튜닝.
증상: 테스트 시작 직후 HTTP 503 등의 에러율이 급증함.
내부 관찰: 톰캣 스레드 풀이 즉시 최대치에 도달해 있으며, 로그에 connection timeout이 빈번하게 찍힘.
추론: 초기 유입 트래픽 제어 실패 혹은 외부 연동 API(다운스트림)의 차단으로 인해 스레드가 풀려나지 못하고 점유됨.
조치: 스레드 풀 및 시스템 타임아웃(Timeout) 값 재설정, 서킷 브레이커 도입 검토.
증상: 부하 크기는 일정한데 초기 응답 시간 100ms가 2시간 뒤 1,500ms로 악화됨.
내부 관찰: Full GC가 수행되어도 JVM Heap 사용량의 최저점이 계속해서 우상향함.
추론: 닫히지 않은 리소스나 글로벌 캐시의 무한 증식으로 인한 메모리 누수.
조치: 힙 덤프(Heap Dump) 분석을 통해 누수를 유발하는 static 객체나 자원 반납 로직 점검.
응답 시간 목표치(SLO)는 평균이 아닌 p95, p99 기준으로 수립했는가?
지표를 읽을 때 에러율 ➡️ 응답 시간 ➡️ TPS 순서로 흐름을 파악했는가?
VU를 높여도 TPS가 상한선에 걸린다면 해당 지점을 시스템의 포화 상태(Saturation)로 인지했는가?
CPU가 놀고 있는데 시스템이 느리다면 DB 커넥션, 네트워크 I/O, Lock 병목을 의심했는가?
CI/CD 파이프라인 성능 회귀 테스트를 위해 K6 Thresholds 기능을 도입했는가?