들어가며
부하테스트를 위한 대표적인 툴로는 jmeter, k6, ngrinder 등이 있다. 그 중 JavaScript 기반으로 스크립트 작성이 간편하고, 빠르고 가벼우며 CLI 환경에서 쉽게 실행할 수 있는 k6를 선택하게 되었다. 다음은 부하테스트 툴을 결정할 때 참고한 개발 블로그이다.
https://baeji-develop.tistory.com/118
성능테스트 (부하테스트 도구 비교) - jmeter, k6, ngrinder, locust
부하 테스트 도구 후보군: jmeter, k6, ngrinder, locust후보군 선정이유: jmeter는 내가 사용해보았고 나머지는 주변에서 사용해봤다는 도구들을 대표적으로 비교해봤다. 구분jmeterk6ngrinderlocust개발 언어
baeji-develop.tistory.com
왜 부하테스트를 하는가?
부하테스트란, 임계값 한계에 도달할 때까지 부하를 증가시키는 테스트이다. 즉, 부하테스트를 통해 개발한 프로그램이 예상되는 트래픽을 수용할 수 있는지를 판단할 수 있다. 현재 진행하고 있는 <모뉴> 프로젝트는 실제 사용자에게 배포되는 프로그램이 아니기 때문에 부하테스트가 불필요하다 할 수 있지만, 기본 기능 개발 구현 이후 심화 학습의 일환으로 부하테스트를 진행했다. 이를 통해 실제 운영 환경에서 발생할 수 있는 병목 지점을 사전에 파악하고 개선하는 경험을 쌓고자 했다.
1. 부하테스트 진행 방법
k6 설치
brew install k6
k6는 위의 명령어로 CLI 환경에서 손쉽게 설치할 수 있다. 이후 테스트 시나리오에 따라 테스트를 곧장 진행하면 된다.
테스트 시나리오
부하테스트는 보통 1000명을 기준으로 산정하며, 대규모 시스템의 경우 더 높게 설정한다. 강사님께 여쭤본 결과, 현재 진행하는 프로젝트의 성격상 500명까지는 통과하는 것이 이상적이지만 대략적으로 테스트를 진행해보았을 때 300명 선에서 API가 통과되는 수준이었고, 이에 따라 대부분의 API를 300명으로 맞추라는 피드백을 받았다. 이에 따라 최종적으로 300명을 기준으로 부하테스트를 진행했다.
테스트 시나리오는 다음과 단계적 부하 증가 시나리오로 구성했다.
- 0~30초: 50명으로 증가
- 30~60초: 300명으로 증가
- 60~70초: 0명으로 감소
시나리오에 따라 작성한 스크립트는 다음과 같다.
import http from 'k6/http';
import { check, sleep } from 'k6';
export const options = {
summaryTrendStats: ['avg', 'min', 'med', 'max', 'p(90)', 'p(95)', 'p(99)'],
stages: [
{ duration: '30s', target: 50 },
{ duration: '30s', target: 300 },
{ duration: '10s', target: 0 },
],
thresholds: {
http_req_duration: ['p(95)<500', 'p(99)<1000'],
http_req_failed: ['rate<0.01'],
},
};
export default function () {
const res = http.get('http://localhost:8080/api/...', params);
check(res, {
'status is 200': (r) => r.status === 200,
'response time < 500ms': (r) => r.timings.duration < 500,
});
sleep(1);
}
options에서 테스트 시나리오와 성능 기준을 설정하고, default function에서 실제 API 요청을 보내는 구조로 작성했다. 각 요청 후 check로 응답 코드와 응답 시간을 검증하고, sleep(1)로 실제 사용자처럼 실제 1초 딜레이를 주었다.
2. 부하테스트 진행 결과
전체 API를 대상으로 부하테스트를 진행한 결과, 대부분의 API는 300명 동시 접속 기준에서도 안정적인 성능을 보였다. 그러나 두 가지 API에서 심각한 병목이 발견되었다.
### 전체 API 결과 요약
| API | avg | p95 | p99 | 에러율 | 결과 |
|-----|-----|-----|-----|--------|------|
| GET /api/interests | 9.14ms | 14.48ms | 32.64ms | 0% | ✅ |
| GET /api/articles (개선 전) | 268.23ms | 788.43ms | 872.13ms | 0% | ❌ |
| GET /api/articles (개선 후) | 2.97ms | 7.91ms | 24.06ms | 0% | ✅ |
| GET /api/articles/{id} | 10.07ms | 16.27ms | 25.96ms | 0% | ✅ |
| GET /api/articles/sources | 4.48ms | 5.99ms | 10.53ms | 0% | ✅ |
| GET /api/notifications | 2.39ms | 3.94ms | 6.49ms | 0% | ✅ |
| GET /api/comments | 3.54ms | 4.84ms | 7.91ms | 0% | ✅ |
| GET /api/user-activities/{id} (개선 전) | 692ms | 1.91s | 1.95s | 0% | ❌ |
| GET /api/user-activities/{id} (개선 후) | 1.28ms | 2.24ms | 4ms | 0% | ✅ |
| GET /api/articles/restore | 8.66s | 16.25s | 21.56s | 0% | ❌* |
| POST /api/articles/{id}/article-views | 10.06ms | 15.1ms | 26.32ms | 0% | ✅ |
| POST /api/interests | - | - | - | 30.99% | ❌** |
| POST /api/interests/{id}/subscriptions | 8.23ms | 14.44ms | 29.76ms | - | ✅*** |
| POST /api/comments | 9.11ms | 13.26ms | 26.67ms | 0% | ✅ |
| POST /api/comments/{id}/comment-likes | 7.43ms | 11.2ms | 22.1ms | - | ✅*** |
| POST /api/users | 7.53ms | 11.96ms | 31.53ms | 0% | ✅ |
| POST /api/users/login | 4.91ms | 8.05ms | 29.29ms | 0% | ✅ |
| PATCH /api/notifications | 5.16ms | 13.27ms | 29.15ms | 0% | ✅ |
| PATCH /api/notifications/{id} | 3.67ms | 5.52ms | 16.37ms | 0% | ✅ |
| PATCH /api/interests/{id} | 9.61ms | 14.14ms | 21.07ms | 8.77% | ❌** |
| PATCH /api/comments/{id} | 9.08ms | 15.24ms | 50.75ms | 27.21% | ❌** |
| PATCH /api/users/{id} | 5.42ms | 7.65ms | 12.09ms | 0% | ✅ |
뉴스 기사 목록 조회
다중 테이블 JOIN, GROUP BYm totalElements 별도 쿼리가 동시에 실행되는 구조로 인해 300명 기준 평균 응답시간 268ms, p95 788ms로 기준을 초과했다. 캐싱 적용 후 평균 응답 시간 2.97ms, p95 7.91ms으로 약 100배 개선되었다.
## 성능 지표 결과
### 개선 전 성능 지표
| 지표 | 결과 | 기준 | 통과 여부 |
|------|------|------|---------|
| 평균 응답시간 (avg) | 268.23ms | - | - |
| p95 응답시간 | 788.43ms | 500ms | ❌ |
| p99 응답시간 | 872.13ms | 1000ms | ✅ |
| RPS (초당 요청 수) | 85.37 | - | - |
| 에러율 | 0.00% | 1% 미만 | ✅ |
| 전체 요청 수 | 6,055 | - | - |
### 개선 후 성능 지표 (캐싱 적용)
| 지표 | 결과 | 기준 | 통과 여부 |
|------|---------|------|---------|
| 평균 응답시간 (avg) | 2.97ms | - | - |
| p95 응답시간 | 7.91ms | 500ms | ✅ |
| p99 응답시간 | 24.06ms | 1000ms | ✅ |
| RPS (초당 요청 수) | 107.46 | - | - |
| 에러율 | 0.00% | 1% 미만 | ✅ |
| 전체 요청 수 | 7,625 | - | - |
사용자 활동내역 조회
MongoDB 조회 성능 부족으로 300명 기준 평균 응답시간 692ms, p95 1.91s로 기준을 크게 초과하였다. 캐싱 적용 후 평균 응답시간이 1.28ms, p95 2.24m로 약 540배 개선되었다.
## 성능 지표 결과
### 개선 전 성능 지표
| 지표 | 결과 | 기준 | 통과 여부 |
|------|------|------|---------|
| 평균 응답시간 (avg) | 698.02ms | - | - |
| p95 응답시간 | 1.91s | 500ms | ❌ |
| p99 응답시간 | 1.96s | 1000ms | ❌ |
| RPS (초당 요청 수) | 65.17 | - | - |
| 에러율 | 0.00% | 1% 미만 | ✅ |
| 전체 요청 수 | 4,593 | - | - |
### 개선 후 성능 지표 (캐싱 적용)
| 지표 | 결과 | 기준 | 통과 여부 |
|------|------|------|---------|
| 평균 응답시간 (avg) | 1.28ms | - | - |
| p95 응답시간 | 2.24ms | 500ms | ✅ |
| p99 응답시간 | 4ms | 1000ms | ✅ |
| RPS (초당 요청 수) | 107.73 | - | - |
| 에러율 | 0.00% | 1% 미만 | ✅ |
| 전체 요청 수 | 7,624 | - | - |
이외로도, 뉴스 기사 복구 API가 S3에서 백업 파일을 읽어 DB에 INSERT하는 무거운 작업 특성상 평균 응답시간 8.66ms, p95 16.25s로 기준을 초과하였으나, 실제 서비스에서 장애 상황에 단발성으로 실행되는 작업이므로 별도의 성능 개선을 진행하지는 않았다.
비관적 락이 적용된 관심사 수정 API와 중복 방지 로직이 있는 구독/좋아요 API는 단일 유저와 소수의 ID로 반복 요청하는 부하테스트 특성상 에러율이 높게 나왔으나, 실제 서비스 환경과는 차이가 있는 부분으로 판단했다.
배우고 느낀 점
부하테스트를 진행하면서 실제로 병목이 발생하는 지점을 찾고 개선해내가는 과정이 흥미로웠다. 특히 캐싱 적용 후 수백 배의 성능 개선이 수치로 확인되는 것이 인상적이었다.
실제 운영 환경이었다면 예측되는 트래픽 범위 안에서 부하테스트를 진행하고, 그 기준에 맞는 성능 목표를 설정하는 것이 중요함을 알게 되었다. 또한 성능 개선과 DB 조회 빈도 사이의 트레이드 오프를 고려하는 것의 중요성도 깨달았다. 캐싱을 적용하면 성능은 향상되지만 데이터 정합성 문제가 생길 수 있고, 캐시 무효화를 너무 자주하면 캐싱 효과가 줄어드는 점에서 적절한 균형을 찾는 것이 중요하다는 것을 배웠다.
정리
부하테스트를 통해 단순히 기능이 동작하는 것을 넘어, 실제 트래픽 환경에서의 성능을 검증하고 개선하는 경험을 할 수 있었다. 캐싱 전략 수집 시 캐시 키 설계, 캐시 무효화 시점, 성능과 데이터 정합성 사이의 트레이드오프를 종합적으로 고려해야 함을 배웠고, 이러한 경험이 실제 운영 환경에서도 큰 도움이 될 것이라 생각한다.