카카오톡 클론코딩(10) - nGrinder(2)
지난 글에이어서 nGrinder의 내용을 추가적으로 적어볼라한다.
이번 글에서는 프로젝트에서 주로 사용되는 시나리오를 테스트 해볼것이다.
로그인 -> 친구목록 조회 -> 채팅방 목록 조회 -> 채팅방의 채팅 메시지 조회 순으로 진행 될것이다.
물론 채팅방 목록을 조회하고 채팅을 치는 과정까지 테스트하면 더 좋겠지만 nGrinder는 웹소켓 테스트는 지원하지 않기 때문에 해당 부분은 생략하고 진행할것이다.
import groovy.json.JsonOutput
import groovy.json.JsonSlurper
import static net.grinder.script.Grinder.grinder
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.Test
import org.junit.runner.RunWith
import org.ngrinder.http.HTTPRequest
import org.ngrinder.http.HTTPRequestControl
import org.ngrinder.http.HTTPResponse
import java.nio.charset.StandardCharsets
@RunWith(GrinderRunner)
class ChatScenarioTest {
public static GTest test
public static HTTPRequest request
public static Map<String, String> headers = [:]
public static String accessToken = ""
public static JsonSlurper jsonSlurper = new JsonSlurper()
@BeforeProcess
static void beforeProcess() {
HTTPRequestControl.setConnectionTimeout(3000)
test = new GTest(1, "로그인 -> 친구 목록 -> 채팅방 목록 -> 메시지 조회")
request = new HTTPRequest()
headers.put("Content-Type", "application/json")
request.setHeaders(headers)
}
@BeforeThread
void beforeThread() {
test.record(this, "test")
grinder.statistics.delayReports = true
}
@Test
void test() {
// 1. 로그인
def loginBody = [
accountId : "kokoaman",
password : "kokoaman",
rememberMe: "false"
]
HTTPResponse loginResponse = request.POST(
"배포주소/api/auth/signin",
JsonOutput.toJson(loginBody).getBytes(StandardCharsets.UTF_8),
[]
)
if (loginResponse.statusCode != 200) {
grinder.logger.error("로그인 실패: ${loginResponse.statusCode}")
return
}
def loginJson = jsonSlurper.parseText(loginResponse.getBodyText())
accessToken = loginJson.accessToken
headers.put("Authorization", "Bearer " + accessToken)
request.setHeaders(headers)
grinder.logger.info("로그인 성공, accessToken: ${accessToken}")
// 2. 친구 목록 조회
HTTPResponse friendResponse = request.GET("배포주소/api/friend/friendList")
grinder.logger.info("친구 목록 조회 상태: ${friendResponse.statusCode}")
// 3. 채팅방 목록 조회
HTTPResponse roomListResponse = request.GET("배포주소/api/chatRoom/list")
grinder.logger.info("채팅방 목록 조회 상태: ${roomListResponse.statusCode}")
// 4. 채팅 메시지 조회 (roomId = 4)
def chatBody = [lastCreatedAt: null]
HTTPResponse chatResponse = request.POST(
"배포주소/api/chatRoom/4/messages",
JsonOutput.toJson(chatBody).getBytes(StandardCharsets.UTF_8),
[]
)
grinder.logger.info("채팅 메시지 조회 상태: ${chatResponse.statusCode}")
}
}
위에서 언급한 시나리오의 스크립트이다. 채팅 메시지는 채팅방 4번의 메시지를 확인해볼것이기 때문에 roomId를 4번으로 넘겨줬다.
지난번 글과 마찬가지로 어노테이션이 맡는 블록마다 역할은 동일하다.
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 1 0 4555.00 0.00 0.22 7755.00 1699.54 0 0.00 0.00 1435.00 "로그인 -> 친구 목록 -> 채팅방 목록 -> 메시지 조회"
Totals 1 0 4555.00 0.00 0.22 7755.00 1699.54 0 0.00 0.00 1435.00
스크립트에 대한 검증을 해보면 에러 없이 잘 진행된게 확인된다.
이제 검증은 끝났으니 지난번과 마찬가지로 성능부하테스트를 진행 해보자
우선 내가 쓰는 헤로쿠 에코 플랜은 cpu가 최대 4개까지 지원이지만 부하가 적을 때만 4개 까지 배정받기 때문에 프로세스는 2개 ~ 3개 까지만 사용할것이다.
우선 무료서버인걸 감안해서 6명이 사용할때 기준으로 테스트를 해봤다. api 4개를 테스트하는거니 하나당 약 700ms 정도 걸린다고 볼 수 있다. 평균 값이기에 실제 api 응답시간은 다를것이다. 물론 추후에 프로메테우스를 이용해서 어디서 시간이 더 소요되는지 확인해봐야 한다. 이정도는 충분히 서버가 감당할 수 있으니 좀더 수를 늘려보자
10명으로 늘렸는데 어째서인지 소요시간이 약 2400ms 로 줄어 들었다.
이에 대한 원인으로는 너무 소수의 유저로는 병렬성 테스트가 제대로 안되거나 서버의 워밍업 시간이 존재 할 수도 있기 때문이다. 이번에도 역시 10명은 충분히 감당하니 다시 수를 늘려보자
이번에도 속도가 더 빨라졌다 이번엔 서버를 워밍업 시켜놨으니 위에 말한 원인중 워밍업을 배제 해볼 수 있을것이다. 그러면 너무 소수의 유저로는 병렬성 테스트가 제대로 안됐다는 건데 이에 대해 좀더 알아보자
너무 적은 부하로는 서버의 리소스가 최대로 활용되지 못했고 요청 처리 효율이 떨어졌을 것이다. 하지만 인원수를 늘리면서 커넥션 풀, 스레드 큐, 캐시 등이 더 적극적으로 사용되기 때문에 더 빠르게 테스트가 된것이다.
커넥션 풀은 당연히 인원이 늘었으니 늘어나는 것이 당연하지만 스레드 큐, 캐시는 어디서? 라는 의문이 들 수 있다.
요청이 들어오면 내부적으로 ThreadPoolExecutor 기반의 요청 처리 큐에 들어가는데 유저가 너무 적으면 큐에 대기 없이 바로 처리된다 한다. 즉 병렬성 테스트가 그냥 개별적으로 처리 되는 것 처럼 진행되는 것이다. 쉽게 말하면 하나의 일을끝내고 다른 하나의 일을 하는것 처럼(직렬 처럼) 진행되는 것이다. 하지만 유저가 많아지고 큐가 생기면, 동시에 여러 요청을 Thread Pool이 병렬로 처리 하기 때문에 효율이 증가한다고 한다.
그러면 이제 캐시부분이다. 갑자기 웬 캐싱? 이라 할 수 있지만 위의 스크립트 흐름을 따르면 채팅 내역이 남아있는 방이 4번 방이었기 때문에 해당 방의 메시지 로그를 조회한다. 완전히 동일한 api, 완전히 동일한 데이터를 조회하는 것이기 때문에 서버가 이걸 기억하고(2차 캐시) 더 빠르게 처리한다는 것이다. 다음에 테스트를 진행하면 더 좋은 성능테스트를 위해서는 동일한 데이터를 조회하는것은 피하는것이 날것 같다.
일단 이렇게 수를 늘려봤지만 언제까지 조금씩 늘려가면서 서버가 어디까지 버틸 수 있는지 일일히 확인하는것은 효율적이지 못하다. 그래서 이전 글에서 말한 Ramp - up 기능을 사용해보자
Ramp - up 기능은 기존의 특정 사용자 수를 고정한 채 부하 테스트를 수행하는 방법과 달리 점진적으로 증가하는 경우를 테스트 하는 것이다.
설정은 최대 60명 까지 증가할 것이고 시작 프로세스는 1개 (20명) 프로세스는 1개씩 증가할 것이며 5초의 간격을 주고 증가하는것이다.
분명 20명은 버텼었는데 시작도 못하고 서버가 바로 터져버렸다. 서버 로그가 너무너무 긴 관계로 전부는 올리지는 않지만 로그를 읽어보면
SQL Error: 1226, SQLState: 42000
User 'f5k1f74p0agvbpc9' has exceeded the 'max_questions' resource (current value: 3600)
헤로쿠에서 사용하는 jawsDb의 무료 플랜은 시간당 3600개의 요청을 허용하는데 이전에 테스트하면서 초과가 되어버려서 그런듯 하다.
일단 시간이 지나고 다시 돌려보겠지만 그전에 조금더 테스트 설정을 세세하게 변경해보자
점진적 증가의 기준을 Process 에서 Thread 로 변경했다. 최초에 5명의 유저에서 시작해서 5초마다 5명씩 증가할것이다.
일단은 서버가 60명까지 점차 증가할때 버티는것을 확인할 수 있다. 여기서 초반보다 약 25초부터 급격하게 tps가 올라간걸 확인 할 수있는데 유저수가 늘어난거 + 유저수가 늘어남에 따라 병렬처리가 더 좋아져서 tps가 늘어난거라고 해석할 수 있다. 하지만 아직 max_questions 를 초과해서 생긴 에러 말고는 부하를 보지 못했다.
현재 시나리오는 조회만 하는것이 서버에 큰 부하를 줄 수 없다고 생각해 시나리오를 수정해보기로 했다
기존 시나리오는 로그인 -> 친구목록 조회 -> 채팅방 목록조회 -> 메시지 조회 순으로 진행되는데
수정된 시나리오는 로그인 -> 친구목록 조회 -> 채팅방 목록조회 -> 메시지 저장(웹소켓 테스트가 불가능하기 때문에 임의로 메시지를 생성해서 저장) -> 메시지 조회
수정된 시나리오는 다음과 같다.
import groovy.json.JsonOutput
import groovy.json.JsonSlurper
import static net.grinder.script.Grinder.grinder
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.Test
import org.junit.runner.RunWith
import org.ngrinder.http.HTTPRequest
import org.ngrinder.http.HTTPRequestControl
import org.ngrinder.http.HTTPResponse
import java.nio.charset.StandardCharsets
@RunWith(GrinderRunner)
class ChatScenarioTest {
public static GTest test
public static HTTPRequest request
public static Map<String, String> headers = [:]
public static String accessToken = ""
public static JsonSlurper jsonSlurper = new JsonSlurper()
@BeforeProcess
static void beforeProcess() {
HTTPRequestControl.setConnectionTimeout(3000)
test = new GTest(1, "로그인 -> 친구 목록 -> 채팅방 목록 -> 메시지 조회")
request = new HTTPRequest()
headers.put("Content-Type", "application/json")
request.setHeaders(headers)
}
@BeforeThread
void beforeThread() {
test.record(this, "test")
grinder.statistics.delayReports = true
}
@Test
void test() {
// 1. 로그인
def loginBody = [
accountId : "kokoaman",
password : "kokoaman",
rememberMe: "false"
]
HTTPResponse loginResponse = request.POST(
"배포주소/api/auth/signin",
JsonOutput.toJson(loginBody).getBytes(StandardCharsets.UTF_8),
[]
)
if (loginResponse.statusCode != 200) {
grinder.logger.error("로그인 실패: ${loginResponse.statusCode}")
return
}
def loginJson = jsonSlurper.parseText(loginResponse.getBodyText())
accessToken = loginJson.accessToken
headers.put("Authorization", "Bearer " + accessToken)
request.setHeaders(headers)
grinder.logger.info("로그인 성공, accessToken: ${accessToken}")
// 2. 친구 목록 조회
HTTPResponse friendResponse = request.GET("배포주소/api/friend/friendList")
grinder.logger.info("친구 목록 조회 상태: ${friendResponse.statusCode}")
// 3. 채팅방 목록 조회
HTTPResponse roomListResponse = request.GET("배포주소/api/chatRoom/list")
grinder.logger.info("채팅방 목록 조회 상태: ${roomListResponse.statusCode}")
// 4. 채팅 메시지 저장 (Redis)
HTTPResponse saveRes = request.POST("배포주소/api/chat/nGrinderMessageTest", "".bytes)
grinder.logger.info("임시 메시지 저장 응답: ${saveRes.statusCode}")
// 5. 채팅 메시지 조회 (roomId = 4)
def chatBody = [lastCreatedAt: null]
HTTPResponse chatResponse = request.POST(
"배포주소/api/chatRoom/4/messages",
JsonOutput.toJson(chatBody).getBytes(StandardCharsets.UTF_8),
[]
)
grinder.logger.info("채팅 메시지 조회 상태: ${chatResponse.statusCode}")
}
}
유저수를 300명 이후 400명으로 테스트 해본 결과 성능이 확연히 떨어지는 병목지점들이 발견되는것을 볼 수 있다.
기존의 시나리오에선 600명까지도 테스트해봤는데 버틴걸 보면 메시지를 임의로 생성해 레디스에 저장하는 부분이 병목지점이라고 의심해볼 수 있다. 이중에서도 레디스에 rightPush()를 통해 쓰기 작업때문에 병목이 생성되었거나, 서버의 메모리/cpu 성능, 직렬화/역직렬화의 비용들을 의심할 수 있는 부분들이 있다. 하지만 서버의 메모리/cpu 등은 프로메테우스로 모니터링 해보는게 더 좋을 것이다.
현재 Ramp-up 설정은 300, 400명일때 모두 같게 설정했다. 설정을 설명해보자면 5초마다 5명의 유저가 추가되고, 시작 유저 수는 1명이다. 따라서 3분이 지난 시점에선 대략 180명 정도가 된다. 이 시점에서 TPS가 급격히 하락하는것을 보면 실제 병목은 180명 정도에서 발생하는것으로 생각해 볼 수 있다.
현재까지 나온 지표들로는 대략 180명정도 까지는 버틸 수 있는 서버로 보인다. tps가 어느정도 떨어져도 심각할 정도로 떨어지지는 않았기 때문이다. 물론 시나리오 테스트에서는 웹소켓 테스트가 불가능하기 때문에 180명 보다 더 줄어들 여지도 충분히 있다. 즉 병목 시점이 앞당겨 진다는 것이다. 현재 시나리오 + 코드구조 + 인프라 로는 300명 정도를 버틸 수 있지만 헤로쿠의 무료 에드온인 jawsDB는 어차피 시간당 요청이 3600개고 maximum-pool-size가 10 이기 때문에 DB를 바꾸지 않는한 180명보다 훨씬 낮은 수치에서 병목이 생길 수 도 있다. 서버의 성능은 어느정도 확인했으니 이제 병목구간의 병목원인이 무엇인지 확인하기 위해서 프로메테우스로 모니터링을 해봐야 할것이다. 그 후 모니터링을 통해 병목구간을 어떻게 개선할지 생각해 볼 수 있을것이다.