Skip to content

Commit a91e820

Browse files
authored
Merge pull request #203 from prgrms-be-devcourse/feat/202-모니터링
feat: 모니터링 설정 및 로컬 k6 부하 테스트 추가
2 parents cb38001 + 315104f commit a91e820

10 files changed

Lines changed: 553 additions & 1 deletion

File tree

build.gradle.kts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -96,6 +96,7 @@ repositories {
9696

9797
dependencies {
9898
implementation("org.springframework.boot:spring-boot-starter-actuator")
99+
implementation("io.micrometer:micrometer-registry-prometheus")
99100
implementation("org.springframework.boot:spring-boot-starter-batch")
100101
implementation("org.springframework.boot:spring-boot-starter-data-jpa")
101102
implementation("org.springframework.boot:spring-boot-starter-oauth2-client")

docs/monitoring-k6-guide.md

Lines changed: 184 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,184 @@
1+
# 모니터링 + k6 부하테스트 가이드
2+
3+
이 문서는 현재 프로젝트 백엔드(Spring Boot) 기준으로, 다음 두 가지를 빠르게 적용/운영하기 위한 정리입니다.
4+
5+
- 모니터링: `Actuator + Prometheus + Grafana`
6+
- 로컬 부하테스트: `k6`
7+
8+
## 1. 현재 백엔드 준비 상태
9+
10+
아래 항목이 코드에 반영되어 있어야 Grafana에서 메트릭을 볼 수 있습니다.
11+
12+
- 의존성
13+
- `org.springframework.boot:spring-boot-starter-actuator`
14+
- `io.micrometer:micrometer-registry-prometheus`
15+
- 설정 (`application-dev.yml`, `application-prod.yml`)
16+
- `management.endpoints.web.exposure.include: health, prometheus`
17+
- `management.prometheus.metrics.export.enabled: true`
18+
- `management.metrics.distribution.percentiles-histogram.http.server.requests: true`
19+
- 보안 허용 (`SecurityConfig`)
20+
- `/actuator/health`
21+
- `/actuator/prometheus`
22+
23+
## 2. 모니터링 점검 순서
24+
25+
### 2.1 백엔드 메트릭 엔드포인트 확인
26+
27+
로컬:
28+
29+
```bash
30+
curl -s http://localhost:8080/actuator/health
31+
curl -s http://localhost:8080/actuator/prometheus | head -n 20
32+
```
33+
34+
배포:
35+
36+
```bash
37+
curl -s http://app_blue:8080/actuator/prometheus | head -n 20
38+
```
39+
40+
### 2.2 Prometheus 타겟 확인
41+
42+
`http://<EC2_IP>:9090/targets`에서 아래가 `UP`인지 확인합니다.
43+
44+
- `prometheus`
45+
- `app-blue` (`app_blue:8080`)
46+
- `app-green` (`app_green:8080`)
47+
48+
### 2.3 Grafana 데이터소스 확인
49+
50+
1. `http://<EC2_IP>:3000` 접속
51+
2. `admin / password_1` 값으로 로그인
52+
3. `Data Sources > Prometheus > Save & test`
53+
54+
## 3. Grafana 추천 패널 (PromQL)
55+
56+
### 3.1 트래픽 (RPS)
57+
58+
```promql
59+
sum(rate(http_server_requests_seconds_count[1m]))
60+
```
61+
62+
### 3.2 평균 응답시간 (초)
63+
64+
```promql
65+
sum(rate(http_server_requests_seconds_sum[1m]))
66+
/
67+
sum(rate(http_server_requests_seconds_count[1m]))
68+
```
69+
70+
### 3.3 p95 응답시간 (초)
71+
72+
```promql
73+
histogram_quantile(
74+
0.95,
75+
sum(rate(http_server_requests_seconds_bucket[5m])) by (le)
76+
)
77+
```
78+
79+
### 3.4 5xx 비율
80+
81+
```promql
82+
sum(rate(http_server_requests_seconds_count{status=~"5.."}[5m]))
83+
/
84+
sum(rate(http_server_requests_seconds_count[5m]))
85+
```
86+
87+
### 3.5 JVM Heap 사용량 (바이트)
88+
89+
```promql
90+
sum(jvm_memory_used_bytes{area="heap"})
91+
```
92+
93+
## 4. Node Exporter 추가 (호스트 메트릭)
94+
95+
EC2에서 아래처럼 실행하면 호스트 CPU/메모리/디스크 지표를 수집할 수 있습니다.
96+
97+
```bash
98+
docker run -d \
99+
--name node_exporter \
100+
--restart unless-stopped \
101+
--network common \
102+
-p 9100:9100 \
103+
--pid="host" \
104+
-v "/:/host:ro,rslave" \
105+
quay.io/prometheus/node-exporter:latest \
106+
--path.rootfs=/host
107+
```
108+
109+
Prometheus 설정(`prometheus.yml`)에 타겟을 추가합니다.
110+
111+
```yaml
112+
scrape_configs:
113+
- job_name: "node"
114+
static_configs:
115+
- targets: ["node_exporter:9100"]
116+
```
117+
118+
적용 후:
119+
120+
```bash
121+
docker restart prometheus_1
122+
curl -s http://localhost:9090/api/v1/targets | head -c 1200
123+
```
124+
125+
Node Exporter가 `UP`이면 Grafana에서 아래 지표를 사용할 수 있습니다.
126+
127+
- `rate(node_cpu_seconds_total{mode!="idle"}[1m])`
128+
- `node_memory_MemAvailable_bytes`
129+
- `node_filesystem_avail_bytes`
130+
131+
## 5. 로컬 k6 부하테스트
132+
133+
## 5.1 설치
134+
135+
macOS(Homebrew):
136+
137+
```bash
138+
brew install k6
139+
```
140+
141+
확인:
142+
143+
```bash
144+
k6 version
145+
```
146+
147+
## 5.2 스크립트 위치
148+
149+
- `k6/smoke.js`: 짧은 기본 점검
150+
- `k6/load.js`: 지속 부하 테스트
151+
- `k6/spike.js`: 급격한 트래픽 증가 테스트
152+
153+
## 5.3 실행 전 환경변수
154+
155+
```bash
156+
export BASE_URL=http://localhost:8080
157+
export K6_EMAIL=admin@gmail.com
158+
export K6_PASSWORD=admin1234
159+
# 선택: 인증 시나리오에서 사용할 문제 ID
160+
export K6_PROBLEM_ID=1
161+
```
162+
163+
`K6_EMAIL/K6_PASSWORD` 계정은 미리 가입되어 있어야 합니다.
164+
165+
## 5.4 실행 명령
166+
167+
```bash
168+
k6 run k6/smoke.js
169+
k6 run k6/load.js
170+
k6 run k6/spike.js
171+
```
172+
173+
## 6. 권장 기준(초안)
174+
175+
- `http_req_failed`: `< 1%`
176+
- 일반 조회 API `p95`: `< 700ms`
177+
- 채점 요청 API `p95`: `< 2s` (비동기 큐 진입 기준)
178+
179+
## 7. 주의사항
180+
181+
- k6는 로컬 또는 별도 로드 제너레이터에서 실행하세요.
182+
- 앱 서버에서 직접 부하를 걸면 CPU/메모리 경쟁으로 결과가 왜곡될 수 있습니다.
183+
- 테스트 전/후 DB 상태를 확인하고, 운영 DB에는 직접 대량 부하를 주지 않는 것을 권장합니다.
184+
- WebSocket 배틀 시나리오는 HTTP 시나리오 안정화 후 2차로 분리해 진행하세요.

k6/README.md

Lines changed: 54 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,54 @@
1+
# k6 로컬 부하테스트
2+
3+
## 1) 사전 준비
4+
5+
- 백엔드 로컬 실행 (`http://localhost:8080`)
6+
- 테스트 계정 준비 (`K6_EMAIL`, `K6_PASSWORD`)
7+
8+
> 로그인은 쿠키 기반이므로, 각 VU에서 최초 1회 로그인 후 인증 API를 호출합니다.
9+
10+
## 2) 설치
11+
12+
```bash
13+
brew install k6
14+
k6 version
15+
```
16+
17+
## 3) 환경변수
18+
19+
```bash
20+
export BASE_URL=http://localhost:8080
21+
export K6_EMAIL=admin@gmail.com
22+
export K6_PASSWORD=admin1234
23+
24+
# 선택: 문제 ID를 고정하고 싶을 때
25+
export K6_PROBLEM_ID=1
26+
```
27+
28+
`K6_PROBLEM_ID`를 지정하지 않으면 `/api/v1/problems?page=0&size=1`에서 첫 문제를 자동 사용합니다.
29+
30+
## 4) 실행
31+
32+
```bash
33+
k6 run k6/smoke.js
34+
k6 run k6/load.js
35+
k6 run k6/spike.js
36+
```
37+
38+
## 5) 시나리오 설명
39+
40+
- `smoke.js`
41+
- 빠른 헬스체크 성격
42+
- 공개 API + 인증 API 기본 응답 확인
43+
- `load.js`
44+
- 점진적 증가 후 유지하는 일반 부하
45+
- 목록/상세/내정보/solo run 혼합
46+
- `spike.js`
47+
- 단시간 급증 트래픽 대응 확인
48+
49+
## 6) 참고
50+
51+
- 결과에서 먼저 볼 지표
52+
- `http_req_failed`
53+
- `http_req_duration p(95), p(99)`
54+
- 메트릭 대시보드는 `docs/monitoring-k6-guide.md`를 참고하세요.

k6/lib/common.js

Lines changed: 56 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,56 @@
1+
import http from "k6/http";
2+
import { check, fail } from "k6";
3+
4+
export const BASE_URL = __ENV.BASE_URL || "http://localhost:8080";
5+
export const EMAIL = __ENV.K6_EMAIL || "admin@gmail.com";
6+
export const PASSWORD = __ENV.K6_PASSWORD || "admin1234";
7+
export const PROBLEM_ID = Number(__ENV.K6_PROBLEM_ID || 0);
8+
9+
export function jsonHeaders() {
10+
return {
11+
headers: {
12+
"Content-Type": "application/json",
13+
},
14+
};
15+
}
16+
17+
export function loginOrFail() {
18+
const payload = JSON.stringify({
19+
email: EMAIL,
20+
password: PASSWORD,
21+
});
22+
23+
const res = http.post(`${BASE_URL}/api/v1/members/login`, payload, jsonHeaders());
24+
const ok = check(res, {
25+
"login status is 200": (r) => r.status === 200,
26+
});
27+
28+
if (!ok) {
29+
fail(`login failed: status=${res.status}, body=${res.body}`);
30+
}
31+
}
32+
33+
export function getProblemList(page = 0, size = 20) {
34+
return http.get(`${BASE_URL}/api/v1/problems?page=${page}&size=${size}`);
35+
}
36+
37+
export function pickProblemIdFromListResponse(res) {
38+
if (res.status !== 200) {
39+
return null;
40+
}
41+
try {
42+
const body = JSON.parse(res.body);
43+
const first = body?.problems?.[0];
44+
return typeof first?.problemId === "number" ? first.problemId : null;
45+
} catch (e) {
46+
return null;
47+
}
48+
}
49+
50+
export function resolveProblemId() {
51+
if (PROBLEM_ID > 0) {
52+
return PROBLEM_ID;
53+
}
54+
const listRes = getProblemList(0, 1);
55+
return pickProblemIdFromListResponse(listRes);
56+
}

k6/load.js

Lines changed: 80 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,80 @@
1+
import { check, sleep, group } from "k6";
2+
import http from "k6/http";
3+
import { BASE_URL, loginOrFail, resolveProblemId, jsonHeaders } from "./lib/common.js";
4+
5+
export const options = {
6+
scenarios: {
7+
sustained_load: {
8+
executor: "ramping-vus",
9+
startVUs: 10,
10+
stages: [
11+
{ duration: "3m", target: 30 },
12+
{ duration: "5m", target: 60 },
13+
{ duration: "5m", target: 60 },
14+
{ duration: "2m", target: 0 },
15+
],
16+
gracefulRampDown: "30s",
17+
},
18+
},
19+
thresholds: {
20+
http_req_failed: ["rate<0.01"],
21+
http_req_duration: ["p(95)<1200", "p(99)<2000"],
22+
},
23+
};
24+
25+
let initialized = false;
26+
let problemId = null;
27+
28+
function initOncePerVu() {
29+
if (initialized) {
30+
return;
31+
}
32+
loginOrFail();
33+
problemId = resolveProblemId();
34+
initialized = true;
35+
}
36+
37+
function runSolo(problemIdValue) {
38+
const payload = JSON.stringify({
39+
problemId: problemIdValue,
40+
language: "python3",
41+
code: "def solve():\n print('test')\n\nif __name__ == '__main__':\n solve()\n",
42+
});
43+
const res = http.post(`${BASE_URL}/api/v1/solo/run`, payload, jsonHeaders());
44+
check(res, {
45+
"solo run accepted": (r) => r.status === 200,
46+
});
47+
}
48+
49+
export default function () {
50+
initOncePerVu();
51+
52+
group("list-and-detail", () => {
53+
const list = http.get(`${BASE_URL}/api/v1/problems?page=0&size=20`);
54+
check(list, {
55+
"list status 200": (r) => r.status === 200,
56+
});
57+
58+
if (problemId) {
59+
const detail = http.get(`${BASE_URL}/api/v1/problems/${problemId}`);
60+
check(detail, {
61+
"detail status 200": (r) => r.status === 200,
62+
});
63+
}
64+
});
65+
66+
group("member-api", () => {
67+
const me = http.get(`${BASE_URL}/api/v1/members/me`);
68+
check(me, {
69+
"me status 200": (r) => r.status === 200,
70+
});
71+
});
72+
73+
if (problemId) {
74+
group("solo-run", () => {
75+
runSolo(problemId);
76+
});
77+
}
78+
79+
sleep(0.5);
80+
}

0 commit comments

Comments
 (0)