diff --git a/k6/problem-ai/config.js b/k6/problem-ai/config.js new file mode 100644 index 0000000..7e3bb9a --- /dev/null +++ b/k6/problem-ai/config.js @@ -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 ', ''); + + 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, + }); +} diff --git a/k6/problem-ai/load-result.json b/k6/problem-ai/load-result.json new file mode 100644 index 0000000..ce944ba --- /dev/null +++ b/k6/problem-ai/load-result.json @@ -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" + } +} \ No newline at end of file diff --git a/k6/problem-ai/load-test.js b/k6/problem-ai/load-test.js new file mode 100644 index 0000000..408a882 --- /dev/null +++ b/k6/problem-ai/load-test.js @@ -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); +} diff --git a/k6/problem-ai/soak-result.json b/k6/problem-ai/soak-result.json new file mode 100644 index 0000000..2eba3c4 --- /dev/null +++ b/k6/problem-ai/soak-result.json @@ -0,0 +1,166 @@ +{ + "root_group": { + "name": "", + "path": "", + "id": "d41d8cd98f00b204e9800998ecf8427e", + "groups": { + "setup": { + "name": "setup", + "path": "::setup", + "id": "5c0f8025f7e0b6654089e5b00e950f1a", + "groups": {}, + "checks": { + "Login Successful (HTTP 200)": { + "fails": 0, + "name": "Login Successful (HTTP 200)", + "path": "::setup::Login Successful (HTTP 200)", + "id": "bedba4f0acf48db4a1e0b99e768818d4", + "passes": 1 + } + } + } + }, + "checks": { + "AI Problem Generation Success (201)": { + "id": "2641500d9aae22ca0aeb735974979037", + "passes": 114, + "fails": 4, + "name": "AI Problem Generation Success (201)", + "path": "::AI Problem Generation Success (201)" + } + } + }, + "metrics": { + "http_req_duration": { + "avg": 6431.187930252105, + "min": 92.5452, + "med": 1865.5074, + "max": 60000.4346, + "p(90)": 13880.99628000001, + "p(95)": 38808.42451999996, + "thresholds": { + "p(95)<15000": true + } + }, + "http_req_receiving": { + "max": 1.5068, + "p(90)": 0.9184, + "p(95)": 1.0269199999999992, + "avg": 0.2940235294117648, + "min": 0, + "med": 0 + }, + "checks": { + "passes": 115, + "fails": 4, + "value": 0.9663865546218487 + }, + "http_req_duration{expected_response:true}": { + "avg": 4568.101827826085, + "min": 92.5452, + "med": 1804.9854, + "max": 49466.0998, + "p(90)": 6804.930180000001, + "p(95)": 27549.091109999994 + }, + "data_sent": { + "count": 190571, + "rate": 263.68869497938397 + }, + "vus": { + "value": 1, + "min": 1, + "max": 2 + }, + "ai_generation_latency": { + "p(95)": 39217.09999999993, + "avg": 6485.4915254237285, + "min": 1482, + "med": 1867.5, + "max": 60001, + "p(90)": 14263.19999999999 + }, + "http_req_blocked": { + "med": 0, + "max": 31.4853, + "p(90)": 0, + "p(95)": 8.950599999999998, + "avg": 0.8108773109243698, + "min": 0 + }, + "iteration_duration": { + "med": 6867.6497500000005, + "max": 65001.2099, + "p(90)": 19263.86304999999, + "p(95)": 44217.79187499993, + "avg": 11485.922727118656, + "min": 6482.2853 + }, + "vus_max": { + "value": 2, + "min": 2, + "max": 2 + }, + "http_req_connecting": { + "min": 0, + "med": 0, + "max": 13.133, + "p(90)": 0, + "p(95)": 8.108739999999994, + "avg": 0.6090352941176471 + }, + "http_req_waiting": { + "p(95)": 38807.87416999995, + "avg": 6430.889257983194, + "min": 92.5452, + "med": 1865.5074, + "max": 60000.4346, + "p(90)": 13879.967000000011 + }, + "http_req_sending": { + "max": 0.5532, + "p(90)": 0, + "p(95)": 0, + "avg": 0.00464873949579832, + "min": 0, + "med": 0 + }, + "http_req_failed": { + "passes": 4, + "fails": 115, + "value": 0.03361344537815126 + }, + "data_received": { + "count": 273211, + "rate": 378.0357559335496 + }, + "iterations": { + "count": 118, + "rate": 0.1632738769674678 + }, + "error_rate": { + "passes": 4, + "fails": 114, + "thresholds": { + "rate<0.05": false + }, + "value": 0.03389830508474576 + }, + "http_reqs": { + "count": 119, + "rate": 0.16465755389092093 + }, + "http_req_tls_handshaking": { + "avg": 0, + "min": 0, + "med": 0, + "max": 0, + "p(90)": 0, + "p(95)": 0 + } + }, + "setup_data": { + "authToken": "eyJhbGciOiJIUzI1NiJ9.eyJzdWIiOiJqdW5AdGVzdC5jb20iLCJyb2xlcyI6WyJST0xFX0FETUlOIl0sIm5hbWUiOiJqdW4iLCJ1c2VyRW1haWwiOiJqdW5AdGVzdC5jb20iLCJ0eXBlIjoiYWNjZXNzIiwiZXhwIjoxNzg1MjIyMjI2LCJ1c2VySWQiOiJjYmE4YTFkZi1hZTYzLTRlYmMtYWI5Ni1kNjc1ZGQxMjljNjMiLCJpYXQiOjE3ODUyMjA0MjYsImp0aSI6ImVkMmI0OGFkLTJhMWEtNGRhOC05MzA4LWU1ZGQ4ZjFlNGUyNCJ9.ZOAJ6NwRMCJjxx_YALM3riXUqSWDA_EVtc5H2OXAsog", + "csrfToken": "5f29b32c-6bc9-46ac-9524-37d2cb81e734" + } +} \ No newline at end of file diff --git a/k6/problem-ai/soak-test.js b/k6/problem-ai/soak-test.js new file mode 100644 index 0000000..8f5b176 --- /dev/null +++ b/k6/problem-ai/soak-test.js @@ -0,0 +1,69 @@ +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)] + * [4. Soak Test - 내구성 및 리소스 누수(Leak) 테스트] + * + * 🎯 테스트 목적: + * - 낮은 VU로 장시간 반복 호출 시 검증용 스레드풀(problemValidationExecutor), + * LLM 클라이언트 커넥션, 메모리에 누수/고갈이 없는지 확인합니다. + * - room-submit(VU 20 / 25분)과 달리 이 API는 호출마다 실제 LLM 비용이 발생하므로 + * VU 2 / 10분으로 축소해서 진행합니다. (JWT 만료 30분 이내 범위) + * ============================================================================ + */ + +const errorRate = new Rate('error_rate'); +const aiLatency = new Trend('ai_generation_latency'); + +export const options = { + stages: [ + { duration: '1m', target: 2 }, // [구간 1] 1분간 VU 2명으로 서서히 진입 (Ramp-up) + { duration: '10m', target: 2 }, // [구간 2] 10분간 VU 2명으로 장시간 지속 부하 주입 (Soak Duration) + { duration: '1m', target: 0 }, // [구간 3] 1분간 VU 0명으로 감속 후 안전 종료 (Ramp-down) + ], + thresholds: { + 'http_req_duration': ['p(95)<15000'], // 12분간 지속 실행 시에도 p95 응답시간 15초 이내 유지 + 'error_rate': ['rate<0.05'], // 전체 에러 비율이 5% 미만일 것 + }, +}; + +export function setup() { + const token = obtainAuthToken(); + const csrfToken = obtainCsrfToken(); + return { authToken: token, csrfToken }; +} + +export default function (data) { + const targetUrl = `${BASE_URL}/api/spaces/${SPACE_ID}/problems/ai`; + + const payload = getProblemAiPayload(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, + }); + + errorRate.add(!isSuccess); + aiLatency.add(duration); + + // 관리자의 현실적인 반복 생성 간격 유도 (5초 휴식) + sleep(5); +} diff --git a/k6/problem-ai/spike-result.json b/k6/problem-ai/spike-result.json new file mode 100644 index 0000000..e44bf7d --- /dev/null +++ b/k6/problem-ai/spike-result.json @@ -0,0 +1,163 @@ +{ + "metrics": { + "ai_generation_latency": { + "avg": 18422.666666666668, + "min": 1669, + "med": 2645, + "max": 60001, + "p(90)": 55194.00000000001, + "p(95)": 60000.55 + }, + "iterations": { + "count": 30, + "rate": 0.2773789835922574 + }, + "data_sent": { + "count": 51853, + "rate": 479.4310812069774 + }, + "http_req_failed": { + "passes": 3, + "fails": 28, + "value": 0.0967741935483871 + }, + "http_reqs": { + "count": 31, + "rate": 0.2866249497119993 + }, + "http_req_duration": { + "avg": 17826.777058064516, + "min": 96.9715, + "med": 2260.2906, + "max": 60000.5223, + "p(90)": 54652.1156, + "p(95)": 59993.476899999994 + }, + "http_req_tls_handshaking": { + "min": 0, + "med": 0, + "max": 0, + "p(90)": 0, + "p(95)": 0, + "avg": 0 + }, + "http_req_sending": { + "max": 0.6761, + "p(90)": 0, + "p(95)": 0, + "avg": 0.02180967741935484, + "min": 0, + "med": 0 + }, + "checks": { + "passes": 28, + "fails": 3, + "value": 0.9032258064516129 + }, + "http_req_connecting": { + "med": 0, + "max": 17.7754, + "p(90)": 14.3874, + "p(95)": 15.778649999999999, + "avg": 5.172193548387097, + "min": 0 + }, + "http_req_waiting": { + "max": 60000.5223, + "p(90)": 54651.2913, + "p(95)": 59993.476899999994, + "avg": 17826.482629032256, + "min": 95.8237, + "med": 2260.2906 + }, + "vus_max": { + "value": 10, + "min": 10, + "max": 10 + }, + "http_req_duration{expected_response:true}": { + "avg": 13308.664739285716, + "min": 96.9715, + "med": 2196.2102, + "max": 54652.1156, + "p(90)": 41791.651280000005, + "p(95)": 45761.562809999996 + }, + "iteration_duration": { + "min": 2169.5639, + "med": 3144.9058, + "max": 60501.0432, + "p(90)": 55695.08303000001, + "p(95)": 60500.954195, + "avg": 18923.123756666668 + }, + "data_received": { + "count": 65957, + "rate": 609.8361873598174 + }, + "vus": { + "value": 1, + "min": 1, + "max": 10 + }, + "http_req_blocked": { + "avg": 5.97144193548387, + "min": 0, + "med": 0, + "max": 37.4544, + "p(90)": 14.7688, + "p(95)": 17.281950000000002 + }, + "error_rate": { + "passes": 3, + "fails": 27, + "thresholds": { + "rate<0.3": false + }, + "value": 0.1 + }, + "http_req_receiving": { + "avg": 0.2726193548387097, + "min": 0, + "med": 0, + "max": 1.2992, + "p(90)": 0.9413, + "p(95)": 1.05395 + } + }, + "setup_data": { + "authToken": "eyJhbGciOiJIUzI1NiJ9.eyJzdWIiOiJqdW5AdGVzdC5jb20iLCJyb2xlcyI6WyJST0xFX0FETUlOIl0sIm5hbWUiOiJqdW4iLCJ1c2VyRW1haWwiOiJqdW5AdGVzdC5jb20iLCJ0eXBlIjoiYWNjZXNzIiwiZXhwIjoxNzg1MjIyMDk2LCJ1c2VySWQiOiJjYmE4YTFkZi1hZTYzLTRlYmMtYWI5Ni1kNjc1ZGQxMjljNjMiLCJpYXQiOjE3ODUyMjAyOTYsImp0aSI6ImE1N2Q5YTA4LTE3Y2MtNDUyMS04ZTQ3LTRlZjNjNGQwOGEwNSJ9.Hv0GAyJtzZOVQv9SgqqivorJh7kGH_WItSvLMYeoak4", + "csrfToken": "c6cc9aa4-f248-4547-8b6a-12beeb3f6eb8" + }, + "root_group": { + "groups": { + "setup": { + "id": "5c0f8025f7e0b6654089e5b00e950f1a", + "groups": {}, + "checks": { + "Login Successful (HTTP 200)": { + "fails": 0, + "name": "Login Successful (HTTP 200)", + "path": "::setup::Login Successful (HTTP 200)", + "id": "bedba4f0acf48db4a1e0b99e768818d4", + "passes": 1 + } + }, + "name": "setup", + "path": "::setup" + } + }, + "checks": { + "AI Problem Generation Success (201)": { + "fails": 3, + "name": "AI Problem Generation Success (201)", + "path": "::AI Problem Generation Success (201)", + "id": "2641500d9aae22ca0aeb735974979037", + "passes": 27 + } + }, + "name": "", + "path": "", + "id": "d41d8cd98f00b204e9800998ecf8427e" + } +} \ No newline at end of file diff --git a/k6/problem-ai/spike-test.js b/k6/problem-ai/spike-test.js new file mode 100644 index 0000000..869736b --- /dev/null +++ b/k6/problem-ai/spike-test.js @@ -0,0 +1,68 @@ +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)] + * [3. Spike Test - 순간 부하 폭증 및 복구력 테스트] + * + * 🎯 테스트 목적: + * - "여러 관리자가 동시에 AI 생성 버튼을 누르는" 순간 폭증 상황에서 + * 서버가 다운되지 않고 부하 해제 후 정상 201로 복구되는지 검증합니다. + * - LLM 실호출 비용 때문에 room-submit(VU 150)과 달리 VU 10 수준으로 폭증을 재현합니다. + * ============================================================================ + */ + +const errorRate = new Rate('error_rate'); +const aiLatency = new Trend('ai_generation_latency'); + +export const options = { + stages: [ + { duration: '20s', target: 1 }, // [구간 1] 20초간 VU 1명 유지 (평상시 상태) + { duration: '10s', target: 10 }, // [구간 2] 🔥 10초 만에 VU 10명으로 급격한 폭증 (Spike 주입) + { duration: '30s', target: 10 }, // [구간 3] 30초간 VU 10명 유지 (동시 클릭 폭증 시뮬레이션) + { duration: '10s', target: 1 }, // [구간 4] 10초 만에 VU 1명으로 급격히 부하 하강 (Spike 해제) + { duration: '30s', target: 1 }, // [구간 5] 30초간 VU 1명 유지하며 정상 201로 복구되는지 관찰 + ], + thresholds: { + 'error_rate': ['rate<0.3'], // 순간 폭증 구간 30% 이내 에러 방어 검증 + }, +}; + +export function setup() { + const token = obtainAuthToken(); + const csrfToken = obtainCsrfToken(); + return { authToken: token, csrfToken }; +} + +export default function (data) { + const targetUrl = `${BASE_URL}/api/spaces/${SPACE_ID}/problems/ai`; + + const payload = getProblemAiPayload(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, + }); + + errorRate.add(!isSuccess); + aiLatency.add(duration); + + sleep(0.5); +} diff --git a/k6/problem-ai/stress-result.json b/k6/problem-ai/stress-result.json new file mode 100644 index 0000000..2b622c4 --- /dev/null +++ b/k6/problem-ai/stress-result.json @@ -0,0 +1,170 @@ +{ + "root_group": { + "name": "", + "path": "", + "id": "d41d8cd98f00b204e9800998ecf8427e", + "groups": { + "setup": { + "name": "setup", + "path": "::setup", + "id": "5c0f8025f7e0b6654089e5b00e950f1a", + "groups": {}, + "checks": { + "Login Successful (HTTP 200)": { + "fails": 0, + "name": "Login Successful (HTTP 200)", + "path": "::setup::Login Successful (HTTP 200)", + "id": "bedba4f0acf48db4a1e0b99e768818d4", + "passes": 1 + } + } + } + }, + "checks": { + "AI Problem Generation Success (201)": { + "name": "AI Problem Generation Success (201)", + "path": "::AI Problem Generation Success (201)", + "id": "2641500d9aae22ca0aeb735974979037", + "passes": 119, + "fails": 44 + }, + "Server Alive (Not 500 Error)": { + "name": "Server Alive (Not 500 Error)", + "path": "::Server Alive (Not 500 Error)", + "id": "0fa9e611fe9555269e171bceaccf51ad", + "passes": 163, + "fails": 0 + } + } + }, + "metrics": { + "iteration_duration": { + "avg": 13484.380167484665, + "min": 2540.5021, + "med": 3843.9342, + "max": 54857.456, + "p(90)": 39002.59976, + "p(95)": 43650.4985 + }, + "iterations": { + "count": 163, + "rate": 0.3902673321672176 + }, + "http_req_duration{expected_response:true}": { + "med": 1846.7712000000001, + "max": 46699.4283, + "p(90)": 11141.320990000002, + "p(95)": 19373.144819999998, + "avg": 4829.905158333333, + "min": 92.521 + }, + "http_req_failed": { + "passes": 44, + "fails": 120, + "value": 0.2682926829268293 + }, + "error_rate": { + "passes": 44, + "fails": 119, + "thresholds": { + "rate<0.3": false + }, + "value": 0.26993865030674846 + }, + "checks": { + "fails": 44, + "passes": 283, + "value": 0.8654434250764526 + }, + "data_sent": { + "count": 274447, + "rate": 657.102444854579 + }, + "http_req_connecting": { + "p(95)": 11.766774999999987, + "avg": 0.9353335365853659, + "min": 0, + "med": 0, + "max": 14.8501, + "p(90)": 0 + }, + "http_req_sending": { + "med": 0, + "max": 1.6093, + "p(90)": 0, + "p(95)": 0, + "avg": 0.01871036585365854, + "min": 0 + }, + "data_received": { + "count": 326210, + "rate": 781.0374627378408 + }, + "http_reqs": { + "count": 164, + "rate": 0.3926616102786729 + }, + "http_req_blocked": { + "avg": 1.2446920731707318, + "min": 0, + "med": 0, + "max": 38.9052, + "p(90)": 0, + "p(95)": 13.67919 + }, + "http_req_tls_handshaking": { + "avg": 0, + "min": 0, + "med": 0, + "max": 0, + "p(90)": 0, + "p(95)": 0 + }, + "http_req_waiting": { + "min": 92.521, + "med": 2738.5003500000003, + "max": 53857.1452, + "p(90)": 37998.92066, + "p(95)": 42648.744865, + "avg": 12407.050471341467 + }, + "http_req_duration": { + "p(90)": 37998.92066, + "p(95)": 42648.86194, + "avg": 12407.386081707322, + "min": 92.521, + "med": 2738.9315500000002, + "max": 53857.1452 + }, + "http_req_receiving": { + "med": 0, + "max": 2.2376, + "p(90)": 0.98165, + "p(95)": 1.1827949999999998, + "avg": 0.31690000000000007, + "min": 0 + }, + "vus": { + "max": 12, + "value": 1, + "min": 1 + }, + "vus_max": { + "min": 12, + "max": 12, + "value": 12 + }, + "ai_generation_latency": { + "p(95)": 42649.2, + "avg": 12483.957055214723, + "min": 1540, + "med": 2844, + "max": 53857, + "p(90)": 38001 + } + }, + "setup_data": { + "authToken": "eyJhbGciOiJIUzI1NiJ9.eyJzdWIiOiJqdW5AdGVzdC5jb20iLCJyb2xlcyI6WyJST0xFX0FETUlOIl0sIm5hbWUiOiJqdW4iLCJ1c2VyRW1haWwiOiJqdW5AdGVzdC5jb20iLCJ0eXBlIjoiYWNjZXNzIiwiZXhwIjoxNzg1MjIxNjQ1LCJ1c2VySWQiOiJjYmE4YTFkZi1hZTYzLTRlYmMtYWI5Ni1kNjc1ZGQxMjljNjMiLCJpYXQiOjE3ODUyMTk4NDUsImp0aSI6Ijc0YzViYzhkLTA4MGEtNDgwZi04NTY3LWUyMWRlYjFlODU4MiJ9.EdZ4qima4jeIJlsygg2nDSdgX4OEKpt8yPhD0_7YKpI", + "csrfToken": "d9105869-2b52-426e-ad7a-74ccb4937e1b" + } +} \ No newline at end of file diff --git a/k6/problem-ai/stress-test.js b/k6/problem-ai/stress-test.js new file mode 100644 index 0000000..645d5c1 --- /dev/null +++ b/k6/problem-ai/stress-test.js @@ -0,0 +1,71 @@ +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)] + * [2. Stress Test - 한계 및 임계점(Break Point) 테스트] + * + * 🎯 테스트 목적: + * - 백엔드 정답 검증용 스레드풀(problemValidationExecutor, max 10)과 + * 동기 LLM 호출 구조상 어느 VU 수준부터 응답이 무너지는지 확인합니다. + * - LLM 실호출 비용 때문에 room-submit(VU 200)과 달리 VU 12까지만 단계적으로 올립니다. + * ============================================================================ + */ + +const errorRate = new Rate('error_rate'); +const aiLatency = new Trend('ai_generation_latency'); + +export const options = { + stages: [ + { duration: '1m', target: 2 }, // [1단계] VU 2명 (기본 정상 작동 확인) + { duration: '1m', target: 4 }, // [2단계] VU 4명 + { duration: '1m', target: 6 }, // [3단계] VU 6명 + { duration: '1m', target: 8 }, // [4단계] VU 8명 + { duration: '1m', target: 10 }, // [5단계] VU 10명 (검증 executor 풀 max와 동일 지점) + { duration: '1m', target: 12 }, // [6단계] VU 12명 (풀 한계 초과 구간) + { duration: '30s', target: 0 }, // [7단계] 쿨다운 + ], + thresholds: { + 'error_rate': ['rate<0.3'], // Stress Test 특성상 한계 탐색이 목적이라 30%까지 허용 + }, +}; + +export function setup() { + const token = obtainAuthToken(); + const csrfToken = obtainCsrfToken(); + return { authToken: token, csrfToken }; +} + +export default function (data) { + const targetUrl = `${BASE_URL}/api/spaces/${SPACE_ID}/problems/ai`; + + const payload = getProblemAiPayload(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, + 'Server Alive (Not 500 Error)': (r) => r.status !== 500, + }); + + errorRate.add(!isSuccess); + aiLatency.add(duration); + + sleep(1); +}