nGrinder 부하 테스트 (1/5)

다음 편: [성능 테스트] 2. Groovy 스크립트와 테스트 설계

서버를 만들었다. 로컬에서는 잘 돌아간다. 그런데 "동시에 100명이 접속하면 어떻게 되지?"라는 질문에는 답할 수 없다. nGrinder는 이 질문에 답하는 도구다. 가상 유저 수백 명이 동시에 API를 두드리게 만들어서, 서버가 어디서 흔들리기 시작하는지 찾아낸다.

부하 테스트라는 개념

식당을 연다고 생각하자. 주방장이 파스타 1인분을 5분에 만든다. 이건 확인했다. 그런데 점심시간에 손님 50명이 한꺼번에 몰리면 어떻게 될까?

  • 주방장 혼자서는 주문이 밀린다 → 서버 스레드 부족
  • 가스레인지가 4개뿐이라 동시에 4개밖에 못 만든다 → DB 커넥션 풀 한계
  • 재료가 떨어져서 주문을 거절한다 → 메모리 부족으로 요청 거부
  • 홀 종업원이 주문을 전달하는 데도 시간이 걸린다 → HTTP 오버헤드(직렬화, 필터, 네트워크)

부하 테스트는 이 "점심시간 시뮬레이션"이다. 실제 손님 대신 가상 유저를 보내서, 주방(서버)이 몇 명까지 감당하는지, 어디서 먼저 터지는지를 찾아내는 것이다.

부하 테스트가 답하는 질문

질문식당 버전서버 버전
최대 처리량은?한 시간에 파스타 몇 개 만들어?초당 몇 건의 요청을 처리해? (TPS)
응답 시간은?주문 후 음식 나오기까지 몇 분?API 요청 후 응답까지 몇 ms? (MTT)
한계점은?손님 몇 명부터 주문이 밀려?VUser 몇 명부터 TPS가 안 올라가? (포화점)
고장점은?손님 몇 명부터 주문 거절이 나와?VUser 몇 명부터 에러가 발생해? (Error Rate)

JMH와 nGrinder의 차이

이전에 JMH로 "배치 로딩이 N+1보다 13배 빠르다"는 것을 확인했다. 이건 파스타 1인분의 조리 시간을 잰 것이다. 주방장 혼자, 방해 없이, 최적의 조건에서.

nGrinder는 다르다. 실제 영업 시간처럼 주문이 동시에 밀려들 때의 상황을 만든다.

JMHnGrinder
뭘 측정하나Java 메서드 1개의 실행 시간HTTP 엔드포인트의 동시 접속 성능
동시성없다. 혼자 실행한다가상 유저 수백~수천 명이 동시에
네트워크미포함. JVM 내부에서 직접 호출포함. HTTP 요청 → 응답 전체 경로
Spring Security, JSON 직렬화미포함포함. 실제 API 호출이니까
답하는 질문"A 방식이 B보다 몇 배 빠른가""동시 N명일 때 서버가 버티는가"

JMH에서 13배 차이가 났다고, nGrinder에서도 13배 차이가 나지는 않는다. 동시 접속이 늘어나면 쿼리 시간보다 커넥션 풀 대기, HTTP 직렬화, Spring 필터 체인이 더 큰 비중을 차지하기 때문이다.

JMH가 "어떤 레시피가 빠른가"를 결정하는 도구라면, nGrinder는 "그 레시피로 점심 장사를 할 수 있는가"를 검증하는 도구다.

flowchart LR subgraph JMH ["JMH — 레시피 비교"] direction TB J1["N+1 vs 배치
→ 배치가 13배 빠르다"] end subgraph nGrinder ["nGrinder — 실전 영업"] direction TB N1["배치 방식으로 구현한 API
→ 동시 100명일 때 TPS는?"] end JMH ==>|"최적 방식 확정"| nGrinder style J1 fill:#E3F2FD,stroke:#2196F3,stroke-width:2px style N1 fill:#FFF3E0,stroke:#FF9800,stroke-width:2px style JMH fill:#f0f4ff,stroke:#2196F3 style nGrinder fill:#fff8f0,stroke:#FF9800

nGrinder의 구조

nGrinder는 세 가지 구성 요소로 이루어져 있다.

Controller — 관제탑

웹 브라우저에서 접속하는 관리 화면이다. 여기서 스크립트를 작성하고, 테스트를 시작하고, 결과를 본다.

하는 일:

  • 스크립트 편집기 제공 — Groovy 코드를 웹에서 작성한다
  • Agent에게 "테스트 시작해" 명령을 내린다
  • Agent가 보내오는 실시간 수치를 수집해서 그래프와 리포트를 만든다

Agent — 요청을 쏘는 일꾼

실제로 HTTP 요청을 만들어서 서버에 보내는 역할이다. Controller가 "이 스크립트로 VUser 50명 돌려"라고 명령하면, Agent가 50개의 스레드를 띄워서 서버를 두드린다.

하는 일:

  • Controller로부터 스크립트를 받아서 실행한다
  • VUser 수만큼 스레드를 만들어 HTTP 요청을 반복한다
  • 응답 시간과 성공/실패를 기록해서 Controller에 보고한다

Target Server — 테스트 대상

우리가 만든 Spring Boot 서버다. nGrinder와 전혀 관련 없는 독립 실행 서버다. Agent가 이 서버의 API를 호출할 뿐이다.

이 세 요소 사이의 흐름을 정리하면 이렇다.

sequenceDiagram autonumber participant U as 사용자 (브라우저) participant C as Controller participant A as Agent participant T as Target Server U->>C: 스크립트 작성 + "테스트 시작" C->>A: 스크립트 전달 + 시작 명령 rect rgb(255, 243, 224) Note over A, T: 부하 발생 구간 loop Duration 동안 반복 A->>T: HTTP 요청 T-->>A: HTTP 응답 end end A-->>C: 실시간 지표 (TPS, MTT, 에러) C-->>U: 그래프 + 리포트

1번에서 사용자가 Controller 웹 UI에서 스크립트를 작성하고 "테스트 시작"을 누른다. 2번에서 Controller가 Agent에게 스크립트를 보내고 실행을 지시한다. 3~4번의 주황색 구간이 실제 부하가 발생하는 구간이다. Agent가 스레드(VUser)를 만들어서 Target Server에 HTTP 요청을 반복 전송한다. 5번에서 Agent가 매초 결과를 Controller에 보고하고, 6번에서 Controller가 실시간 그래프로 보여준다.

Agent와 Target Server를 같은 머신에 두면

Agent가 CPU와 네트워크를 잡아먹으므로 Target Server의 성능이 실제보다 나빠 보인다. 로컬 개발 환경에서는 어쩔 수 없지만, 결과를 해석할 때 이 점을 감안해야 한다. 프로덕션 부하 테스트에서는 Agent를 별도 머신에 둔다.

[!TIP] 왜 nGrinder인가

JMeter, k6, Gatling 등 다른 도구도 있다. nGrinder의 장점은 Groovy(JVM 언어)로 스크립트를 작성하므로 Java/Spring 개발자에게 익숙하다는 점이다. 웹 UI에서 스크립트 작성부터 실행, 결과 분석까지 한 곳에서 해결된다. 네이버 오픈소스로 한국어 자료도 풍부하다.

Docker로 nGrinder 띄우기

사전 조건

Docker Desktop이 실행 중이어야 한다. Windows에서는 Docker Desktop 설정 → Resources에서 메모리를 최소 4GB 이상으로 할당한다. Controller + Agent를 동시에 띄우면 기본 2GB로는 부족하다.

docker-compose 파일 작성

프로젝트 루트에 docker-compose-ngrinder.yml 파일을 만든다. 기존 docker-compose.yml(PostgreSQL, Redis 등)과 분리하는 이유는 생명주기가 다르기 때문이다. 인프라 서비스는 개발하는 동안 계속 켜두지만, nGrinder는 부하 테스트할 때만 띄운다.

# docker-compose-ngrinder.yml
services:
  ngrinder-controller:
    image: ngrinder/controller:3.5.9
    container_name: ngrinder-controller
    ports:
      - "8880:80"
      - "16001:16001"
      - "12000-12009:12000-12009"
    volumes:
      - ngrinder-controller:/opt/ngrinder-controller

  ngrinder-agent:
    image: ngrinder/agent:3.5.9
    container_name: ngrinder-agent
    depends_on:
      - ngrinder-controller
    environment:
      - CONTROLLER_ADDR=ngrinder-controller:80
    extra_hosts:
      - "host.docker.internal:host-gateway"

volumes:
  ngrinder-controller:

이 파일에서 각 설정이 무엇을 의미하는지 풀어본다.

포트 설정

  • 8880:80 — Controller의 웹 UI다. 브라우저에서 http://localhost:8880으로 접속한다. 컨테이너 내부에서는 80번 포트를 쓰지만, 호스트에서는 8880으로 연결했다
  • 16001:16001 — Controller와 Agent가 서로 대화하는 포트다. Agent가 이 포트로 "나 여기 있어"라고 등록하고, 테스트 시작 명령을 받는다
  • 12000-12009 — 테스트 실행 중 데이터를 주고받는 포트 범위다. 10개이므로 동시에 10개의 테스트를 실행할 수 있다. 로컬에서는 1개면 충분하지만 기본값을 그대로 둔다

볼륨 설정

ngrinder-controller 볼륨은 Controller의 데이터(스크립트, 테스트 결과, 설정)를 컨테이너 밖에 저장한다. 이 설정이 없으면 컨테이너를 삭제할 때 지금까지 작성한 스크립트와 테스트 결과가 전부 날아간다. 볼륨을 지정하면 컨테이너를 삭제하고 다시 만들어도 데이터가 유지된다.

extra_hosts 설정

host.docker.internal:host-gateway는 Docker 컨테이너 안에서 호스트 머신의 localhost에 접근할 수 있게 해주는 설정이다.

Agent는 Docker 안에서 실행된다. 그런데 우리의 Spring Boot 서버는 Docker 밖, 호스트에서 localhost:8080으로 돌아간다. 컨테이너 입장에서 localhost는 자기 자신이지, 호스트 머신이 아니다. 이 설정을 넣으면 컨테이너 안에서 host.docker.internal이라는 주소로 호스트 머신에 접근할 수 있게 된다.

Windows Docker Desktop에서는 이 설정 없이도 동작하는 경우가 많지만, 명시적으로 넣어두면 환경 차이에 의한 문제를 예방한다.

전체 구성도

지금까지 설명한 환경의 전체 그림이다.

flowchart TD subgraph Docker ["Docker 환경"] direction TB subgraph Infra ["docker-compose.yml (기존)"] PG[("PostgreSQL
:5432")] RD[("Redis
:6379")] ES[("Elasticsearch
:9200")] end subgraph NGrinder ["docker-compose-ngrinder.yml (새로 추가)"] CTRL["Controller
:8880"] AGT["Agent"] end end subgraph Host ["호스트 머신"] APP["Spring Boot
:8080"] end AGT -->|"HTTP 요청"| APP APP --> PG APP --> RD APP --> ES AGT -->|"지표 보고"| CTRL style PG fill:#E3F2FD,stroke:#2196F3,stroke-width:2px style RD fill:#FFEBEE,stroke:#F44336,stroke-width:2px style ES fill:#EDE7F6,stroke:#673AB7,stroke-width:2px style APP fill:#E8F5E9,stroke:#4CAF50,stroke-width:2px style CTRL fill:#FFF3E0,stroke:#FF9800,stroke-width:2px style AGT fill:#FFF3E0,stroke:#FF9800,stroke-width:2px style Infra fill:#f0f4ff,stroke:#2196F3 style NGrinder fill:#fff8f0,stroke:#FF9800 style Host fill:#f0fff0,stroke:#4CAF50

Agent가 호스트의 Spring Boot에 HTTP 요청을 보내고, Spring Boot는 Docker 안의 DB/캐시/검색엔진을 사용한다. Agent는 측정 결과를 Controller에 보고하고, 사용자는 Controller 웹 UI에서 그래프를 본다.

실행 순서

# 1. 인프라 서비스 시작 (이미 띄워져 있다면 생략)
docker compose up -d

# 2. Spring Boot 애플리케이션 시작
./gradlew bootRun

# 3. nGrinder 시작
docker compose -f docker-compose-ngrinder.yml up -d

-d는 백그라운드 실행이다. -f로 파일을 지정해야 한다. 지정하지 않으면 기본 docker-compose.yml을 찾는다. 처음 실행하면 이미지를 다운로드하는 데 시간이 걸린다. 이후에는 빠르게 시작된다.

정상 실행 확인은 이렇게 한다.

docker compose -f docker-compose-ngrinder.yml ps

두 컨테이너 모두 running 상태여야 한다. Exit 상태이면 docker logs ngrinder-controller로 로그를 확인한다.

웹 UI 접속과 Agent 승인

첫 로그인

브라우저에서 http://localhost:8880에 접속한다. Controller가 시작되는 데 30초~1분 정도 걸릴 수 있다. 접속이 안 되면 조금 기다렸다가 다시 시도한다.

  • 계정 : admin
  • 비밀번호 : admin

로그인하면 대시보드가 나온다. 상단 메뉴에 핵심 탭 3개가 있다.

  • Script — 스크립트를 작성하고 관리하는 곳
  • Performance Test — 테스트를 생성하고 실행하는 곳
  • Agent Management — Agent 상태를 확인하고 승인하는 곳

Agent 승인

이 단계를 빠뜨리면 테스트가 실행되지 않는다.

Agent Management 페이지로 이동한다. docker-compose로 Agent를 함께 띄웠으므로 Agent가 자동으로 Controller에 연결되어 있다. 하지만 Unapproved 상태로 표시된다.

해당 Agent를 선택하고 Approve 버튼을 누른다. nGrinder가 보안상 Agent를 수동 승인하도록 설계되어 있기 때문이다. 승인 후 Status가 "Ready"로 바뀌면 테스트를 실행할 수 있다.

Agent 미승인 상태에서 테스트 실행 시

"No available agent" 에러가 발생한다. 테스트 설정 화면에서 Agent 수를 지정해도 실제 사용 가능한 Agent가 없으면 실행이 불가능하다. Agent Management에서 Status가 "Ready"인지 반드시 확인한다.

핵심 용어 — 처음 보면 헷갈리는 것들

nGrinder UI와 결과 리포트에 나오는 용어들이다. 나중에 결과를 읽을 때 계속 등장하므로 여기서 정리해둔다.

VUser — 가상 유저

Virtual User. 실제 사람 대신 서버에 요청을 보내는 가상의 사용자다. VUser 1명은 1개의 스레드가 "요청 보내기 → 응답 받기 → 다시 요청 보내기"를 끊임없이 반복하는 것이다.

식당에서 손님 50명이 동시에 앉아서 주문하는 상황을 만들려면 VUser를 50으로 설정한다.

VUser 수는 Process × Thread로 결정된다.

  • Process — Agent 안에서 실행되는 별도의 JVM 프로세스다. Process를 늘리면 독립적인 JVM이 추가되므로 메모리를 더 먹는다
  • Thread — 한 Process 안의 스레드다. Thread를 늘리면 같은 JVM 안에서 스레드만 추가되므로 가볍다

예를 들어 Process 2, Thread 25이면 VUser는 50이다. 처음에는 Process 1, Thread만 조절하는 것으로 시작하면 된다. Process를 늘리는 건 단일 JVM의 한계에 부딪혔을 때다.

TPS — 초당 처리량

Transactions Per Second. 서버가 1초에 몇 건의 요청을 성공적으로 처리했는가. 높을수록 좋다.

식당에서 "1시간에 파스타 120개를 만들 수 있다"와 같은 지표다.

TPS를 볼 때 가장 중요한 패턴 세 가지가 있다.

  • VUser가 늘어나면 TPS도 올라간다 → 서버에 여유가 있다. 주문이 늘어도 주방이 따라가고 있다
  • VUser를 올렸는데 TPS가 더 이상 올라가지 않는다 → 포화점에 도달했다. 주방이 풀가동 상태여서 손님이 더 와도 속도를 못 올린다
  • TPS가 떨어지기 시작한다 → 과부하. 주방이 한계를 넘어서 오히려 효율이 떨어진다

MTT — 평균 응답 시간

Mean Test Time. 하나의 HTTP 요청을 보내고 응답을 받기까지의 평균 시간이다. 단위는 밀리초(ms). 낮을수록 좋다.

식당에서 "주문 후 음식이 나오기까지 평균 5분"과 같다.

MTT와 TPS는 밀접한 관계에 있다.

  • 서버에 여유가 있을 때 : VUser를 올려도 MTT가 유지된다. 주문이 늘어도 대기 시간이 없다
  • 포화 상태에 도달하면 : TPS는 안 올라가는데 MTT만 계속 증가한다. 처리 속도는 한계인데 대기열에서 기다리는 시간이 쌓이는 것이다

이 관계에서 나오는 공식이 리틀의 법칙이다.

VUser = TPS × MTT(초)

VUser 50, MTT 100ms(0.1초)이면 TPS는 500이다. VUser를 100으로 올렸는데 TPS가 500에 머물러 있다면, MTT가 200ms로 늘어났다는 뜻이다. 대기 시간이 2배로 늘었다.

Peak TPS — 최고 순간 처리량

테스트 Duration 동안 달성한 가장 높은 TPS 값이다. 평균 TPS보다 높다. JVM 워밍업 직후, GC가 일어나기 전에 찍히는 경우가 많다. 시스템의 이론적 최대치에 가까운 값이다.

Ramp-up — 점진적 부하 투입

VUser를 한 번에 다 투입하지 않고 천천히 늘려가는 것이다.

식당에 손님 100명이 동시에 문을 열고 들어오면 카운터가 마비된다. 10명씩 간격을 두고 들어오면 주방이 적응할 시간이 있다. Ramp-up은 이 "천천히 늘리기"다.

VUser 100에서 Ramp-up을 활성화하고 초기 대기 시간을 1000ms로 설정하면, VUser가 1초 간격으로 1명씩 추가된다. Ramp-up 없이 시작하면 100명이 동시에 첫 요청을 보내서 서버에 갑작스런 충격을 준다.

VUser가 1명일 때는 Ramp-up이 의미 없다. VUser 10 이상부터 켜는 것이 일반적이다.

Error Rate — 에러율

전체 요청 중 실패한 비율이다. 0%가 정상이다.

  • 0% — 모든 요청이 성공했다. 서버가 감당하고 있다
  • 0%를 벗어남 — 일부 요청이 실패했다. 서버가 일부 요청을 처리하지 못한 것이다. 원인을 찾아야 한다
  • 100% — 모든 요청이 실패했다. 서버가 꺼져 있거나, 주소가 틀렸거나, 연결 자체가 불가능하다

자주 하는 실수

Target Server를 안 띄우고 테스트 실행

nGrinder Agent가 서버에 HTTP 요청을 보내는 구조이므로, Spring Boot 서버가 먼저 실행 중이어야 한다. 서버가 안 떠있으면 Error Rate 100%가 찍히면서 테스트가 무의미해진다.

[!WARNING] Agent 승인을 깜빡하고 "No available agent"

Controller에 Agent가 붙어 있어도 수동 승인을 해야 사용 가능하다. Agent Management → 해당 Agent 선택 → Approve. 이걸 빠뜨리고 테스트를 만들면 Agent를 찾을 수 없다는 에러가 뜬다.

[!WARNING] Docker Desktop 메모리 부족으로 OOM

Controller와 Agent를 동시에 띄우면 메모리를 꽤 먹는다. Docker Desktop 기본 메모리(2GB)로는 부족할 수 있다. Settings → Resources에서 4GB 이상으로 올린다. 컨테이너가 갑자기 죽으면 docker logs ngrinder-controller로 OOM 여부를 확인한다.

[!WARNING] VUser를 너무 높게 잡고 시작

처음부터 VUser 100을 넣으면 서버가 바로 뻗어서 유의미한 데이터를 얻기 어렵다. VUser 1로 기준선을 잡고, 10 → 30 → 50 → 100 순서로 올려가며 변화를 관찰하는 것이 올바른 순서다. 다음 편에서 이 과정을 다룬다.