[TODOMON] EP.9 부하 테스트 진행(야매)
이 부하테스트는 todo_instance를 제거하기 전에 진행한 것이기에 todo_instance에 대한 내용이 포함되어 있음
테스트 개요
테스트 범위 및 데이터 개수
- 테스트 범위: WAS - DB까지
- 데이터 개수
- member: 50만 개
- diligence: 50만 개(member 수와 동일)
- todo: (
member 수
) * (한명 당 30개(단일 25개 + 반복 5개)
) = 1500만개- single todo: 25 *
member 수
= 1250만개 - repeat todo: 5 *
member 수
= 250만개
- single todo: 25 *
- repeat_info: (
member 수
) * (member 당 반복 todo 수
) = 50만 * 5 = 250만 - todo_instance: (
member 수
) * (member 당 반복 todo 수
) * (인스턴스 반복 횟수
) = 50만 * 5 * 3 = 750만개
테스트 목표
구현 값을 토대로 부하 테스트 목표를 정리하면 다음과 같다.
- 평소 트래픽 VUser: 29
- 최대 트래픽 VUser: 86
- Throughput:
57.3rps
~171.9rps
- Latency: 0.5s 이하
- 성능 유지 기간: 30분
투두 조회 테스트
- 투두 조회(다음 3개 중 랜덤 1개)
- 투두 일별 조회 API("
/api/todo/day?date={date}
") - 투두 주별 조회 API("
/api/todo/week?startOfWeek={startOfWeek}
") - 투두 월별 조회 API("
/api/todo/month?yearMonth={yearMonth}
")
- 투두 일별 조회 API("
스크립트
import io.jsonwebtoken.Jwts
import io.jsonwebtoken.SignatureAlgorithm
import javax.crypto.SecretKey;
import javax.crypto.spec.SecretKeySpec;
import java.util.Date
import java.nio.charset.StandardCharsets
import java.util.Random
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
@RunWith(GrinderRunner)
class TestRunner {
public String token
public static GTest test
public static HTTPRequest request
public static Map<String, Object> params = [:]
public static List<Cookie> cookies = []
public static String secret_key = "uSJkMCJ0wgiLMlySm6QNktw4ucwhMEFeu+O4GaXHhLxWptRN6dO" // String should be in double quotes
public static long expiration = 86400000
@BeforeProcess
public static void beforeProcess() {
HTTPRequestControl.setConnectionTimeout(300000)
test = new GTest(1, "GET TODO API TEST")
request = new HTTPRequest()
}
@BeforeThread
public void beforeThread() {
test.record(this, "test")
grinder.statistics.delayReports = true
}
@Before
public void before() {
// 쿠키 설정
CookieManager.addCookies(cookies)
}
@Test
public void test() {
// 1 ~ 500000 사이의 랜덤 userId 생성
Random random = new Random()
long userId = random.nextInt(500000) + 1
String email = "tester" + userId + "@test.com"
String username = "tester" + userId
// 매 요청마다 토큰 생성
token = generateAccessToken(userId, email, username)
// Authorization 헤더 추가
Map<String, String> headers = [:]
headers.put("Authorization", "Bearer " + token)
request.setHeaders(headers)
// URL 배열 정의
String[] urls = [
"http://127.0.0.1:8080/api/todo/day",
"http://127.0.0.1:8080/api/todo/week",
"http://127.0.0.1:8080/api/todo/month"
]
// 랜덤으로 URL 선택
String url = urls[random.nextInt(urls.length)]
grinder.logger.info("Selected URL: {}, UserId: {}", url, userId)
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))
}
}
public String generateAccessToken(Long id, String email, String username) {
SecretKey secretKey = new SecretKeySpec(secret_key.getBytes(StandardCharsets.UTF_8), SignatureAlgorithm.HS256.getJcaName())
Date now = new Date()
return Jwts.builder()
.setSubject(email)
.claim("id", id)
.claim("username", username)
.claim("provider", "GOOGLE")
.claim("role", "ROLE_USER")
.setIssuedAt(now)
.setExpiration(new Date(now.getTime() + expiration))
.signWith(secretKey)
.compact()
}
}
- 매 요청마다 다른 유저가 접근하는 것을 묘사하기 위해 매 요청마다 Jwt 토큰을 발급하고 있다.
- io.jsonwebtoken 라이브러리를 추가하기 위해 nGrinder의 script 탭에서 lib 폴더를 만들어 다음의 jar 파일들을 넣어주었다.
jackson-annotations-2.12.7.jar
jackson-core-2.12.7.jar
jackson-databind-2.12.7.jar
jjwt-0.12.3.jar
jjwt-api-0.12.3.jar
jjwt-impl-0.12.3.jar
jjwt-jackson-0.12.3.jar
결과 - 평소 트래픽(Vusers: 29, 10분)
목표값보다 훨씬 준수하게 결과가 나오고 있다! 거뜬해보인다
처음 로직을 작성할 때부터 N + 1 문제가 발생하지 않게 작성을 해두어서 다행인 것 같다.
결과 - 최대 트래픽(Vusers: 86, 10분)
TPS는 아주 조금 증가(혹은 거의 유사)했지만, 트래픽이 증가하니 이전보다 MTT가 3배 정도 증가하였다.. 원인을 찾기 위해 모니터링 툴을 살펴보니 DB 커넥션과 JVM 스레드가 다음과 같은 그래프를 보여주고 있었다.
인덱스가 걸리지 않은 컬럼에 대한 범위 검색 쿼리가 발생하다보니 쿼리가 조금 시간이 걸리게 되고, 이로 인해 다른 스레드들은 커넥션이 반납되기를 기다려야 했을 것이다. 대부분의 스레드들이 TIMED_WAITING
상태인 것을 확인할 수 있다.
이미 충분한 성능을 보여주지만 조금만 더 개선해보자! 인덱스를 걸어도 좋겠지만, 일단 DB 커넥션 풀의 사이즈를 20 → 50으로 늘려보았다.
그 결과...
TPS는 조금 증가하긴 했지만, MTT의 경우는 거의 변화가 없었다..
커넥션 사이즈를 늘려서 동시에 처리될 수 있는 양을 늘리긴 했지만, 쿼리 자체의 성능은 그대로이므로 MTT는 변하지 않은 상황이다. 이를 해결하기 위해서는 쿼리 튜닝 및 인덱스 추가가 필요할 것이다.
전체 랭킹 조회 테스트
- 전체 랭킹 조회(다음 4개중 랜덤 1개)
- 전체 일간 투두 달성 랭킹 조회 API("
/api/overall/rank/achievement/daily
") - 전체 주간 투두 달성 랭킹 조회 API("
/api/overall/rank/achievement/weekly
") - 전체 일관성 랭킹 조회 API("
/api/overall/rank/diligence
") - 전체 도감 랭킹 조회 API("
/api/overall/rank/collection
")
- 전체 일간 투두 달성 랭킹 조회 API("
스크립트
import io.jsonwebtoken.Jwts
import io.jsonwebtoken.SignatureAlgorithm
import javax.crypto.SecretKey;
import javax.crypto.spec.SecretKeySpec;
import java.util.Date
import java.nio.charset.StandardCharsets
import java.util.Random
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 String token
public static GTest test
public static HTTPRequest request
public static Map<String, Object> params = [:]
public static List<Cookie> cookies = []
public static String secret_key = "uSJkMCJ0wgiLMlySm6QNktw4ucwhMEFeu+O4GaXHhLxWptRN6dO" // String should be in double quotes
public static long expiration = 86400000
@BeforeProcess
public static void beforeProcess() {
HTTPRequestControl.setConnectionTimeout(300000)
test = new GTest(1, "Overall Diligence Rank Test")
request = new HTTPRequest()
}
@BeforeThread
public void beforeThread() {
test.record(this, "test")
grinder.statistics.delayReports = true
}
@Before
public void before() {
// 쿠키 설정
CookieManager.addCookies(cookies)
}
@Test
public void test() {
Random random = new Random()
long userId = random.nextInt(500000) + 1
String email = "tester" + userId + "@test.com"
String username = "tester" + userId
// 매 요청마다 토큰 생성
token = generateAccessToken(userId, email, username)
// Authorization 헤더 추가
Map<String, String> headers = [:]
headers.put("Authorization", "Bearer " + token)
request.setHeaders(headers)
String[] urls = [
"http://127.0.0.1:8080/api/overall/rank/achievement/daily",
"http://127.0.0.1:8080/api/overall/rank/achievement/weekly",
"http://127.0.0.1:8080/api/overall/rank/diligence",
"http://127.0.0.1:8080/api/overall/rank/collection"
]
String url = urls[random.nextInt(urls.length)]
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 {
grinder.logger.error("[User - {}] Unexpected response code: {}. Response Body: {}", userId, response.statusCode, response.getBodyText())
assertThat("Unexpected status code " + response.statusCode, response.statusCode, is(200))
}
}
public String generateAccessToken(Long id, String email, String username) {
SecretKey secretKey = new SecretKeySpec(secret_key.getBytes(StandardCharsets.UTF_8), SignatureAlgorithm.HS256.getJcaName())
Date now = new Date()
return Jwts.builder()
.setSubject(email)
.claim("id", id)
.claim("username", username)
.claim("provider", "GOOGLE")
.claim("role", "ROLE_USER")
.setIssuedAt(now)
.setExpiration(new Date(now.getTime() + expiration))
.signWith(secretKey)
.compact()
}
}
결과 - 평소 트래픽(Vusers: 29, 10분)
처참하다.. ㅋㅋ
사실 예상은 했던 결과긴 하지만 실제로 보니 좀 충격적이긴 하다. 현재 테스트하는 API들은 복잡한 쿼리들로 작성되어 있으며 수많은 데이터들에 대한 집계연산을 포함한다. 이들 단건 조회 자체도 테스트 시 2초 정도가 걸리며, 전체 도감 랭킹 조회의 경우 9초가 넘어간다 ㅋㅋ
CPU, 메모리, 스레드, 커넥션 풀 등은 모두 문제없이 잘 돌아간다. 바로 성능을 개선하기 위한 방법으론 3가지 정도가 있을 것 같다.
- 쿼리 튜닝
- 인덱스 적용
- 캐싱
각 방법 모두 적용하면 좋겠으나 쿼리를 여기서 더 튜닝하는건 너무 어려운 싸움이 될 것 같다.. 그리고 인덱스를 적용하자니 어디에 어떻게 걸어야 할지도 모르겠으며 고작 하루만 사용할 데이터에 대해 인덱스를 적용하기에는 의미가 없어 보인다.
현재 테스트 중인 API들은 아주 중요한 특징이 있다. 모든 유저에 대해 동일한 응답을 뱉으며, 이는 하루 혹은 일주일동안 변화가 없는 데이터들이다. 이런 데이터에 대해 매 요청마다 쿼리를 실행하여 새롭게 데이터를 만드는 것은 너무 비효율적인 작업이다. 어차피 같은 데이터고 하루종일 유지된다면 이는 캐싱을 적용하는 것이 가장 효율적일 것이다!
여기서는 캐싱을 사용할 부분이 많아보이고, 추후에 분산 시스템까지 도입할 의향이 있으므로 굳이 로컬 캐시를 선택하기보다는 글로벌 캐시인 Redis를 도입해보겠다!
(최대 트래픽 상황은 굳이 테스트하지 않겠다. 개선 이후에 진행해보자)
투두 & 투두 인스턴스 생성 테스트
스크립트
import io.jsonwebtoken.Jwts
import io.jsonwebtoken.SignatureAlgorithm
import javax.crypto.SecretKey;
import javax.crypto.spec.SecretKeySpec;
import java.util.Date
import java.time.LocalDate;
import java.time.LocalDateTime;
import java.time.format.DateTimeFormatter;
import java.nio.charset.StandardCharsets
import java.util.Random
import java.util.List
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.JsonOutput
@RunWith(GrinderRunner)
class TestRunner {
public String token
public static GTest test
public static HTTPRequest request
public static String secret_key = "uSJkMCJ0wgiLMlySm6QNktw4ucwhMEFeu+O4GaXHhLxWptRN6dO"
public static long expiration = 86400000
@BeforeProcess
public static void beforeProcess() {
test = new GTest(1, "CREATE TODO & TOOD_INSTANCE API TEST")
request = new HTTPRequest()
}
@BeforeThread
public void beforeThread() {
test.record(this, "test")
grinder.statistics.delayReports = true
}
@Test
public void test() {
Random random = new Random()
long userId = random.nextInt(500000) + 1
String email = "tester" + (userId - 1) + "@test.com"
String username = "tester" + (userId - 1)
// 매 요청마다 토큰 생성
token = generateAccessToken(userId, email, username)
// Authorization 헤더 추가
Map<String, String> headers = [:]
headers.put("Authorization", "Bearer " + token)
headers.put("Content-Type", "application/json")
request.setHeaders(headers)
Map<String, Object> params = [:]
grinder.logger.info("User: " + userId)
CreateTodoReq req = createRandomTodoRequest(random);
String body = JsonOutput.toJson(req)
grinder.logger.info(body)
HTTPResponse response = request.POST("http://localhost:8080/api/todo", 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 if (response.statusCode == 201) {
grinder.logger.info("Request was successful with status code 201.")
assertThat(response.statusCode, is(201))
} else {
grinder.logger.error("[User - {}] Unexpected response code: {}. Response Body: {}", userId, response.statusCode, response.getBodyText())
assertThat("Unexpected status code " + response.statusCode, response.statusCode, is(201))
}
}
public static String generateAccessToken(Long id, String email, String username) {
SecretKey secretKey = new SecretKeySpec(secret_key.getBytes(StandardCharsets.UTF_8), SignatureAlgorithm.HS256.getJcaName())
Date now = new Date()
return Jwts.builder()
.setSubject(email)
.claim("id", id)
.claim("username", username)
.claim("provider", "GOOGLE")
.claim("role", "ROLE_USER")
.setIssuedAt(now)
.setExpiration(new Date(now.getTime() + expiration))
.signWith(secretKey)
.compact()
}
public static long calculateObjectId(long userId) {
long cycle = (userId - 1) / 500
long baseId = (cycle * 1000) + userId
return baseId
}
public class CreateTodoReq {
String content
String startAt
String endAt
Boolean isAllDay
String color
RepeatInfoReqItem repeatInfoReqItem
CreateTodoReq(String content, String startAt, String endAt, Boolean isAllDay, String color, RepeatInfoReqItem repeatInfoReqItem) {
this.content = content
this.startAt = startAt
this.endAt = endAt
this.isAllDay = isAllDay
this.color = color
this.repeatInfoReqItem = repeatInfoReqItem
}
}
public class RepeatInfoReqItem {
String frequency;
int interval;
Integer count;
public RepeatInfoReqItem(int interval, Integer count) {
this.frequency = "DAILY";
this.interval = interval;
this.count = count;
}
}
public CreateTodoReq createRandomTodoRequest(Random random) {
DateTimeFormatter formatter = DateTimeFormatter.ofPattern("yyyy-MM-dd'T'HH:mm:ss")
LocalDateTime startAtTime = LocalDate.now().atStartOfDay().plusHours(random.nextInt(22)) // 오늘 날짜의 랜덤 시각 (0-1440분)
String startAt = startAtTime.format(formatter)
String endAt = startAtTime.plusHours(1).format(formatter) // startAt에 1시간 추가
// LocalDateTime을 문자열로 변환
Boolean isAllDay = random.nextBoolean() // 랜덤 boolean 값
String color = "#FFFFFF"
String content = "test"
RepeatInfoReqItem repeatInfoReqItem = null;
Boolean isInstance = random.nextBoolean()
if(isInstance) {
repeatInfoReqItem = createRandomRepeatInfo(random);
}
return new CreateTodoReq(content, startAt, endAt, isAllDay, color, repeatInfoReqItem)
}
public RepeatInfoReqItem createRandomRepeatInfo(Random random) {
int interval = random.nextInt(5);
int count = random.nextInt(5);
return new RepeatInfoReqItem(interval, count);
}
}
결과 - 평소 트래픽(Vusers: 29, 10분)
코드를 잘 짠건지 모르겠지만, TPS와 MTT 모두 목표치 보다 매우 준수하게 잘 나오는 것을 확인할 수 있었다. 하지만, 하나 걸리는 점이 있는데 1분마다 MTT가 지연되는 급증하면서 TPS가 급감하는 부분이 발생하는 것을 확인할 수 있었는데 이 원인을 찾아볼 필요가 있어보인다.
이후에 찾아보니 확실한 것은 아니지만 Docker의 CPU throttling으로 인한 처리량 급감으로 보였다. docker는 CPU 사용량이 컨테이너에 할당된 리소스 한도를 초과할 때 도커가 해당 컨테이너의 CPU 접근을 제한하는 방식으로 이루어진다. 이때 컨테이너는 CPU에 대한 접근을 대기하게 되어 TPS가 급감할 수 있다.
결과 - 최대 트래픽(Vusers: 86, 10분)
(Error 1729개는 무시하자..! 테스트 스크립트에서 뭔가 꼬여서 시작하는 순간에 발생한 에러인 것 같다)
여전히 1분마다 TPS 급감(MTT 급증) 현상이 발생하고 있다.. 일단, 이를 무시하고 살펴보면 TPS는 거의 변화가 없고, MTT는 32.6ms → 97.87ms로 약 3배 증가한 모습이다. 이유를 찾기 위해 Grafana 그래프를 보니 DB Connections 탭에서 Pending 상태의 커넥션들이 발생하는 것을 확인할 수 있었다.
DB Connection 수를 2배로 늘려서 다시 테스트를 해본 결과는 다음과 같다.
음.. 성능에는 큰 차이가 없어보인다.
더 이상 Pending 되는 커넥션은 없어졌지만, 알고보니…
도커 컨테이너로 실행 중인 내 백엔드 서버 컨테이너의 CPU가 터지기 직전이었다.. 이러니 성능이 안 늘지 ㅠㅠ CPU 할당량을 더 늘리고, 충분히 warm up 한 이후 다시 테스트를 진행해보았다!
음.. 여전히 변화가 없다. 그리고 나는 멍청했다.
내가 간과하고 있던 개념이 있는데, 포화점(Saturation Point
)라는 개념이다.
사용자가 지속적으로 늘어나면 어느 순간부터 TPS가 더 이상 증가하지 않고 응답시간만 길어지는 상황이 발생한다. 이렇게 증가하지 않게 되는 지점을 Saturation Point라고 한다. 튜닝이 되지 않은 서비스는 오히려 Saturation Point 이후 TPS가 떨어지기도 하는데, 다행히도 우리 서비스는 TPS가 고정되는 모습을 보인다. Saturation Point를 찾기 위해서는 VUser를 점차 늘려가면서 TPS가 더 이상 늘지 않는 지점에서의 VUser 값을 찾아야 한다.
신기한 건, CPU 자원을 더 할당해주니 1분마다 TPS가 급감하고 MTT가 급증하는 현상이 해결(?)된 것처럼 보였다. 자세한건 찾아보아야 겠다…
Ramp-Up(VUser 1 → 60)
VUsers=29일 때와 VUsers=86일때 모두 TPS가 800대로 일정했다면, 실제 Saturation Point는 VUser=29보다 더 이전일 것이다. 이를 위해 2분 간격으로 VUser = 1부터 2씩 Ramp-Up한 결과 그래프는 위와 같이 나왔다.
정확한 값을 특정할 수는 없지만, VUser=13 정도부터 TPS가 800대를 유지하는 것을 확인할 수 있었다. Todo 및 TodoInstance를 생성하는 작업 자체가 가볍지 않은 작업이라 최대 허용 동시 사용자가 많지 않았다.. 이를 해결하기 위해서는 다음과 같은 방법들이 있을 것이다.
- 스케일 아웃(로드밸런싱)
- 스케일 업
- 쿼리 최적화
- 캐싱
- 메세지 큐
- 애플리케이션 성능 최적화
- 코드 최적화
- 멀티 프로세싱
- 비동기 처리
투두 & 투두 인스턴스 시나리오 테스트
예상 시나리오
유저 수: 50만 명
- 일간 투두 조회
- 투두 & 투두 인스턴스를 랜덤하게 생성
- 일간 투두 다시 조회 → 아직 완료되지 않은 object 하나 선택
- 선택된 투두 & 투두 인스턴스에 대한 완료 처리
- 선택된 투두 & 투두 인스턴스에 대한 수정 작업
스크립트
import HTTPClient.NVPair
import groovy.json.JsonOutput
import groovy.json.JsonSlurper
import io.jsonwebtoken.Jwts
import io.jsonwebtoken.SignatureAlgorithm
import net.grinder.plugin.http.HTTPPluginControl
import net.grinder.script.GTest
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.Test
import org.junit.runner.RunWith
import org.ngrinder.http.HTTPRequest
import org.ngrinder.http.HTTPResponse
import javax.crypto.SecretKey
import javax.crypto.spec.SecretKeySpec
import java.nio.charset.StandardCharsets
import java.time.LocalDate
import java.time.LocalDateTime
import java.time.format.DateTimeFormatter
import static net.grinder.script.Grinder.grinder
import static org.hamcrest.Matchers.is
import static org.junit.Assert.assertThat
@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 Map<Integer, Object> selectedTodoMap = [:]
public static Map<Integer, String> tokenMap = [:]
public static List<HTTPRequest> requests = []
public static String secret_key = "uSJkMCJ0wgiLMlySm6QNktw4ucwhMEFeu+O4GaXHhLxWptRN6dO"
public static long expiration = 86400000
public HTTPRequest request
@BeforeProcess
public static void beforeProcess() {
HTTPPluginControl.getConnectionDefaults().timeout = 6000
for (int i = 0; i < 100; i++) {
requests.add(new HTTPRequest())
}
test1 = new GTest(1, "GET /api/todo/day")
test2 = new GTest(2, "POST /api/todo")
test3 = new GTest(3, "GET /api/todo/day")
test4 = new GTest(4, "PATCH /api/todo/{todoId}/status")
test5 = new GTest(5, "PATCH /api/todo/{todoId}")
for (int i = 0; i < 100; i++) {
String email = "tester" + i + "@test.com"
String username = "tester" + i
String token = generateAccessToken(i + 1, email, username)
tokenMap.put(i, token)
}
grinder.logger.info("before process.")
}
@BeforeThread
public void beforeThread() {
test1.record(this, "test1")
test2.record(this, "test2")
test3.record(this, "test3")
test4.record(this, "test4")
test5.record(this, "test5")
grinder.statistics.delayReports = true
grinder.logger.info("before thread.")
}
@Before
public void before() {
request = requests[grinder.threadNumber]
String token = tokenMap.get(grinder.threadNumber)
NVPair[] headers = [new NVPair("Authorization", "Bearer " + token), new NVPair("Content-Type", "application/json")]
request.setHeaders(headers)
}
@Test
public void test1() {
grinder.logger.info("[ThreadNum - {}] Auth Headers: {}", grinder.threadNumber, request.headers.get(0))
HTTPResponse response = request.GET("http://127.0.0.1:8080/api/todo/day")
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() {
CreateTodoReq req = createRandomTodoRequest();
String body = JsonOutput.toJson(req)
grinder.logger.info("[ThreadNum - {}] Auth Headers: {}", grinder.threadNumber, request.headers.get(0))
HTTPResponse response = request.POST("http://127.0.0.1:8080/api/todo", 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 if (response.statusCode == 201) {
assertThat(response.statusCode, is(201))
} else {
grinder.logger.error("[User - {}] Unexpected response code: {}. Response Body: {}", grinder.threadNumber, response.statusCode, response.getBodyText())
assertThat(response.statusCode, is(201))
}
}
@Test
public void test3() {
grinder.logger.info("[ThreadNum - {}] Auth Headers: {}", grinder.threadNumber, request.headers.get(0))
HTTPResponse response = request.GET("http://127.0.0.1:8080/api/todo/day")
def result = response.getBody({ new JsonSlurper().parseText(it) })
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))
List incompleteTodos = result.data.findAll { it.done == false }
if (!incompleteTodos.isEmpty()) {
// 랜덤 인덱스 생성
Random random2 = new Random()
int randomIndex = random2.nextInt(incompleteTodos.size())
// `done`이 false인 TodoItem 중 하나 선택
selectedTodoMap.put(grinder.threadNumber, incompleteTodos[randomIndex])
// 필요한 추가 작업을 여기서 수행할 수 있음
} else {
grinder.logger.warn("No incomplete TodoItems found.")
}
}
}
@Test
public void test4() {
def selectedTodo = selectedTodoMap.get(grinder.threadNumber)
boolean isInstance = (selectedTodo.parentId != null);
String url = "http://127.0.0.1:8080/api/todo/" + selectedTodo.todoId + "/status?isInstance=" + isInstance;
Map<String, Object> params = [:]
params.put("isDone", true)
grinder.logger.info("[ThreadNum - {}] Auth Headers: {}", grinder.threadNumber, request.headers.get(0))
HTTPResponse response = request.PATCH(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 if (response.statusCode == 204) {
assertThat(response.statusCode, is(204))
} else {
grinder.logger.error("[User - {}] Unexpected response code: {}. Response Body: {}", grinder.threadNumber, response.statusCode, response.getBodyText())
assertThat("Unexpected status code " + response.statusCode, response.statusCode, is(204))
}
}
@Test
public void test5() {
def selectedTodo = selectedTodoMap.get(grinder.threadNumber)
boolean isInstance = (selectedTodo.parentId != null);
String url = "http://127.0.0.1:8080/api/todo/" + selectedTodo.todoId + "?isInstance=" + isInstance + (isInstance ? "&targetType=THIS_TASK" : "");
UpdateTodoReq req = createUpdateTodoRequest();
String body = JsonOutput.toJson(req)
grinder.logger.info("[ThreadNum - {}] Auth Headers: {}", grinder.threadNumber, request.headers.get(0))
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 if (response.statusCode == 204) {
assertThat(response.statusCode, is(204))
} else {
grinder.logger.error("[User - {}] Unexpected response code: {}. Response Body: {}", grinder.threadNumber, response.statusCode, response.getBodyText())
assertThat("Unexpected status code " + response.statusCode, response.statusCode, is(204))
}
}
public class CreateTodoReq {
String content
String startAt
String endAt
Boolean isAllDay
String color
RepeatInfoReqItem repeatInfoReqItem
CreateTodoReq(String content, String startAt, String endAt, Boolean isAllDay, String color, RepeatInfoReqItem repeatInfoReqItem) {
this.content = content
this.startAt = startAt
this.endAt = endAt
this.isAllDay = isAllDay
this.color = color
this.repeatInfoReqItem = repeatInfoReqItem
}
}
public class RepeatInfoReqItem {
String frequency;
int interval;
Integer count;
public RepeatInfoReqItem(int interval, Integer count) {
this.frequency = "DAILY";
this.interval = interval
this.count = count;
}
}
public class UpdateTodoReq {
String content
String startAt
String endAt
Boolean isAllDay
String color
RepeatInfoReqItem repeatInfoReqItem
UpdateTodoReq(String content, String startAt, String endAt, Boolean isAllDay, String color, RepeatInfoReqItem repeatInfoReqItem) {
this.content = content
this.startAt = startAt
this.endAt = endAt
this.isAllDay = isAllDay
this.color = color
this.repeatInfoReqItem = repeatInfoReqItem
}
}
CreateTodoReq createRandomTodoRequest() {
Random random = new Random()
DateTimeFormatter formatter = DateTimeFormatter.ofPattern("yyyy-MM-dd'T'HH:mm:ss")
LocalDateTime startAtTime = LocalDate.now().atStartOfDay().plusHours(random.nextInt(12))
// 오늘 날짜의 랜덤 시각 (0-1440분)
String startAt = startAtTime.format(formatter)
String endAt = startAtTime.plusHours(1).format(formatter) // startAt에 1시간 추가
// LocalDateTime을 문자열로 변환
Boolean isAllDay = random.nextBoolean() // 랜덤 boolean 값
String color = "#000000"
String content = "test"
RepeatInfoReqItem repeatInfoReqItem = null;
Boolean isInstance = random.nextBoolean()
if (isInstance) {
repeatInfoReqItem = createRandomRepeatInfo();
}
return new CreateTodoReq(content, startAt, endAt, isAllDay, color, repeatInfoReqItem)
}
RepeatInfoReqItem createRandomRepeatInfo() {
Random random = new Random()
int interval = random.nextInt(5);
int count = random.nextInt(5) + 2;
return new RepeatInfoReqItem(interval, count);
}
UpdateTodoReq createUpdateTodoRequest() {
Random random = new Random()
DateTimeFormatter formatter = DateTimeFormatter.ofPattern("yyyy-MM-dd'T'HH:mm:ss")
LocalDateTime startAtTime = LocalDate.now().atStartOfDay().plusHours(random.nextInt(12))
// 오늘 날짜의 랜덤 시각 (0-1440분)
String startAt = startAtTime.format(formatter)
String endAt = startAtTime.plusHours(1).format(formatter) // startAt에 1시간 추가
// LocalDateTime을 문자열로 변환
Boolean isAllDay = random.nextBoolean() // 랜덤 boolean 값
String color = "#FFFFFF"
String content = "updated"
return new UpdateTodoReq(content, startAt, endAt, isAllDay, color, null)
}
static String generateAccessToken(Long id, String email, String username) {
SecretKey secretKey = new SecretKeySpec(secret_key.getBytes(StandardCharsets.UTF_8), SignatureAlgorithm.HS256.getJcaName())
Date now = new Date()
return Jwts.builder()
.setSubject(email)
.claim("id", id)
.claim("username", username)
.claim("provider", "GOOGLE")
.claim("role", "ROLE_USER")
.setIssuedAt(now)
.setExpiration(new Date(now.getTime() + expiration))
.signWith(secretKey)
.compact()
}
}
- 많은 스레드가 병렬로 테스트를 실행하는 상황에서 static으로 선언된 request끼리 충돌하는 현상이 생겨서 미리 request를 생성해두고 각 threadNumber를 기준으로 request를 사용하도록 하게 했다.
- tokenMap과 selectedTodoMap 역시 스레드끼리 충돌이 일어나는 것을 막기위해 threadNumber와 해당 값을 매칭시켜 자신의 값만 꺼내쓸 수 있도록 하였다.
결과 - 평소 트래픽(Vusers: 29, 10분)
시간이 지날수록 점점 TPS가 급격히 감소하며 MTT는 증가하는 모습이었다. RAMP-UP을 시키는게 아닌데도 이러한 양상을 보이는 이유는 데이터가 점차 쌓이면서 이를 조회하는 데에 시간이 오래 걸리게 되기 때문이다.
최대 트래픽에 대한 테스트는 역시 생략하겠다
문제점
- 데이터가 이미 많이 쌓여 있다보니 투두 조회 요청에서부터 요청 시간이 길어지며 전체적인 TPS가 크게 떨어지는 것을 확인할 수 있었다.
- 처음에는 GET 요청 하나가 100ms 안팎으로 응답했으나, 후반에는 평균적으로 500ms 정도까지 증가했다
- CPU 사용량이 매우 커져 리소스가 부족해 처리량이 감소하였다.
- mysql의 경우 CPU 사용량이 보통 550%를 넘어가고 최대 800%까지 찍는 모습을 확인할 수 있었고
- 백엔드 애플리케이션의 경우 CPU 사용량이 200 ~ 400 사이를 왔다갔다 했다.
- DB 쪽의 슬로우쿼리를 확인해보니 test1과 test3에서 발생하는 조회 쿼리가 매우 많이 쌓여있었다.. ㅎㅎ 그리고 해당 쿼리를 EXPLAIN 키워드와 함께 실행시켜보니 어떠한 인덱스 없이 오직 PRIMARY 인덱스만 타고 있는 것을 확인할 수 있었다.
EXPLAIN select ti1_0.id,ti1_0.color,ti1_0.content,ti1_0.created_at,ti1_0.end_at,ti1_0.is_all_day,ti1_0.is_done,ti1_0.start_at,ti1_0.todo_id,t1_0.id,t1_0.color,t1_0.content,t1_0.created_at,t1_0.end_at,t1_0.is_all_day,t1_0.is_done,ri1_0.id,ri1_0.by_day,ri1_0.by_month_day,ri1_0.count,ri1_0.created_at,ri1_0.frequency,ri1_0.repeat_interval,ri1_0.until,ri1_0.updated_at,t1_0.start_at,t1_0.updated_at,t1_0.writer_id,ti1_0.updated_at from todo_instance ti1_0 join todo t1_0 on t1_0.id=ti1_0.todo_id join repeat_info ri1_0 on ri1_0.id=t1_0.repeat_info_id where t1_0.writer_id=9 and ti1_0.start_at between '2024-11-01 00:00:00' and '2024-11-01 23:59:59.001';
=> 투두 조회 쿼리에 대한 쿼리 튜닝이 필요해보인다