-
Notifications
You must be signed in to change notification settings - Fork 1
test: add k6 load/stress/spike/soak scripts for AI problem generation… #48
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,99 @@ | ||
| import http from 'k6/http'; // k6의 HTTP 통신 모듈 (GET, POST 등 요청용) | ||
| import { check } from 'k6'; // 응답 결과(200 OK 등) 성공 여부 검증용 모듈 | ||
|
|
||
| /** | ||
| * ============================================================================ | ||
| * [Problem-AI 도메인 전용 k6 설정 모듈] | ||
| * | ||
| * 📌 대상 API: POST /api/spaces/{spaceId}/problems/ai (AI 기반 문제 자동 생성) | ||
| * 📌 주의: 이 API는 호출마다 실제 LLM(외부 AI) API를 호출합니다. | ||
| * VU/duration을 무리하게 올리면 실제 비용이 발생하고, LLM 제공사 | ||
| * 자체 rate limit(429)에 걸려 우리 서버 문제와 구분이 안 될 수 있습니다. | ||
| * -> room-submit 테스트보다 VU를 훨씬 낮게 잡습니다. | ||
| * ============================================================================ | ||
| */ | ||
|
|
||
| // 1. [서버 주소] 현재 배포된 AWS ALB(로드밸런서) 퍼블릭 DNS 주소 | ||
| export const BASE_URL = 'http://momogo-alb-1906718718.ap-northeast-2.elb.amazonaws.com'; | ||
|
|
||
| // 2. [테스트 계정] ADMIN 권한 + 공간(Space) 소유 계정 | ||
| export const ADMIN_USER = { | ||
| email: 'jun@test.com', | ||
| password: '7964a23!', // TODO: 실제 비밀번호로 교체해서 로컬에서만 사용 (커밋 금지) | ||
| }; | ||
|
|
||
| // 3. [테스트 대상] ADMIN_USER 소유 공간/카테고리 UUID | ||
| export const SPACE_ID = '9d683a9a-9e5d-4a7c-9230-b40f7c62d31e'; | ||
| export const CATEGORY_ID = 'effa0724-42c5-4335-964c-1e2556007e1a'; | ||
|
|
||
| /** | ||
| * [JWT 인증 토큰 발급 setup 함수] | ||
| * 💡 백엔드 Spring Security 폼 로그인(/api/auth/sign-in) 규격에 맞춰 JWT AccessToken을 받아옵니다. | ||
| */ | ||
| export function obtainAuthToken() { | ||
| const loginUrl = `${BASE_URL}/api/auth/sign-in`; | ||
|
|
||
| const formData = `username=${encodeURIComponent(ADMIN_USER.email)}&password=${encodeURIComponent(ADMIN_USER.password)}`; | ||
|
|
||
| const headers = { | ||
| headers: { | ||
| 'Content-Type': 'application/x-www-form-urlencoded', | ||
| }, | ||
| }; | ||
|
|
||
| const loginRes = http.post(loginUrl, formData, headers); | ||
|
|
||
| const isLoginSuccess = check(loginRes, { | ||
| 'Login Successful (HTTP 200)': (r) => r.status === 200, | ||
| }); | ||
|
|
||
| if (!isLoginSuccess) { | ||
| console.error(`[Setup 에러] 로그인 실패 (Status: ${loginRes.status}, Body: ${loginRes.body}).`); | ||
| console.error(`=> DB에 ${ADMIN_USER.email} 계정이 존재하는지, ADMIN 권한인지 확인해 주세요!`); | ||
| return 'mock-jwt-access-token-sample'; | ||
| } | ||
|
|
||
| const token = loginRes.json('accessToken') || loginRes.headers['Authorization'] || 'mock-jwt-access-token-sample'; | ||
| const cleanToken = token.replace('Bearer ', ''); | ||
|
Comment on lines
+50
to
+57
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🎯 Functional Correctness | 🟠 Major | ⚡ Quick win 🧩 Analysis chain🏁 Script executed: #!/bin/bash
set -euo pipefail
echo "Files:"
git ls-files | rg '(^|/)k6/problem-ai/config\.js$|k6/problem-ai|config\.js' || true
echo
echo "Outline:"
ast-grep outline k6/problem-ai/config.js || true
echo
nl -ba k6/problem-ai/config.js | sed -n '1,130p'Repository: MoMoGo-QuizPlatform/MoMoGo Length of output: 957 🏁 Script executed: #!/bin/bash
set -euo pipefail
echo "config.js relevant sections:"
awk 'NR>=1 && NR<=110 {printf "%6d: %s\n", NR, $0}' k6/problem-ai/config.js
echo
echo "load-test.js relevant sections:"
awk 'NR>=1 && NR<=220 {printf "%6d: %s\n", NR, $0}' k6/problem-ai/load-test.js
echo
echo "Search for obtainAuthToken/obtainCsrfToken usages:"
rg -n "obtainAuthToken|obtainCsrfToken|csrf|accessToken|mock-jwt|CSRF" k6/problem-ai -SRepository: MoMoGo-QuizPlatform/MoMoGo Length of output: 11386 🌐 Web query:
💡 Result: In k6, the behavior of an exception depends on where it occurs and how you handle it. By default, uncaught exceptions during the test execution phase (in the default function) abort the current iteration but do not necessarily terminate the entire test run with a non-zero exit code [1][2]. To ensure your test process exits with a non-zero code when an error occurs, you can use the following approaches: 1. Use exec.test.abort: If you want to stop the entire test execution immediately and signal a failure, use the k6/execution module [3]. import exec from 'k6/execution'; // When you encounter an error exec.test.abort('Aborting test due to critical error'); This will cause k6 to stop and exit with a non-zero status [4]. 2. Use Soft Assertions: If you are using the k6/testing library, soft assertions can mark a test as failed while allowing it to continue execution. If any soft assertions fail, k6 will exit with code 110 upon completion [5]. import { expect } from 'https://jslib.k6.io/k6-testing/0.6.1/index.js'; export default function { expect.soft(response.status).toBe(200); } 3. Exceptions in the Init Context: If an exception is thrown in the init context (outside the default function), k6 will immediately abort the entire test execution with a non-zero exit code [3]. 4. Understanding Exit Codes: k6 uses specific exit codes for different scenarios [4]. A ScriptException typically corresponds to code 107 [4]. If you are looking to distinguish between different failure types, note that these codes are internal to k6 and serve as the standard mechanism for reporting how the process finished [4]. Summary: - fail only aborts the current iteration [2]. - exec.test.abort terminates the entire test run [3]. - Soft assertions (from k6/testing) will cause an exit code of 110 [5]. - Uncaught exceptions generally do not guarantee a non-zero exit code for the entire test process, so explicit abortion is recommended for reliable CI/CD integration [6][7]. Citations:
[중요] 초기 인증 또는 CSRF 획득 실패 시 부하 테스트를 중단하세요.
🧰 Tools🪛 ast-grep (0.45.0)[warning] 51-51: Avoid logging sensitive data (log-sensitive-data) 🤖 Prompt for AI Agents |
||
|
|
||
| console.log(`[Setup 성공] 테스트 계정(${ADMIN_USER.email}) 로그인 및 JWT 토큰 발급 완료!`); | ||
| return cleanToken; | ||
| } | ||
|
|
||
| /** | ||
| * [CSRF 토큰 조회 함수] | ||
| * 💡 SecurityConfig에서 '/api/auth/sign-in'만 CSRF 예외 처리됨. POST/PUT/PATCH/DELETE 요청은 | ||
| * 전부 CSRF 토큰(쿠키+헤더 X-XSRF-TOKEN 일치) 없으면 403 Forbidden 처리됨 (JWT 인증과 별개). | ||
| * ⚠️ 로그인 응답(/api/auth/sign-in)이 이미 XSRF-TOKEN 쿠키를 같이 내려주므로, | ||
| * 반드시 obtainAuthToken()을 먼저 호출한 뒤에 이 함수를 호출해야 함. | ||
| * (별도로 /api/auth/csrf-token GET을 다시 호출하면, 이미 쿠키가 있어 서버가 Set-Cookie를 | ||
| * 다시 내려주지 않아 응답 자체에서는 토큰을 못 얻는 함정이 있어 쿠키 jar에서 직접 읽음) | ||
| */ | ||
| export function obtainCsrfToken() { | ||
| const cookies = http.cookieJar().cookiesForURL(BASE_URL); | ||
|
|
||
| const csrfToken = cookies['XSRF-TOKEN'] ? cookies['XSRF-TOKEN'][0] : null; | ||
|
|
||
| if (!csrfToken) { | ||
| console.error('[Setup 에러] CSRF 토큰 쿠키를 찾지 못했습니다. obtainAuthToken()을 먼저 호출했는지 확인하세요.'); | ||
| } | ||
|
|
||
| return csrfToken; | ||
| } | ||
|
|
||
| /** | ||
| * [AI 문제 생성 요청 Payload 생성 함수] | ||
| * ProblemAiCreateRequest 스펙: categoryId(필수), referenceText(필수, 10000자 이하), questionCount(1~10) | ||
| */ | ||
| export function getProblemAiPayload(questionCount = 3) { | ||
| return JSON.stringify({ | ||
| categoryId: CATEGORY_ID, | ||
| referenceText: `세종대왕은 1443년 훈민정음을 창제하여 백성들이 쉽게 글을 배우고 쓸 수 있도록 했다. | ||
| 당시 한자는 배우기 어려워 일반 백성들이 문자 생활에서 소외되어 있었는데, 세종대왕은 이를 문제로 인식하고 | ||
| 집현전 학자들과 함께 소리 나는 대로 적을 수 있는 표음 문자 체계를 연구했다. 훈민정음은 자음 17자와 모음 11자, | ||
| 총 28자로 구성되어 있으며 초성, 중성, 종성을 조합해 음절을 표기하는 독창적인 원리를 갖고 있다. | ||
| 1446년 훈민정음 해례본을 반포하며 그 창제 원리와 사용법을 상세히 기록했다. 오늘날 한글은 배우기 쉽고 | ||
| 표현이 정확한 문자로 세계적으로도 과학적인 문자 체계로 평가받고 있다.`, | ||
| questionCount, | ||
| }); | ||
| } | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,166 @@ | ||
| { | ||
| "root_group": { | ||
| "id": "d41d8cd98f00b204e9800998ecf8427e", | ||
| "groups": { | ||
| "setup": { | ||
| "groups": {}, | ||
| "checks": { | ||
| "Login Successful (HTTP 200)": { | ||
| "name": "Login Successful (HTTP 200)", | ||
| "path": "::setup::Login Successful (HTTP 200)", | ||
| "id": "bedba4f0acf48db4a1e0b99e768818d4", | ||
| "passes": 1, | ||
| "fails": 0 | ||
| } | ||
| }, | ||
| "name": "setup", | ||
| "path": "::setup", | ||
| "id": "5c0f8025f7e0b6654089e5b00e950f1a" | ||
| } | ||
| }, | ||
| "checks": { | ||
| "AI Problem Generation Success (201)": { | ||
| "name": "AI Problem Generation Success (201)", | ||
| "path": "::AI Problem Generation Success (201)", | ||
| "id": "2641500d9aae22ca0aeb735974979037", | ||
| "passes": 74, | ||
| "fails": 4 | ||
| } | ||
| }, | ||
| "name": "", | ||
| "path": "" | ||
| }, | ||
| "metrics": { | ||
| "error_rate": { | ||
| "passes": 4, | ||
| "fails": 74, | ||
| "thresholds": { | ||
| "rate<0.05": true | ||
| }, | ||
| "value": 0.05128205128205128 | ||
| }, | ||
| "http_req_sending": { | ||
| "min": 0, | ||
| "med": 0, | ||
| "max": 0.5059, | ||
| "p(90)": 0, | ||
| "p(95)": 0, | ||
| "avg": 0.006403797468354431 | ||
| }, | ||
| "checks": { | ||
| "passes": 75, | ||
| "fails": 4, | ||
| "value": 0.9493670886075949 | ||
| }, | ||
| "http_req_failed": { | ||
| "passes": 4, | ||
| "fails": 75, | ||
| "value": 0.05063291139240506 | ||
| }, | ||
| "http_req_blocked": { | ||
| "p(95)": 0.8032699999999544, | ||
| "avg": 0.9820379746835444, | ||
| "min": 0, | ||
| "med": 0, | ||
| "max": 41.5508, | ||
| "p(90)": 0 | ||
| }, | ||
| "data_sent": { | ||
| "count": 126051, | ||
| "rate": 505.8668829872479 | ||
| }, | ||
| "http_req_connecting": { | ||
| "p(90)": 0, | ||
| "p(95)": 0.8032699999999544, | ||
| "avg": 0.6721151898734177, | ||
| "min": 0, | ||
| "med": 0, | ||
| "max": 17.0669 | ||
| }, | ||
| "vus": { | ||
| "value": 1, | ||
| "min": 1, | ||
| "max": 3 | ||
| }, | ||
| "iteration_duration": { | ||
| "avg": 8400.927180769231, | ||
| "min": 4431.3608, | ||
| "med": 4720.69545, | ||
| "max": 50535.0224, | ||
| "p(90)": 15569.640969999991, | ||
| "p(95)": 30778.169084999983 | ||
| }, | ||
| "vus_max": { | ||
| "value": 3, | ||
| "min": 3, | ||
| "max": 3 | ||
| }, | ||
| "http_req_tls_handshaking": { | ||
| "avg": 0, | ||
| "min": 0, | ||
| "med": 0, | ||
| "max": 0, | ||
| "p(90)": 0, | ||
| "p(95)": 0 | ||
| }, | ||
| "http_req_waiting": { | ||
| "avg": 5332.521148101266, | ||
| "min": 103.6248, | ||
| "med": 1720.0426, | ||
| "max": 47533.7525, | ||
| "p(90)": 12265.669140000009, | ||
| "p(95)": 27687.98786999999 | ||
| }, | ||
| "http_req_duration{expected_response:true}": { | ||
| "med": 1717.5031, | ||
| "max": 29299.3488, | ||
| "p(90)": 9299.28142, | ||
| "p(95)": 12569.229029999991, | ||
| "avg": 3606.2454613333334, | ||
| "min": 103.6248 | ||
| }, | ||
| "data_received": { | ||
| "count": 181631, | ||
| "rate": 728.9201023701265 | ||
| }, | ||
| "ai_generation_latency": { | ||
| "min": 1431, | ||
| "med": 1720.5, | ||
| "max": 47534, | ||
| "p(90)": 12569.199999999992, | ||
| "p(95)": 27777.649999999983, | ||
| "avg": 5400.384615384615 | ||
| }, | ||
| "iterations": { | ||
| "count": 78, | ||
| "rate": 0.3130289872591676 | ||
| }, | ||
| "http_req_duration": { | ||
| "max": 47534.4313, | ||
| "p(90)": 12265.857620000008, | ||
| "p(95)": 27688.06979999999, | ||
| "avg": 5332.896022784809, | ||
| "min": 103.6248, | ||
| "med": 1720.0426, | ||
| "thresholds": { | ||
| "p(95)<15000": true | ||
| } | ||
| }, | ||
| "http_reqs": { | ||
| "count": 79, | ||
| "rate": 0.31704217940351587 | ||
| }, | ||
| "http_req_receiving": { | ||
| "p(95)": 1.50718, | ||
| "avg": 0.36847088607594924, | ||
| "min": 0, | ||
| "med": 0, | ||
| "max": 1.8783, | ||
| "p(90)": 0.94332 | ||
| } | ||
| }, | ||
| "setup_data": { | ||
| "authToken": "eyJhbGciOiJIUzI1NiJ9.eyJzdWIiOiJqdW5AdGVzdC5jb20iLCJyb2xlcyI6WyJST0xFX0FETUlOIl0sIm5hbWUiOiJqdW4iLCJ1c2VyRW1haWwiOiJqdW5AdGVzdC5jb20iLCJ0eXBlIjoiYWNjZXNzIiwiZXhwIjoxNzg1MjIxMTczLCJ1c2VySWQiOiJjYmE4YTFkZi1hZTYzLTRlYmMtYWI5Ni1kNjc1ZGQxMjljNjMiLCJpYXQiOjE3ODUyMTkzNzMsImp0aSI6IjIzODMwZDg0LWFkZmQtNDg2Ni1iNmZmLTIyNGM1OTYzOTU0MSJ9.AI070Smmkh9iEhirwSCgCyHs7vY6I6i6FKezWwE5RA8", | ||
| "csrfToken": "bb86b3a1-9911-4131-99f3-64c75d44f20b" | ||
|
Comment on lines
+163
to
+164
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🔒 Security & Privacy | 🔴 Critical | 🏗️ Heavy lift [차단] 실행 결과에 포함된 인증 정보를 즉시 제거하고 폐기하세요. 커밋된 결과 파일에 JWT와 CSRF 토큰이 그대로 포함되어 있습니다. 결과 저장 전
🧰 Tools🪛 Betterleaks (1.7.0)[high] 163-163: Uncovered a JSON Web Token, which may lead to unauthorized access to web applications and sensitive user data. (jwt) 📍 Affects 4 files
🤖 Prompt for AI AgentsSource: Linters/SAST tools |
||
| } | ||
| } | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,74 @@ | ||
| import http from 'k6/http'; // k6의 HTTP 통신 모듈 (GET, POST 등 전송용) | ||
| import { check, sleep } from 'k6'; // 응답 결과 검증(check) 및 요청 간 쉼(sleep) 모듈 | ||
| import { Rate, Trend } from 'k6/metrics'; // 에러율(Rate) 및 응답시간(Trend) 커스텀 측정 모듈 | ||
| import { BASE_URL, SPACE_ID, obtainAuthToken, obtainCsrfToken, getProblemAiPayload } from './config.js'; // Problem-AI 전용 설정 모듈 참조 | ||
|
|
||
| /** | ||
| * ============================================================================ | ||
| * [도메인: Problem-AI / 기능: AI 기반 문제 자동 생성 (POST /api/spaces/{spaceId}/problems/ai)] | ||
| * [1. Load Test - 평시 목표 부하 테스트] | ||
| * | ||
| * 🎯 테스트 목적: | ||
| * - 관리자 2~3명이 동시에 AI 문제 생성을 사용하는 평시 상황에서 | ||
| * 응답 속도/에러율이 안정적인지 baseline을 확인합니다. | ||
| * - 이 API는 동기 LLM 호출 구조(캐시/큐 없음)라 room-submit보다 훨씬 낮은 VU로 진행합니다. | ||
| * ============================================================================ | ||
| */ | ||
|
|
||
| const errorRate = new Rate('error_rate'); // 전체 요청 중 실패한 요청 비율 수집 변수 | ||
| const aiLatency = new Trend('ai_generation_latency'); // AI 문제 생성 API 응답 지연시간(ms) 수집 변수 | ||
|
|
||
| export const options = { | ||
| stages: [ | ||
| { duration: '30s', target: 3 }, // [구간 1] 30초간 VU 3명까지 서서히 상향 (Ramp-up) | ||
| { duration: '3m', target: 3 }, // [구간 2] 3분간 VU 3명 유지하며 평시 부하 측정 (Steady State) | ||
| { duration: '30s', target: 0 }, // [구간 3] 30초간 VU 0명으로 감속하여 종료 (Ramp-down) | ||
| ], | ||
| thresholds: { | ||
| 'http_req_duration': ['p(95)<15000'], // LLM 호출 특성상 15초 기준 (baseline 확보 후 재조정) | ||
| 'error_rate': ['rate<0.05'], // 에러 발생 비율이 5% 미만이어야 성공 | ||
| }, | ||
| }; | ||
|
|
||
| // [setup 단계] 테스트 시작 전 ADMIN 계정으로 JWT 토큰 + CSRF 토큰 1회 발급 | ||
| export function setup() { | ||
| const token = obtainAuthToken(); | ||
| const csrfToken = obtainCsrfToken(); | ||
| return { authToken: token, csrfToken }; | ||
| } | ||
|
|
||
| // [main 단계] 가상 유저(VU)들이 반복 실행할 메인 테스트 로직 | ||
| export default function (data) { | ||
| const targetUrl = `${BASE_URL}/api/spaces/${SPACE_ID}/problems/ai`; | ||
|
|
||
| const payload = getProblemAiPayload(3); // 문항 3개 생성 요청 | ||
|
|
||
| const params = { | ||
| headers: { | ||
| 'Content-Type': 'application/json', | ||
| 'Authorization': `Bearer ${data.authToken}`, | ||
| 'X-XSRF-TOKEN': data.csrfToken, | ||
| 'Cookie': `XSRF-TOKEN=${data.csrfToken}`, | ||
| }, | ||
| }; | ||
|
|
||
| const startTime = Date.now(); | ||
|
|
||
| const res = http.post(targetUrl, payload, params); | ||
|
|
||
| const duration = Date.now() - startTime; | ||
|
|
||
| const isSuccess = check(res, { | ||
| 'AI Problem Generation Success (201)': (r) => r.status === 201, | ||
| }); | ||
|
|
||
| if (!isSuccess) { | ||
| console.error(`[요청 실패] Status: ${res.status}, Body: ${res.body}`); | ||
| } | ||
|
|
||
| errorRate.add(!isSuccess); | ||
| aiLatency.add(duration); | ||
|
|
||
| // 관리자가 결과 확인 후 다시 생성하는 현실적인 간격 유도 (3초 휴식) | ||
| sleep(3); | ||
| } |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
🔒 Security & Privacy | 🔴 Critical | ⚡ Quick win
🧩 Analysis chain
🏁 Script executed:
Repository: MoMoGo-QuizPlatform/MoMoGo
Length of output: 742
🏁 Script executed:
Repository: MoMoGo-QuizPlatform/MoMoGo
Length of output: 1057
🏁 Script executed:
Repository: MoMoGo-QuizPlatform/MoMoGo
Length of output: 34324
기본값 없는 비밀값 구성만 요청합니다.
k6/problem-ai/config.js의 테스트 계정 비밀번호가 저장소에 직접 들어갔습니다. 커밋이 완료된 상태이므로 이 암호는 폐기/교체해 주세요. 동시에BASE_URL과ADMIN_USER도 기본값 없이__ENV.*에서 주입하도록 분리하는 것이 좋습니다.🤖 Prompt for AI Agents