공부해봅시당

[성능 테스트] nGrinder + Springboot 부하 테스트 - 험난한 GET/POST 테스트 스크립트 작성 여정(feat. multipart/form-data) 본문

STUDY/개발 고민

[성능 테스트] nGrinder + Springboot 부하 테스트 - 험난한 GET/POST 테스트 스크립트 작성 여정(feat. multipart/form-data)

tngus 2023. 11. 17. 23:42

아래 링크를 참고하여 작성된 글입니다
https://leezzangmin.tistory.com/42

 

nGrinder + Springboot 부하 테스트 튜토리얼

개인 프로젝트를 진행하면서 스프링 어플리케이션의 성능을 측정해보고자, 네이버에서 만든 (오픈소스 + 무료 + 한글 + Java스러운 Groovy 스크립트 지원 + 자료가 그나마 많은) 부하테스트 툴 nGrinde

leezzangmin.tistory.com

 

최종으로 수정한 코드

시간 없으신 분들은 아래 코드로 multipart/form-data의 최종 코드를 확인하고 적절히 수정하여 사용하길 바람

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 net.grinder.plugin.http.HTTPPluginControl

import HTTPClient.Codecs
import HTTPClient.NVPair
import groovy.time.*
import java.time.*

@RunWith(GrinderRunner)
class TestRunner {

	public static GTest testRecord1 // 친구 목록 리스트 조회
	public static GTest testRecord2 // 채팅방 목록 조회
	public static GTest testRecord3 // 채팅방 상단 고정 목록 조회
	public static GTest testRecord4 // 채팅방 접속 == 채팅 메시지 내역 조회
	public static GTest testRecord5 // 채팅 파일(사진, 영상 등) 전송
	
	public static HTTPRequest request
	public static Map<String, String> headers = [:]
	public static Map<String, Object> params = [:]
	public static List<Cookie> cookies = []
	public static HTTPPluginControl pluginControl
	
	public static NVPair[] nvHeaders = []
	
	public static String basicUrl = "http://127.0.0.1:8080"
	public static String serverPath = "/api/v1"
	public static String serverUrl = basicUrl + serverPath

	@BeforeProcess
	public static void beforeProcess() {
		HTTPRequestControl.setConnectionTimeout(300000)
		testRecord1 = new GTest(1, "/matching?mode=all | get friends list by room")
		testRecord2 = new GTest(2, "/chatting/rooms/accounts | get chatting message list by room")
		testRecord3 = new GTest(3, "/chatting/rooms/top | get top chatting room list by user")
		testRecord4 = new GTest(4, "/chatting/rooms/1/messages?page=0&size=20 | get chatting message list by user")
		testRecord5 = new GTest(5, "/chatting/rooms/1/messages/files | post chatting file by user")
		
        request = new HTTPRequest()
		headers.put("Authorization", "Your Token")
		grinder.logger.info("before process.")
	}

	@BeforeThread
	public void beforeThread() {
		testRecord1.record(this, "test01")
		testRecord2.record(this, "test02")
		testRecord3.record(this, "test03")
		testRecord4.record(this, "test04")
		testRecord5.record(this, "test05")
		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 test01() { // 친구 목록 리스트 조회
		grinder.logger.info("test1")
		String url = serverUrl + "/matching?mode=all"
		HTTPResponse response = request.GET(url, params)
		resultCheck(response)
	}
	
	@Test
	public void test02() { // 채팅방 목록 조회
		grinder.logger.info("test2")
		String url = serverUrl + "/chatting/rooms/accounts"
		HTTPResponse response = request.GET(url, params)
		resultCheck(response)
	}
	
	@Test
	public void test03() { // 채팅방 상단 고정 목록 조회
		grinder.logger.info("test3")
		String url = serverUrl + "/chatting/rooms/top"
		HTTPResponse response = request.GET(url, params)
		resultCheck(response)
	}
	
	@Test
	public void test04() { // 채팅방 접속 == 채팅 메시지 내역 조회
		grinder.logger.info("test4")
		String url = serverUrl + "/chatting/rooms/1/messages?page=0&size=20"
		HTTPResponse response = request.GET(url, params)
		resultCheck(response)
	}
	
	@Test
	public void test05() { // 채팅 파일(사진, 영상 등) 전송
		grinder.logger.info("test5")
		String url = serverUrl + "/chatting/rooms/1/messages/files"

		NVPair[] nvfiles = [new NVPair("file", "/Users/parksoohyun/Desktop/nGrinder/travel.png")]
		nvHeaders = [
        	new NVPair("Content-Type", "multipart/form-data"), 
			new NVPair("Authorization", "Your Token")]
		
		def data = Codecs.mpFormDataEncode(null, nvfiles, nvHeaders)
		request.setHeaders(nvHeaders)
		
		HTTPResponse response = request.POST(url, data)
		resultCheck(response)
	}

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

 
 


문제점과 해결하는 과정

위 코드에서 가장 큰 문제가 있었던 부분은 multipart/form-data로 파일 업로드 하는 로직을 테스트 하는 부분이었음
 
처음 작성했던 코드는 아래와 같음

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 net.grinder.plugin.http.HTTPPluginControl

import HTTPClient.Codecs
import HTTPClient.NVPair
import groovy.time.*
import java.time.*

@RunWith(GrinderRunner)
class TestRunner {

	public static GTest testRecord1 // 친구 목록 리스트 조회
	public static GTest testRecord2 // 채팅방 목록 조회
	public static GTest testRecord3 // 채팅방 상단 고정 목록 조회
	public static GTest testRecord4 // 채팅방 접속 == 채팅 메시지 내역 조회
	public static GTest testRecord5 // 채팅 파일(사진, 영상 등) 전송
	
	public static HTTPRequest request
	public static Map<String, String> headers = [:]
	public static Map<String, Object> params = [:]
	public static List<Cookie> cookies = []
	public static HTTPPluginControl pluginControl
	
	public static NVPair[] nvHeaders = []
	
	public static String basicUrl = "http://127.0.0.1:8080"
	public static String serverPath = "/api/v1"
	public static String serverUrl = basicUrl + serverPath

	@BeforeProcess
	public static void beforeProcess() {
		HTTPRequestControl.setConnectionTimeout(300000)
		testRecord1 = new GTest(1, "/matching?mode=all | get friends list by room")
		testRecord2 = new GTest(2, "/chatting/rooms/accounts | get chatting message list by room")
		testRecord3 = new GTest(3, "/chatting/rooms/top | get top chatting room list by user")
		testRecord4 = new GTest(4, "/chatting/rooms/1/messages?page=0&size=20 | get chatting message list by user")
		testRecord5 = new GTest(5, "/chatting/rooms/1/messages/files | post chatting file by user")
		request = new HTTPRequest()
		
		headers.put("Content-Type", "multipart/form-data")
		headers.put("Authorization", "Your token")
		
		grinder.logger.info("before process.")
	}

	@BeforeThread
	public void beforeThread() {
		testRecord1.record(this, "test01")
		testRecord2.record(this, "test02")
		testRecord3.record(this, "test03")
		testRecord4.record(this, "test04")
		testRecord5.record(this, "test05")
		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")
	}
	
    // mulitpart/form-data 테스트를 위해 test1~4는 주석처리
	/*
	@Test
	public void test01() { // 친구 목록 리스트 조회
		grinder.logger.info("test1")
		String url = serverUrl + "/matching?mode=all"
		HTTPResponse response = request.GET(url, params)
		resultCheck(response)
	}
	
	@Test
	public void test02() { // 채팅방 목록 조회
		grinder.logger.info("test2")
		String url = serverUrl + "/chatting/rooms/accounts"
		HTTPResponse response = request.GET(url, params)
		resultCheck(response)
	}
	
	@Test
	public void test03() { // 채팅방 상단 고정 목록 조회
		grinder.logger.info("test3")
		String url = serverUrl + "/chatting/rooms/top"
		HTTPResponse response = request.GET(url, params)
		resultCheck(response)
	}
	
	@Test
	public void test04() { // 채팅방 접속 == 채팅 메시지 내역 조회
		grinder.logger.info("test4")
		String url = serverUrl + "/chatting/rooms/1/messages?page=0&size=20"
		HTTPResponse response = request.GET(url, params)
		resultCheck(response)
	}
	*/
	
	@Test
	public void test05() {
		grinder.logger.info("test5")
		String url = serverUrl + "/chatting/rooms/1/messages/files"

		NVPair[] nvfiles = [new NVPair("file", "/Users/parksoohyun/Desktop/nGrinder/travel.png")]
		nvHeaders = [new NVPair("Content-Type", "multipart/form-data"), 
		new NVPair("Authorization", "Your token")]
		
		def data = Codecs.mpFormDataEncode(null, nvfiles, nvHeaders)
		
		HTTPResponse response = request.POST(url, data)

		resultCheck(response)
	}

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

 
처음에 발생했던 에러는 다음과 같음
ngrinder에서는 아래와 같은 에러가 발생함

spring boot에서는 다음과 같은 에러 발생

 
Boundary를 찾을 수 없다는 의미의 에러메시지

org.apache.tomcat.util.http.fileupload.FileUploadException: the request was rejected because no multipart boundary was found

 
 
여기서 Boundary에 대해 알아보자
 


multipart/form-data의 Boundary

아래 링크를 참고하여 정리함
https://infos.tistory.com/4658#google_vignette

 

multipart form-data 와 boundary

multipart/form-data 개요 http의 data에 전송 규격은 단일 방식이 아니다. 각 방식을 정의 하는 방법으로 'content-type'를 사용한다. file전송은 용량이 큰 경우도 가능해야 한다. 그래서 한번에 모두 보낼

infos.tistory.com

파일을 전송하기위해서는multipart/form-data방식을 써야 함
application/x-www-form-urlencoded방식은 파일을 전송할 수 없기 때문
 

이 방식의 전송데이터는 다음과 같이 생겼음

POST /test HTTP/1.1
Host: foo.example
Content-Type: multipart/form-data;boundary="boundary"

--boundary
Content-Disposition: form-data; name="field1"

value1

--boundary
Content-Disposition: form-data; name="field2"; filename="example.txt"

value2

--boundary--

Content-Type에 사용되는 boundary값 앞에 "--"을 붙은 "--boundary"가 실제 boundary
전송데이터의 마지막에는 마지막에 "--"을 붙여서 "--boundary--"가 마지막 boundary로 데이터 전송이 끝났음을 표시함
 

실제로 확인해보면 다음과 같이 생김
확인해보면 마지막 줄의 boundary 오른쪽 끝에 "--"가 더 붙은 것을 확인할 수 있음

POST /api/v1/chatting/rooms/1/messages/files HTTP/1.1
Content-Type: multipart/form-data; boundary=--------ieoau._._+2_8_GoodLuck8.3-ds0d0J0S0Kl234324jfLdsjfdAuaoei-----
Authorization: <<이 부분은 Authorization을 위한 토큰을 의미>>
Content-Length: 338840
Host: 127.0.0.1:8080
Connection: keep-alive
User-Agent: Apache-HttpCore/5.0.3 (Java/11.0.21)
Expect: 100-continue

HTTP/1.1 100 

----------ieoau._._+2_8_GoodLuck8.3-ds0d0J0S0Kl234324jfLdsjfdAuaoei-----
Content-Disposition: form-data; name="appcode"

999
----------ieoau._._+2_8_GoodLuck8.3-ds0d0J0S0Kl234324jfLdsjfdAuaoei-----
Content-Disposition: form-data; name="appversion"

17
----------ieoau._._+2_8_GoodLuck8.3-ds0d0J0S0Kl234324jfLdsjfdAuaoei-----
Content-Disposition: form-data; name="hashcode"

11223344556677
----------ieoau._._+2_8_GoodLuck8.3-ds0d0J0S0Kl234324jfLdsjfdAuaoei-----
Content-Disposition: form-data; name="file"; filename="travel.png"
Content-Type: image/png


<이미지 파일이기 때문에 포함되는 byte code는 생략>


----------ieoau._._+2_8_GoodLuck8.3-ds0d0J0S0Kl234324jfLdsjfdAuaoei-------

 


따라서 Boundary가 있어야 전송데이터의 처음과 끝을 알 수 있음
그렇다면 왜 Boundary가 없다고 하는 것일까?
 

해결 시도 1: Codecs.mpFormDataEncode() 메소드 특성 이해

처음에 시도했던 해결책은 아래와 같음
깃허브 링크: https://gist.github.com/ihoneymon/a83b22a42795b349c389a883a7bbf356

 

nGrinder 파일 업로드 테스트 스크립트

nGrinder 파일 업로드 테스트 스크립트. GitHub Gist: instantly share code, notes, and snippets.

gist.github.com

 
위 깃허브 링크에 대한 내용을 뒷받침하는 공식문서: https://grinder.sourceforge.net/g3/script-javadoc/HTTPClient/Codecs.html

 

Codecs (The Grinder Documentation)

This method encodes name/value pairs and files into a byte array using the multipart/form-data encoding. The boundary is returned as part of ct_hdr. Example: NVPair[] opts = { new NVPair("option", "doit") }; NVPair[] file = { new NVPair("comment", "comment

grinder.sourceforge.net

 
Codecs.mpFormDataEncode() 메소드가 아래와 같이 정의되어 있음을 공식문서에서 확인
ct_hdr[0]에 바로 Boundary를 붙여주기 때문에 ct_hdr에 매개변수를 넘겨줄 때는 순서에 유의해야 한다는 것

public static final byte[] mpFormDataEncode(NVPair[] opts, NVPair[] files, NVPair[] ct_hdr) throws IOException {
    return mpFormDataEncode(opts, files, ct_hdr, (FilenameMangler)null);
}

public static final byte[] mpFormDataEncode(NVPair[] opts, NVPair[] files, NVPair[] ct_hdr, FilenameMangler mangler) throws IOException {
    //  생략
    if (pos != len) {
        throw new Error("Calculated " + len + " bytes but wrote " + pos + " bytes!");
    } else {
        ct_hdr[0] = new NVPair("Content-Type", "multipart/form-data; boundary=" + new String(boundary, 4, boundary.length - 4, "8859_1"));
        return res;
    }

}

 
 

해결시도2: request.setHeaders() 정의 부분 변경

하지만 위와 같은 문제는 아니었고, 내 코드의 test05 메소드에서 정의하는 nvheader를 request.setHeaders()에 정의해주지 않아서 생기는 문제라고 판단하여 코드를 아래와 같이 수정함
nvheader를 test05 메소드가 아니라 beforeProcess 메소드에서 정의해주는 것

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 net.grinder.plugin.http.HTTPPluginControl

import HTTPClient.Codecs
import HTTPClient.NVPair
import groovy.time.*
import java.time.*

@RunWith(GrinderRunner)
class TestRunner {

	public static GTest testRecord1 // 친구 목록 리스트 조회
	public static GTest testRecord2 // 채팅방 목록 조회
	public static GTest testRecord3 // 채팅방 상단 고정 목록 조회
	public static GTest testRecord4 // 채팅방 접속 == 채팅 메시지 내역 조회
	public static GTest testRecord5 // 채팅 파일(사진, 영상 등) 전송
	
	public static HTTPRequest request
	public static Map<String, String> headers = [:]
	public static Map<String, Object> params = [:]
	public static List<Cookie> cookies = []
	public static HTTPPluginControl pluginControl
	
	public static NVPair[] nvHeaders = []
	
	public static String basicUrl = "http://127.0.0.1:8080"
	public static String serverPath = "/api/v1"
	public static String serverUrl = basicUrl + serverPath

	@BeforeProcess
	public static void beforeProcess() {
		HTTPRequestControl.setConnectionTimeout(300000)
		testRecord1 = new GTest(1, "/matching?mode=all | get friends list by room")
		testRecord2 = new GTest(2, "/chatting/rooms/accounts | get chatting message list by room")
		testRecord3 = new GTest(3, "/chatting/rooms/top | get top chatting room list by user")
		testRecord4 = new GTest(4, "/chatting/rooms/1/messages?page=0&size=20 | get chatting message list by user")
		testRecord5 = new GTest(5, "/chatting/rooms/1/messages/files | post chatting file by user")
		request = new HTTPRequest()
		
		nvHeaders = [new NVPair("Content-Type", "multipart/form-data"), 
		new NVPair("Authorization", "Your token")]
		
		grinder.logger.info("before process.")
	}

	@BeforeThread
	public void beforeThread() {
		testRecord1.record(this, "test01")
		testRecord2.record(this, "test02")
		testRecord3.record(this, "test03")
		testRecord4.record(this, "test04")
		testRecord5.record(this, "test05")
		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 test01() { // 친구 목록 리스트 조회
		grinder.logger.info("test1")
		String url = serverUrl + "/matching?mode=all"
		HTTPResponse response = request.GET(url, params)
		resultCheck(response)
	}
	
	@Test
	public void test02() { // 채팅방 목록 조회
		grinder.logger.info("test2")
		String url = serverUrl + "/chatting/rooms/accounts"
		HTTPResponse response = request.GET(url, params)
		resultCheck(response)
	}
	
	@Test
	public void test03() { // 채팅방 상단 고정 목록 조회
		grinder.logger.info("test3")
		String url = serverUrl + "/chatting/rooms/top"
		HTTPResponse response = request.GET(url, params)
		resultCheck(response)
	}
	
	@Test
	public void test04() { // 채팅방 접속 == 채팅 메시지 내역 조회
		grinder.logger.info("test4")
		String url = serverUrl + "/chatting/rooms/1/messages?page=0&size=20"
		HTTPResponse response = request.GET(url, params)
		resultCheck(response)
	}
	*/
	
	@Test
	public void test05() {
		grinder.logger.info("test5")
		String url = serverUrl + "/chatting/rooms/1/messages/files"

		NVPair[] nvfiles = [new NVPair("file", "/Users/parksoohyun/Desktop/nGrinder/travel.png")]

		def data = Codecs.mpFormDataEncode(null, nvfiles, nvHeaders)
		
		HTTPResponse response = request.POST(url, data)

		resultCheck(response)
	}

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

 
하지만 아래와 같은 문제가 발생함
테스트가 성공한 것처럼 보일지라도 아래를 확인해보면 status code가 302인 것을 알 수 있음

 
확실한 이해를 위해 301과 302 status code를 비교해보자
 


301, 302 status code

아래 링크를 바탕으로 정리함
https://im-first-rate.tistory.com/73

 

HTTP 응답 상태 코드 301 / 302 에 대해 알아보자.

개발자들이 제일 많이 보는 응답 상태 코드는 404 / 500 응답 코드일것이다. 이유는 간단하다. 개발을 하다보면 오류는 필연적이기 때문에 500 코드를 볼 것이고, 없는 페이지에 접속하게 되면 404

im-first-rate.tistory.com

301, Permanently Moved

Permanently라는 뜻이 영구히, 영구적인 이라는 뜻이므로, 영구적으로 이동한다는 것
요청된 리소스가 영구적으로 이동 페이지로 이동되었다는 것
 

302, Temporarily Moved

 

Temporarily라는 뜻이 임시적, 임시적인 이라는 뜻이므로, 임시적으로 이동했다는 것
요청된 리소스가 임시적으로 이동 페이지로 이동되었다는 것


위는 일반적으로 사용하거나 확인할 수 있는 301과 302 status code에 대한 내용임
 
여기서 나는 authorization이 제대로 되지 않아 로그인 페이지로 redirection되어 302 code가 발생한 것으로 추정
해당 가설에 대해서는 GPT 4.0의 도움을 받음

 
 
그렇다면 Authorization이 잘 안된다는 것인데, 확인해보니 코드를 그냥 제대로 고치지 못한 것이었음
 
오류 난 코드

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

 
수정 후 코드

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

 
request.setHeaders() 메소드 사용 시, 수정했던 nvHeaders가 아닌 headers를 매개변수로 사용했기 때문에 발생한 문제였음
 
따라서 수정하니 아래와 같은 에러 발생

 
기존에 발생하던 문제와 동일해짐
 
하지만 모든 설정을 완료했음
따라서 패킷을 캡처하고 wireshark를 통해 분석해보기로 함
 

해결시도3: Wireshark로 패킷 분석

wireshark 다운로드는 아래 링크에서 진행 가능
https://www.wireshark.org/download.html

 

Wireshark · Download

Wireshark: The world's most popular network protocol analyzer

www.wireshark.org

 
터미널에서 아래 명령어를 사용해 패킷을 캡처해야 함

sudo tcpdump -i any -w packet.dump

 
명령어를 입력하면 아래와 같은 상태가 됨

 
그럼 다시 nGrinder의 script 창에서 validate 버튼을 눌러 에러가 발생하는 상황 재연

 
이후 tcpdump 명령어를 입력했던 터미널에서 ctrl+C를 눌러 패킷 캡처 종료

 
와이어샤크 접속

 
File -> Open으로 dump 파일 열기

 
아래와 같은 화면

 
검색창에 http 검색

 
요청보냈던 URL 확인

 
TCP stream 클릭

 
파란색 배경으로 된 글자의 2번째 줄에 있는 Content-Type: multipart/form-data 을 확인해보니 boundary가 실제로 없음

 
 

해결: request.setHeaders() 위치 재변경

다시 위에서 해결했던 깃허브를 확인해보니 Content-Type: multipart/form-data 뒤에 boundary가 붙는 것이
Codecs.mpFormDataEncode() 메소드에서 진행됨
https://gist.github.com/ihoneymon/a83b22a42795b349c389a883a7bbf356

 

nGrinder 파일 업로드 테스트 스크립트

nGrinder 파일 업로드 테스트 스크립트. GitHub Gist: instantly share code, notes, and snippets.

gist.github.com

 
그렇다면 boundary가 붙고 나서 request.setHeaders()를 하는 것이 맞을 것 같다는 가정 설립
 
따라서 request.setHeaders(nvHeaders)의 위치를 test05 메소드 내의 Codecs.mpFormDataEncode() 다음 줄로 변경

	@Test
	public void test05() { // 채팅 파일(사진, 영상 등) 전송
		grinder.logger.info("test5")
		String url = serverUrl + "/chatting/rooms/1/messages/files"

		NVPair[] nvfiles = [new NVPair("file", "/Users/parksoohyun/Desktop/nGrinder/travel.png")]
		nvHeaders = [
        	new NVPair("Content-Type", "multipart/form-data"), 
			new NVPair("Authorization", "Your Token")]
		
		def data = Codecs.mpFormDataEncode(null, nvfiles, nvHeaders)
		request.setHeaders(nvHeaders)
		
		HTTPResponse response = request.POST(url, data)
		resultCheck(response)
	}

 
 
드디어 성공, 서버 측에서도 정상 로그가 찍힘

 
이를 확인하기 위해 다시 한 번 패킷 캡처 진행 후 확인 해보니 boundary가 제대로 붙어있음

 
이를 통해 multipart/form-data 파일 성능 테스트를 할 수 있게 됨
 
test01~test04는 multipart/form-data가 아니므로 기존 코드대로 동작할 수 있도록 before() 메소드에서 headers를 설정해주고, test05에서는 파일 전송 테스트를 진행하기 때문에 test05에서 headers를 다시 설정해주는 방식으로 아래 최종 코드가 완성됨

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 net.grinder.plugin.http.HTTPPluginControl

import HTTPClient.Codecs
import HTTPClient.NVPair
import groovy.time.*
import java.time.*

@RunWith(GrinderRunner)
class TestRunner {

	public static GTest testRecord1 // 친구 목록 리스트 조회
	public static GTest testRecord2 // 채팅방 목록 조회
	public static GTest testRecord3 // 채팅방 상단 고정 목록 조회
	public static GTest testRecord4 // 채팅방 접속 == 채팅 메시지 내역 조회
	public static GTest testRecord5 // 채팅 파일(사진, 영상 등) 전송
	
	public static HTTPRequest request
	public static Map<String, String> headers = [:]
	public static Map<String, Object> params = [:]
	public static List<Cookie> cookies = []
	public static HTTPPluginControl pluginControl
	
	public static NVPair[] nvHeaders = []
	
	public static String basicUrl = "http://127.0.0.1:8080"
	public static String serverPath = "/api/v1"
	public static String serverUrl = basicUrl + serverPath

	@BeforeProcess
	public static void beforeProcess() {
		HTTPRequestControl.setConnectionTimeout(300000)
		testRecord1 = new GTest(1, "/matching?mode=all | get friends list by room")
		testRecord2 = new GTest(2, "/chatting/rooms/accounts | get chatting message list by room")
		testRecord3 = new GTest(3, "/chatting/rooms/top | get top chatting room list by user")
		testRecord4 = new GTest(4, "/chatting/rooms/1/messages?page=0&size=20 | get chatting message list by user")
		testRecord5 = new GTest(5, "/chatting/rooms/1/messages/files | post chatting file by user")
		
        request = new HTTPRequest()
		headers.put("Authorization", "Your Token")
		grinder.logger.info("before process.")
	}

	@BeforeThread
	public void beforeThread() {
		testRecord1.record(this, "test01")
		testRecord2.record(this, "test02")
		testRecord3.record(this, "test03")
		testRecord4.record(this, "test04")
		testRecord5.record(this, "test05")
		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 test01() { // 친구 목록 리스트 조회
		grinder.logger.info("test1")
		String url = serverUrl + "/matching?mode=all"
		HTTPResponse response = request.GET(url, params)
		resultCheck(response)
	}
	
	@Test
	public void test02() { // 채팅방 목록 조회
		grinder.logger.info("test2")
		String url = serverUrl + "/chatting/rooms/accounts"
		HTTPResponse response = request.GET(url, params)
		resultCheck(response)
	}
	
	@Test
	public void test03() { // 채팅방 상단 고정 목록 조회
		grinder.logger.info("test3")
		String url = serverUrl + "/chatting/rooms/top"
		HTTPResponse response = request.GET(url, params)
		resultCheck(response)
	}
	
	@Test
	public void test04() { // 채팅방 접속 == 채팅 메시지 내역 조회
		grinder.logger.info("test4")
		String url = serverUrl + "/chatting/rooms/1/messages?page=0&size=20"
		HTTPResponse response = request.GET(url, params)
		resultCheck(response)
	}
	
	@Test
	public void test05() { // 채팅 파일(사진, 영상 등) 전송
		grinder.logger.info("test5")
		String url = serverUrl + "/chatting/rooms/1/messages/files"

		NVPair[] nvfiles = [new NVPair("file", "/Users/parksoohyun/Desktop/nGrinder/travel.png")]
		nvHeaders = [
        	new NVPair("Content-Type", "multipart/form-data"), 
			new NVPair("Authorization", "Your Token")]
		
		def data = Codecs.mpFormDataEncode(null, nvfiles, nvHeaders)
		request.setHeaders(nvHeaders)
		
		HTTPResponse response = request.POST(url, data)
		resultCheck(response)
	}

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

 
 
 


험난했던 트러블 슈팅 후기

그 어디에도 해당 코드가 제대로 설명되어 있지 않아 해결하는 과정이 복잡하고 힘들긴 했지만, 나름 근거에 비롯해 가설을 세우고 하나하나 해결해나가는 모든 과정을 거치면서 깨달은 것과 배운 점이 많아 트러블 슈팅의 의의가 있었다고 생각함
특히 multipart/form-data의 Boundary나 301과 302 status code, wireshark 사용법은 트러블슈팅으로 배운 것이기 때문에 앞으로도 잊지 않을 것 같음
 
다음 시간에는 작성한 스크립트를 기반으로 성능테스트를 진행해 결과를 분석해보도록 하겠음