5차 데모데이의 요구사항으로 톰캣 설정 중 다음과 같은 값을 적절하게 설정하고 해당 값으로 설정한 이유를 공유하는 공통 요구사항이 있었다.
그리고 현재 WAS 가 올라가 있는 EC2 인스턴스의 사항은 t4g.micro
이다.
우리팀(모아모아)에서는 K6 와 pinpoint, 그리고 JMeter 를 활용해보았다.
적정 Thread 수를 설정하기 위해서는 쓰레드 수가 몇 개일 때, 최적의 성능을 낼 수 있는지를 확인하여야 한다고 생각한다.
따라서 우리팀에서는 부하테스트를 통해서 여러명의 사용자가 동시에 우리 WAS 로 요청을 보낼 때, 쓰레드 수를 조정해가며 적정 쓰레드 갯수를 알아내려고 하였다.
우선 스프링 부트에서 제공하는 쓰레드 설정 3가지에 대해 정리해보자.
The maximum number of request processing threads to be created by this Connector, which therefore determines the maximum number of simultaneous requests that can be handled. If not specified, this attribute is set to 200. If an executor is associated with this connector, this attribute is ignored as the connector will execute tasks using the executor rather than an internal thread pool. Note that if an executor is configured any value set for this attribute will be recorded correctly but it will be reported (e.g. via JMX) as -1 to make clear that it is not used.
Connector에서 만들 최대 요청 처리 쓰레드 수를 의미한다. 그리고 이는 처리할 수 있는 최대 동시 요청 수
를 결정하게 된다. 특별하게 지정하지 않으면 default로 200으로 설정된다.
실행자가 이 Connector와 연결된 경우 connector 가 내부 쓰레드 풀이 아닌 실행자를 사용하여 작업을 실행하므로 이 속성은 무시된다. (이 부분은 잘 이해가 가지 않는다.)
The maximum number of connections that the server will accept and process at any given time. When this number has been reached, the server will accept, but not process, one further connection. This additional connection be blocked until the number of connections being processed falls below maxConnections at which point the server will start accepting and processing new connections again. Note that once the limit has been reached, the operating system may still accept connections based on the acceptCount setting. The default value is 8192.
서버가 지정된 시간에 수락하고 처리할 최대 연결의 수를 의미한다.
해당 수에 도달하게 되면 서버는 추가적인 연결을 허용하기는 하지만 처리하지 않는다. 처리 중인 연결 수가 max connections 아래로 떨어질 때까지 이 추가 연결은 차단되며, 이 시점에서 서버가 새 연결을 다시 허용하고 처리하기 시작한다. 제한에 도달한 이후에도 OS 는 accept count 설정에 따라 연결을 허용할 수 있으며 기본값은 8192이다.
The maximum length of the operating system provided queue for incoming connection requests when maxConnections has been reached. The operating system may ignore this setting and use a different size for the queue. When this queue is full, the operating system may actively refuse additional connections or those connections may time out. The default value is 100.
max connections 에 도달했을 때 들어오는 연결 요청에 대해 OS가 제공하는 최대 길이이다.
OS 는 이 설정을 무시하고 큐에 다른 크기를 사용할 수도 있으며 해당 큐가 가득 차면 OS가 추가 연결을 적극적으로 거부하거나 연결 시간이 초과될 수 있다. 기본값은 100이다.
다음 문서를 참고하였습니다.
참고: tomcat9.0 Docs
앞서 스프링 부트의 내장 톰캣에 대해 설정 할 수 있는 쓰레드 관련 항목들에 대해서 알아보았으므로 다음으로는 부하 테스트의 종류에 어떤 것들이 있는지 간단히 정리해보자.
우선 부하테스트를 통해서 무엇을 검증하는 지를 먼저 정리하자.
애플리케이션의 경우에는 보통 TPS(Transaction Per Second)
와 응답 시간(Response Time)
을 확인한다고 한다. 즉, 우리가 원하는 응답시간이 100ms 라고 했을 때 초당 몇개의 트랜잭션을 처리할 수 있는지와 같은 내용을 테스트한다는 것이다.
이를 위한 테스트 종류로는 성능 테스트, 부하 테스트, 스트레스 테스트 등이 존재한다.
성능 테스트는 뒤이어 나올 부하 테스트나 스트레스 테스트 등을 모두 포괄하는 매우 광범위한 테스트를 의미한다. 즉, 스모크 테스트나 부하 테스트나 스트레스 테스트, 내구성 테스트 등을 모두 아우르는 테스트를 말한다.
우리가 진행한 성능 테스트는 부하 테스트와 스트레스 테스트이므로 이 둘에 대해서만 알아보자.
부하 테스트와 스트레스 테스트에 대한 내용은 부하테스트를 진행한 툴인 K6 의 문서를 참고하였다.
참고 : K6 문서
부하 테스트는 성능 테스트 중 하나로 동시 사용자 또는 초당 요청 측면에서 시스템의 성능을 테스트 하는 것이다.
부하 테스트는 정상 및 피크 조건 모두에서 시스템의 동작을 확인하는데 사용되는 테스트로 여러 사용자가 동시에 액세스할 때 애플리케이션이 만족스럽게 수행되는지를 확인하기 위해 사용된다.
예를 들어 평소에 60명의 사용자가 접근하고 피크 시간에는 100명의 사용자가 접근하는 서비스를 운영하고 있다고 가정해보자. 그리고 우리는 평균적인 상황 뿐 아니라 피크 시간 모두에서 성능 목표를 달성하는 것이 목표이므로 부하 테스트를 다음과 같이 준비할 수 있다. (100명인 경우, 즉 피크일 때도 시스템이 만족스러운 결과를 도출하도록)
import http from 'k6/http';
import { check, group, sleep } from 'k6';
export const options = {
stages: [
{ duration: '5m', target: 100 }, // simulate ramp-up of traffic from 1 to 100 users over 5 minutes.
{ duration: '10m', target: 100 }, // stay at 100 users for 10 minutes
{ duration: '5m', target: 0 }, // ramp-down to 0 users
],
thresholds: {
'http_req_duration': ['p(99)<1500'], // 99% of requests must complete below 1.5s
'logged in successfully': ['p(99)<1500'], // 99% of requests must complete below 1.5s
},
};
const BASE_URL = 'https://test-api.k6.io';
const USERNAME = 'TestUser';
const PASSWORD = 'SuperCroc2020';
export default () => {
const loginRes = http.post(`${BASE_URL}/auth/token/login/`, {
username: USERNAME,
password: PASSWORD,
});
check(loginRes, {
'logged in successfully': (resp) => resp.json('access') !== '',
});
const authHeaders = {
headers: {
Authorization: `Bearer ${loginRes.json('access')}`,
},
};
const myObjects = http.get(`${BASE_URL}/my/crocodiles/`, authHeaders).json();
check(myObjects, { 'retrieved crocodiles': (obj) => obj.length > 0 });
sleep(1);
};
테스트를 진행하는 그래프의 모습은 다음과 같을 것이다.
이렇게 특정 vUser 까지 서서히 증가시킨 이후 일정 시간 동안 vUser를 고정하여 테스트하고 서서히 내려오는 그래프 모습을 가지게 된다.
스트레스 테스트와 스파이크 테스트는 극한의 조건에서 시스템의 한계와 안정성을 테스트하는 것이다.
앞서 살펴 본 부하 테스트는 주로 시스템 성능 평가와 관련되지만 스트레스 테스트의 목적은 무거운 부하에서 시스템의 가용성과 안정성을 평가하는 것이 목적이다. (극한의 조건에서 시스템의 안정성과 신뢰성을 확인)
K6로 스트레스 테스트를 한다면 예시는 다음과 같다.
import http from 'k6/http';
import { sleep } from 'k6';
export const options = {
stages: [
{ duration: '2m', target: 100 }, // below normal load
{ duration: '5m', target: 100 },
{ duration: '2m', target: 200 }, // normal load
{ duration: '5m', target: 200 },
{ duration: '2m', target: 300 }, // around the breaking point
{ duration: '5m', target: 300 },
{ duration: '2m', target: 400 }, // beyond the breaking point
{ duration: '5m', target: 400 },
{ duration: '10m', target: 0 }, // scale down. Recovery stage.
],
};
export default function () {
const BASE_URL = 'https://test-api.k6.io'; // make sure this is not production
const responses = http.batch([
['GET', `${BASE_URL}/public/crocodiles/1/`, null, { tags: { name: 'PublicCrocs' } }],
['GET', `${BASE_URL}/public/crocodiles/2/`, null, { tags: { name: 'PublicCrocs' } }],
['GET', `${BASE_URL}/public/crocodiles/3/`, null, { tags: { name: 'PublicCrocs' } }],
['GET', `${BASE_URL}/public/crocodiles/4/`, null, { tags: { name: 'PublicCrocs' } }],
]);
sleep(1);
}
이를 그래프로 나타내어 보면 다음과 같다.
이전에 부하테스트는 어떠한 임계치를 정해두고 해당 임계치에서 유지를 시켜 만족스러운 결과를 내는지를 테스트했다면, 스트레스 테스트는 계속해서 요청을 증가시키면서 한계점에서 안정성을 확인하는 테스트라고 정리해볼 수 있다.
부하 테스트 툴로는 JMeter, nGrinder, K6 등이 존재한다.
모아모아 성능 테스트 툴 정리
JMeter 는 복잡하고 디테일한 부하 테스트가 가능하며, 시나리오 기반의 테스트가 가능하다는 장점이 있지만, 러닝커브가 높다는 단점이 존재한다. 우리의 경우에는 부하 테스트를 통해서 톰캣의 쓰레드 개수를 정하는 것이 목적이었으므로 러닝커브가 높은 JMeter 는 큰 단점으로 다가왔다. 심지어 우리의 경우에는 복잡한 테스트를 할 필요가 없었으며 시나리오 테스트도 불필요하다고 판단하였다.
다음으로는 nGrinder 가 있는데, Groovy 기반의 문법을 사용하는데 우리 팀원들 중 Groovy 문법에 익숙한 팀원이 없었으며 시나리오 테스트나 세밀한 테스트는 불필요했으므로 패스하였다.
다음으로는 K6인데, JS로 코드를 작성해야 하지만, K6 사이트에서 우리가 원하는 테스트를 등록해두면(예를 들어, 0분 ~ 2분까지는 50명까지 vUser 증가, 50명에서 10분 유지 ...) 우리가 등록한대로 스크립트를 작성해주고, 우리는 약간의 수정만 진행해주면 된다. 또한 참고할만한 레퍼런스도 충분하다고 판단하여 이를 활용하였다.
부하 테스트 도구에 대한 선택은 되었지만, 어떻게 모니터링 할지에 대한 고민이 있었다. K6에서 요청을 모두 보내고 나면 요청에 대한 실패율이나 응답 시간의 평균, 95% 등 나오지만 보기에 불편한 감이 있었다. 그래서 우리는 유료이기는 하지만 우아한형제들 등의 회사에서 사용하는 pinpoint 를 활용하여 TPS 를 함께 확인해주었다.
이제 스프링 부트에서 톰캣 쓰레드를 설정할 수 있는 3가지 항목에 대해서도 정리를 해보았고, 부하 테스트의 종류에 대해서도 학습해보았다. 부하 테스트 툴에 대한 선택도 끝내 모든 준비를 마쳤다.
그럼 이제 테스트할 일만 남았는데, 막상 테스트를 하려고 하니 기준이 필요했다.
중간에 이런 저런 기준들이 제시되었지만, 우선 그런것들을 제외하고 갈피를 잡은 부분부터 정리를 해보려고한다.
(워낙 너무 많은 테스트를 진행했어서 잘 기억나지도 않는다. 다만, vUser 에 대한 값을 우리 서비스 상황에 대입하여 계산하고 난 이후로는 조금 기준이 생긴 느낌이었기 때문에 이 시점부터 정리하려고 한다.)
참고한 블로그는 김태희 님께서 쓰신 백엔드 성능 개선 리포트(WAS Scale-out + DB Replication) 이다.
우선 예상 1일 사용자 수(DAU)를 정하고, 피크 시간대의 집중률을 예상한다.
그리고 1명당 1일 평균 접속 혹은 요청수를 예상한다.
이를 바탕으로 Throughput을 계산하여 vUser 수를 구하는데 Latency는 해당 글에 따르면 보통 50~100ms 로 잡는 것이 좋다고 하여 우리 팀에서는 100ms 로 잡았다. (실제로 우리 서비스는 특정 하나의 API당 100ms 정도의 응답을 내보내주고 있었다.)
- Throughput : 1일 평균 rps ~ 1일 최대 rps
- 1일 사용자수(DAU) * 1명당 1일 평균 접속수 = 1일 총 접속수
- 1일 총 접속 수 / 86,400 = 1일 평균 rps
- 1일 평균 rps * (최대 트래픽 / 평소 트래픽) = 1일 최대 rps
다음으로 트래픽이라는 용어를 일반인들도 많이 접하는데 이 트래픽은 인터넷에서 사이트의 정보를 노출 시킬 수 있는 하루 동안의 용량 이라고 이야기한다. (트래픽의 단위가 용량이라는 것에 놀랐다.🫢)
그리고 이런 트래픽을 계산하는 방법은 방문 수 * 평균 페이지뷰 * 1페이지당 평균 바이트
이다.
우리의 경우에는 트래픽에 대한 계산을 통한 부하테스튼느 하지 않았고, 해당 글을 참고하여 vUser에 대한 계산만 진행해주었다.
그럼 이제 공식을 정리해보자.
Request Rate : measured by the number of request per second (RPS)
VU : the number of virutal users
R : the number of requests per VU iteration : 12
T : a value larger than the time needed to complete a VU iteration : 1
T = (R http_req_duration) (+ 1s) ; 내부망에서 테스트할 경우 예상 latency를 추가한다.
VUser = (목표 rps T) / R
우선 우리와 비슷한 성격의 웹 사이트인 OKKY의 한 달 동안 얼마나 많은 유저가 접속하는지 확인해주는 사이트를 통해서 DAU를 구해보았다. (22년 9월 기준)
DAU = 780,000 / 30 = 26,000
1일 총 접속수 = DAU 1 명당 1일 평균 접속수 = 26,000 10 = 260,000
1일 평균 rps = 1일 총 접속수 / 86,400 = 260,000 / 86,400 = 3.01
1일 최대 rps = 1일 평균 rps (최대 트래픽 / 평소 트래픽) = 3.01 4 (평소의 4배라고 가정) = 12.04
VUser = (목표 rps * T) / R
R = 4
T = (4 * 0.1) = 0.4
평소 트래픽 VUser = (3.01 * 0.4) / 4 = 0.301
최대 트래픽 VUser = (12.04 * 0.4) / 4 =1.204
우리의 모아모아에서는 DAU를 100,000으로 잡았다. (실제 트래픽이 발생하지 않는 서비스였어서 가정을 해주었다.)
이전 테스트에 진행했던 내용도 많은데 정리되지 않은 내용이므로 생략
우리의 목표는 VUser 50 명이 모두 100ms 이내의 응답을 받을 수 있도록 톰캣 쓰레드의 개수를 조정해주는 것 이라고 정리할 수 있다.
앞선 테스트들은 제외하고, 의미있는 테스트를 하기 위해서 DB의 데이터를 증설한 곳에 연결을 하고 Dev 서버에서 테스트를 진행한 시점부터 이야기를 풀어가려고 한다.
메인 페이지에 대한 요청인데, 임계값인 100ms 를 초과하여 바로 실패하는 것을 확인할 수 있다. (some thresholds have failed)
따라서 메인 페이지에 관련된 쿼리를 index를 이용해서 개선해주었는데, 이에 대한 내용은 별도의 DB index 관련 포스팅에서 정리하려고 한다. 현재는 스터디 전체 목록 조회에 대한 쿼리에 개선을 진행해주었다고 생각하면 될 것 같다.
위에서 쿼리를 개선해주었음에도 40 대에서 100ms 를 넘겨 응답하여 VUser 를 더 수용하지 못하는 것을 확인할 수 있다.
그래서 이번에는 쓰레드 수를 200으로 늘리고, 대신 connection을 100으로 낮추어 테스트를 해보았는데 역시나 원하는 결과가 나오지 않았다.
그래서 우리팀은 다른것을 건드리기 보다는 쓰레드 수만 건드리면서 확인해보기로 하였다.
쓰레드 개수는 200이며, db connection pool, 즉 HikariCP 도 디폴트인 10개를 사용할 때 메인페이지를 확인해보았다.
원하는 결과가 도출된다. 50명의 유저를 원하는 100ms 이내로 응답해주고 있다. 그런데 생각을 다시 해보니 우리의 모아모아 메인 페이지 에서는 총 3개의 API 를 호출하고 있었다. (스터디 목록, 태그 목록, me 요청)
따라서 3개를 요청했을 때에 대해서 테스트를 진행해보았다. 처음에는 thread 개수를 50개로 제한해서 요청을 보냈다 그랬더니 실패하는 것을 확인할 수 있었다.
그래서 default인 200개로 늘렸더니 다시 성공하는 것을 확인할 수 있었다.
그런데 의문이 드는 점은 쓰레드를 25개로 낮춰주었을 때에도 다음과 같이 성공한다는 거이다.
심지어는 50개로 늘려도 성공한다.
이렇게 되다 보니 의문이 들기 시작했다.
쓰레드 수가 25개여도, 50개여도 200개여도 성공한다. 그래서 어떤 쓰레드로 설정해야할지 더욱 막막해졌다.
위와 같은 여러 테스트를 시도해보았지만, 의미있는 데이터를 도출해보지는 못했다.
JMeter 로도 아래와 같이 진행해보았는데, 의미있는 데이터를 얻지 못했다.
그래서 실제로 발표에서도 위와 같은 내용을 보이며 실패한 이야기를 하였다.
굉장히 아쉬운 마음을 뒤로하고, 실패한 이유를 되짚어보았다.
우선 DB쪽과 리버스 프록시 역할을 하는 NGINX 쪽에 대한 격리를 제대로 하지 못하였다. 내가 생각하기에 톰캣 쓰레드의 적정한 설정을 위해서는 외부 요인을 제외하고 WAS에 대해서만 테스트를 진행해야 할 것 같은데 그러지 못하였다.두번째로는 구체적인 설계를 하지 못하였다. 어떤 테스트를 할지에 대해서 먼저 정하고 그 순서대로 테스트를 진행했어야 할 것 같다. 예를 들면 이런식이다. '우리가 보아야할 부분은 TPS 이므로 톰캣 쓰레드를 10, 50, 100, 200로 설정하면서 TPS 가 어떻게 변화하는지 보자.' 와 같이 테스트를 했어야 했을 것 같다.
하지만 우리는 테스트를 진행하다가 CPU 사용량은 어떻지? DB쪽 CPU 사용량과 커넥션은?? 과 같이 꾸준하게 하나의 요소에 대한 테스트를 진행하지 못하고, 중간중간 떠오르는 사항들을 테스트하려고 하다보니 어떤 테스트를 했는지 기록도 정리도 제대로 되지 않았던 것 같다. 몸으로 부딪히기 보다는 먼저 계획을 하고 테스트를 했어야 하는데 아무래도 정해진 시간이 있다보니 그러지 못한 것이 아쉽게 느껴졌다.
팀 내에서는 다음 필수 요구사항으로 주어지는 문제에 대해서 시간적 여유가 있을 때 미리미리 하자는 이야기를 나누었다. 결론적으로 이번 부하 테스트에서는 성공적인 테스트를 진행하지 못하여 의미 있는 쓰레드 개수 설정을 하지 못하고 default 로 두었지만, 다음에 부하 테스트를 하게 된다면 테스트에 대한 계획을 미리 세우고 진행해볼 생각이다.