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

2022. 8. 27. 00:37기타

개인 프로젝트를 진행하면서 스프링 어플리케이션의 성능을 측정해보고자,

네이버에서 만든 (오픈소스 + 무료 + 한글 + Java스러운 Groovy 스크립트 지원 + 자료가 그나마 많은) 부하테스트 툴 nGrinder를 사용하기로 결정했다. 

아예 처음 해보는 토픽이고 어디서 간편하게 가르쳐주지도 않아서 구글링으로 하나하나 차근차근 배워가며 적용해보려고 한다.

미래에 또 같은 문제로 헤맬 본인에게 이 글을 바칩니다.. 나란놈..

 

모니터링을 하려면 추가적으로 pinpoint 같은 APM을 사용해야 한다. 핀포인트는 아직 이른 것 같아 일단 nGrinder 먼저..!


부하테스트는 툴 깔고 스크립트 짜서 실행한 후에 결과보고 분석하는 것이 끝일 줄 알았는데,

크게 계획, 시나리오 수립, 실제 테스트, 분석, 적용

다섯 단계를 거쳐야 합니다. 다섯 단계를 다 해보는 것은 힘들 수 있겠지만 최대한 기록해보겠습니다.

 

 

0. 설치

 

일단 재미없는건 옆으로 잠시 미뤄두고 먼저 설치부터 해보고 조금씩 감을 잡아봅시다...

 

https://github.com/naver/ngrinder/releases

 

Releases · naver/ngrinder

enterprise level performance testing solution. Contribute to naver/ngrinder development by creating an account on GitHub.

github.com

 

 

엔그라인더 공식 저장소 릴리즈 페이지

 

들어가서 ngrinder-controller-{version}.war 파일을 다운받는다.

아래 보이는 war 파일을 다운받으면 된다. 업데이트가 작년이 마지막이군요

본인 환경이 이상한 것일 수도 있지만 다운로드에 30분 이상 소요되었다.

 

다운로드를 완료했다면, war 파일을 터미널로 실행해준다. 마지막에 설정해주는 포트번호를 기억하자

java -jar ngrinder-controller-{version}.war --port=8300

터미널에서 실행이 완료된 것 같으면

 

브라우저에서 localhost:8300 으로 접속하자

엔그라인더 인덱스 페이지

초기 ID,PW는 admin, admin 이다

 

 

다음엔 에이전트를 설치해야한다.

우측 상단 메뉴바에서 에이전트 다운로드를 클릭한다.

 

다운로드가 완료되었다면

// 압축을 풉니다. 
$ tar -xvf ngrinder-agent-{version}-localhost.tar
// 압축이 풀린 에이전트 폴더로 이동합니다 
$ cd ngrinder-agent

에이전트 폴더 내의 모습

 

이젠 에이전트를 실행해야 한다.

./run_agent.sh

실행 모습

실행이 완료되었으면 아까 접속했던 localhost:8300의 에이전트 관리 탭으로 넘어가면 확인해볼 수 있다. 

이창민 맥북에어에서 실행중인게 보인다

 

이제 준비작업은 거의 끝났다.

다음으로는 테스트 스크립트를 작성해야한다.

Groovy라는 언어로 작성한다고 하는데, Junit5 기반이고 Java랑 비슷해서 어렵지 않다고 한다.

 

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 java.util.Random

/**
* 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 = []

	@BeforeProcess
	public static void beforeProcess() {
		HTTPRequestControl.setConnectionTimeout(300000)
		test = new GTest(1, "127.0.0.1")
		request = new HTTPRequest()
		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 single_post_test() {
		String url = "http://127.0.0.1:8080/post/";
		String randomPostNumber = RandomPostIdIssuer.getRandomNumbers();

		// randomPostNumber = Math.abs(new Random().nextInt() % 600 + 1)
		HTTPResponse response = request.GET(url + randomPostNumber, 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))
		}
	}	

    class RandomPostIdIssuer {

    private static int NUMBER_LENGTH=4;
    private static int NUMBER_BOUND=10;

    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();
    }

  }
}

 

일단 구글링 해가며 대충 작성하고 우측 상단의 검증 버튼을 누르면 시험적으로 1회 실행된다.

시험 실행도 실제 프로젝트에 요청이 온다. 신기하구만

 

시험 실행 결과

 

 

 

1. 계획

 

환경설정은 끝난 것 같으니 이제 본격적으로 테스트 계획을 세워보자

 

부하 테스트(stress test) 란 서버가 얼마만큼의 요청을 견딜 수 있는지 테스트하는 방법이다.
작성한 API 에 병목 현상과 얼마 만큼의 트래픽을 수용할 수 있는지에 대한 여부를 확인하고자 스트레스 테스트를 작성한다.

가장 중요한 것은 작성한 코드에 문법적, 논리적 오류가 없더라도 실 운영 상황에서는 예기치 못한 제약으로 서비스가 중단될 수도 있다. 이 예기치 못한 제약을 미리 찾아내고 예측하는 것이 가장 큰 목적이다.

 

 

이를 파악하기 위한 지표:

  • Users: 동시에 사용할 수 있는 유저 수
  • TPS: 초당 몇개의 테스트를 처리할 수 있는지 (Test per second - transaction 아님!)
    • 한 명만 사용해도 처리량이 좋지 않으면 → scale up
    • 부하 증가시 문제 → scale out
  • Time: 얼마나 빠른지

 

 

테스트 환경:

테스트는 실제 운영 환경과 동일하거나 유사해야 합니다. 인프라와 데이터의 양 모두 같으면 좋다.

또 외부 API를 호출하는 경우 임의의 Mock 로직을 삽입한 서버를 구동해야 한다. 외부에 의존성이 있는 테스트는 언제나 좋지 못하다.

본인 프로젝트에도 github oauth 로직이 있는데 mocking해서 테스트해야한다.

 

 

 

전제조건:

  • 테스트하려는 Target 시스템의 범위를 정해야 합니다.
  • 부하 테스트시에 저장될 데이터 건수와 크기를 결정하세요. 서비스 이용자 수, 사용자의 행동 패턴, 사용 기간 등을 고려하여 계산합니다.
  • 목푯값에 대한 성능 유지기간을 정해야 합니다.
  • 서버에 같이 동작하고 있는 다른 시스템, 제약 사항 등을 파악합니다.

 

target 시스템의 범위는 springboot + mysql + redis로 설정했다.

데이터 수는 테이블별로 5 ~ 10 만개의 임의 데이터를 넣고 진행한다.

성능 유지기간은 30분으로 설정했다.

제약사항은 뭐가 있지

 

 

 

테스트 종류:

 

1) Smoke 테스트

  • VUser: 1 ~ 2
  • 최소의 부하로 시나리오를 검증해봅니다.

2) Load 테스트

  • 평소 트래픽과 최대 트래픽일 때 VUser를 계산 후 시나리오를 검증해봅니다.
  • 결과에 따라 개선해보면서 테스트를 반복합니다.

3) Stress 테스트

  • 최대 사용자 혹은 최대 처리량인 경우의 한계점을 확인하는 테스트입니다.
  • 점진적으로 부하를 증가시켜봅니다.
  • 테스트 이후 시스템이 수동 개입 없이 자동 복구되는지 확인해봅니다.

 

 

성능 목표 설정:

 

성능 목표를 정하기 위해서는 VUser를 구해야합니다.(Load 테스트인 경우)

VUser를 구하기 위해서는 아래와 같은 지표들이 필요합니다.

  • DAU(일일 활동 사용자 수)
  • 피크시간대 집중률(최대 트래픽 / 평소 트래픽)
  • 1명 당 1일 평균 요청 수

 

실제 트래픽을 경험해보지 못해서 어떻게 설정해야할지 잘 모르겠네요.

대충 DAU 5만, 집중률 3, 평균 요청수 10으로 잡고

Throughput 계산하는게 있는데 너무 복잡한 세계다.... -_-; 튜토리얼이니 넘어가자..

 

 

 

테스트 종합 목표:

 

1) Smoke 테스트

  • VUser: 1 ~ 2
  • Throughput: 11.3 ~ 34 이상
  • Latency: 50 ~ 100ms 이하

2) Load 테스트

  • 평소 트래픽 VUser: 7
  • 최대 트래픽 VUser: 22
  • Throughput: 11.3 ~ 34 이상
  • Latency: 50 ~ 100ms 이하
  • 성능 유지 기간: 30분

3) Stress 테스트

  • VUser: 점진적으로 증가시켜보기

어디서 가져온건데 잘 모르겠으니 일단 이대로 해보는걸로...

 

 

 

 

2. 시나리오 수립

 

임시로 만든 API 명세가 있다. 

포스트맨으로 맹글었다

통상 웹서비스에서는 CUD 보다 R의 비율이 압도적으로 높다고 한다. 감안해서 실제 상황과 비슷하게 시나리오를 작성하도록 노력하자

아직 모든 API가 완성된 것은 아니라서 시나리오가 조금 빈약할 수도 있다. 

 

시나리오1:

로그인 - 개인 댓글 조회 - 질문게시판 {x}페이지 조회 - 질문글 1개 조회 - 추천 - 댓글남기기 - 방금 남긴 댓글 삭제 - 자유게시판 {x+1}페이지 조회 - 글 작성 - 자유게시판 1페이지 조회 - 개인 게시글 조회 - 글 수정 - 방금 쓴 글 조회 - 글 삭제

 

시나리오2:

질문게시판 {x}페이지 조회 - .... 

아직 api가 적으므로 시나리오1에 모든 api를 때려박아서 테스트해보는 것으로 하자.

 

 

 

 

3. 실제 테스트

이제 테스트를 해보자.

아까 임시로 작성했었던 스크립트를 이젠 수립한 시나리오대로 제대로 작성해야한다.

그루비가 자바랑 비슷해서 다행이다.

 

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.apache.commons.lang.RandomStringUtils

import org.ngrinder.http.cookie.CookieManager
import java.util.Random
import java.time.LocalDateTime

@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 GTest testRecord6
	public static GTest testRecord7
	public static GTest testRecord8
	public static GTest testRecord9
	public static GTest testRecord10
	public static GTest testRecord11
	public static GTest testRecord12
	public static GTest testRecord13
	
	public static HTTPRequest request = new HTTPRequest()
	public Map<String, String> headers = [:]
	public Map<String, Object> params = [:]
	public List<Cookie> cookies = []
	public Cookie cookie
	public Long temp
	


	@BeforeProcess
	public static void beforeProcess() {
		HTTPRequestControl.setConnectionTimeout(300000)
		testRecord1 = new GTest(1, "127.0.0.1")
		testRecord2 = new GTest(2, "127.0.0.1")
		testRecord3 = new GTest(3, "127.0.0.1")
		testRecord4 = new GTest(4, "127.0.0.1")
		testRecord5 = new GTest(5, "127.0.0.1")
		testRecord6 = new GTest(6, "127.0.0.1")
		testRecord7 = new GTest(7, "127.0.0.1")
		testRecord8 = new GTest(8, "127.0.0.1")
		testRecord9 = new GTest(9, "127.0.0.1")
		testRecord10 = new GTest(10, "127.0.0.1")
		testRecord11 = new GTest(11, "127.0.0.1")
		testRecord12 = new GTest(12, "127.0.0.1")
		testRecord13 = new GTest(13, "127.0.0.1")
		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")
		testRecord6.record(this, "test06")
		testRecord7.record(this, "test07")
		testRecord8.record(this, "test08")
		testRecord9.record(this, "test09")
		testRecord10.record(this, "test10")
		testRecord11.record(this, "test11")
		testRecord12.record(this, "test12")
		testRecord13.record(this, "test13")
		
		grinder.statistics.delayReports = true
		grinder.logger.info("before thread.")
	}

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

// 1.로그인 - 2.개인 댓글 조회 - 3.질문게시판 {x}페이지 조회 - 4.질문글 1개 조회 - 5.추천 - 6.댓글남기기 - 7.방금 남긴 댓글 삭제 - 8.자유게시판 {x}페이지 조회 - 9.글 작성 - 10.자유게시판 1페이지 조회 - 11.개인 게시글 조회 - 12.글 수정 - 13. 랜덤 글 조회
	@Test
	public void test01() {
		grinder.logger.info("test1")
		String url1 = "http://127.0.0.1:8080/oauth/github-url";
		String url2 = "http://127.0.0.1:8080/login?code=this_is_fake_code";
		
		HTTPResponse response = request.GET(url1, 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))
		}
	
		HTTPResponse response2 = request.GET(url2, 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))
		}
	}

	@Test
	public void test02() {
		grinder.logger.info("test2")
		String url = "http://127.0.0.1:8080/comments/my";

		HTTPResponse response = request.GET(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))
		}
	}
	
	@Test
	public void test03() {
		grinder.logger.info("test3")
		String frontURL = "http://127.0.0.1:8080/posts?page="
		String backURL = "&size=10&categoryName=QNA&sort=postId,desc"
		String url = frontURL + RandomPostIdIssuer.getRandomNumbers() + backURL;
		
		HTTPResponse response = request.GET(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))
		}
	}
	
	@Test
	public void test04() {
		grinder.logger.info("test4")
		String url = "http://127.0.0.1:8080/post/";
		String randomPostNumber = RandomPostIdIssuer.getRandomNumbers();

		HTTPResponse response = request.GET(url + randomPostNumber, 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))
		}
	}
	
	@Test
	public void test05() {
		grinder.logger.info("test5")
		String url = "http://127.0.0.1:8080/post/recommend"
		String postId=RandomPostIdIssuer.getRandomNumbers();
		String userId=RandomPostIdIssuer.getRandomNumbers();
		String body = "{\"postId\":"+postId+",\n \"userId\":"+userId+"}"
		grinder.logger.info(body);
		HTTPResponse response = request.POST(url, body.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))
		}
		
	}
	
	@Test
	public void test06() {
		grinder.logger.info("test6")
		String url = "http://127.0.0.1:8080/comment"
		String postId = RandomPostIdIssuer.getRandomNumbers();
		String commentId = RandomPostIdIssuer.getRandomNumbers();
		String charset = (('A'..'Z') + ('0'..'9')).join()
		Integer length = 500
		String commentContent = RandomStringUtils.random(length, charset.toCharArray())
		String body = "{\"referencePostId\":"+postId+",\n \"commentContent\":\""+commentContent+"\",\n \"createdAt\":\""+LocalDateTime.now()+"\"}"
		grinder.logger.info(body)
		HTTPResponse response = request.POST(url, body.getBytes())
		
		temp = response.getBodyText().toLong()
		
		

		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 test07() {
		grinder.logger.info("test7")
		Long deleteCommentId = temp;
		String url = "http://127.0.0.1:8080/comment/"+deleteCommentId
		HTTPResponse response = request.DELETE(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))
		}
	}
	
	@Test //자유게시판 조회
	public void test08() {
		grinder.logger.info("test8")
		String frontURL = "http://127.0.0.1:8080/posts?page="
		String backURL = "&size=10&categoryName=FREE&sort=postId,desc"
		String url = frontURL + RandomPostIdIssuer.getRandomNumbers() + backURL;
		
		HTTPResponse response = request.GET(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))
		}
	}
	
	@Test //글작성
	public void test09() {
		grinder.logger.info("test9")
		String url = "http://127.0.0.1:8080/post"
		String userId=RandomPostIdIssuer.getRandomNumbers();
		String charset = (('A'..'Z') + ('0'..'9')).join()
		Integer length = 500
		String postSubject = RandomStringUtils.random(30, charset.toCharArray())
		String postContent = RandomStringUtils.random(700, charset.toCharArray())
		
		String body = "{\"postSubject\":\""+postSubject+"\",\n \"userId\":"+userId+",\n \"postContent\":\""+postContent+"\",\n \"createdAt\":\""+LocalDateTime.now()+"\",\"postCategoryId\":1}"
		grinder.logger.info(body)
		HTTPResponse response = request.POST(url, body.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))
		}
		temp = response.getBodyText().toLong()
		grinder.logger.info("temp!:" + temp)
	}
	
	@Test
	public void test10() {
		grinder.logger.info("test10")
		String frontURL = "http://127.0.0.1:8080/posts?page="
		String backURL = "&size=10&categoryName=FREE&sort=postId,desc"
		String url = frontURL + RandomPostIdIssuer.getRandomNumbers() + backURL;
		
		HTTPResponse response = request.GET(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))
		}
	}
	
	@Test
	public void test11() {
		grinder.logger.info("test11")
		String url = "http://127.0.0.1:8080/posts/my";

		HTTPResponse response = request.GET(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))
		}
	}
	
	@Test //글수정
	public void test12() {
		grinder.logger.info("test12")
		String url = "http://127.0.0.1:8080/post/" + temp
		String charset = (('A'..'Z') + ('0'..'9')).join()

		String postSubject = RandomStringUtils.random(30, charset.toCharArray())
		String postContent = RandomStringUtils.random(700, charset.toCharArray())
		
		String body = "{\"postSubject\":\""+postSubject+"\",\n\"postContent\":\""+postContent+"\",\n \"updatedAt\":\""+LocalDateTime.now()+"\"}"
		HTTPResponse response = request.PATCH(url, body.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))
		}
	}
	
	@Test
	public void test13() {
		grinder.logger.info("test13")
		String url = "http://127.0.0.1:8080/post/";
		String randomPostNumber = RandomPostIdIssuer.getRandomNumbers();

		HTTPResponse response = request.GET(url + randomPostNumber, 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))
		}
	}
		
		
	class RandomPostIdIssuer {

      private static int NUMBER_LENGTH=4;
      private static int NUMBER_BOUND=10;

      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)));
          }
		  String temp = sb.toString()
		  if(temp.charAt(0)=="0") {
			  return RandomPostIdIssuer.getRandomNumbers();
		  }
          return temp;
      }
	  
  }
}

 

딱히 신뢰할만한 레퍼런스가 엔그라인더밖에 없는데, 이마저도 불친절하고 IDE 지원 없이 웹에서 작업하다 보니

스크립트 작성에 시간이 많이 걸렸다.

 

스크립트 작성 시 주의할 점은, 이처럼 한 파일에 여러 테스트를 넣을 경우에 로그인 로직이 얽혀있어 쿠키 설정이 잘못될 수 있다.

초기에 파일을 생성하면 headers, params, cookies 가 static 변수로 생성되어 있는데, 에이전트가 여러 프로세스에서 static 변수들을 동시에 사용하다보니 동시성 문제로 스프링 서버에서 인증, 인가가 잘 이루어지지 않았다.

모든 변수를 멤버변수로 바꾸고, HTTPRequest 변수는 스펙상 static으로 바꿀 수가 없어서 @Before 로직에서 매번 새로 생성하게 만들었다.

이렇게 하면 스레드가 각자의 변수를 가지고 로직을 수행할 수 있어 동시성 문제에서 오는 인증 오류는 많이 줄지만, 100건당 1건 정도 오류가 여전히 발생한다. 애초에 파일 하나에 테스트 여러개를 때려박지 말라는 엔그라인더의 의도인 것 같다. 이 글을 보는 여러분들은 api 하나당 파일 하나 만드시길..

 

 

 

 

스크립트 작성을 마쳤으니, 저장을 하고

 

웹페이지 상단의 '성능 테스트' 버튼을 눌러 들어간다.

성능 테스트 설정 페이지

에이전트 댓수, 가상 상요자, 프로세스, 쓰레드 숫자를 정하고

실행할 스크립트를 정한 다음

우측 위의 복제 후 시작 버튼을 누르면 알아서 테스트를 진행한다.

 

테스트 진행중인 모습

 

설정해 둔 시간이 끝나면 테스트를 스스로 마친다.

상세 보고서 라는 페이지에서 테스트 결과를 볼 수 있다.

 

테스트 결과

테스트를 마쳤으니 이 결과를 갖고 유의미한 output을 만들어보자.

 

 

 

 

4. 분석

 

 테스트 아웃풋 중 가장 먼저 봐야할 것은 MTT이다. MTT(=Mean Test Time)

말 그대로 테스트의 평균적인 1회 수행 시간이라고 보면 된다.

먼저 MTT에 집중하자

단위는 ms인데, 지금 보면 3번, 8번, 10번 ID를 가진 테스트의 평균 수행시간이 2초를 넘어가고 있다.

실 서비스에서는응답시간을 최소 1초 이하로 줄여야한다.

 

무슨 API인지 확인해보니, 모두 게시글 Pagination 관련 기능이었다.

가장 우선적인 병목 포인트인 DB를 살펴보자.

 

 

현재 페이지네이션 api 실행시 발생하는 쿼리는 아래와 같다.

Hibernate: 
    select
        post0_.post_id as post_id1_1_0_,
        users1_.user_id as user_id1_5_1_,
        post0_.created_at as created_2_1_0_,
        post0_.hit_count as hit_coun3_1_0_,
        post0_.reference_category_id as referenc7_1_0_,
        post0_.post_content as post_con4_1_0_,
        post0_.post_subject as post_sub5_1_0_,
        post0_.updated_at as updated_6_1_0_,
        post0_.user_id as user_id8_1_0_,
        users1_.created_at as created_2_5_1_,
        users1_.updated_at as updated_3_5_1_,
        users1_.user_email as user_ema4_5_1_,
        users1_.user_name as user_nam5_5_1_,
        users1_.user_nickname as user_nic6_5_1_,
        users1_.user_role as user_rol7_5_1_ 
    from
        post post0_ 
    inner join
        users users1_ 
            on post0_.user_id=users1_.user_id 
    where
        post0_.reference_category_id=? 
    order by
        post0_.post_id desc limit ?,
        ?
Hibernate: 
    select
        count(postrecomm1_.post_recommend_id) as col_0_0_ 
    from
        post post0_ 
    left outer join
        post_recommend postrecomm1_ 
            on (
                post0_.post_id=postrecomm1_.post_id
            ) 
    where
        post0_.post_id in (
            ? , ? , ? , ? , ? , ? , ? , ? , ? , ?
        ) 
    group by
        post0_.post_id
Hibernate: 
    select
        count(comment1_.reference_post_id) as col_0_0_ 
    from
        post post0_ 
    left outer join
        comment comment1_ 
            on (
                post0_.post_id=comment1_.reference_post_id
            ) 
    where
        post0_.post_id in (
            ? , ? , ? , ? , ? , ? , ? , ? , ? , ?
        ) 
    group by
        post0_.post_id

총 3개의 조회쿼리가 발생한다. 차례대로

 

1. 게시글 10개 조회

@Query("select p from Post p join fetch p.user where p.postCategory.postCategoryId=:categoryId")
List<Post> findPageByCategoryId(@Param("categoryId") Long categoryId, Pageable pageable);

2. 각 게시글의 추천 count 조회

@Query("select count(pr.postRecommendId) from Post p left join PostRecommend pr on p.postId = pr.post.postId where p.postId in :postIds group by p.postId")
List<Integer> countAllByPostId(@Param("postIds") List<Long> postIds);

3. 게시글에 달린 댓글 count 조회 순이다.

@Query("select count(c.post.postId) from Post p left join Comment c on p.postId = c.post.postId where p.postId in :postIds group by p.postId")
List<Integer> countByIds(@Param("postIds") List<Long> postIds);

 

 

결론적으로, 병목이 생기던 쿼리는 1번의 limit 부분이었다.

맥북에어 m1 16gb 램 기준으로 0.4초가 소요된다.

테스트 특성상 페이지 번호를 랜덤으로 천번대의 숫자를 넘겨주게 되는데, 

limit offset 특성상 큰 offset을 전달받게 되면 앞의 데이터를 모두 읽고 버린 후에 차례대로 약속된 개수의 데이터를 가져온다.

 

 

만약

 select * from test limit 10000,10;

이런 쿼리가 있다고 하면, 앞의 10000개 레코드는 스토리지 엔진을 통해 읽기 작업이 수행되지만 사용되지 않고 버려진다. 

의미없는 10000번의 I/O가 발생하게 되는 것이다.

offset이 클수록 더 큰 오버헤드가 발생한다.

 

 

문제가 되는 쿼리의 실행계획을 보자

1번 쿼리 실행계획
워크벤치 쩐당

잘 모르겠다. 아래의 글을 보자

 

https://jojoldu.tistory.com/529

 

2. 페이징 성능 개선하기 - 커버링 인덱스 사용하기

2. 커버링 인덱스 사용하기 앞서 1번글 처럼 No Offset 방식으로 개선할 수 있다면 정말 좋겠지만, NoOffset 페이징을 사용할 수 없는 상황이라면 커버링 인덱스로 성능을 개선할 수 있습니다. 커버링

jojoldu.tistory.com

 

limit 페이징 쿼리를 커버링 인덱스를 활용해서 서브쿼리로 풀어낸다.

select p1.*
from post as p1
join 
(select p.post_id
from post as p 
where p.reference_category_id=1 
limit 20000,10) as temp
on temp.post_id = p1.post_id
inner join users as u on p1.user_id = u.user_id;

맥북에어 m1 16gb 램 기준으로 0.05초가 소요된다.

시간을 1/8 수준으로 줄였다. 이 정도면 쓸만할 것 같다.

 

그런데, 이 문법은 jpql에서 지원하는 스펙이 아니다.. 그래서 쿼리를 쪼개서 날리거나

더 low한 jdbc api를 사용해야한다.

로우레벨은 싫으니 쿼리를 쪼갠다.

 

쿼리를 다음 순서대로 날리면,

1.
@Query("select p.postId from Post p where p.postCategory.postCategoryId=:categoryId")
List<Long> findPaginationPostIds(@Param("categoryId") Long categoryId, Pageable pageable);

2.
@Query("select p from Post p join fetch p.user where p.postId in :postIds")
List<Post> paginationByPostIds(@Param("postIds") List<Long> postIds);

3.
@Query("select count(pr.postRecommendId) from Post p left join PostRecommend pr on p.postId = pr.post.postId where p.postId in :postIds group by p.postId")
List<Integer> countAllByPostId(@Param("postIds") List<Long> postIds);

4.
@Query("select count(c.post.postId) from Post p left join Comment c on p.postId = c.post.postId where p.postId in :postIds group by p.postId")
List<Integer> countByIds(@Param("postIds") List<Long> postIds);

리팩토링 완성

 

 

 

이제 다시 성능테스트를 진행해본다.

 

굿

 

MTT가 10분의 1 수준으로 줄었다.

그에 따라 병목이 해소되어 글로벌 TPS도 3배정도 증가했다.

 

 

아직 TPS 튀는 문제나 개별 api 테스트, 원클릭 자동화(빌드, 인프라 생성 및 부하 테스트, 결과 전송 및 인프라 정리, 개발 환경 배포) 등 할 일이 많다. 

처음이니까 조금씩 만져보자

 

 

ngrinder 튜토리얼 끝~

뿌듯하다

 

 

 

 

 

레퍼런스 :

 

https://velog.io/@max9106/nGrinderPinpoint-test1

 

nGrinder와 Pinpoint를 이용한 성능 / 부하 테스트1 - 테스트 계획

이 포스팅에서는 nGrinder설치와 Pinpoint설치 과정에 대해서는 다루지 않습니다!

velog.io

https://www.youtube.com/watch?v=jWxUMtum-H0 

https://inpa.tistory.com/entry/JEST-📚-부하-테스트-Stress-Test

 

[Artillery] 📚 부하 테스트 (Stress Test) 하는법

부하 테스트 부하 테스트 (stress test) 란 서버가 얼마만큼의 요청을 견딜 수 있는지 테스트하는 방법이다. 우리는 작성한 API 에 병목 현상과 얼마 만큼의 트래픽을 수용할 수 있는지에 대한 여부

inpa.tistory.com

https://grapevine9700.tistory.com/402

 

책 요약정리 – 성능 테스트, 부하 테스트, 스트레스 테스트 by Nguyen

출처: Testing Applications on the Web, 저자 Hung Q. Nguyen, 2001년 16장 성능 테스트, 부하 테스트, 스트레스 테스트(Performance, Load, and Stress Tests), 311~335 페이지 서론 웹 애플리케이션의 장점은..

grapevine9700.tistory.com

https://jojoldu.tistory.com/529

 

2. 페이징 성능 개선하기 - 커버링 인덱스 사용하기

2. 커버링 인덱스 사용하기 앞서 1번글 처럼 No Offset 방식으로 개선할 수 있다면 정말 좋겠지만, NoOffset 페이징을 사용할 수 없는 상황이라면 커버링 인덱스로 성능을 개선할 수 있습니다. 커버링

jojoldu.tistory.com