제리의 배움 기록

[부하테스트] nGrinder 부하테스트 경험 해보기 본문

개발기록

[부하테스트] nGrinder 부하테스트 경험 해보기

제리92 2021. 11. 29. 21:58

현재 진행중인 "부동산 실거래가 비교 시스템" 프로젝트에 부하테스트를 해보았습니다.

부하테스트 툴에는 주로 nGrinder와 jmeter를 많이 사용하는데요, 저는 nGrinder를 사용해보았습니다.
nGrinder를 선택한 이유는

1) 실무에서도 많이 사용되고 있는 툴이고
2) 테스트의 결과를 보여주는 레포트 UI가 심플하고 직관적이며
3) 부하테스트 스크립트 작성에 친숙한 groovy 언어를 사용하기 때문입니다.

1.nGrinder 구성

nGrinder는 Controller와 Agent로 구성되어 있습니다.

Controller

  • Controller는 Web Application으로 Tomcat과 같은 웹서버 엔진을 이용하여 구동할 수 있습니다.
  • 사용자와의 인터페이스를 담당하여 테스트 프로세스 정의, 스크립트 작성 등을 지원합니다.
  • Agent에 스크립트를 전달하여 테스트를 일임합니다.

Agent

  • Controller로 부터 테스트 script를 전달받아 실제로 타겟 서버에 부하 테스트를 진행합니다.

2. nGrinder 설치

아래 페이지를 통해 Controller와 Agent를 다운받을 수 있습니다.

공식 설치 페이지
nGrinder용 디렉토리를 하나 생성하여 다운받은 1)Controller용 war 파일과 2)Agent 디렉토리를 이동합니다.

예시

- ngrinder
    - ngrinder-controller-3.5.5-p1.war => Controller용
    - ngrinder-agent => Agent용
        - lib
        ...

3. nGrinder 실행

Controller 실행

  • 8081포트로 Controller 실행실행시 console에 gradle을 찾을 수 없단 warning이 출력된다면 gradle을 먼저 설치해야합니다.
    gradle 공식사이트를 통해 다운 받은 후 환경변수 path를 지정해야 문제가 발생하지 않습니다.
  • cd /ngrinder sudo java -XX:MaxPermSize=200m -jar ngrinder-controller-3.5.5-p1.war --port 8081

Agent 실행

cd /ngrinder/ngrinder-agent
run_agent.sh

4. Controller 접속

localhost:8081

5. 시나리오 정의 및 스크립트 작성

위 화면에서 script를 바로 작성할 수도 있지만, Maven + junit 테스트 조합으로 프로젝트를 구성하면 1)IDE에서 편리하게 테스트 스크립트를 작성할 수 있고, 2)git을 이용한 버전관리로 여러명이 쉽게 공유할 수 있고 3)테스트 이력 관리에도 용이한 장점이 있다고 생각하여 프로젝트를 구성해보았습니다.

pom.xml 설정
<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0"
         xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
         xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd">
    <modelVersion>4.0.0</modelVersion>

    <groupId>com.test.ngrinder</groupId>
    <artifactId>ngrinder-test</artifactId>
    <version>1.0-SNAPSHOT`</version>

    <build>
        <plugins>
            <plugin>
                <groupId>org.apache.maven.plugins</groupId>
                <artifactId>maven-surefire-plugin</artifactId>
                <version>2.15</version>
                <configuration>
                    <argLine>
                        -javaagent:${settings.localRepository}/net/sf/grinder/grinder-dcr-agent/3.9.1/grinder-dcr-agent-3.9.1.jar
                    </argLine>
                    <useSystemClassLoader>true</useSystemClassLoader>
                </configuration>
            </plugin>
        </plugins>
    </build>

    <repositories>
        <repository>
            <id>nhnopensource.maver.repo</id>
            <url>https://github.com/nhnopensource/nhnopensource.maven.repo/raw/master/releases</url>
        </repository>
    </repositories>

    <dependencies>
        <dependency>
            <groupId>org.ngrinder</groupId>
            <artifactId>ngrinder-groovy</artifactId>
            <version>3.3</version>
        </dependency>
    </dependencies>
</project>

(1) [회원가입] - [로그인] - [테스트용 간단한 웹서비스 호출]

  • 인증 필요
import HTTPClient.Cookie
import HTTPClient.CookieModule
import HTTPClient.HTTPResponse
import HTTPClient.NVPair
import net.grinder.plugin.http.HTTPPluginControl
import net.grinder.plugin.http.HTTPRequest
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.Before
import org.junit.Test
import org.junit.runner.RunWith    

import static net.grinder.script.Grinder.grinder  
import static org.hamcrest.Matchers.is  
import static org.junit.Assert.assertThat

@RunWith(GrinderRunner)  
class AuthTestRunner {

public static GTest test
public static HTTPRequest request
public static NVPair[] headers = []
public static NVPair[] params = []
public static Cookie[] cookies = []

@BeforeProcess
static void beforeProcess() {
    HTTPPluginControl.getConnectionDefaults().timeout = 6000
    test = new GTest(1, "타겟서버url")
    request = new HTTPRequest()
    headers=[new NVPair("Content-Type","application/json")]
    grinder.logger.info("before process.")
}

@BeforeThread
void beforeThread() {
    test.record(this, "test")
    grinder.statistics.delayReports = true
    request.setHeaders(headers)
    String id = RandomStrIssuer.getRandomNumbers();
    String password = RandomStrIssuer.getRandomNumbers();
    def userParam ='{"id":"'+id+'","password":"'+password+'"}'

    // reset to the all cookies
    def threadContext = HTTPPluginControl.getThreadHTTPClientContext()
    cookies = CookieModule.listAllCookies(threadContext)
    cookies.each {
        CookieModule.removeCookie(it, threadContext)
    }

    request.POST("타겟서버url", userParam.getBytes(),headers)
    HTTPResponse result = request.POST("타겟서버url", userParam.getBytes(),headers)
    cookies = CookieModule.listAllCookies(threadContext)
    grinder.logger.info("before thread.")
}

@Before
void before() {
    def threadContext = HTTPPluginControl.getThreadHTTPClientContext()
    cookies.each {
        CookieModule.addCookie(it ,threadContext)
        grinder.logger.info("{}", it)
    }
}

@Test
void test() {
    HTTPResponse result = request.GET("타겟서버url")
    if (result.statusCode == 301 || result.statusCode == 302) {
        grinder.logger.warn("Warning. The response may not be correct. The response code was {}.", result.statusCode)
    } else {
        assertThat(result.statusCode, is(200))
    }
}

  class RandomStrIssuer {

      private static int NUMBER_LENGTH=5;
      private static int NUMBER_BOUND=100;

      public static String getRandomNumbers(){
          Random random = new Random();
          StringBuilder sb= new StringBuilder();
          for(int i=0; i<NUMBER_LENGTH; i++){
              sb.append(String.valueOf(random.nextInt(NUMBER_BOUND)));
          }
          return sb.toString();
      }
  }
}
  • 각 thread 마다 회원가입, 로그인에 사용할 계정을 랜덤하게 생성하기 위해 RandomStrIssuer 클래스를 정의하여 활용
  • @BeforeThread 에서 회원가입과 로그인을 수행하고 Session을 생성

테스트 결과

  • 150개로 테스트 하였을 시 아래와 같이 @BeforeThread 단계에서 병목 발생하여 제대로된 테스트가 이루어지지 않았습니다.
    net.grinder.scriptengine.groovy.GroovyScriptEngine$GroovyScriptExecutionException: Exception occurs in @BeforeThread block.
  • 동시 스레드 100개 정도가 안정적으로 테스트 해볼 수 있었습니다.
    • 각 스레드당 5000건을 시도하도록 하였을때 평균 TPS 933.4로 안정적으로 처리

로컬 PC의 한계로 스레드 수를 제한한 것이지 서버가 다운되어서는 아닙니다.
스레드당 처리 건수는 스트레스 테스트에 큰 영향을 미치지는 못하는 것으로 분석됩니다.
여러대의 테스트 서버를 이용하여 agent와 스레드 수를 늘려 테스트를 해보아야 임계 포인트를 찾을 수 있을 것으로 예상됩니다.

(2) [아파트 목록 조회]

  • 인증 불필요

script 작성

import HTTPClient.Cookie  
import HTTPClient.CookieModule  
import HTTPClient.HTTPResponse  
import HTTPClient.NVPair  
import net.grinder.plugin.http.HTTPPluginControl  
import net.grinder.plugin.http.HTTPRequest  
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.Before  
import org.junit.Test  
import org.junit.runner.RunWith

import static net.grinder.script.Grinder.grinder  
import static org.hamcrest.Matchers.is  
import static org.junit.Assert.assertThat

@RunWith(GrinderRunner)  
class NonAuthTestRunner {


public static GTest test
public static HTTPRequest request
public static NVPair[] headers = []
public static NVPair[] params = []
public static Cookie[] cookies = []

@BeforeProcess
static void beforeProcess() {
    HTTPPluginControl.getConnectionDefaults().timeout = 6000
    test = new GTest(1, "타겟서버url")
    request = new HTTPRequest()
    headers=[new NVPair("Content-Type","application/json")]
    grinder.logger.info("before process.")
}

@BeforeThread
void beforeThread() {
    test.record(this, "test")
    grinder.statistics.delayReports = true
    grinder.logger.info("before thread.")
}

@Before
void before() {
    request.setHeaders(headers)
    cookies.each { CookieModule.addCookie(it, HTTPPluginControl.getThreadHTTPClientContext()) }
    grinder.logger.info("before thread. init headers and cookies")
}

  @Test
  void test() {
      HTTPResponse result = request.GET("타겟서버url", params)

      if (result.statusCode == 301 || result.statusCode == 302) {
          grinder.logger.warn("Warning. The response may not be correct. The response code was {}.", result.statusCode)
      } else {
          assertThat(result.statusCode, is(200))
      }
  }

}

테스트 결과

  • 시나리오 1에 비해 조회 하나만 처리함에도 불구하고 tps가 현저히 낮습니다.
  • 조회를 페이징하지 않아서, 대용량 데이터를 조회하다 보니 이런 결과가 발생한 것으로 보입니다.
  • ==> 페이징을 통한 해결이나 캐싱을 필히 활용해야 한다는 결론을 얻었습니다.

[참고]
ngrinder - github
maven-groovy project

Comments