실제 서비스 시작하기 전에, 현재 구축한 서버에서 어느정도 유저를 커버할 수 있는지, 대략 어느 시점부터 서버에 에러가 발생하기 시작하는지 알기 위해 부하를 부어보는 테스트다.
실제 서비스할 서버 환경과 동일한 환경에서 부하를 늘려가면서 부어본다.
네이버에서 진행한 오픈 소스 기반 플젝
서버 부하 테스트를 위한 도구
서비스 전에 서버가 얼마나 많은 사용자를 수용할 수 있는지 요청을 전송해봄으로써 서버의 성능을 측정해볼 수 있음
스크립트 레코딩
웹 브라우저에서 사용자 동작을 녹화해 테스트 스크립트를 생성할 수 있음
jpython을 사용해 스크립트 작성 가능
분산 테스트
실시간 모니터링
분석 및 리포팅
JMeter는 단일 데스크톱 컴퓨터에서 수행되는 반면에 nGrinder는 컨트롤러 및 에이전트로 구성된 분산 아키텍처로 수행된다.
nGrinder는 jython 또는 Groovy 같은 스크립트 언어를 사용하여 스크립트를 작성한다.
도커, 클라우드 환경에서도 실행 가능하다
nGrinder 설명하기 위한 글이 아니므로 간단하게 설명하고 넘어가겠다~~
성능 측정을 위한 웹 인터페이스를 제공하며, Web Application으로 Tomcat과 같은 웹서버 엔진을 이용하여 구동
사용자와의 인터페이스를 담당하여 테스트 프로세스 정의, 스크립트 작성 등을 지원
스크립트 수정 기능 제공하며, Agent에 스크립트를 전달하여 테스트를 일임
controller의 명령을 받아 실행하며, target에 프로세스와 스레드를 실행시켜 부하를 발생시킴
에이전트 모드에서 실행할 때 대상 시스템에 부하를 주는 프로세스 및 스레드를 실행
필자는 전반적인 User의 Flow를 테스트해보고 싶었다.
그래서 유저의 flow를 다음과 같이 가정했다.
- 회원 가입
- 로그인
- 게시물 5개 작성
- 게시물 100개 조회
- 게시물 좋아요 15회
- 게시물 북마크 15회
- 게시물 검색 15회
- 로그아웃
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 org.ngrinder.http.multipart.MultipartEntityBuilder
import java.nio.file.Files
import java.nio.file.Paths
import java.util.UUID
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 = []
// 본인 ip 주소 넣기
public static String ipAddr = "000.00.00.00"
@BeforeProcess
public static void beforeProcess() {
HTTPRequestControl.setConnectionTimeout(300000)
test = new GTest(1, ipAddr)
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 test() {
String uuid = UUID.randomUUID().toString()
String email = uuid + "@test.com"
String password = "test1234@"
String nickname = "user_" + uuid
String body = "{\n \"email\": \"" + email + "\",\n \"password\": \"" + password + "\",\n \"nickname\": \"" + nickname + "\"\n}"
HTTPResponse response = request.POST("http://" + ipAddr + ":8080/" + "api/users/signup", 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))
if (response.statusCode == 200) {
grinder.logger.info("========== SIGNUP SUCCESS ==========")
login(email, password) // 로그인
// 총합 : 게시물 작성 5회 / 게시물 조회 100회 / 게시물 검색 15회 / 좋아요 15회 / 북마크 15회
int loopCount = 5
Long idForGetPost = 1L
Long likeId = 1L
Long bookmarkId = 1L
while (loopCount-- > 0) {
createPost() // 게시물 작성 1번
for (long i = idForGetPost; i < idForGetPost + 20; i++) getPostById(i) // 조회 20번
for (int i = 0; i < 3; i++) like(likeId++) // 게시물 3개 좋아요 누르기
for (int i = 0; i < 3; i++) bookmark(bookmarkId++) // 게시물 3개 북마크 누르기
for (int i = 0; i < 4; i++) searchPostWithElasticsearch(randomSearchTextIssuer()) // Elasticsearch DB에 게시물 검색 3번
idForGetPost += 20
}
logout() // 로그아웃
}
}
}
// ok
public void login(String email, String password) {
String body = "{\n \"email\": \"" + email + "\",\n \"password\": \"" + password + "\"\n}"
HTTPResponse response = request.POST("http://" + ipAddr + ":8080/" + "api/users/login", 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))
if (response.statusCode == 200) {
grinder.logger.info("========== LOGIN SUCCESS ==========")
}
}
}
// ok
public void logout() {
HTTPResponse response = request.DELETE("http://" + ipAddr + ":8080/" + "api/users/logout")
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))
if (response.statusCode == 200) {
grinder.logger.info("========== LOGOUT SUCCESS ==========")
}
}
}
// ok
public void createPost() {
String contents = "contents : " + UUID.randomUUID().toString()
def multipart = MultipartEntityBuilder.create()
.addEntity("contents", contents)
.addEntity("imgFiles", new File("resources/test.PNG"))
.build();
HTTPResponse response = request.POST("http://" + ipAddr + ":8080/api/posts", multipart)
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))
if (response.statusCode == 200) {
grinder.logger.info("========== CREATE POST SUCCESS ==========")
}
}
}
// ok
public void getPostById(Long postId) {
String url = "http://" + ipAddr + ":8080/" + "api/posts/" + postId
long startTime = System.currentTimeMillis()
HTTPResponse response = request.GET(url)
long endTime = System.currentTimeMillis()
long duration = endTime - startTime;
grinder.logger.info("========== GetPostById " + postId + " Call Duration: " + duration + " ms ==========")
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))
}
}
public void like(Long postId) {
String url = "http://" + ipAddr + ":8080/" + "api/posts/" + postId + "/likes"
HTTPResponse response = request.POST(url)
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))
}
}
public void unlike(Long postId) {
String url = "http://" + ipAddr + ":8080/" + "api/posts/" + postId + "/likes"
HTTPResponse response = request.DELETE(url)
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))
}
}
public void bookmark(Long postId) {
String url = "http://" + ipAddr + ":8080/" + "api/posts/" + postId + "/bookmark"
HTTPResponse response = request.POST(url)
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))
}
}
public void unbookmark(Long postId) {
String url = "http://" + ipAddr + ":8080/" + "api/posts/" + postId + "/bookmark"
HTTPResponse response = request.DELETE(url)
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))
}
}
public void searchPostWithElasticsearch(String searchText) {
String url = "http://" + ipAddr + ":8080/" + "api/elasticsearch-posts?searchText=" + searchText
HTTPResponse response = request.GET(url)
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))
}
}
// post의 contents가 uuid로 만들어져있어서 두자릿수 숫자로 검색하면 웬만하면 검색 결과 나옴
public String randomSearchTextIssuer() {
Random random = new Random()
return Integer.toString(random.nextInt(90) + 10)
}
}
SNS 특성 상 WRITE에 비해 READ가 훨씬 많기에 script도 이런 비율로 작성을 했다.
Test 1개당 153개의 api 호출함. 즉 user 한명당 153개의 api를 호출한다는 의미
NCP 인스턴스에 vcpu 4개, 메모리 32GB, 디스크 50GB
한 인스턴스에서 애플리케이션 실행 + docker로 DB 구축(RDS 사용 X)
Processes : 10
Threads : 300
-> Vusers : 3000
test 시간 : 30분동안 진행하라고 요청함
test 당 평균 소요 시간이 16분이 걸리는 말도 안되는 상황...
이걸 api 개수로 나눠보면 평균적으로 api 1개당 소요 시간이 6.63 초가 나온다...
로그를 살펴본 결과 초기에는 게시물 1개 조회하는데 17ms 소요되다가 점점 늘어나더니 2s까지 늘어났다.
이렇게 된 이유를 고민해보니 애플리케이션과 서버가 같은 인스턴스에 있어서 DB에서 CPU를 거의 다 차지하고 사용량도 MAX를 쳤기에 애플리케이션 성능이 매우 떨어진 것 같음
그럼 인스턴스를 DB 서버와 애플리케이션 서버로 나눈 후 테스트를 다시 진행해보자
같은 조건에 기존 vcpu 4, RAM 32GB 인스턴스에는 DB docker만 남겨두고,
애플리케이션은 vcpu 4, RAM 8GB 서버에서 실행하기
즉, 애플리케이션과 DB를 분리하기
확실히 애플리케이션과 DB를 분리하니깐 cpu max 치던게 최대 75%까지로 내려왔다.
이에 따른 성능도 매우 많이 향상됨 (아래 표를 확인해보자)
인스턴스 분리 이후 모든 부분에서 성능이 극적으로 향상되었다
cpu max 치게 되면 애플리케이션의 성능이 급감한다는 것을 몸소 느끼게 되었다...
인스턴스 분리 후에도 여전히 게시물 1개 조회 시 최대 소요시간이 1.5s인 문제 발생...
또한, test가 Too Low TPS 에러와 함께 종료
내가 생각했을 때는 두가지 문제점 모두 애초에 서버와 DB cpu가 4개이기 때문에 vuser 3000명을 감당하기 힘들어 하는 것 같음
그래서 vuser를 조절해보면서 해당 서버 환경이 언제부터 Too Low TPS 에러와 함께 종료되는지 테스트 해보자
vuser 400명일 때, 부하 테스트 진행 시 cpu max 치다가 Too Low TPS 에러와 함께 테스트 종료
vuser 300명일 때는 문제 없이 테스트 수행
vcpu 4, RAM 32GB의 서버는 대략 300명~350명 사이에 유저가 30m간 해당 script 수행을 소화할 수 있다
CPU MAX 치기 직전에서 유지중
로그 확인한 결과 게시물 1개 조회 api 소요 시간 20ms~110ms 사이
초기 부하 테스트 vuser 3000 & 인스턴스 1개 & 30m간 테스트 수행으로 진행 시, DB에서 인스턴스의 CPU를 다 잡아 먹어서 애플리케이션의 성능 매우 하락
인스턴스를 분리한 결과, 성능이 많이 향상 되었지만, 아직도 게시물 단건 조회 시, 최대 1.5초까지 소요
vuser를 내려보면서 해당 서버 스펙에서 감당할 수 있는 vuser 찾아본 결과 대략 300~350명까지 소화 가능
vuser 301명 일 때 인스턴스 분리 유무에 따른 성능 차이 비교한 결과, DB 서버와 애플리케이션 서버를 분리하면 성능이 약 50 % 향상하는 것을 확인함