계기
프로젝트가 거의 마무리 되고 조만간 프론트엔드 분들도 작업이 완료될 예정이라고 한다. 이에 따라 사용자를 받기 전에 목표하는 트래픽을 감당할 수 있을지 성능테스트를 해보고 필요하다면 로컬 캐시, etag, 인덱스를 태우거나 쿼리 튜닝을 하고자 한다.
목표하는 트래픽 잡기
내가 진행중인 프로젝트를 간단하게 소개하자면 지도에 사진, 평점 등과 같은 것들을 기록하고 일기를 작성하는 지도기반 커플 다이어리이다. 유사한 어플리케이션으로 커플 앱인 비트윈, 썸원이 있고 다이어리 어플인 timetreep이라는 어플리케이션이 있다.
이제 similarweb이라는 사이트에서 위 어플리케이션들이 얼마나 많은 트래픽을 받는지 확인하고 목표 트래픽을 잡아보자.
썸원인 경우 한달에 약 23만, 비트윈인 경우 한달에 약 14만정도의 방문자를 받는다. timetreep이라는 다이어리 어플리케이션은 한달에 약 653만이라는 방문자를 받는데, 우리 어플리케이션의 목표 방문자는 이들의 중간 값인 300만을 받는 것으로 설정했다.
추가적으로, 트래픽이라고 말하지 않고, 방문자라고 말한 이유는 다음과 같다. 위 사이트에서 표기되는 트래픽 지표는 방문자가 세션 중에 하나 이상의 페이지에 액세스할 때 카운트된다.
이후 페이지뷰는 사용자가 30분 이상 비활성 상태일 때까지 동일한 방문에 포함된다. 사용자가 30분 후에 다시 활성화되면 새로운 방문으로 계산되며, 새 세션은 자정에도 시작된다.
요약하자면 30분동안은 같은 같은 사용자가 많은 요청을 하더라도 트래픽이 오르지 않는다는 의미이다.
rps: 초당 요청 수(request per sec)
tps: 초당 트랜잭션 처리 수 (transaction per sec)
아무튼 위 사항까지 고려하면 계산 과정이 매우 복잡해지고 가설을 많이 세워야 하기 때문에, 정적으로 한달 방문자를 300만으로 목표로 한다면, 목표로 하는 rps를 얼마로 잡아야 할지 계산해보자.
먼저 한달에 300만의 트래픽을 받으므로 하루에는 10만의 방문자를 받는다고 가정하자. 또한 방문자 한명당 요청은 대략 15번 한다고 임의로 가정하자. 하루가 86400초 이므로 초당 약 10만 x 15 / 86400 즉 17.3 정도의 트래픽을 받는다고 생각하면 될 것 같다. 다시 말하지만 정확한 수치는 아니고 그냥 정적으로 계산한 값이다. 이제 목표 rps=17.3으로 구했으니 붐비는 시간대는 대략 이 값의 세배인 52로 가정하고 최대 트래픽의 수는 52라고 가정하자.
목표 rps: 17.3, 최대 rps: 52
시나리오 작성
이제 테스트 시나리오를 작성해보자.
테스트 시나리오는 아래와 같으며 일반적인 부분과 우리 어플리케이션에서 가장 복잡한 쿼리가 있는 부분들을 포함시켰다.
로그인 -> 프로필 조회 -> 프로필 편집 -> 다이어리 조회 -> 다이어리 조회 -> 다이어리 조회 -> 다이어리 작성 -> 일정 조회 -> 일정 조회 -> 일정 조회-> 일정 작성 -> 오늘의 질문 조회
시나리오를 작성했으니 목표하는 rps에 도달하기 위해서는 위 시나리오를 수행해야하는 vuser의 인원을 계산해보자.
시나리오 1개를 실행하는데 평균적으로 3초 ~ 6초정도 걸린다. 요청 하나당 0.25 ~ 0.5초 정도 걸리는 셈이다. 넉넉하게 기타 오버헤드들을 전부 포함시켜 시나리오 하나에 6초 걸린다고 하면 위 시나리오를 실행하는 동안 받아야 하는 요청 수는 총 rps x 6 = 17.3 x 6 = 103이 된다. 즉 시나리오가 실행되는 동안 103개의 요청이 있어야 한다.
이 시나리오에는 총 12개의 요청이 있으므로 시나리오가 실행되는 동안 103개의 요청을 받으려면 103 / 12 약 9명의 유저가 동시에 이 시나리오를 실행하면 6초동안 103개의 요청을 하는 셈이다. 한마디로 1초에 17.3개의 요청을 할 수 있다. 붐비는 시간대도 이 시나리오로 테스트하려면 3배엔 27명의 가상의 사용자가 필요하다.
테스트 스크립트 작성하기
시나리오도 작성하고, 필요한 가상의 유저도 설정했으니깐 테스트 스크립트를 작성해보자.
nGrinder 설치 방법은 문서에 잘나와있으니 생략하고 스크립트를 코드를 작성하는 요령을 설명하겠다. 일단 기본적으로 nGrinder에서 제공해주는 스크립트가 있는데, 위 복잡한 시나리오를 테스트 하기에는 턱없이 부족하다. 해당 스크립트를 이해하고 응용할 필요가 있는데, 텍스트로만 되어 있는 이 코드를 분석하기에는 너무 어렵기 때문에 IDE를 활용하자.
빌드 툴로 그레이들을 사용한다고 했을 때 아래 두개의 의존성을 추가하고 테스트 코드에 그루비 파일을 만들자.
implementation group: 'org.codehaus.groovy', name: 'groovy-all', version: '3.0.19', ext: 'pom'
implementation group: 'org.ngrinder', name: 'ngrinder-groovy', version: '3.5.8'
이제 여기에서 nGrinder에서 제공해주는 기본 스크립트를 복사 붙여넣기를 하고 ctrl + 마우스 왼쪽으로 클래스에 들어가서 어떤 인자를 받고 어떤 식으로 사용하는지 확인하며 코드를 작성하면 그나마 쉽게 작성할 수 있다. 운이 좋으면 해당 클래스를 사용하는 방법도 주석을 통해 확인 가능하다.
결과적으로 스크립트 코드는 아래와 같이 구성했다. 여기서 중요한 점은 테스트 스크립트마다 GTest 객체를 만들어줘야 한다. nGrinder 지표 분석은 GTest 객체를 기준으로 지표를 분석하기 때문에, 하나의 GTest에 요청을 여러개 집어넣으면 요청당 결과분석이 아닌 12개가 전부 들어있는 하나의 객체의 결과분석만 보게 된다. 또한, tps도 GTest가 하나 수행될 때를 기준으로 측정하기 때문에 이상한 tps가 나오게 될 것이다.
테스트는 알파벳 순으로 실행되기 때문에 test1, test2 와 같은 네이밍을 해줬다.
package com.pr.logpractice
import groovy.json.JsonBuilder
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
/**
* 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 test1
public static GTest test2
public static GTest test3
public static GTest test4
public static GTest test5
public static GTest test6
public static GTest test7
public static GTest test8
public static GTest test9
public static GTest test10
public static GTest test11
public static GTest test12
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)
test1 = new GTest(1, "/v1/members")
test2 = new GTest(2, "/v1/couples")
test3 = new GTest(3, "/v1/members")
test4 = new GTest(4, "/v1/diaries?page=0&size=10")
test5 = new GTest(5, "/v1/diaries?page=1&size=10")
test6 = new GTest(6, "/v1/diaries?page=2&size=10")
test7 = new GTest(7, "/v1/diaries")
test8 = new GTest(8, "/v1/calendars?from=2023-11-01&to=2023-11-30")
test9 = new GTest(9, "/v1/calendars?from=2023-10-01&to=2023-10-30")
test10 = new GTest(10, "/v1/calendars?from=2023-09-01&to=2023-09-30")
test11 = new GTest(11, "/v1/calendars")
test12 = new GTest(12, "/v1/questions/daily")
request = new HTTPRequest()
headers.put("Authorization", "Bearer eyJhbGciOiJIUzI1NiJ9.eyJzdWIiOiJraW1zazMxMTNAbmF2ZXIuY29tIiwiYXV0aCI6IkdVRVNUIiwiZXhwIjoxNzAwNzI3NDA3fQ.cEk6KWYA6BpraTFwa2ZAfh3sq-3kbw4oPC88W7viNWU")
grinder.logger.info("프로세스 시작 전")
}
@BeforeThread
public void beforeThread() {
test1.record(this, "test1")
test2.record(this, "test2")
test3.record(this, "test3")
test4.record(this, "test4")
test5.record(this, "test5")
test6.record(this, "test6")
test7.record(this, "test7")
test8.record(this, "test8")
test9.record(this, "test9")
test10.record(this, "test10")
test11.record(this, "test11")
test12.record(this, "test12")
grinder.statistics.delayReports = true
grinder.logger.info("스레드 시작 전")
}
@Before
public void before() {
request.setHeaders(headers)
CookieManager.addCookies(cookies)
grinder.logger.info("헤더 쿠키 초기화")
}
@Test
public void test1() {
//첫 번째 요청 로그인(/v1/members)
HTTPResponse responseMembers = request.GET("https://love-back.kro.kr/v1/members", params)
if (responseMembers.statusCode == 200) {
assertThat(responseMembers.statusCode, is(200))
grinder.logger.info("/v1/members 요청 성공: {}", responseMembers.statusCode)
} else {
grinder.logger.info("/v1/members 응답: {}", responseMembers.getBodyText())
grinder.logger.error("/v1/members 요청 실패: {}", responseMembers.statusCode)
}
}
@Test
public void test2() {
// 두 번째 요청 프로필 조회(/v1/couples)
HTTPResponse responseCouples = request.GET("https://love-back.kro.kr/v1/couples", params)
if (responseCouples.statusCode == 200) {
assertThat(responseCouples.statusCode, is(200))
grinder.logger.info("/v1/couples 요청 성공: {}", responseCouples.statusCode)
} else {
grinder.logger.info("/v1/couples 응답: {}", responseCouples.getBodyText())
grinder.logger.error("/v1/couples 요청 실패: {}", responseCouples.statusCode)
}
}
@Test
public void test3() {
//세 번째 요청 - 프로필 수정
String boundary = "----WebKitFormBoundary" + UUID.randomUUID().toString()
headers.put("Content-Type", "multipart/form-data; boundary=" + boundary)
String jsonTexts = '{' +
'"nickname":"대한독립만세",' +
'"birthday":"1998-12-19",' +
'"mbti":"infj",' +
'"calendarColor":"#F95656"' +
'}'
String body = "--" + boundary + "\r\n" +
"Content-Disposition: form-data; name=\"texts\"\r\n" +
"Content-Type: application/json\r\n\r\n" +
jsonTexts + "\r\n" +
"--" + boundary + "--"
request.setHeaders(headers)
HTTPResponse responseEditProfile = request.PATCH("https://love-back.kro.kr/v1/members", body.bytes)
if (responseEditProfile.statusCode == 200) {
assertThat(responseEditProfile.statusCode, is(200))
grinder.logger.info("/v1/members patch 요청 성공: {}", responseEditProfile.statusCode)
} else {
grinder.logger.error("/v1/members patch 요청 실패: {}", responseEditProfile.statusCode)
grinder.logger.info("/v1/members patch 응답: {}", responseEditProfile.getBodyText())
}
}
@Test
public void test4() {
//네 번째 요청 다이어리 조회1
HTTPResponse responseDiaries = request.GET("https://love-back.kro.kr/v1/diaries?page=0&size=10", params)
if (responseDiaries.statusCode == 200) {
assertThat(responseDiaries.statusCode, is(200))
grinder.logger.info("/v1/diaries?page=0&size=10 요청 성공: {}", responseDiaries.statusCode)
} else {
grinder.logger.error("/v1/diaries?page=0&size=10 요청 실패: {}", responseDiaries.statusCode)
grinder.logger.info("/v1/diaries?page=0&size=10 응답: {}", responseDiaries.getBodyText())
}
}
@Test
public void test5() {
//다섯번째 요청 다이어리 조회2
HTTPResponse responseDiaries2 = request.GET("https://love-back.kro.kr/v1/diaries?page=1&size=10", params)
if (responseDiaries2.statusCode == 200) {
assertThat(responseDiaries2.statusCode, is(200))
grinder.logger.info("/v1/diaries?page=1&size=10 요청 성공: {}", responseDiaries2.statusCode)
} else {
grinder.logger.error("/v1/diaries?page=1&size=10 요청 실패: {}", responseDiaries2.statusCode)
grinder.logger.info("/v1/diaries?page=1&size=10 응답: {}", responseDiaries2.getBodyText())
}
}
@Test
public void test6() {
//여섯번째 요청 다이어리 조회3
HTTPResponse responseDiaries3 = request.GET("https://love-back.kro.kr/v1/diaries?page=2&size=10", params)
if (responseDiaries3.statusCode == 200) {
assertThat(responseDiaries3.statusCode, is(200))
grinder.logger.info("/v1/diaries?page=2&size=10 요청 성공: {}", responseDiaries3.statusCode)
} else {
grinder.logger.error("/v1/diaries?page=2&size=10 요청 실패: {}", responseDiaries3.statusCode)
grinder.logger.info("/v1/diaries?page=2&size=10 응답: {}", responseDiaries3.getBodyText())
}
}
@Test
public void test7() {
//일곱번째 요청 다이어리 작성
String boundary = "----WebKitFormBoundary" + UUID.randomUUID().toString()
headers.put("Content-Type", "multipart/form-data; boundary=" + boundary)
def file = new File("/var/local/images/KakaoTalk_20231012_015153064.jpg");
def fileContent = file.bytes.encodeBase64().toString()
def jsonText = new JsonBuilder([
kakaoMapId: 1,
address: "경기도 고양시",
placeName: "스타벅스",
score: 4,
datingDay: "2023-11-01",
category: "ETC",
latitude: 37.5665,
longitude: 126.9780,
text: "테스트 텍스트"
]).toString()
String body = "--" + boundary + "\r\n" +
"Content-Disposition: form-data; name=\"images\"; filename=\"${file.name}\"\r\n" +
"Content-Type: image/jpeg\r\n\r\n" +
fileContent + "\r\n" +
"--" + boundary + "\r\n" +
"Content-Disposition: form-data; name=\"texts\"\r\n" +
"Content-Type: application/json\r\n\r\n" +
jsonText + "\r\n" +
"--" + boundary + "--"
request.setHeaders(headers)
HTTPResponse responseCreateDiary = request.POST("https://love-back.kro.kr/v1/diaries", body.bytes)
if (responseCreateDiary.statusCode == 201) {
assertThat(responseCreateDiary.statusCode, is(201))
grinder.logger.info("/v1/diaries post 요청 성공: {}", responseCreateDiary.statusCode)
} else {
grinder.logger.error("/v1/diaries post 요청 실패: {}", responseCreateDiary.statusCode)
grinder.logger.info("/v1/diaries post 응답: {}", responseCreateDiary.getBodyText())
}
}
@Test
public void test8() {
//여덟번째 요청 일정 조회
HTTPResponse responseCalendars = request.GET("https://love-back.kro.kr/v1/calendars?from=2023-11-01&to=2023-11-30", params)
if (responseCalendars.statusCode == 200) {
assertThat(responseCalendars.statusCode, is(200))
grinder.logger.info("/v1/calendars?from=2023-11-01&to=2023-11-30 요청 성공: {}", responseCalendars.statusCode)
} else {
grinder.logger.error("/v1/calendars?from=2023-11-01&to=2023-11-30 요청 실패: {}", responseCalendars.statusCode)
grinder.logger.info("/v1/calendars?from=2023-11-01&to=2023-11-30 응답: {}", responseCalendars.getBodyText())
}
}
@Test
public void test9() {
//아홉째 요청 일정 조회
HTTPResponse responseCalendars2 = request.GET("https://love-back.kro.kr/v1/calendars?from=2023-10-01&to=2023-10-30", params)
if (responseCalendars2.statusCode == 200) {
assertThat(responseCalendars2.statusCode, is(200))
grinder.logger.info("/v1/calendars?from=2023-10-01&to=2023-10-30 요청 성공: {}", responseCalendars2.statusCode)
} else {
grinder.logger.error("/v1/calendars?from=2023-10-01&to=2023-10-30 요청 실패: {}", responseCalendars2.statusCode)
grinder.logger.info("/v1/calendars?from=2023-10-01&to=2023-10-30 응답: {}", responseCalendars2.getBodyText())
}
}
@Test
public void test10() {
//열번째 요청 일정 조회
HTTPResponse responseCalendars3 = request.GET("https://love-back.kro.kr/v1/calendars?from=2023-09-01&to=2023-09-30", params)
if (responseCalendars3.statusCode == 200) {
assertThat(responseCalendars3.statusCode, is(200))
grinder.logger.info("/v1/calendars 요청 성공?from=2023-09-01&to=2023-09-30: {}", responseCalendars3.statusCode)
} else {
grinder.logger.error("/v1/calendars?from=2023-09-01&to=2023-09-30 요청 실패: {}", responseCalendars3.statusCode)
grinder.logger.info("/v1/calendars?from=2023-09-01&to=2023-09-30 응답: {}", responseCalendars3.getBodyText())
}
}
@Test
public void test11() {
//열한번째 요청 일정 작성
headers.put("Content-Type", "application/json")
def body = new JsonBuilder([
startDate: "2023-11-01",
endDate: "2023-11-15",
scheduleDetails: "공부",
scheduleType: "PERSONAL"
]).toString()
request.setHeaders(headers)
HTTPResponse createCalendars = request.POST("https://love-back.kro.kr/v1/calendars", body.bytes)
if (createCalendars.statusCode == 201) {
assertThat(createCalendars.statusCode, is(201))
grinder.logger.info("/v1/calendars post 요청 성공: {}", createCalendars.statusCode)
} else {
grinder.logger.error("/v1/calendars post 요청 실패: {}", createCalendars.statusCode)
grinder.logger.info("/v1/calendars post 응답: {}", createCalendars.getBodyText())
}
}
@Test
public void test12() {
//열두번째 요청 오늘의 질문 조회
HTTPResponse responseDailyQuestions = request.GET("https://love-back.kro.kr/v1/questions/daily", params)
if (responseDailyQuestions.statusCode == 200) {
assertThat(responseDailyQuestions.statusCode, is(200))
grinder.logger.info("/v1/questions/daily 요청 성공: {}", responseDailyQuestions.statusCode)
} else {
grinder.logger.error("/v1/questions/daily 요청 실패: {}", responseDailyQuestions.statusCode)
grinder.logger.info("/v1/questions/daily 응답: {}", responseDailyQuestions.getBodyText())
}
}
}
Ngrinder로 지표 보기
테스트 설정은 아래와 같이 했다. 위에서 계산한대로 9명의 가상 유저를 설정하고 db에 데이터는 10만개 넣고 3분동안 테스트를 진행해봤다. (평상시 트래픽이니깐 1시간은 진행해야 더 큰 의미가 있지 않을까 싶다.)
테스트를 진행하고 결과를 보면 아래와 같은 요약 지표가 나오는데 자세한 내용은 로그 파일, csv 파일을 받아 확인 가능하다.
에러는 1개기 때문에 어느정도 안정적이기는 하지만 tps가 부족하다. (여기서의 에러는 응답 조차 하지 않는 에러로 보인다.)
로그를 분석해보자. nGrinder에서 제공하는 로그 파일이 가독성이 안좋은데, 어느정도 정리하자면 아래와 같이 작성할 수 있다.
Tests Errors Mean Test Test Time TPS Mean Response Response Mean time to Mean time to Mean time to
Time (ms) Standard response bytes per errors resolve host establish first byte
Deviation length second connection
(ms)
Test 1 204 0 625.78 113.53 1.15 355.00 408.74 0 0.00 0.03 4.40 "/v1/members"
Test 2 203 0 623.29 94.62 1.15 612.00 701.19 0 0.00 0.04 4.69 "/v1/couples"
Test 3 202 0 636.58 97.75 1.14 166.21 189.49 2 0.00 0.01 9.43 "/v1/members"
Test 4 199 0 667.54 93.29 1.12 3105.00 3487.39 0 0.00 0.03 0.03 "/v1/diaries?page=0&size=10"
Test 5 199 0 672.31 112.45 1.12 3107.00 3489.63 0 0.00 0.03 4.08 "/v1/diaries?page=1&size=10"
Test 6 199 0 661.55 100.96 1.12 3107.00 3489.63 0 0.00 0.05 0.05 "/v1/diaries?page=2&size=10"
Test 7 198 0 723.61 123.52 1.12 389.09 434.81 21 0.00 0.05 7.14 "/v1/diaries"
Test 8 198 0 672.04 117.13 1.12 118532.18 132460.62 0 0.00 0.02 3.73 "/v1/calendars?from=2023-11-01&to=2023-11-30"
Test 9 198 0 615.98 119.59 1.12 353.00 394.48 0 0.00 0.03 25.15 "/v1/calendars?from=2023-10-01&to=2023-10-30"
Test 10 206 1 711.76 160.54 1.16 353.00 410.42 0 0.00 17.46 634.67 "/v1/calendars?from=2023-09-01&to=2023-09-30"
Test 11 205 0 647.19 108.40 1.16 297.60 344.33 3 0.00 0.03 8.18 "/v1/calendars"
Test 12 205 0 619.28 101.27 1.16 262.00 303.14 0 0.00 0.04 10.01 "/v1/questions/daily"
Totals 2416 1 656.29 118.30 13.64 10715.42 146113.86 26 0.00 1.52 60.51
총 11개의 지표가 있는데 다음과 같다. 왼쪽부터
- Tests: 수행된 총 테스트 요청의 수
- Errors: 테스트 중 발생한 오류의 수
- Mean Test Time (ms): 평균 테스트 응답 시간으로, 요청이 처리되고 응답을 받는 데 걸린 평균 시간(밀리초)
- Test Time Standard Deviation (ms): 테스트 응답 시간의 표준 편차로, 응답 시간의 변동성을 나타냄
- TPS: 초당 트랜잭션 수로, 시스템이 매초 처리할 수 있는 트랜잭션의 수
- Mean response length: 응답의 평균 길이(바이트)
- Response bytes per second: 초당 전송된 평균 바이트 수
- Response errors: 초당 오류 응답의 수
- Mean time to resolve host: 호스트 이름을 IP 주소로 변환하는 데 걸린 평균 시간
- Mean time to establish connection: 클라이언트가 서버에 연결을 설정하는 데 걸린 평균 시간
- Mean time to first byte: 서버로부터 첫 번째 바이트를 받기까지 걸린 평균 시간
위 분석을 통해 7번 엔드포인트가 불안정하면서 가장 느린 엔드포인트로 인지할 수 있다. 이를 기반으로 서버의 로그, 모니터링 지표, 코드를 확인해서 성능을 개선해보도록 하자.
참고로 성능 개선은 최범균님의 초식에 잘나와있다.
잘못된 정보가 있다면 꼭 댓글에 남겨주길 바랍니다. 감사합니다.
'프로젝트 > 고민' 카테고리의 다른 글
성능 최적화(캐시) (2) | 2023.12.07 |
---|---|
성능 최적화 (스케일 업, 인덱스 튜닝) (1) | 2023.12.02 |
코드의 품질 관리를 도움 받기: SonarCloud, CodeMetrics (0) | 2023.10.13 |
우리의 문제 상황에 맞는 락 구현 (0) | 2023.10.06 |
이벤트 기반으로 책임 분리 (0) | 2023.10.05 |