nGrinder로 부하 테스트 시작하기

2024-07-22 21:07:51
#ngrinder#load-testing#performance#groovy

부하 테스트가 필요한 이유

서비스 출시 전에 "우리 서버가 동시 접속자 1만 명을 버틸 수 있을까?"라는 질문에 답하려면 부하 테스트가 필요합니다. 실제 트래픽이 몰리기 전에 병목 지점을 찾고, 서버 스펙을 결정하는 데 근거가 되죠.

부하 테스트로 확인할 수 있는 것들:

  • 최대 처리량 (Peak TPS)
  • 응답 시간 분포
  • 병목 지점 (DB, 외부 API, CPU, 메모리 등)
  • 장애 발생 임계점

nGrinder란?

네이버에서 만든 오픈소스 부하 테스트 도구입니다. Grinder를 기반으로 웹 UI와 분산 테스트 기능을 추가했어요.

다른 도구와 비교

도구 언어 특징
nGrinder Groovy/Jython 웹 UI, 분산 테스트, 스크립트 버전 관리
JMeter XML (GUI) 가장 널리 사용, 플러그인 풍부, GUI 무거움
Gatling Scala 코드 기반, 고성능, 리포트 예쁨
k6 JavaScript 가볍고 빠름, 클라우드 연동 좋음

웹 UI에서 테스트를 관리하고, Agent 여러 대를 붙이면 대규모 분산 테스트도 어렵지 않게 돌릴 수 있어요.

아키텍처

nGrinder는 2개의 구성 요소로 이루어집니다.

nGrinder Architecture

Controller

  • 웹 UI 제공 (테스트 생성, 실행, 결과 확인)
  • Agent 관리 및 할당
  • 스크립트 버전 관리 (내장 SVN)
  • 테스트 결과 저장 및 시각화

Agent

  • 실제 부하를 발생시키는 워커
  • Controller에서 스크립트를 받아 실행
  • 여러 대를 붙여 분산 테스트 가능

Controller와 Agent는 12001~120XX 포트로 통신합니다.

설치하기

Docker로 설치 (권장)

가장 간편한 방법입니다.

# Controller 실행
docker run -d --name ngrinder-controller \
  -p 80:80 -p 16001:16001 -p 12000-12009:12000-12009 \
  ngrinder/controller

# Agent 실행 (Controller IP로 변경)
docker run -d --name ngrinder-agent \
  ngrinder/agent CONTROLLER_IP:80

직접 설치

# Controller
wget https://github.com/naver/ngrinder/releases/download/ngrinder-3.5.9-p1-20240624/ngrinder-controller-3.5.9-p1.war
java -jar ngrinder-controller-3.5.9-p1.war --port=8080

# Agent (Controller UI에서 다운로드 가능)
# 압축 해제 후 run_agent.sh 실행

nGrinder Agent Download

Agent 설정 파일 (agent.conf):

common.start_mode=agent
agent.controller_host=192.168.1.100  # Controller IP
agent.controller_port=16001
agent.region=Seoul
agent.host_id=agent-01

초기 접속

  • URL: http://localhost:80 (또는 설정한 포트)
  • 기본 계정: admin / admin

테스트 스크립트 작성

nGrinder는 Groovy 또는 Jython으로 스크립트를 작성합니다. Groovy를 추천해요.

기본 구조

nGrinder 스크립트는 JUnit 스타일로 작성합니다. @BeforeProcess는 프로세스당 1회, @BeforeThread는 스레드당 1회 실행돼요. Process 2, Thread 5 설정이면 @BeforeProcess는 2번, @BeforeThread는 10번 호출됩니다.

import static net.grinder.script.Grinder.grinder
import static org.junit.Assert.*
import net.grinder.script.GTest
import net.grinder.scriptengine.groovy.junit.GrinderRunner
import net.grinder.scriptengine.groovy.junit.annotation.BeforeProcess
import net.grinder.scriptengine.groovy.junit.annotation.BeforeThread
import org.junit.Test
import org.junit.runner.RunWith
import HTTPClient.HTTPRequest
import HTTPClient.HTTPResponse
import HTTPClient.NVPair

@RunWith(GrinderRunner)
class TestRunner {

    public static GTest test
    public static HTTPRequest request

    @BeforeProcess
    public static void beforeProcess() {
        // 프로세스 시작 시 1회 실행
        test = new GTest(1, "API Test")
        request = new HTTPRequest()
    }

    @BeforeThread
    public void beforeThread() {
        // test 메서드의 실행 시간을 측정하도록 등록
        test.record(this, "test")
        // 테스트 완료 후 통계를 한 번에 리포트 (정확한 측정을 위해)
        grinder.statistics.delayReports = true
    }

    @Test
    public void test() {
        // 실제 테스트 로직
        HTTPResponse response = request.GET("http://api.example.com/users")

        if (response.statusCode == 200) {
            grinder.logger.info("Success")
        } else {
            grinder.logger.error("Failed: ${response.statusCode}")
            fail("Status code is not 200")
        }
    }
}

POST 요청 with JSON

// 클래스 상단에 토큰 변수 정의
public static String token = "your-api-token"

@Test
public void testCreateUser() {
    def headers = [
        new NVPair("Content-Type", "application/json"),
        new NVPair("Authorization", "Bearer ${token}")
    ] as NVPair[]

    def body = '{"name": "홍길동", "email": "hong@example.com"}'

    HTTPResponse response = request.POST(
        "http://api.example.com/users",
        body.getBytes(),
        headers
    )

    assertEquals(201, response.statusCode)
}

동적 데이터 사용

import java.util.Random

@BeforeThread
public void beforeThread() {
    // 스레드별로 다른 사용자 ID 사용
    grinder.threadNumber  // 0, 1, 2, ...
}

@Test
public void test() {
    def userId = grinder.threadNumber + 1
    def response = request.GET("http://api.example.com/users/${userId}")
}

파일에서 테스트 데이터 읽기

// resources 폴더에 users.csv 파일 준비
// user_id,name
// 1,홍길동
// 2,김철수

public static List<String[]> testData = []

@BeforeProcess
public static void beforeProcess() {
    def csvFile = new File(System.getProperty("user.dir") + "/resources/users.csv")
    csvFile.eachLine { line, index ->
        if (index > 1) {  // 헤더 스킵
            testData.add(line.split(","))
        }
    }
}

@Test
public void test() {
    def row = testData[grinder.threadNumber % testData.size()]
    def userId = row[0]
    def userName = row[1]
    // userId, userName으로 테스트
}

테스트 설정

nGrinder test configuration

설정 설명
Agent 사용할 Agent 수
Vuser per agent Agent당 가상 사용자 수 (Process × Thread)
Script 실행할 테스트 스크립트
Duration 테스트 실행 시간
Run Count 테스트 실행 횟수 (Duration과 택1)
Ramp-Up 점진적 부하 증가 설정

vUser 계산

Total vUser = Agent × Process × Thread

예: Agent 2대, Process 2, Thread 5 → 총 20 vUser

Process vs Thread 선택 기준

  • Thread 우선: 가벼운 HTTP 요청 위주라면 Thread를 늘리는 게 효율적
  • Process 우선: 스크립트가 무겁거나 메모리를 많이 쓰면 Process를 늘려서 격리
  • 일반적으로 Process 12, Thread 1050 조합을 많이 씁니다

Ramp-Up 전략

처음부터 전체 부하를 주면 서버가 즉시 다운될 수 있어요. Ramp-Up으로 점진적으로 늘려가면서 임계점을 찾는 게 좋습니다.

예시: 10분 동안 vUser를 10 → 100으로 증가
- Initial vUser: 10
- Increment: 10
- Interval: 1분

결과 분석

주요 지표

지표 설명 좋은 값 기준
TPS 초당 처리 요청 수 높을수록 좋음
Peak TPS 최대 TPS 목표치 이상
MTT (Mean Test Time) 평균 테스트 시간 낮을수록 좋음
MTTFB 첫 바이트까지 평균 시간 API 응답 시간과 유사
Errors 에러 발생 수 0에 가까울수록

참고문헌

프로필 이미지
@chani
바둑, 스타크래프트 등 고전 게임을 좋아하는 내향인 개발자입니다

댓글