시리즈

- nGrinder 아키텍처와 환경 구성

- Groovy 스크립트와 테스트 설계

- 03 결과 해석과 병목 진단

테스트를 실행하면 TPS 그래프가 그려진다. 숫자와 그래프를 보는 것만으로는 부족하다. 그래프의 모양이 서버의 상태를 말해주고, 지표 간의 관계를 이해하면 병목이 어디인지 추론할 수 있다. 이 편에서는 nGrinder 결과를 읽는 법과, JMH 결과와 교차 분석하는 방법을 다룬다.

핵심 지표 해석

TPS, MTT, Error Rate

지표의미좋은 방향
TPS초당 처리 트랜잭션 수높을수록
MTT평균 응답 시간 (ms)낮을수록
Peak TPS테스트 중 최고 TPS안정 TPS와 차이가 적을수록
Error Rate실패 비율 (%)0%가 목표

리틀의 법칙

세 지표는 독립적이지 않다. 리틀의 법칙으로 연결된다.

VUser = TPS × MTT(초)

VUser 50, MTT 100ms(0.1초)이면 TPS는 50 / 0.1 = 500이다. VUser를 100으로 올렸는데 TPS가 500에서 안 올라가고 MTT만 200ms로 늘어났다면, 100 / 0.2 = 500으로 법칙이 맞다. 이것은 서버가 포화 상태에 도달했다는 뜻이다.

리틀의 법칙으로 필요 VUser 역산

목표 TPS가 1000이고 예상 MTT가 50ms라면, 필요한 VUser = 1000 × 0.05 = 50이다. 이렇게 역산하면 테스트 전에 적절한 VUser 수를 설정할 수 있다.

TPS 그래프 패턴 읽기

그래프의 모양이 서버의 상태를 말해준다. 주요 패턴 4가지를 알면 대부분의 상황을 진단할 수 있다.

이상적 패턴

Ramp-up 구간에서 TPS가 점진적으로 올라가다가, 안정 구간에서 수평선을 유지한다. MTT도 일정하다. 서버가 현재 VUser를 충분히 감당하고 있다.

포화 패턴

VUser를 늘려도 TPS가 더 이상 올라가지 않는다. 대신 MTT가 VUser에 비례해서 올라간다. 서버의 처리 능력이 한계에 도달한 것이다. 이 시점의 TPS가 서버의 최대 처리량이다.

병목 원인을 찾아야 한다.

  • CPU 사용률 100% → 애플리케이션 로직 병목
  • DB 커넥션 풀 대기 → HikariCP maximum-pool-size 부족
  • Thread 풀 포화 → Tomcat max-threads 부족

붕괴 패턴

특정 VUser 이후 TPS가 하락하면서 에러율이 급증한다. 포화를 넘어 시스템이 무너지는 단계다. 타임아웃, OOM, 커넥션 거부 등이 발생한다. 이 VUser 수는 "절대 넘으면 안 되는 한계선"이다.

톱니 패턴

TPS가 주기적으로 올랐다 내렸다를 반복한다. GC가 주기적으로 발생하면서 애플리케이션이 멈추는 것이 대표적 원인이다. GC 로그를 확인하거나 힙 사이즈를 조정한다.

flowchart LR subgraph Patterns ["TPS 그래프 패턴"] direction TB P1["이상적
━━━━━━━"] P2["포화
━━━━┅┅┅"] P3["붕괴
━━━╲╲╲"] P4["톱니
╱╲╱╲╱╲"] end style P1 fill:#E8F5E9,stroke:#4CAF50,stroke-width:2px style P2 fill:#FFF3E0,stroke:#FF9800,stroke-width:2px style P3 fill:#FFEBEE,stroke:#F44336,stroke-width:2px style P4 fill:#EDE7F6,stroke:#673AB7,stroke-width:2px style Patterns fill:#fafafa,stroke:#999

병목 진단 체크리스트

TPS가 기대보다 낮거나, 에러율이 0%가 아닐 때 원인을 좁히는 순서다.

MTT가 높을 때 — 느린 응답

  1. DB 쿼리 확인EXPLAIN ANALYZE로 Slow Query를 찾는다. Full Table Scan이나 Lock Wait이 원인일 수 있다.
  2. 커넥션 풀 모니터링 — HikariCP의 active-connectionspending-threads를 확인한다. pending이 0이 아니면 커넥션 대기가 발생하고 있다.
  3. 외부 호출 확인 — S3 URL 생성, Redis 조회 등 외부 서비스 호출이 병목일 수 있다.

Error Rate가 높을 때 — 요청 실패

  1. 401/403 — 토큰 만료 또는 CSRF 토큰 누락. 스크립트의 인증 로직을 점검한다.
  2. 500 — 서버 에러 로그를 확인한다. 동시 접속에서만 발생하는 Race Condition일 수 있다.
  3. Connection Refused — Tomcat Thread Pool이 포화됐거나, DB 커넥션이 고갈된 것이다.

TPS가 안 오를 때 — 포화

  1. CPU 확인docker stats로 Target Server 컨테이너의 CPU 사용률을 본다.
  2. Thread Dumpjstack이나 Actuator의 /threaddump로 Thread 상태를 확인한다. WAITING이나 BLOCKED 상태의 Thread가 많으면 락 경합이다.
  3. 리틀의 법칙 검증 — VUser = TPS × MTT가 맞는지 확인한다. 맞으면 순수한 처리 능력 한계, 안 맞으면 에러나 타임아웃이 TPS를 깎고 있는 것이다.

JMH 결과와의 크로스 레퍼런스

JMH와 nGrinder는 같은 API를 다른 각도에서 측정한다. 두 결과를 교차 분석하면 코드 수준과 인프라 수준의 병목을 분리할 수 있다.

결과 비교 프레임워크

관점JMHnGrinder
배치 조회 평균18msMTT 35ms
차이 원인HTTP 직렬화 + Security 필터 + 네트워크 = 17ms

nGrinder의 MTT에서 JMH의 Score를 빼면, HTTP 계층의 오버헤드가 드러난다. 이 오버헤드가 전체 MTT의 절반 이상이면, 쿼리 최적화보다 직렬화나 캐싱 전략이 더 효과적일 수 있다.

두 결과가 불일치할 때

JMH에서 배치가 N+1보다 13배 빨랐는데, nGrinder에서는 3배밖에 차이가 안 날 수 있다. 이것은 HTTP 오버헤드가 상수로 붙기 때문이다.

JMH:   N+1 = 245ms, 배치 = 18ms → 13.6배
nGrinder: N+1 = 262ms, 배치 = 35ms → 7.5배
         (HTTP 오버헤드 약 17ms가 양쪽에 동일하게 추가)

비율이 줄어든 것은 병목이 사라진 것이 아니라, 기저 비용이 추가된 것이다. 절대 차이(245 - 18 = 227ms vs 262 - 35 = 227ms)는 동일하다.

보완적 의사결정

질문답하는 도구
"어떤 쿼리 전략이 더 빠른가"JMH
"현재 API가 동시 몇 명까지 버티나"nGrinder
"쿼리를 바꾸면 TPS가 얼마나 오를까"JMH 비율 × 현재 nGrinder TPS로 추정
"인프라 스케일링이 필요한가"nGrinder 포화점 기반

올바른 순서: JMH로 최적 구현을 결정 → nGrinder로 그 구현의 용량을 검증 → 포화점이 부족하면 인프라 스케일링을 계획한다.

자주 하는 실수

Peak TPS를 서버 성능이라고 보고

Peak TPS는 순간 최고값이다. 안정 구간의 평균 TPS가 실제 서버 성능이다. Peak가 안정 TPS의 2배 이상이면 측정이 불안정한 것이므로 Duration을 늘려야 한다.

[!WARNING] VUser 1명 결과로 성능 판단

VUser 1명의 MTT는 "경합 없는 최적 조건"이다. 프로덕션에서는 동시 접속이 항상 존재하므로, 예상 동시 접속 수에 가까운 VUser로 테스트해야 의미 있는 수치가 나온다.

[!WARNING] 로컬 테스트 결과를 프로덕션에 대입

Agent와 Target이 같은 머신이면 CPU/네트워크를 공유한다. TPS가 실제보다 낮게 나올 수 있다. 로컬 결과는 상대 비교병목 탐색에만 사용하고, 절대값은 프로덕션 환경에서 재측정한다.

[!WARNING] 에러율이 0%인데 안심

에러율 0%는 "HTTP 응답이 성공"이라는 뜻이지, "비즈니스 로직이 정확하다"는 뜻이 아니다. 응답 본문을 검증하지 않으면, 빈 목록이나 잘못된 데이터가 반환되어도 성공으로 집계된다. @Test에서 응답 내용까지 assert한다.

면접 Q&A

JMH와 nGrinder의 차이가 뭔가요? 둘 다 써야 하는 이유가 있나요?

JMH는 JVM 안에서 메서드를 직접 호출해 쿼리 전략의 상대적 성능을 비교한다. nGrinder는 HTTP 요청을 대량으로 보내서 동시 접속 상황에서의 처리량과 응답 시간을 측정한다. JMH로 "배치가 N+1보다 13배 빠르다"를 확인하고, nGrinder로 "그 배치 API가 동시 100명에서 TPS 500, MTT 50ms"를 확인한다. 전자는 코드 수준 최적화, 후자는 인프라 수준 용량 계획에 사용한다.

[!QUESTION] nGrinder에서 TPS가 더 이상 안 올라갈 때 어떻게 진단했나요?

리틀의 법칙(VUser = TPS × MTT)으로 포화 여부를 먼저 확인한다. 포화가 확인되면 CPU, DB 커넥션 풀, Tomcat Thread Pool을 순서대로 점검한다. docker stats로 CPU를 보고, HikariCP 메트릭으로 pending 커넥션을 확인하고, Thread Dump로 블로킹 상태의 Thread를 찾는다. 이 프로젝트에서는 HikariCP의 maximum-pool-size가 10이라 VUser 50 이상에서 커넥션 대기가 발생했고, 30으로 올리니 TPS가 비례해서 증가했다.

[!QUESTION] 부하 테스트 결과의 절대값을 프로덕션 성능으로 볼 수 있나요? (함정 질문)

아니다. 로컬 Docker 환경에서는 Agent와 Target이 리소스를 공유하고, 네트워크 지연도 프로덕션과 다르다. 절대값보다 포화 패턴과 상대 비교에 의미를 둔다. "VUser 50에서 포화되었다"는 것은 "프로덕션에서 50명이 한계"라는 뜻이 아니라, "이 환경에서의 포화점이 50"이라는 뜻이다. 프로덕션 성능은 스테이징 환경이나 APM(Pinpoint, Datadog)으로 실제 트래픽에서 측정한다.