1. nGrinder로 성능테스트
1.1 테스트 시나리오 작성
KBO-Ticketing이 예매 서비스인 만큼 ‘좌석 예매’에 집중하기로 하였으므로 다음과 같이 시나리오를 만들었다.
1. 로그인
2. 팀 목록
3. 경기 목록
4. 경기 정보
5. 좌석 등급 목록
6. 좌석 등급 정보
7. 예약 좌석 목록
8. 좌석 선택
9. 예매
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 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.JsonSlurper
/**
* 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 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 HTTPRequest request
public static Map<String, String> headers = [:]
public static Map<String, Object> params = [:]
public static String signInbody = "{\\n \\"email\\":\\"test@gmail.com\\",\\n \\"password\\":\\"Pa\\$\\$w0rd!\\"\\n}"
public static String seatBody = "{\\n \\"scheduleId\\" :1, \\n \\"seatGradeId\\":1,\\n \\"seatNumber\\":1\\n}"
public static String reservationbody = "{\\n \\"seats\\": [\\n {\\n \\"scheduleId\\": 1,\\n \\"seatGradeId\\": 1,\\n \\"seatNumber\\": 1\\n }\\n ]\\n}"
public static List<Cookie> cookies = []
@BeforeProcess ///프로세스가 호출되기 전 처리할 동작
public static void beforeProcess() {
HTTPRequestControl.setConnectionTimeout(300000)
test = new GTest(1, "로그인")
test1 = new GTest(1, "팀 목록 조회")
test2 = new GTest(2, "경기 목록 조회")
test3 = new GTest(3, "경기 정보 조회")
test4 = new GTest(4, "좌석 등급 목록 조회")
test5 = new GTest(5, "좌석 등급 정보 조회")
test6 = new GTest(6, "예약 좌석 목록")
test7 = new GTest(7, "좌석 선택")
test8 = new GTest(8, "예매")
request = new HTTPRequest()
//테스트 전에 사전 작업으로 로그인
HTTPResponse response = request.POST("<http://{server_address}:8081/sign-in>", signInbody.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))
}
def responseBody = response.getBodyText()
def jsonSlurper = new JsonSlurper()
def json = jsonSlurper.parseText(responseBody)
grinder.logger.info("token: {}",json.data)
// Set header data after login
headers.put("Content-Type", "application/json")
headers.put("authorization", "Bearer " + json.data)
grinder.logger.info("before process.")
}
@BeforeThread //각 쓰레드가 실행되기 전 처리할 동작 정의
public void beforeThread() {
test.record(this, "test")
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")
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 test1() {
HTTPResponse response = request.GET("<http://{server_address}:8081/teams>", 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 test2() {
HTTPResponse response = request.GET("<http://{server_address}:8081/schedules?teamId=1>", 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 test3() {
HTTPResponse response = request.GET("<http://{server_address}:8081/schedules/1>", 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 test4() {
HTTPResponse response = request.GET("<http://{server_address}:8081/schedules/1/seat-grades>", 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 test5() {
HTTPResponse response = request.GET("<http://{server_address}:8081/seat-grades/1>", 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 test6() {
HTTPResponse response = request.GET("<http://{server_address}:8081/schedules/1/seat-grades/1>", 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))
}
}
//좌석 선택 (redis)
@Test
public void test7() {
HTTPResponse response = request.POST("<http://{server_address}:8081/seats>", seatBody.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 test8() {
HTTPResponse response = request.POST("<http://{server_address}:8081/reservations>", reservationbody.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))
}
}
}
1.2 성능테스트 실행
테스트 설정은 다음과 같다.
- Agent : 성능 측정에 사용할 Agent 개수. 여러개로 구성하고 싶은 경우 여러개의 인스턴스 생성해 설치
- Vuser per agent : Agent당 설정할 가상 사용자 수 - 동시에 요청을 날리는 사용자 수
- Process / Thread : 하나의 Agent에서 생성할 프로세스와 쓰레드 수
- Script : 성능 측정 시 각 Agent에서 실행할 스크립트
- Duration : 성능 측정 수행 시간
- Run count : 쓰레드 당 테스트 코드를 수행하는 횟수
- Initial Count : 처음 시작 시 가상 사용자 수
이제 성능 테스트를 수행해보자. 서버에 갑작스런 부하가 가지 않도록 Ramp-Up 을 설정한 후에 진행했다.
- Agent : 1대 , 테스트 유저 : 100명, 테스트 시간 : 1분
해당 테스트의 TPS는 1,584.9, Mean Test Time 는 33.29ms이다. 즉, 초당 1585번의 API 요청을 처리할 수 있다.
CPU 사용량은 최대 60%로 안정적이었다.
여기서 잠깐! 성능 지표를 알아보자.
- Throughput(처리량) : TPS, RPS라고 부르며 , 1초에 처리하는 작업량을 의미한다. 수치가 높을수록 성능이 좋다.
- Latency(지연시간) : 클라이언트로부터 Request를 받아서 Response를 보내주기까지 걸리는 시간을 의미하며 특정 작업을 얼마나 빨리 처리할 수 있는지 나타내는 성능 지표로 낮을 수록 성능이 좋다. 사용자 관점에서의 처리 시간이다.
위 그래프를 보면 서비스를 이용하는 사용자가 증가하면 TPS는 증가한다. 하지만 어느 지점이 되면 사용자가 늘어나더라도 TPS는 더 이상 늘어나지 않는다. 이러한 지점을 Saturation Point(포화지점)이라고한다.
그러면 포화지점에서 더 이상 TPS가 증가하지 않는 것은 무슨 의미를 가질까? 초당 처리할 수 있는 Transaction의 수가 한계에 도달했고 그때부터 사용자가 증가하면 Latency 시간이 증가한다. 이 포화지점은 해당 서버가 감당할 수 있는 부하의 한계를 정의할 수 있다.
그렇다면 테스트 유저를 늘려가며 포화지점을 찾아보자! 😃
2. Agent : 1대 , 테스트 유저 : 300명, 테스트 시간 : 1분
해당 테스트의 TPS는 1,429, Mean Test Time 는 93.68ms이다. 즉, 초당 1429번의 API 요청을 처리할 수 있다.
다시 한번 테스트를 수행했다.
해당 테스트의 TPS는 1,249, Mean Test Time 는 100.74ms이다. 즉, 초당 1274번의 API 요청을 처리할 수 있다.
테스트 유저 100명에서 300명으로 늘렸는데도 불구하고 TPS가 줄어들었다. 즉 여기가 포화지점이라는 것을 알 수 있었다.
또 CPU를 확인해봤을 때 최대 60%로 안정적이었다.
2. Pinpoint로 문제점 및 병목지점 파악
다음은 테스트 유저를 300명으로 실행했을 때의 Pinpoint Server Map이다.
응답시간 평균 31ms, 최대 607ms인 것을 알 수 있었다. 평균적으로 괜찮은 응답시간을 보이지만 최대는 607ms로 안 좋은 응답시간을 보였다.
Good API Response Time : A response time of 600 ms is generally considered good for a website’s initial server response. However, faster response times, ideally in the range [of 100 to 500 ms] are often targeted for optimal user experience. A good API response time is typically below 100 ms. Fast API responses are crucial for real-time applications and a smooth user experience.
응답 시간의 기준을 알기 위해서 구글링한 결과, 사용자 최적의 경험을 위해서 100 ~ 500ms 를 목표로 한다고 한다. 따라서 최대 응답 시간을 줄이는 것을 목표로 병목점을 찾아보았다.
Response 시간을 확인해보니 단순히 GET으로 ‘팀 목록’을 확인하거나 ‘경기목록’을 확인할때는 빠른 응답시간을 보였고,
‘좌석 예매’ API 호출시 응답시간이 느리다는 것을 발견했다. 우측 Res가 응답시간이다.
병목이 가장 많이 생기는 ‘좌석예매’ API의 Call Stack을 확인한 결과, ReservationService의 reserveSeats()에서 병목현상이 발생한다는 것을 알 수 있었다.
그리고 더 자세히 확인했을 때,
getConnection()에서 가장 큰 시간을 차지하고 있었다. 즉, 여러 사용자가 동시에 DB에 접속하려고 할때 connection pool에 있는 connection이 부족해서 호출이 느려지고있음을 알 수 있었다.
2.1 성능 개선 방안
위와 같은 문제를 파악했고, 2가지 방법을 통해 성능을 개선해보려고한다.
- scale out을 통해 트래픽을 분산해 TPS 올리기
- connection pool 크기 확장하기
다음 포스팅에서는 성능 개선 방안을 하나씩 수행해보면서 성능을 개선한 과정을 포스팅해보겠다!
'Spring' 카테고리의 다른 글
nGrinder와 Pinpoint로 성능테스트 및 개선하기 (4) - Scale out (0) | 2024.05.20 |
---|---|
nGrinder와 Pinpoint로 성능테스트 및 개선하기 (3) - Connection Pool 크기 조절 (0) | 2024.05.19 |
nGrinder와 Pinpoint로 성능테스트 및 개선하기 (1) - 환경 구축 (0) | 2024.05.10 |
Redis Lua Script 실제로 Atomic할까? (0) | 2024.04.18 |
Redis Lua Script 와 synchronized 간의 성능 비교 테스트 (+ 수정) (0) | 2024.04.15 |