기존에 해커톤에서 진행한 프로젝트를 리펙토링을 진행했습니다.
코드의 통일성도 높이고 DB 접근 기술도 Mybatis에서 JPA와 Querydsl로 마이그레이션 했습니다.
JPA를 사용하면서 생기는 N+1 문제를 해결하면서 마이그레이션을 진행했습니다.
이제는 부하테스트를 하고 싶었습니다.
부하테스트에서 할 시나리오는 여러 개 만들었고, 그중 즐겨찾기 시나리오를 먼저 진행해 성능 최적화까지 이뤄낸 과정을 정리하겠습니다
즐겨찾기 시나리오
헤커톤에서 마든 메인 기능 중 하나는 즐겨찾기 한 공고와 , 해당 공고에 맞는 서류리스트를 불러오고 , 사용자가 제출한 서류리스트까지 보내는 과정입니다
즉
홈에 갔다가 → 즐겨 찾기 목록 확인 → 특정 공고를 상세 정보를 확인 ( 이때 제출 서류 목록까지 다 확인할 수 있습니다)
그후 유저가 제출 완료 한 서류 목록 을 입력해서 넘겨줌
이러한 과정으로 진행됩니다
호출되는 API 목록을 확인하자면 다음과 같은 순서로 API를 호출하게 됩니다
/auth/login → /home → /favorite → /announce/{announce_id} → /checklist/{announce_id}
목표 설정
API 별로 소요되는 시간을 예상을 해보면
- 로그인: 2~5초
- 홈 탐색: 10~30초
- 관심 목록: 10~20초
- 공고 클릭 + 읽기: 30~90초
- 체크리스트 확인: 10~30초
최소 1.5 분 ~ 최대 3분 정도가 소요됩니다. 저는 이 중간값인 2분 정도 소요된다고 가정했습니다
결국 2분간 총 5개의 API 호출이 생기게 됩니다.
저희 서비스의 동접자수는 10만으로 잡았고 , 배포환경이 ASG로 인해 서버가 5개까지 늘어나기 때문에 다음과 같은 수식을 새웠습니다.
- 유저 한 명당 : 5 / 120 = 0.0416… TPS
- 동접자수 10만 이니깐 ⇒ 100000*0.0416 = 4160 TPS 가 소요된다
- 근데 5개의 서버로 로드밸런싱 할 거 기 때문에 한 서버당 평균 832 TPS
832 TPS 가 나왔지만 실제 환경에서는 사용자 행동 편차로 인해 TPS가 피크 구간에서 더 높게 발생할 수 있으므로, 이를 고려해 서버당 1000 TPS 부하테스트를 목표로 삼았습니다.
목표시간을 결정하는 도중 구글 SRE를 알게 되었습니다.
해당 문서를 읽어본 결과 다음과 같은 기준을 새울 수 있었습니다
응답시간체감 수준서비스 평가 SRE 관점
| ~100ms | 즉각 반응 | 최고 | P50 목표 수준 |
| 100~300ms | 매우 빠름 | 매우 우수 | P95 목표로 이상적 |
| 300~500ms | 빠름 | 매우 좋음 | 현실적인 P95 기준 |
| 500~800ms | 약간 대기 느낌 | 양호 | 트래픽 많으면 허용 |
| 800~1000ms | 느리다고 인식 | 보통 | P99 한계선 |
| 1~2초 | 답답함 | 좋지 않음 | 병목 의심 구간 |
| 2초+ | 이탈 시작 | 위험 | 장애 수준 |
https://sre.google/sre-book/service-level-objectives/
Google SRE - Defining slo: service level objective meaning
Service Level Objectives Written by Chris Jones, John Wilkes, and Niall Murphy with Cody SmithEdited by Betsy Beyer It’s impossible to manage a service correctly, let alone well, without understanding which behaviors really matter for that service and ho
sre.google
이를 토대로 결국 1000 TPS에서 95P 응답이 300ms 이내 처리, 성공률 99% 이상을 목표로 삼았습니다.
토큰 풀
하나의 아이디에서 AccessToken을 부여받고 , 해당 AccessToken만 사용해서 1000 TPS를 사용할 수 있지만 이는 실제 유저 환경과는 많이 다르다고 느꼈습니다.
그래서 테스트의 Setup 단계에서 미리 토큰 풀을 만들었습니다
Setup 이 너무 길어질 수도 있기에 , http.batch()를 사용해 20개씩 병렬로 처리해 setup시간을 단축했고 배치 사이에 100ms sleep을 줘서 서버 과부하도 방지했습니다.
그리고 토큰이 1000개 미만이면 테스트 자체를 Setup 단계에서 중단시키도록 스크립트를 작성하였습니다.
모니터링 환경

테스트를 하기 앞서 병목 현상이 발생하였을 때 어떻게 해당 지점을 찾을지 고민이었습니다.
APM을 사용할 수도 있었지만 , 우선은 새로운 기술을 도입하기 전에 기존에 사용하는 기술 스택에서 문제를 해결하고 싶었습니다.
그래서 Prometheus와 Grafana를 통해 모니터링 지표를 직접 만들고 모니터링을 진행하였습니다.
모니터링 지표는 다음과 같이 만들었습니다
- Hikari CP (Max , 사용 중 , 대기 중 , 유휴 Connection)
- Tomcat Thread (Max , 사용 중인 Thead)
- Redis 응답시간
- Redis 사용 메모리 및 RSS 지표
- Redis 히트율
- 현재 서버의 TPS 지표
- 각 API의 P95 응답시간
- JVM 힙 메모리
- 서버의 CPU 이용률
도커 환경에서 테스트 진행
테스트 환경은 다음과 같이 설정했습니다
실제 AWS에 서비스를 직접 배포해 부하테스트를 할 수 있지만 , 그만큼 요금이 나가는 게 부담스러워서 우선 로컬에서 진행하였습니다.
로컬의 컴퓨터의 CPU는 총 12 코어입니다. AWS 서버의 환경과 로컬의 CPU 코어, 메모리를 맞추기 위해 도커 컨테이너를 배포할 때 다음과 같이 CPU 제한을 걸었습니다
├── k6 (1000 TPS) → 2~3코어
├── Spring 앱 → 2코어
├── MySQL → 1코어
├── Redis → 거의 0
├── Prometheus → 0.5코어
├── Grafana → 0.5코어
└── Windows OS → 0.5코어
메모리 또한 다음과 같이 제한을 걸어줬습니다
- Xms512 m
- Xmx512 m
- XX:MaxRAM=1024m
프리티어 기준으로 1GB 중 512를 힙 메모리에 할당했습니다.
더미 데이터 입력
우리 서비스에서 사용하는 테이블의 목록은 다음과 같습니다
- Alarm
- AlarmPreference
- Announce
- Article
- Auth
- Business
- BusinessCode (이건 10만 개)
- Document
- Festival
- Member
- MemberDocument
- MemberFavorite
- Notice
- Profile_Image
- Sos
- Sos_Image
총 15개의 Table 이 있고 , 이 각각의 Table에 200만 개의 더미 데이터를 넣었습니다
그래서 총 2800만 개의 더미데이터를 Batch를 통해 넣었습니다.
마무리
이렇게 첫 부하테스트를 하기 전 환경 세팅을 완료하였습니다.
이제는 직접 부하테스트를 해보고 그 결과에 맞춰 성능 향상 해 보겠습니다.
혹시 궁금할 것 같아서 작성한 K6 Script 써 놓고 가겠습니다!

K6 테스트 스크립트
import http from 'k6/http';
import { check, group, sleep } from 'k6';
import { htmlReport } from 'https://raw.githubusercontent.com/benc-uk/k6-reporter/main/dist/bundle.js';
import { randomIntBetween, randomItem } from 'https://jslib.k6.io/k6-utils/1.4.0/index.js';
import { Trend, Rate } from 'k6/metrics';
const BASE_URL = __ENV.BASE_URL || 'http://localhost:8080';
const PASSWORD = __ENV.PASSWORD || 'encoded_password';
const TOKEN_COUNT = parseInt(__ENV.TOKEN_COUNT || '200');
const BATCH_SIZE = parseInt(__ENV.BATCH_SIZE || '20');
const USER_MIN = 1;
const USER_MAX = 120000;
const ANNOUNCE_ID_MIN = 1;
const ANNOUNCE_ID_MAX = 260000;
const DOC_ID_MAX = 1040027;
const homeDuration = new Trend('duration_home', true);
const favDuration = new Trend('duration_favorite', true);
const annDuration = new Trend('duration_announce', true);
const chkDuration = new Trend('duration_checklist', true);
const apiErrorRate = new Rate('api_error_rate');
export const options = {
setupTimeout: '2m',
insecureSkipTLSVerify: true,
scenarios: {
main_flow: {
executor: 'ramping-arrival-rate',
startRate: 0,
timeUnit: '1s',
preAllocatedVUs: 300,
maxVUs: 1000,
stages: [
{ target: 100, duration: '1m' },
{ target: 300, duration: '1m' },
{ target: 500, duration: '1m' },
{ target: 700, duration: '1m' },
{ target: 1000, duration: '2m' },
{ target: 0, duration: '1m' },
],
gracefulStop: '30s',
},
},
};
export function setup() {
const tokens = [];
for (let i = 0; i < TOKEN_COUNT; i += BATCH_SIZE) {
const currentBatch = Math.min(BATCH_SIZE, TOKEN_COUNT - i);
const batchReqs = Array.from({ length: currentBatch }, () => {
const uid = randomIntBetween(USER_MIN, USER_MAX);
return {
method: 'POST',
url: `${BASE_URL}/auth/login`,
body: JSON.stringify({ email: `user${uid}@test.com`, password: PASSWORD }),
params: { headers: { 'Content-Type': 'application/json' } },
};
});
http.batch(batchReqs).forEach((res) => {
const token = res.json()?.data?.access_token;
if (token) tokens.push(token);
});
sleep(0.1);
}
console.log(`[setup] 토큰 ${tokens.length}개 확보`);
if (tokens.length < 50) throw new Error(`토큰 부족(${tokens.length}개)`);
return { tokens };
}
function makeParams(token, apiTag) {
return {
headers: { Authorization: `Bearer ${token}`, 'Content-Type': 'application/json' },
tags: { api: apiTag },
timeout: '10s',
};
}
export default function (data) {
const token = randomItem(data.tokens);
const announceId = randomIntBetween(ANNOUNCE_ID_MIN, ANNOUNCE_ID_MAX);
group('home', () => {
const res = http.get(`${BASE_URL}/home`, makeParams(token, 'home'));
check(res, { '[home] 200': (r) => r.status === 200 });
homeDuration.add(res.timings.duration);
apiErrorRate.add(res.status !== 200);
});
sleep(randomIntBetween(1, 3) * 0.1);
group('favorite', () => {
const res = http.get(`${BASE_URL}/favorite`, makeParams(token, 'favorite'));
check(res, { '[favorite] 200 or 204': (r) => r.status === 200 || r.status === 204 });
favDuration.add(res.timings.duration);
apiErrorRate.add(res.status !== 200 && res.status !== 204);
});
sleep(randomIntBetween(2, 5) * 0.1);
group('announce', () => {
const res = http.get(`${BASE_URL}/announce/${announceId}`, makeParams(token, 'announce'));
check(res, { '[announce] 200 or 404': (r) => r.status === 200 || r.status === 404 });
annDuration.add(res.timings.duration);
apiErrorRate.add(res.status !== 200 && res.status !== 404);
});
sleep(randomIntBetween(3, 8) * 0.1);
group('checklist', () => {
const count = randomIntBetween(1, 5);
const body = JSON.stringify(
Array.from({ length: count }, () => ({
document_id: String(randomIntBetween(1, DOC_ID_MAX)),
checked: Math.random() > 0.5,
}))
);
const res = http.post(
`${BASE_URL}/checklist/${announceId}`,
body,
makeParams(token, 'checklist')
);
check(res, { '[checklist] 200': (r) => r.status === 200 });
chkDuration.add(res.timings.duration);
apiErrorRate.add(res.status !== 200);
});
}
export function handleSummary(data) {
return {
'reports/load-test.html': htmlReport(data),
'reports/summary.json': JSON.stringify(data, null, 2),
};
}'🖥️ 컴퓨터 공부 > 부하테스트 & 성능최적화' 카테고리의 다른 글
| 해커톤 성능 최적화 -3 (0) | 2026.04.24 |
|---|---|
| 해커톤 성능 최적화 -2 (0) | 2026.04.24 |
| 해커톤 성능 최적화 - 1 (0) | 2026.04.24 |
| K6 테스트 기본 구조 및 실행 (0) | 2026.04.04 |
| K6 설치하기 (0) | 2026.04.04 |