지금까지 진행된 내용은 JVM 환경을 모니터링하고 DB 커넥션을 빠르게 되돌려 줄 수 있게 쿼리 최적화와, 트랜잭션의 동작, QueryHints 어노테이션, DB의 접근하는 로직을 최소화할 수 있도록 구성했다.
이제 스트레스 테스트를 실제로 진행해야 한다.
1. 스트레스 테스트 with K6
K6는 그라파나 Lab에서 개발된 오픈소스로 다른 스트레스 테스트 툴에 비해서 들어가는 리소스가 적다. 또한 Go언어로 만들어져 빠르고 실제 테스트 스크립트 작성은 Js로 구성할 수 있다.
https://kiru-dev-study.tistory.com/26
Spring Boot 프로파일링 및 Stress Test [Spring]
현재 진행하고 있는 프로젝트의 대한 최적화를 진행하기 위해 다음과 같은 기술 스택을 사용했고 어떻게 진행했는지, 어떤 과정으로 나아갔는지 작성해보도록 하겠다.Spring Boot 3.3.2Java 21Docker각
kiru-dev-study.tistory.com
K6의 metric에 대한 내용은 이곳에서 간단히 보거나 공식문서를 활용하면 된다.
각 OS마다 설치방법은 아래 공식블로그를 보고 설치하면 된다.
https://grafana.com/docs/k6/latest/set-up/install-k6/
Install k6 | Grafana k6 documentation
User-centered observability: load testing, real user monitoring, and synthetics Learn how to use load testing, synthetic monitoring, and real user monitoring (RUM) to understand end users' experience of your apps. Watch on demand.
grafana.com
2. 테스트를 통해 하려고 하는것
테스트는 즉 목적을 위해 수행이 되어야 한다. 스트레스 테스트든 Junit등의 코드 테스트등의 방식또한 마찬가지로 목적을 위해 수행이 되어야한다. 여기서 내가 목표로 한 것은 다음과 같다.
- P95의 응답속도 0.5s 이내
- CPU를 최대한 사용하는 것 -> 코드상에서 CPU가 놀고 있지는 않는지 검증
- Test 성공률 99% 달성
2.1 테스트 설정
기본 설정은 다음과 같이 작성했다.
const TOTAL_USERS = 2000;
const SCENARIO_RATIOS = {
getUsers: 0.20,
getUserMe: 0.15,
getUserMeChatroom: 0.15,
getUserMeEmail: 0.01,
getUserChatroomDetail: 0.15,
getUserPortfolios: 0.15,
getUserPortfoliosDetail: 0.15,
sendLikeOrDisLike: 0.15
};
총 2000명의 가상 사용자를 설정하고 이 가상 사용자는 동시에 접속할 수 있는 동시접속자 수이다.
내 애플리케이션에 2000명의 가상사용자가 동시에 접속한다고 가정하고 각 API를 통계에 맞게 어떤 비율로 사용자가 사용할 것인지로 분류해 각 사용자의 비율을 나누어 서비스 사용패턴을 시뮬레이션하려고 구성했다.
export const options = {
setupTimeout: '5m', // setup 함수의 타임아웃 시간을 10분으로 설정
thresholds: {
// the rate of successful checks should be higher than 90%
checks: ['rate>0.95'],
},
// noConnectionReuse: true,
scenarios: {
getUsers: {
executor: 'ramping-vus',
stages: [
{ duration: '1m', target: Math.floor(TOTAL_USERS * SCENARIO_RATIOS.getUsers) },
{ duration: '2m', target: Math.floor(TOTAL_USERS * SCENARIO_RATIOS.getUsers) + SECOND_SENAIRO },
{ duration: '2m', target: Math.floor(TOTAL_USERS * SCENARIO_RATIOS.getUsers) + THIRD_SENAIRO },
],
exec: 'getUsers'
},
다음은 시나리오를 설정하는 부분인데 처음부터 한 번에 사용자가 들어오는 것이 아닌 점차 Ramup 방식으로 사용자가 늘어나도록 구성했다. 하나의 예시를 가져온 것이며 threasholds를 통해서 사용자가 각각 확인하고 싶은 검증수단을 정할 수 있다.
위 예시에서는 95% 성공이상 달성시 check로 표시되는 것이다.
senarios는 말 그대로 테스트의 시나리오를 작성하는 것인데 위처럼 executor를 통해서 어떤 방식으로 요청을 보낼 건지 결정할 수 있고 각각 stages를 통해서 몇 분 동안 얼마만큼의 가상유저를 할당할 건지 정할 수 있고 exec를 통해서 어느 함수를 실행시킬 건지 정할 수 있다. 위에서는 getUsers 함수를 1분 동안 내가 설정한 Vuser의 비율만큼, 2분 동안 거기에 추가로 SECOND_SENAIRO를 더한 만큼 등으로 각 stage에 어떻게 동작할 건지 정한 코드이다.
export function getUsers() {
const user = getRandomUser(data);
const authHeaders = {
...commonHeaders,
Authorization: `${user.accessToken}`
};
const response = http.get('${baseUrl}/api/v1/users', { headers: authHeaders ,tags: { name: 'getUsers' } });
const checkRes = check(response, { 'status is 200': (r) => r.status === 200 });
const error = response.status === 200;
if (!checkRes) {
console.log(`getUsers Error - Status: ${response.status}, Response: ${response.body}, UserId: ${user.userId}`);
errorRate.add(error);
}
sleep(1);
}
이처럼 getUsers라는 함수를 통해서 각각의 함수로 요청을 보낼 수 있고 check 조건을 통해서 response의 status가 200인지 확인하는 동작을 지정한 것이다.
나는 여기서 여러 명의 유저들의 사용성 테스트도 진행하기 위해서 다음과 같이 k6에서 지원하는 자료구조로 데이터를 불러오도록 지정했다.
const data = new SharedArray('users', function () {
try {
let d= JSON.parse(open('/home/kihoon/Desktop/Test/new_users.json'));
const data = d;
return data;
} catch (err) {
console.error('Failed to load users frhom file:', err);
return [];
}
});
다음에는 각 userId, accessToken 등이 들어가 있으며 이를 통해서 각 함수에서 사용자를 랜덤으로 지정해서 사용할 수 있다.
최종적으로 사용된 전체 코드는 다음과 같다.
import http from 'k6/http';
import { sleep, check } from 'k6';
import { Rate } from 'k6/metrics';
import { SharedArray } from 'k6/data';
const TOTAL_USERS = 2000;
const SCENARIO_RATIOS = {
getUsers: 0.20, // 15%
getUserMe: 0.15, // 15%
getUserMeChatroom: 0.15, // 15%
getUserMeEmail: 0.01, // 5% (이메일 조회는 낮은 비율)
getUserChatroomDetail: 0.15, // 15%
getUserPortfolios: 0.15, // 20%
getUserPortfoliosDetail: 0.15 , // 15%
sendLikeOrDisLike : 0.15 // 10%
};
const PASSWORD_RESET_EMAILS = 400;
const SECOND_SENAIRO = 100;
const THIRD_SENAIRO = 100;
const errorRate = new Rate('errors');
// k6 테스트 옵션 설정
export const options = {
setupTimeout: '10m', // setup 함수의 타임아웃 시간을 10분으로 설정
thresholds: {
// the rate of successful checks should be higher than 90%
checks: ['rate>0.95'],
},
// noConnectionReuse: true,
scenarios: {
getUsers: {
executor: 'ramping-vus',
stages: [
{ duration: '1m', target: Math.floor(TOTAL_USERS * SCENARIO_RATIOS.getUsers) },
{ duration: '2m', target: Math.floor(TOTAL_USERS * SCENARIO_RATIOS.getUsers) + SECOND_SENAIRO },
{ duration: '2m', target: Math.floor(TOTAL_USERS * SCENARIO_RATIOS.getUsers) + THIRD_SENAIRO },
],
exec: 'getUsers'
},
getUserMe: {
executor: 'ramping-vus',
stages: [
{ duration: '1m', target: Math.floor(TOTAL_USERS * SCENARIO_RATIOS.getUserMe) },
{ duration: '2m', target: Math.floor(TOTAL_USERS * SCENARIO_RATIOS.getUserMe) + SECOND_SENAIRO },
{ duration: '2m', target: Math.floor(TOTAL_USERS * SCENARIO_RATIOS.getUserMe) + THIRD_SENAIRO },
],
exec: 'getUserMe'
},
getUserMeChatroom: {
executor: 'ramping-vus',
stages: [
{ duration: '1m', target: Math.floor(TOTAL_USERS * SCENARIO_RATIOS.getUserMeChatroom) },
{ duration: '2m', target: Math.floor(TOTAL_USERS * SCENARIO_RATIOS.getUserMeChatroom) + SECOND_SENAIRO },
{ duration: '2m', target: Math.floor(TOTAL_USERS * SCENARIO_RATIOS.getUserMeChatroom) + THIRD_SENAIRO },
],
exec: 'getUserMeChatroom'
},
getUserMeEmail: {
executor: 'ramping-vus',
stages: [
{ duration: '1m', target: Math.floor(TOTAL_USERS * SCENARIO_RATIOS.getUserMeEmail) },
{ duration: '2m', target: Math.floor(TOTAL_USERS * SCENARIO_RATIOS.getUserMeEmail) + 50 },
{ duration: '2m', target: Math.floor(TOTAL_USERS * SCENARIO_RATIOS.getUserMeEmail) + 50 }
],
exec: 'getUserMeEmail'
},
getUserChatroomDetail: {
executor: 'ramping-vus',
stages: [
{ duration: '1m', target: Math.floor(TOTAL_USERS * SCENARIO_RATIOS.getUserChatroomDetail) },
{ duration: '2m', target: Math.floor(TOTAL_USERS * SCENARIO_RATIOS.getUserChatroomDetail) + SECOND_SENAIRO },
{ duration: '2m', target: Math.floor(TOTAL_USERS * SCENARIO_RATIOS.getUserChatroomDetail) + THIRD_SENAIRO }
],
exec: 'getUserChatroomDetail'
},
getUserPortfolios: {
executor: 'ramping-vus',
stages: [
{ duration: '1m', target: Math.floor(TOTAL_USERS * SCENARIO_RATIOS.getUserPortfolios) },
{ duration: '2m', target: Math.floor(TOTAL_USERS * SCENARIO_RATIOS.getUserPortfolios) + SECOND_SENAIRO },
{ duration: '2m', target: Math.floor(TOTAL_USERS * SCENARIO_RATIOS.getUserPortfolios) + THIRD_SENAIRO}
],
exec: 'getUserPortfolios'
},
getUserPortfoliosDetail: {
executor: 'ramping-vus',
stages: [
{ duration: '1m', target: Math.floor(TOTAL_USERS * SCENARIO_RATIOS.getUserPortfoliosDetail) },
{ duration: '2m', target: Math.floor(TOTAL_USERS * SCENARIO_RATIOS.getUserPortfoliosDetail) + SECOND_SENAIRO },
{ duration: '2m', target: Math.floor(TOTAL_USERS * SCENARIO_RATIOS.getUserPortfoliosDetail) + THIRD_SENAIRO }
],
exec: 'getUserPortfoliosDetail'
},
sendLikeOrDislike: {
executor: 'ramping-vus',
stages: [
{ duration: '1m', target: Math.floor(TOTAL_USERS * SCENARIO_RATIOS.sendLikeOrDisLike) },
{ duration: '2m', target: Math.floor(TOTAL_USERS * SCENARIO_RATIOS.sendLikeOrDisLike) + SECOND_SENAIRO },
{ duration: '2m', target: Math.floor(TOTAL_USERS * SCENARIO_RATIOS.sendLikeOrDisLike) + THIRD_SENAIRO }
],
exec: 'sendLikeOrDislike'
}
},
};
// 공통 헤더 설정
const commonHeaders = {
'Content-Type': 'application/json'
};
const data = new SharedArray('users', function () {
try {
let d= JSON.parse(open('/home/kihoon/Desktop/Test/new_users.json'));
const data = d;
return data;
} catch (err) {
console.error('Failed to load users frhom file:', err);
return [];
}
});
export function getUsers() {
const user = getRandomUser(data);
const authHeaders = {
...commonHeaders,
Authorization: `${user.accessToken}`
};
const response = http.get(`${baseUrl}/api/v1/users`, { headers: authHeaders ,tags: { name: 'getUsers' } });
const checkRes = check(response, { 'status is 200': (r) => r.status === 200 });
const error = response.status === 200;
if (!checkRes) {
console.log(`getUsers Error - Status: ${response.status}, Response: ${response.body}, UserId: ${user.userId}`);
errorRate.add(error);
}
sleep(1);
}
export function getUserMe() {
const user = getRandomUser(data);
const authHeaders = {
...commonHeaders,
Authorization: `${user.accessToken}`
};
const response = http.get(`${baseUrl}/api/v1/users/me`, { headers: authHeaders ,tags: { name: 'getUserMe' } });
const checkRes = check(response, { 'status is 200': (r) => r.status === 200 });
const error = response.status === 200;
if (!checkRes) {
console.log(`getUserMe Error - Status: ${response.status}, Response: ${response.body}, UserId: ${user.userId}`);
errorRate.add(error);
}
sleep(1);
}
export function getUserMeChatroom() {
const user = getRandomUser(data);
const authHeaders = {
...commonHeaders,
Authorization: `${user.accessToken}`
};
const response = http.get(`${baseUrl}/api/v1/users/me/chatroom`, { headers: authHeaders, tags: { name: 'getChatRoomList' } });
const checkRes = check(response, { 'status is 200': (r) => r.status === 200 });
const error = response.status === 200;
if (!checkRes) {
errorRate.add(error);
console.log(`getUserMeChatroom - Status: ${response.status}, Response: ${response.body}, UserId: ${user.userId}`);
}
sleep(1);
if (error) {
const roomIds = response.json().map(room => room.id);
return { user, roomIds };
}
return { user, roomIds: [] };
}
export function getUserMeEmail() {
const user = getRandomUser(data);
const authHeaders = {
...commonHeaders,
Authorization: `${user.accessToken}`
};
for(let i = 1; i < 10000; i++) {
const response = http.get(`${baseUrl}/api/v1/users/me/email?email=user${i}@example.com`, { headers: authHeaders ,tags: { name: 'getUserMeEmail' } });
const checkRes = check(response, { 'status is 200': (r) => r.status === 200 });
if (!checkRes) {
console.log(`getUserMeEmail - Status: ${response.status}, Response: ${response.body}, UserId: ${user.userId}`);
errorRate.add(checkRes);
}
sleep(1);
}
}
export function getUserChatroomDetail() {
const { user, roomIds } = getUserMeChatroom(data);
const authHeaders = {
...commonHeaders,
Authorization: `${user.accessToken}`
};
for (const id of roomIds) {
const response = http.get(`${baseUrl}/api/v1/users/me/chatroom/${id}`, { headers: authHeaders, tags: { name: 'getUserChatroomDetail' } });
const checkRes = check(response, { 'status is 200': (r) => r.status === 200 });
if (!checkRes) {
console.log(`getUserChatroomDetail - Status: ${response.status}, Response: ${response.body}, UserId: ${user.userId}`);
errorRate.add(checkRes);
}
if(response.status === 403){
console.log(`getUserChatroomDetail - Status: ${response.status}, Response: ${response.body}, UserId: ${user.userId}`);
}
sleep(1);
}
}
export function getUserPortfolios() {
const user = getRandomUser(data);
const authHeaders = {
...commonHeaders,
Authorization: `${user.accessToken}` // Bearer 추가
};
for (let page = 0; page < 100; page++) {
let requestUrl = `${baseUrl}/api/v1/users/portfolios?page=${page}&size=5`;
const response = http.get(
requestUrl,
{
headers: authHeaders,
tags: { name: 'getUserPortfolios' },
timeout: '30s',
validateStatus: false // 모든 상태 코드에 대한 응답 허용
}
);
const checkRes = check(response, { 'status is 200': (r) => r.status === 200 });
// 응답 체크 로직 개선
if (response.status !== 200) {
console.log(`REQUEST : ${requestUrl}`);
console.log(`getUserPortfolios Error - Status: ${response.status}, Response: ${response.body}, UserId: ${user.userId}, Token: ${user.accessToken}`);
errorRate.add(true);
// 401 에러시 해당 유저 스킵
if (response.status === 401) {
console.log(`Invalid token for user: ${user.userId}`);
break;
}
}
sleep(1);
}
}
export function getUserPortfoliosDetail() {
const user = getRandomUser(data);
const authHeaders = {
...commonHeaders,
Authorization: `${user.accessToken}`
};
for (let id = 37; id <= 10000; id++) {
const response = http.get(`${baseUrl}/api/v1/users/portfolios/${id}`, { headers: authHeaders, tags: { name: 'getUserPortfoliosDetail' } });
const checkRes = check(response, { 'status is 200': (r) => r.status === 200 });
if (!checkRes) {
console.log(`getUserPortfoliosDetail - Status: ${response.status}, Response: ${response.body}, UserId: ${user.userId}`);
errorRate.add(checkRes);
}
sleep(1);
}
}
export function resetPasswordsAndSignIn() {
return new Promise(async (resolve) => {
const newUsers = [];
for (let i = 1; i <= PASSWORD_RESET_EMAILS; i++) {
if (i % 100 === 0) {
console.log(`Processing user ${i}`);
}
const email = `user${i}@example.com`;
const resetResponse = http.patch(`${baseUrl}/api/v1/users/me/pwd`, JSON.stringify({
password: 'asdf',
email: email
}), { headers: commonHeaders });
if (resetResponse.status !== 200) {
console.log(`Password reset failed for ${email}: ${resetResponse.status}`);
continue;
}
const signInResponse = http.post('${baseUrl}/api/v1/users/signin', JSON.stringify({
password: 'asdf',
email: email
}), { headers: commonHeaders });
if (signInResponse.status === 201) {
const signInData = signInResponse.json();
newUsers.push({
userId: signInData.userId,
accessToken: signInData.accessToken,
email: email
});
} else {
console.log(`Sign in failed for ${email}: ${signInResponse.status}`);
}
sleep(0.1); // Add small delay between requests
}
console.log(`Created ${newUsers.length} users`);
resolve(newUsers);
});
}
function getRandomUser(users) {
if (!users || users.length === 0) {
console.log('No users available');
return null;
}
return users[Math.floor(Math.random() * users.length)];
}
export function sendLikeOrDislike() {
if (!data || data.length === 0) {
console.log('No user data available');
console.log(data);
return;
}
const user = getRandomUser(data);
if (!user) {
console.log('Failed to get random user');
return;
}
const authHeaders = {
...commonHeaders,
Authorization: `${user.accessToken}`
};
let selectlikedUserId = user.userId;
while(selectlikedUserId === user.userId){
selectlikedUserId = Math.floor(Math.random() * 400) + 37;
}
const likeRequest = {
likedUserId: selectlikedUserId,
status: Math.random() > 0.5 ? 'LIKE' : 'DISLIKE'
};
const response = http.post(`${baseUrl}/api/v1/users/likes`, JSON.stringify(likeRequest), { headers: authHeaders, tags: { name: 'sendLikeOrDislike' } });
const checkRes = check(response, { 'status is 200': (r) => r.status === 201 });
const error = response.status === 201 || response.status === 400 ;
if (!checkRes) {
errorRate.add(error);
console.log(`sendLikeOrDislike - Status: ${response.status}, Response: ${response.body}, UserId: ${user.userId}, likedUserId: ${likeRequest.likedUserId}`);
}
sleep(1);
}
이제 다음과 같이 script.js를 작성하고 아래에서 influxDB에 대한 Docker Compose 파일을 찾아 실행시키면 된다.
Spring Boot 서비스 환경 스트레스 테스트 [Spring/Java]
1. 구성하게 된 이유스타트업 프로젝트를 2주안에 서버 구축이 완료되어어야 한다는 소리에 다급하게 프로젝트를 시작하고 인프라 구축, API 개발까지 완료를 한 상황에서 물론 스스로 응답에
kiru-dev-study.tistory.com
K6_INFLUXDB_PUSH_INTERVAL=5s k6 run --out influxdb=http://localhost:8086/k6 script.js \
--system-tags="status,method,url" \
--compatibility-mode=base
# --console-output="errors"
그리고 다음 명령어를 수행하게 되면 K6의 스트레스 테스트가 수행된다. K6가 진행된 뒤에 metric을 확인할 수도 있고 grafana와 연동해서 현재 작업을 볼 수 도 있다.
3. Grafana 연동 방법
Grafana와 연동하는 방법을 간단히 설명하자면 저 위 블로그에서 docker compose 파일을 찾아 docker compose up -d 명령어로 Docker compose 파일을 실행하고 localhost:3000번으로 들어가게 되면 grafana 화면이 보인다. 처음 생성했다면 이름과 비밀번호는 둘 다 admin이다.
grafana home에 들어가면
다음 창이 보일 텐데 influxDB를 등록해 주기 위해 다음을 클릭하고 influxDB를 찾아 클릭해 준다.
그러면 다음과 같은 창이 보일 텐데 Name은 알아서 지정하면 되고 influxdb 버전 1.x를 쓴다면 influxQL로 지정하면 된다. 2부터는 flux방식으로 바뀌어서 더 설정할 것들이 있어 진행하지 않았다.
그리고 다음을 자신의 설정에 맞게 구성해 주면 되는데 docker compose파일을 그대로 실행시켜 주었다면 저대로 구성을 진행해 주면 된다. 그리고 save&test 버튼을 눌러 접속이 되는지 확인하고 왼쪽 부분에 DashBoard에 들어간다.
그 후에 import 버튼을 눌러 K6 stress Test dashboard를 import 받고 아래와 같이 influxDB를 연결해 주면 된다.
이제 테스트를 수행하면 다음과 같은 창을 볼 수 있다.
K6 콘솔에서는 다음과 같이 metric정보들과 Vuser의 대한 정보, http request 정보들이 나오게 된다.
4. Intellij Profiler 사용방법
Intellij를 사용한다면 편하게 Java Application에 대한 프로파일링을 수행할 수 있다.
이를 통해서 메모리 사용률, CPU 시간, 총 시간율 등을 파악하고 각 스레드별로도 파악할 수 있는 좋은 기능이다.
애플리케이션을 프로파일링으로 시작하게 되면 런타임 시점에 각각의 지표들을 계산해 준다.
후에 이런 스냅숏을 남겨서 기록할 수도 있고 최신 Intellij를 사용한다면 사용중간에 기록을 할 수도 있다.
이런 식으로 플레임그래프를 통해서 어느 메서드에서 시간이 오래 걸렸는지 파악할 수도 있고 메모리를 많이 차지한다면
다음과 같이 메모리 할당 탭으로 변경하여 메모리가 많이 할당된 부분도 볼 수 있다.
코드 부분으로 넘어가서 이런 식으로 스냅숏이 활성화되면 코드 왼쪽에 각 탭 활성화를 메모리 할당, CPU 시간, 총 시간 등으로 변경하여 각각의 탭에 따라 활성화되는 것을 볼 수 있다.
다음의 내용을 활용해서 파악할 수도 있고 혹은 OpenTelemetry를 활용한 Digma를 사용할 수도 있다.
Digma는 마찬가지로 Runtime시점에 각 소스코드의 느린 부분, N+1문제, 느린 쿼리등을 파악할 수 있게 도와주는 Plugin이다.
단순히 켜놓기만 해도 다음과 같이 application을 otlp metric을 통해서 분석을 진행해 주고 현재는 다양한 문제점들을 표시해 주고 해결점을 제시해 주며 코드로 바로 Trace가 가능하다.
따라서 다음 부분들을 이용해서 코드상에서 성능을 높이고 리소스를 최대한 효율적으로 사용할 수 있는 방식으로 코드를 구성하게 되면 최종적으로 자원을 최대로 활용할 수 있는 애플리케이션을 구성할 수 있다.
최종적인 결과물은 다음과 같으며 이를 통해서 Application을 분석하고, 테스트를 진행하면서 성능을 어떻게 끌어올리며 어느부분에서 병목이 생기는지 구체적으로 확인 할 수있었던 좋은 기회였다고 생각한다.
3. 코드 최적화 전
3. 코드 최적화 후
'Spring' 카테고리의 다른 글
객체지향적으로 코드 리팩토링 하기 [Spring/Java] (1) | 2025.01.25 |
---|---|
Spring Boot 서비스 환경 스트레스 테스트 2 [Spring/Java] (0) | 2025.01.08 |
Spring Boot 서비스 환경 스트레스 테스트 [Spring/Java] (1) | 2025.01.04 |
Spring Boot 프로파일링 및 Stress Test [Spring] (0) | 2024.12.21 |
멀티모듈 프로젝트 Docker 빌드 전략: Path 기반 접근법[Docker&Github Action] (1) | 2024.12.14 |