Home Ngrinder
Post
Cancel

Ngrinder

nGrinder

nGrinder는 성능 테스트를 위한 오픈 소스 플랫폼으로, Controller와 에이전트(Agent)로 구성된다.


  • 컨트롤러 (Controller)

nGrinder 성능 테스트를 관리하고 조정하는 중앙 집중형 서버다.

사용자는 웹 인터페이스를 통해 컨트롤러에 접속하여 테스트를 설정하고 실행할 수 있다.

컨트롤러는 테스트를 위해 Agent들을 관리하고, 테스트 결과를 수집하여 보고서를 생성한다.



  • 에이전트 (Agent)

Controller에서 지시를 받아 성능 테스트를 수행하는 노드다.

여러 대의 Agent가 동시에 여러 서버에 분산되어 실행될 수 있다.

Agent는 실제 테스트 대상 시스템에 부하를 가해 성능을 측정하고, 이러한 결과를 Controller에 보고한다.



먼저 Controller를 설정하고 실행하여 웹 인터페이스를 통해 성능 테스트를 관리한다.

그런 다음, 테스트를 실행하기 위해 하나 이상의 에이전트를 준비하고 컨트롤러에 연결한다.

Controller에서 성능 테스트를 설정하고 실행하면, 에이전트들이 테스트를 수행하고 결과를 컨트롤러에 제공한다.





설치 (3가지 방법 중 1개 선택)

1. war 파일 다운

설치 링크

image

ngrinder-controller-3.5.9.war 다운


1
$ java -jar ngrinder-controller-3.5.9-p1.war --port=8300

Tomcat과 충돌하지 않는 포트 번호를 선택하여 사용했다.(충돌하지 않는 포트번호면 어떠한 것도 상관없음)





2. Docker

Controller 설치 및 실행

1
2
$ docker pull ngrinder/controller
$ docker run -d -v ~/ngrinder-controller:/opt/ngrinder-controller --name controller -p 80:80 -p 16001:16001 -p 12000-12009:12000-12009 ngrinder/controller
  • --name controller : 컨테이너의 이름을 controller로 지정

  • -p 80:80 : 호스트의 80번 포트와 컨테이너의 80번 포트를 연결

  • -p 16001:16001 : 호스트의 16001번 포트와 컨테이너의 16001번 포트를 연결

  • -p 12000-12009:12000-12009 : 호스트의 12000부터 12009까지의 포트와 컨테이너의 동일한 범위의 포트를 연결

    호스트의 8081번 포트를 컨테이너의 80번 포트로 사용하고 싶다면 -p 8081:80 으로 수정할 수 있디.


Agent 설치 및 실행

1
2
$ docker pull ngrinder/agent
$ docker run -d --name agent --link controller:controller ngrinder/agent
  • --name agent : 컨테이너의 이름을 agent로 지정

  • --link controller:controller : 다른 컨테이너인 controller와 agent를 연결

  • ngrinder/agent: 이 컨테이너의 이미지로 ngrinder/agent를 사용


참고로 로컬에서 간단하게 테스트 하는 것이 아니라면 controller 와 agent 는 물리적으로 다른 서버에 두는 것이 좋다.

성능이 나오지 않아 원하는 만큼의 부하를 줄 수 가 없기 때문이다.

controller 와 agent 중 agent 서버의 성능이 더 중요하다.




3. Docker Compose

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
version: '3.9'
services:
  controller:
    image: ngrinder/controller
    restart: always
    ports: 
      - "80:80"
      - "16001:16001"
      - "12000-12009:12000-12009"
    volumes:
      - ./ngrinder-controller:/opt/ngrinder-controller
  agent:
    image: ngrinder/agent
    restart: always
    links:
      - controller

version: '3.9' : Docker Compose 파일의 버전

restart: always : 컨테이너가 종료되었을 때 자동으로 다시 시작하도록 지정



docker-compose.yml 파일 실행

1
$ docker-compose up

만약 docker-com.yml과 같이 파일 명이 다를 경우 docker-compose -f docker-com.yml up




사이트 확인

Controller를 실행한 포트 번호로 브라우저 주소창에 http://localhost:[포트번호]/login을 입력하면

아래와 같은 화면을 확인할 수 있다.

ex) http://localhost:80/login

image

초기 아이디와 패스워드는 모두 admin



메인 화면에서 오른쪽 상단 admin → Agent Management에 Agent 서버가 Controller에 정상적으로 적용되었는지 확인한다.

image

image




API 호출 테스트

API 호출을 테스트하기 위해서는 Script를 작성해야한다. 어떤 REST API로 요청을 보낼 것인지 설정해준다.

정상적으로 Agent가 동작중이라면 화면 상단에 위치한 탭에서 Script를 클릭하여 스크립트 화면으로 이동한 뒤,

image

테스트를 위해 + Create 버튼을 누르고, Create a script를 선택한다.

image




Script Name : 아무거나 작성

여기서 주의할 점은 URL에 ‘localhost, 127.0.0.1’로는 테스트가 불가능하다.

http://12.34.45.56:8080 과 같이 12.34.45.56 를 본인 ip 주소를 작성하면 된다.


image

스크립트 생성 후, 우측 상단의 ‘Validate’ 버튼을 누르면 스크립트가 정상적으로 동작하는 지 검증할 수 있다.



상단의 Performance Test를 눌러서 실제 성능테스트를 진행한다.

image

image


image

  • Agent: 방금 생성한 한 개의 script가 있기 때문에 1 입력

  • Vuser per agent: 테스트할 사용자 수 입력

  • Duration: 테스트할 시간을 지정한다.

  • Save and Start: 오른쪽 위 파란 버튼 클릭 후 실행





테스트 결과 확인하기

Total Vusers (Virtual Users): 테스트에서 사용된 가상 사용자의 총 수

Agent: 테스트를 수행하는 데 사용된 ngrinder 에이전트의 수

Processes: 테스트 동안 실행된 프로세스의 수

Threads: 테스트 동안 사용된 스레드의 수

Sample Ignore: 무시된 샘플의 수

TPS (Transactions per Second): 초당 처리되는 트랜잭션의 수

Peak TPS (Peak Transactions per Second): 테스트 중 최고로 높았던 TPS 값

Mean Test Time: 평균 테스트 시간

Executed Tests: 실행된 테스트 케이스의 총 수

Successful Tests: 성공적으로 완료된 테스트 케이스의 수

Errors: 발생한 오류의 수



image

  • Total Vusers 99 → 99명의 가상 사용자가 사용
  • Agent 1 → 1개의 에이전트가 사용
  • Processes Threads 3 / 33 → 총 33개의 스레드 중에 3개의 스레드가 사용
  • Sample Ignore 0 → 0개의 샘플이 무시
  • TPS 102.2 → 평균 102.2개의 트랜잭션이 처리
  • Peak TPS 179 → 179개의 트랜잭션을 초당 가장 많이 처리
  • Mean Test Time 998.92 ms → 평균적으로 998.92밀리초가 소요
  • Executed Tests 5,449 → 총 5,449개의 테스트 케이스가 실행
  • Successful Tests 5,449 → 5,449개의 테스트 케이스가 성공적으로 완료
  • Errors 0 → 오류가 발생하지 않음






로그인 후 test

주된 api는 대부분 로그인 한 후 token을 header에 담아서 진행되기 때문에 로그인 인증 정보를 제공해야했다.


image

위와 같이 header와 param을 입력해도 되고 아래 주석으로 작성한 것과 같이 직접 넣을 수 있다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
@RunWith(GrinderRunner)
class TestRunner {

    public static GTest test
    public static HTTPRequest request
    public static Map<String, String> headers = [:]
    public static Map<String, Object> params = [:]
    public static List<Cookie> cookies = []

/*
    public static Map<String, String> headers = ["Content-Type" : "application/json"]
    public static Map<String, Object> params = ["nickname" : "hello"]
*/

    @BeforeProcess // 프로세스가 호출되기 전 처리할 동작 정의
    public static void beforeProcess() {
        HTTPRequestControl.setConnectionTimeout(300000)
        test = new GTest(1, "{ip 주소}")
        request = new HTTPRequest()

        // Set header data
        headers.put("Content-Type", "application/x-www-form-urlencoded")
        grinder.logger.info("before process.")
    }

    @BeforeThread // 각 쓰레드가 실행되기 전 처리할 동작 정의
    public void beforeThread() {
        test.record(this, "test")
        grinder.statistics.delayReports = true
        grinder.logger.info("before thread.")
    }

    @Before //  @Test 가 수행되기 전 처리할 동작 정의
    public void before() {
        request.setHeaders(headers)
        CookieManager.addCookies(cookies)
        grinder.logger.info("before. init headers and cookies")
    }

    @Test // 테스트할 동작 정의  
    public void test() {
        HTTPResponse response = request.POST("https://{ip주소}:8080/{test할 url}", params)

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




위를 통해 아래와 같이 코드를 적용한 후 실행시켰다. 수정한 부분은 ⭐로 체크했다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
import org.json.JSONObject;
import groovy.json.JsonOutput

@RunWith(GrinderRunner)
class TestRunner {

    public static GTest test
    public static HTTPRequest request
    public static Map<String, String> headers = [:]
    public static Map<String, Object> params = [:]
    public static List<Cookie> cookies = []
    public static String user = "{\"nickname\": \"nickname\", \"password\": \"password\"}" // ⭐
    public static String nickname = "{\"nickname\": \"nickname\"}" // ⭐
    private String token // ⭐

    @BeforeProcess
    public static void beforeProcess() {
        HTTPRequestControl.setConnectionTimeout(300000)
        test = new GTest(1, "{ip 주소}")
        request = new HTTPRequest()

        // Set header data
        headers.put("Content-Type", "application/json; charset=utf-8") // ⭐ 
        grinder.logger.info("before process.")
    }

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

    @Before
    public void before() {
        request.setHeaders(headers)
        CookieManager.addCookies(cookies)
        grinder.logger.info("before. init headers and cookies")
    }

    @Test
    public void test1() {
        // ⭐
        HTTPResponse response = request.POST("https://{ip 주소}:8080/sign-in", user.getBytes())
        def loginInfo = response.getBodyText()
        JSONObject jsonObject = new JSONObject(loginInfo)
        token = jsonObject.getString("token")
        headers.put("Authorization", token)
        grinder.logger.info("token : {}", token)

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

    @Test
    public void test2() {
        // ⭐
        request.setHeaders(headers)
        HTTPResponse response = request.POST("https://{ip 주소}:8080/room", nickname.getBytes())
      
        if (response.statusCode == 301 || response.statusCode == 302) {
            grinder.logger.warn("Warning. The response may not be correct. The response code was {}.", response.statusCode)
        } else {
            assertThat(response.statusCode, is(200))
        }
    }
}

image

정상적으로 로그인 동작 후 해당 token을 header에 담아서 로그인 후의 api도 동작하는 것을 확인할 수 있다.


서버 log에서도 해당 header의 token 검증을 완료한 것을 확인할 수 있다.

image




로그인 한번만 진행 후 api test

위에서 작성한 코드대로 하면 로그인 한번 후 test를 한 다음 다시 로그인을 하게 된다.

그래서 로그인을 한번만 진행한 후에 test를 진행하기 위해 수정했다.

동일한 아이디로 로그인한 후 특정 API를 테스트하기 때문에 @BeforeProcess에 작성했다.


@BeforeProcess는 모든 쓰레드가 공유하는 공통된 상태를 설정하는 데 유용하고,

@BeforeThread는 각 쓰레드가 독립된 상태를 설정하는 데 유용하다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
import static net.grinder.script.Grinder.grinder
import static org.junit.Assert.*
import static org.hamcrest.Matchers.*
import net.grinder.script.GTest
import net.grinder.script.Grinder
import net.grinder.scriptengine.groovy.junit.GrinderRunner
import net.grinder.scriptengine.groovy.junit.annotation.BeforeProcess
import net.grinder.scriptengine.groovy.junit.annotation.BeforeThread
// import static net.grinder.util.GrinderUtils.* // You can use this if you're using nGrinder after 3.2.3
import org.junit.Before
import org.junit.BeforeClass
import org.junit.Test
import org.junit.runner.RunWith

// import 추가
import groovy.json.JsonOutput
import groovy.json.JsonSlurper
  
import org.ngrinder.http.HTTPRequest
import org.ngrinder.http.HTTPRequestControl
import org.ngrinder.http.HTTPResponse
import org.ngrinder.http.cookie.Cookie
import org.ngrinder.http.cookie.CookieManager

/**
* A simple example using the HTTP plugin that shows the retrieval of a single page via HTTP.
*
* This script is automatically generated by ngrinder.
*
* @author admin
*/
@RunWith(GrinderRunner)
class TestRunner {

    public static GTest test
    public static HTTPRequest request
    public static Map<String, String> headers = [:]
    public static Map<String, Object> params = [:]
    public static List<Cookie> cookies = []
    public static String token
    
    @BeforeProcess
    public static void beforeProcess() {
        HTTPRequestControl.setConnectionTimeout(300000)
        test = new GTest(1, "{ip 주소}")
        request = new HTTPRequest()

        // Set header data
        //headers.put("Content-Type", "application/json; charset=utf-8");
        grinder.logger.info("before process.")


   
        // login
        HTTPResponse response = request.POST("https://{ip 주소}:8080/sign-in", [nickname: "nickname", password: "password"]) 
      
        def responseBody = response.getBodyText()
        def jsonResponse = new JsonSlurper().parseText(responseBody)
        token = jsonResponse.token

        headers.put("Authorization", token)
        grinder.logger.info("token : {}", token)
    }

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

    @Before
    public void before() {
        request.setHeaders(headers)
        CookieManager.addCookies(cookies)
        grinder.logger.info("before. init headers and cookies")
   
    }

    @Test
    public void test() {
        // login 후 
        request.setHeaders(headers)
        HTTPResponse response = request.POST("https://{ip 주소}:8080/room", [nickname: "nickname"])
      
        if (response.statusCode == 301 || response.statusCode == 302) {
            grinder.logger.warn("Warning. The response may not be correct. The response code was {}.", response.statusCode)
        } else {
            assertThat(response.statusCode, is(200))
        }
    }

}



script를 작성하고 performance test를 진행했다.


해당 /room이 동작하는 방법에 대해서 설명하자면 채팅방 버튼을 누르면 실행이 된다.

채팅 repository에서 user가 있는지 확인을 하고 없다면 채팅방을 생성한다.

만약 user가 있다면 해당 roomId를 db에서 가져온 다음 가장 마지막에 작성된 글을 가져온다.

마지막 글이 null일 경우(채팅방 개설만 하고 채팅을 하지 않은 경우) 임의로 지정한 글을 넘겨주고

만약 마지막 글이 있을 경우 해당 글을 가져오는데 Map을 활용하여

roomId에 해당하는 Map이 있을 경우 Map에 있는 값을 가져오고 아닐 경우 file에서 마지막 글을 조회한다.



여기서 redis로 cache를 적용했는데 차이가 있는지 비교해봤다.

< Redis Cache 적용 ⭕ >

Total Vusers500
Agent1
Processes Threads10 / 50
Sample Ignore0
TPS139.1
Peak TPS342
Mean Test Time3,586.78 ms
Executed Tests247,650
Successful Tests247,648
Errors2



< Redis Cache 적용 ❌ >

Total Vusers500
Agent1
Processes Threads10 / 50
Sample Ignore0
TPS111.5
Peak TPS344
Mean Test Time4,476.48 ms
Executed Tests198,007
Successful Tests197,864
Errors143



  • TPS (초당 트랜잭션) 비교

    Redis를 사용한 경우: 139.1
    Redis를 사용하지 않은 경우: 111.5
    Redis를 사용한 경우 TPS가 약 24.9% 더 높다.

  • Mean Test Time (평균 테스트 시간) 비교

    Redis를 사용한 경우: 3,586.78 ms
    Redis를 사용하지 않은 경우: 4,476.48 ms
    Redis를 사용한 경우 평균 테스트 시간이 약 20.0% 감소했다.

  • 에러 수 비교

    Redis를 사용한 경우: 2
    Redis를 사용하지 않은 경우: 143
    Redis를 사용한 경우에는 에러가 거의 발생하지 않았다.



따라서 Redis를 사용하는 것이 성능 및 안정성 면에서 더 효율적임을 알 수 있다.

특히 평균 테스트 시간의 감소로 인해 사용자는 빠른 응답 속도를 경험할 수 있으며,

에러 수의 감소로 시스템 안정성이 향상된다.






게시글 curd script

formData 작성(post)

방법 1.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
    public static Long idx;

    @BeforeProcess
    public static void beforeProcess() {
        headers.put("Content-Type", "application/x-www-form-urlencoded")
    }

    @Test
    public void test() {
        request.setHeaders(headers)
        HTTPResponse response = request.POST("http://{ip 주소}:8080/registry", [title: "title", main: "main"])

        // 참고. id값만 저장하기
        byte[] contentBytes = response.getBodyText();
        String content = new String(contentBytes, "UTF-8");
        def jsonObject = new JSONObject(content);
        idx = jsonObject["idx"]
    }



방법 2.

1
2
3
4
5
6
7
8
9
10
11
12
13
import HTTPClient.NVPair

    @Test
    public void test() {
        request.setHeaders(headers) // token 저장 
              
        NVPair param1 = new NVPair("title", "title");
        NVPair param2 = new NVPair("main", "main");   
        NVPair[] params = [param1, param2]
      
        HTTPResponse response = request.POST("http://{ip 주소}:8080/registry", params)
    }



최종 전체 코드

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
import static net.grinder.script.Grinder.grinder
import static org.junit.Assert.*
import static org.hamcrest.Matchers.*
import net.grinder.script.GTest
import net.grinder.script.Grinder
import net.grinder.scriptengine.groovy.junit.GrinderRunner
import net.grinder.scriptengine.groovy.junit.annotation.BeforeProcess
import net.grinder.scriptengine.groovy.junit.annotation.BeforeThread
// import static net.grinder.util.GrinderUtils.* // You can use this if you're using nGrinder after 3.2.3
import org.junit.Before
import org.junit.BeforeClass
import org.junit.Test
import org.junit.runner.RunWith

import org.ngrinder.http.HTTPRequest
import org.ngrinder.http.HTTPRequestControl
import org.ngrinder.http.HTTPResponse
import org.ngrinder.http.cookie.Cookie
import org.ngrinder.http.cookie.CookieManager

import groovy.json.JsonOutput
import groovy.json.JsonSlurper
import org.json.JSONObject;

/**
* A simple example using the HTTP plugin that shows the retrieval of a single page via HTTP.
*
* This script is automatically generated by ngrinder.
*
* @author admin
*/
@RunWith(GrinderRunner)
class TestRunner {

    public static GTest test
    public static HTTPRequest request
    public static Map<String, String> headers = [:]
    public static Map<String, Object> params = [:]
    public static List<Cookie> cookies = []
    public static token
    public static Long idx


    @BeforeProcess
    public static void beforeProcess() {
        HTTPRequestControl.setConnectionTimeout(300000)
        test = new GTest(1, "{ip 주소}")
        request = new HTTPRequest()
      
        // login
        HTTPResponse response = request.POST("http://{ip 주소}:8080/sign-in", [nickname: "nick.123", password: "nick.123"]) 
      
        def responseBody = response.getBodyText()
        def jsonResponse = new JsonSlurper().parseText(responseBody)
        token = jsonResponse.token
        headers.put("Authorization", token)
    }

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

    @Before
    public void before() {
        request.setHeaders(headers)
        CookieManager.addCookies(cookies)
        grinder.logger.info("before. init headers and cookies")
    }

    @Test
    public void test() { // 게시글 저장
        headers.put("Content-Type", "application/x-www-form-urlencoded")
        request.setHeaders(headers)
        HTTPResponse response = request.POST("http://{ip 주소}:8080/registry", [title: "nick.123", main: "nick.123"])
      
        byte[] contentBytes = response.getBodyText();
        String content = new String(contentBytes, "UTF-8");
        def jsonObject = new JSONObject(content);
        grinder.logger.info("response : {} " , jsonObject)

        idx = jsonObject["idx"]
      
        if (response.statusCode == 301 || response.statusCode == 302) {
            grinder.logger.warn("Warning. The response may not be correct. The response code was {}.", response.statusCode)
        } else {
            assertThat(response.statusCode, is(200))
        }
    }
   
    @Test
    public void test2() { // 게시글 수정 
        headers.put("Content-Type", "application/json")
        request.setHeaders(headers)

        HTTPResponse response = request.PUT("http://{ip 주소}:8080/registry/"+ idx, [title : "title1", main : "main1"]  )
        byte[] contentBytes = response.getBodyText()
        String content = new String(contentBytes, "UTF-8");
        def jsonObject = new JSONObject(content)
        grinder.logger.info("response : {} " , jsonObject)

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

    @Test
    public void test3() {
        grinder.logger.info("게시글 조회")  
        request.setHeaders(headers)
        HTTPResponse response = request.GET("http://{ip 주소}:8080/registry?idx="+idx)

        byte[] contentBytes = response.getBodyText()
        String content = new String(contentBytes, "UTF-8");
        def jsonObject = new JSONObject(content)
        grinder.logger.info("response : {} " , jsonObject) 
      
        if (response.statusCode == 301 || response.statusCode == 302) {
            grinder.logger.warn("Warning. The response may not be correct. The response code was {}.", response.statusCode)
        } else {
            assertThat(response.statusCode, is(200))
        }
    }
   
   
    @Test
    public void test4() {  
        grinder.logger.info("게시글 삭제")
        request.setHeaders(headers)
        HTTPResponse response = request.DELETE("http://{ip 주소}:8080/registry/"+idx)

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





REFERENCE

This post is licensed under CC BY 4.0 by the author.