CPU, Memory 모니터링을 통한 성능 측정 및 병목현상 원인 파악과 해결 과정
병목현상이란?
병목현상은 다수의 요청이 동일한 자원이나 시스템으로 몰려 특정 지점에서 처리 용량을 초과하게 되어 발생하는 성능 저하 현상
을 의미합니다.
이번 포스팅에서는 병목현상의 발생 원인을 시각적 자료와 함께 분석하고, 이를 해결하기 위한 성능 개선 방법을 제시해보고자 합니다.
또한, 병목현상을 분석하여 그 원인이 내부 시스템 흐름에 어떻게 영향을 미치는지에 대한 과정도 기록하겠습니다.
이 글은 프로젝트에서 개발한 API를 실제 운영 환경과 유사한 조건에서 성능 테스트를 진행한 결과를 바탕으로 작성되었습니다.
AWS 테스트 환경 구성
[db.t3.micro] Mysql RDS 20GB 1vCPU, 1GB Mem
- 데이터베이스[t3a.small] AWS EC2 WAS 8GB 2vCPU, 2GiB Mem
- 애플리케이션 서버 (WAS)[t3a.small] AWS EC2 K6 8GB 2vCPU, 2GiB Mem
- K6 부하 생성기AWS ELB(Elastic Load Balancing)
- 로드밸런서AWS CloudWatch
- 모니터링
RDS 테스트 데이터 구성
사용자 데이터 1000건
여러 상태의 모임 데이터 70만건
챌린지 데이터 70만건
테스트 시나리오
- 가상 사용자 수(VU)를 10분에 걸쳐 50명으로 설정하여 점진적으로 부하 테스트를 진행.
- 95% 확률로 게시물 조회를 요청하고, 5% 확률로 로그인 후 게시물을 생성하는 시나리오.
- 사용자는 요청 후 1초간 sleep 후 다시 요청한다.
- K6를 사용한 스크립트를 통해 테스트를 진행.
K6 테스트 스크립트
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
import http from 'k6/http';
import { sleep } from 'k6';
export const options = {
// 부하를 생성하는 단계(stages)를 설정
stages: [
// 10분에 걸쳐 vus(virtual users, 가상 유저수)가 50에 도달하도록 설정
{ duration: '10m', target: 50 }
],
};
export default function () {
let random = Math.random();
// 100명 중 5명의 비율로 게시글을 작성
if (random < 0.05) {
// 1. 로그인 요청
const loginPayload = JSON.stringify({
email: 'email1@naver.com',
password: 'Wjdwptk1!',
});
const loginHeaders = { 'Content-Type': 'application/json' };
const loginResponse = http.post('http://{ELB 주소}/api/auths/login', loginPayload, { headers: loginHeaders });
if (loginResponse.status === 200) {
const loginResult = JSON.parse(loginResponse.body);
const token = loginResult?.result?.token; // 토큰 값 추출
const postPayload = JSON.stringify({
name: '제목',
content: '내용',
endDate: '2025-03-13',
startDate: '2025-02-08',
minCapacity: 1001,
maxCapacity: 1002,
bookId: 1,
gatheringStatus: 'RECRUITING',
readingTimeGoal: 'ONE_HOUR',
gatheringWeek: 'ONE_WEEK',
});
const postHeaders = {
'Content-Type': 'application/json',
Authorization: `Bearer ${token}`, // Authorization 헤더 추가
};
http.post('http://{ELB 주소}/api/gathering', postPayload, { headers: postHeaders });
}
// 100명 중 95명의 비율로 게시글을 조회
} else {
http.get('http://{ELB 주소}/api/gatheringSearch/filtering?gatheringSortType=NEWEST_FIRST&page=0&size=10&startDate=2025-01-20&endDate=2025-03-30&readingTimeGoals=ONE_HOUR&today=false');
}
// 1초 휴식을 주어 더 현실적으로 테스트
sleep(1);
}
테스트 측정 결과
Throughput
- VUs : 50
- TPS : 1.2/s
Latency
- 평균 요청 비율 : 1.2/s
- 평균 응답시간(Latency) : 46.3/s
HTTP Request Fail
이번 테스트는 50명의 가상 사용자가 동시 요청하는 기준으로 진행되었습니다. 테스트 결과, 초당 처리 가능한 요청 수(TPS)는 평균 1.2/s 로 측정되었으며, 지연시간(Latency)는 약 46.3/s에 달했습니다.
HTTP Request Fail의 수 또한 0.55/s 로 측정되어 전체적으로 많은 트래픽도 아니였지만 현저히 불안정한 테스트 결과가 발생하였습니다.
CPU, Memory 모니터링 측정 결과
테스트 실행 초기 모니터링 결과, EC2와 RDS의 CPU 및 메모리 사용률이 점진적으로 증가하며 부하가 발생하는 모습이 확인되었습니다. 이 시점에서는 서버와 DB가 균등하게 요청을 처리하고 있음을 파악할 수 있었습니다.
테스트 실행 최종 모니터링 측정 결과 VUs가 증가함에 따라 트래픽이 늘었고, EC2와 RDS의 CPU 사용률이 급격히 차이를 보였습니다. 특히, RDS의 CPU 사용률은 99% 가까이 차지하며 DB에서 병목현상이 발생하고 있다는 것을 확인할 수 있었습니다.
RDS 병목 현상 분석 (커넥션 풀 부족)
테스트에서 발생한 오류는 다음과 같습니다
1
2
java.sql.SQLTransientConnectionException: HikariPool-1 - Connection is not available, request timed out after 30000ms
(total=10, active=10, idle=0, waiting=1)
Command | Description |
---|---|
total=10 | 커넥션 풀 최대 크기가 10개로 설정됨. |
active=10 | 10개의 커넥션이 모두 사용 중. |
idle=0 | 유휴 상태(사용 가능한) 커넥션이 없음. |
waiting=1 | 새로운 요청이 커넥션을 기다리고 있음. |
request timed out after 30000ms | 30초 동안 기다렸지만 커넥션을 얻지 못해 타임아웃 발생. |
이 오류는 DB 커넥션 풀의 최대 크기인 10개가 모두 사용 중일 때 새로운 요청이 대기 상태로 오래 머물게 되어 발생한 문제입니다. EC2의 CPU 사용량이 낮고, RDS CPU 사용량이 높았던 것도 이러한 이유 때문입니다.
이로 인해 발생하는 문제
- 동시 요청이 많아질수록 점점 더 많은 스레드가 대기 상태로 쌓임
- 일정 수 이상의 요청이 들어오면 스레드 풀이 가득 차서 새로운 요청을 처리하지 못함
- 결과적으로 응답 지연이 발생하고 서버가 제대로 응답하지 못하는 성능 저하 현상이 발생
RDS 성능 개선 해결책
- 캐싱 적용 (Redis 등) → DB 조회 요청을 줄여서 커넥션 점유 시간을 줄임.
- 쿼리 최적화 → EXPLAIN을 사용하여 실행 계획을 확인 후, 적절한 인덱스 추가.
- RDS 인스턴스 업그레이드
이러한 해결책 중 제가 개발한 API 문제에선 쿼리 최적화가 부족하다고 판단하였습니다.
쿼리 최적화 및 병목 현상 해결 과정
우선적으로 문제가 되는 쿼리를 확인하여 분석해보았습니다.
문제 발생 쿼리
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 select g1_0.GATHERING_ID, b1_0.BOOK_ID, b1_0.AUTHOR, ... 생략, g1_0.START_DATE, g1_0.VIEW_COUNT from GATHERING g1_0 left join CHALLENGE c1_0 on c1_0.CHALLENGE_ID=g1_0.CHALLENGE_ID left join BOOK b1_0 on b1_0.BOOK_ID=g1_0.BOOK_ID left join IMAGE i1_0 on i1_0.IMAGE_ID=g1_0.IMAGE_ID where c1_0.START_DATE>='2025-01-20' and c1_0.END_DATE<='2025-03-30' and c1_0.READING_TIME_GOAL='ONE_HOUR' order by g1_0.CREATED_TIME desc limit 0, 10;
Explain Analyze 쿼리 실행 계획 분석
- 70만 데이터에 대해 Full Table Scan이 발생하며 응답 시간이 약 3362ms로 측정
- 응답시간(Latency) : 3362ms
1차 해결책으로 초기 풀테이블 스캔하며 필터링하는 조건을 Index로 설정하여 다시 실행 결과를 분석해보았습니다.
1
CREATE INDEX idx_challenge_dates_goal ON CHALLENGE(READING_TIME_GOAL, START_DATE, END_DATE);
Index 적용 후 Explain Analyze 쿼리 실행 결과
- 70만 데이터 Full Table Scan
- 응답시간(Latency) : 2072ms
Index를 적용하였지만 MySQL 옵티마이저는 인덱스를 활용하는 것 보다 Full Table Scan을 사용하는 것이 더 효율적이라고 판단했는지 Index 가 적용되지 않은 것을 확인할 수 있었습니다.
문제 파악 및 최적화 접근 방식
문제의 원인은 Sorting(정렬 과정)에서 발생
한 것으로 확인되었습니다.
처음에 MySQL 옵티마이저는 멀티 컬럼 인덱스를 사용하여 필터링 후 정렬을 시도할 계획이었으나, 실제로 인덱스를 사용하여 필터링한 후 정렬을 수행하는 것이 오히려 비효율적이라 판단하였습니다.
이에 따라 옵티마이저는 전체 테이블 스캔이 더 효율적일 것이라고 결론을 내렸습니다.
1
CREATE INDEX idx_challenge_end_date ON CHALLENGE(END_DATE);
멀티 컬럼 인덱스를 사용하지 않고 order by 조건에만 Index를 추가한다.
따라서 해결책으로는 코드 레벨에서 불필요한 ORDER BY 조건을 제거하고, 초기 필터링 과정에서 동시에 정렬을 수행하도록 개선
하였습니다.
최종 실행 결과
- 이전 Sorting -> 필터링 과 조인이 진행된 후 실행
- 개선된 Sorting -> 필터링하고 정렬 후 조인 실행
- 3362ms(3.362초) -> 0.0703ms(0.7초) 로 단축시켰습니다.
개선된 API 테스트 측정 및 CPU, Memory 모니터링 결과
Throughput
- VUs : 49
- 최적화 전 TPS : 1.2/s
- 최적화 후 TPS : 47.8/s
TPS가 약 39.83배 증가하였습니다. Scale Up/Out 및 캐싱 없이 단순 쿼리 최적화만으로 성능이 높게 향상된 것을 확인할 수 있습니다.
Latency
- 최적화 전 Latency : 46.3/s
- 최적화 후 Latency : 0 ~ 50/ms
쿼리 최적화를 통하여 Latency가 약 926배 감소하였습니다. 이는 시스템의 효율성이 높아져 한정된 시간 내에 더 많은 요청을 처리할 수 있음을 의미하며, 궁극적으로는 서비스의 확장성과 안정성 향상에 기여하였습니다.
HTTP Request Fail
요청 실패 수가 이전 결과에 비해 10배 감소하여 다소 안정적인 모습이 확인되었습니다.
CPU, Memory 모니터링 측정 결과
- 최적화 전 RDS CPU 사용량 : 약 99.4 %
- 최적화 후 RDS CPU 사용량 : 약 4.57 %
RDS CPU 사용률 90% 이상 감소하였습니다.
마무리하며
이번 테스트를 통해 병목현상이 발생하는 원인을 구체적으로 확인할 수 있었습니다.
병목현상의 해결 방안으로는 쿼리 최적화를 선택했지만, 실제 상황에서는 다양한 원인에 따라 적합한 해결책이 달라질 수 있습니다. 따라서 상황에 맞는 대응이 중요하다고 생각합니다.
또한, CPU와 메모리 모니터링을 활용해 병목현상의 정확한 원인을 파악하는 것은 굉장히 중요하다고 생각합니다.