diff --git a/.gitignore b/.gitignore index b130afa..b3e9326 100644 --- a/.gitignore +++ b/.gitignore @@ -37,6 +37,20 @@ out/ ### VS Code ### .vscode/ + +### Performance Testing ### +# Performance test build outputs +performance/dist/ +performance/node_modules/ +performance/package-lock.json +performance/.env +performance/*.log +performance/reports/ +performance/summary.* + +# Log files +logs/ + # Compiled class file *.class diff --git a/performance/.gitignore b/performance/.gitignore new file mode 100644 index 0000000..a053641 --- /dev/null +++ b/performance/.gitignore @@ -0,0 +1,70 @@ +# Dependencies +node_modules/ +npm-debug.log* +yarn-debug.log* +yarn-error.log* +package-lock.json + +# Build outputs +dist/ +build/ +*.tsbuildinfo + +# Environment files +.env +.env.* +!.env.example + +# Test reports and outputs +reports/ +summary.html +summary.json +summary.txt +*.log + +# k6 specific +k6-*-report.html +k6-results.json + +# IDE files +.vscode/ +.idea/ +*.swp +*.swo +*~ + +# OS generated files +.DS_Store +.DS_Store? +._* +.Spotlight-V100 +.Trashes +ehthumbs.db +Thumbs.db + +# Temporary files +*.tmp +*.temp + +# Coverage reports +coverage/ +.nyc_output/ + +# Dependency directories +jspm_packages/ + +# Optional npm cache directory +.npm + +# ESLint cache +.eslintcache + +# Editor directories and files +.vscode/* +!.vscode/extensions.json +.idea +*.suo +*.ntvs* +*.njsproj +*.sln +*.sw? diff --git a/performance/200vu-simulation.js b/performance/200vu-simulation.js new file mode 100644 index 0000000..895b6c7 --- /dev/null +++ b/performance/200vu-simulation.js @@ -0,0 +1,200 @@ +import http from 'k6/http'; +import { check, sleep } from 'k6'; +import { SharedArray } from 'k6/data'; +import { Trend } from 'k6/metrics'; + +const BASE_URL = 'http://localhost:8080/api/v1'; +const USERS_ID = new SharedArray('users', function () { + const arr = []; + for(let i = 1; i< 51; i++) { + arr.push(i); + } + return arr; +}); + +let tokens = []; + +const requestCount = new Trend('request_count', true); + +export const options = { + thresholds: { + http_req_failed: ['rate<0.01'], + http_req_duration: ['p(95)<1000'], + }, + + scenarios: { + normal: { + executor: 'per-vu-iterations', + vus: 50, // 50명의 가상 사용자 + iterations: 400, // 각 가상 사용자가 400번의 요청을 수행 + maxDuration: '2m', // 테스트 전체 지속 시간 + exec: 'normal' // 기록 범위 조회 API + }, + + mypage: { + executor: 'constant-arrival-rate', + rate: 10, + timeUnit: '4s', // 4초 당 10회 요청 + duration: '2m', // 2분 지속 + preAllocatedVUs: 4, + maxVUs: 50, // 최대 50명의 가상 사용자 + exec: 'mypage', // 마이페이지 조회 API + }, +}; + +export function setup() { + console.log('테스트 셋업 시작: 사용자 로그인 및 토큰 획득'); + + for (const user of USERS_ID) { + const res = http.post(`${BASE_URL}/auth/test/login`, JSON.stringify({ user_id: user }), { + headers: { 'Content-Type': 'application/json' }, + }); + + const success = check(res, { + 'logged in successfully': (r) => r.status === 200 && r.json('payload.tokens.access_token'), + }); + + if (success) { + tokens.push({ + user_id: user, + accessToken: res.json('payload.tokens.access_token'), + refreshToken: res.json('payload.tokens.refresh_token'), + }); + } else { + console.error(`사용자 ${user} 로그인 실패: ${res.status}`); + } + } + + console.log(`셋업 완료: ${tokens.length}개의 토큰 획득`); + return { tokens }; +} + +export default function (data) { + +} + + + +export function mypage (data) { + const vuIndex = (__VU - 1) % data.tokens.length; + let { user_id, accessToken } = data.tokens[vuIndex]; + let res = http.get(`${BASE_URL}/users/me`, { + headers: { Authorization: `Bearer ${accessToken}` }, + }); + if (res.status === 401) { + const newToken = refreshAccessToken(data.tokens[vuIndex].refreshToken); + if (newToken) { + accessToken = newToken; + res = http.get(`${BASE_URL}/records/me${queryString}`, { + headers: { Authorization: `Bearer ${newToken}` }, + }); + data.tokens[vuIndex].accessToken = newToken; + requestCount.add(1); + } + } + check(res, { + 'status is 200': (r) => r.status === 200, + }); +} + + + + +export function normal(data ) { + const vuIndex = (__VU - 1) % data.tokens.length; + let { user_id, accessToken } = data.tokens[vuIndex]; + const page = randomIntBetween(0, 5); + const size = randomIntBetween(1, 20); + let startDate = getRandomDateInPast(30); + let endDate = getRandomDateInPast(30); + + if (startDate > endDate) { + const tmp = startDate; + startDate = endDate; + endDate = tmp; + } + + const queryString = `?page=${page}&size=${size}&startDate=${formatDate(startDate)}&endDate=${formatDate(endDate)}`; + + let res = http.get(`${BASE_URL}/records/me${queryString}`, { + headers: { Authorization: `Bearer ${accessToken}` }, + }); + + + requestCount.add(1); + + if (res.status === 401) { + const newToken = refreshAccessToken(data.tokens[vuIndex].refreshToken); + if (newToken) { + accessToken = newToken; + res = http.get(`${BASE_URL}/records/me${queryString}`, { + headers: { Authorization: `Bearer ${newToken}` }, + }); + data.tokens[vuIndex].accessToken = newToken; + requestCount.add(1); + } + } + check(res, { + 'status is 200': (r) => r.status === 200, + }); +} + +function runStressScenario(user_id, access_token, vuIndex) { + + let res = http.get(`${BASE_URL}/users/me`, { + headers: { Authorization: `Bearer ${accessToken}` }, + }); + if (res.status === 401) { + const newToken = refreshAccessToken(data.tokens[vuIndex].refreshToken); + if (newToken) { + accessToken = newToken; + res = http.get(`${BASE_URL}/records/me${queryString}`, { + headers: { Authorization: `Bearer ${newToken}` }, + }); + data.tokens[vuIndex].accessToken = newToken; + requestCount.add(1); + } + } + check(res, { + 'status is 200': (r) => r.status === 200, + }); +} + +export function teardown() { + const totalDurationInSeconds = 10 + 60 + 30; + const totalRequests = requestCount._sum; + + const throughput = totalRequests / totalDurationInSeconds; + console.log(`\n---\n Throughput (requests/sec): ${throughput.toFixed(2)}\n---`); +} + +// Utils +function randomIntBetween(min, max) { + return Math.floor(Math.random() * (max - min + 1)) + min; +} + +function formatDate(date) { + return date.toISOString().slice(0, 10); +} + +function getRandomDateInPast(daysAgo = 30) { + const date = new Date(); + date.setDate(date.getDate() - randomIntBetween(0, daysAgo)); + return date; +} + +function refreshAccessToken(refreshToken) { + const res = http.post(`${BASE_URL}/auth/refresh`, JSON.stringify({ + refresh_token: refreshToken + }), { + headers: { + 'Content-Type': 'application/json', + Authorization: `Bearer ${refreshToken}` + }, + }); + if (res.status === 200 && res.json('payload.access_token')) { + return res.json('payload.access_token'); + } + + return null; +} diff --git a/performance/README.md b/performance/README.md new file mode 100644 index 0000000..eac3441 --- /dev/null +++ b/performance/README.md @@ -0,0 +1,59 @@ +## 🚀 설치 및 실행 + +### 1. 의존성 설치 +```bash +cd performance +npm install +``` + +### 2. 환경 설정 +```bash +cp .env.example .env +# .env 파일을 편집하여 실제 API URL 및 설정값 입력 +``` + +### 3. 빌드 +```bash +npm run build +``` + +### 4. 테스트 실행 + +#### 개별 테스트 실행 +```bash +# 기본 부하 테스트 +npm run test:basic-load + +# 간단한 반복 테스트 +npm run test:simple + +# 실제 사용자 시뮬레이션 (22분 소요) +npm run test:real-user + +# 다중 시나리오 테스트 +npm run test:multi +``` + +#### 환경변수와 함께 실행 +```bash +API_BASE_URL=https://your-api.com/v1 MAX_USERS=100 k6 run dist/basic-load.js +``` + +#### 모든 테스트 실행 +```bash +npm run test:all +``` + +### 개발 모드 (파일 변경 감지) +```bash +npm run build:watch +``` + +### 새로운 테스트 추가 +1. `src/tests/` 디렉토리에 새 TypeScript 파일 생성 +2. `webpack.config.js`의 entry에 새 파일 추가 +3. `package.json`의 scripts에 실행 명령 추가 + +### 새로운 시나리오 추가 +1. `src/scenarios/` 디렉토리에 새 시나리오 클래스 생성 +2. 기존 테스트에서 import하여 사용 diff --git a/performance/package.json b/performance/package.json new file mode 100644 index 0000000..f7f2284 --- /dev/null +++ b/performance/package.json @@ -0,0 +1,25 @@ +{ + "name": "k6-performance-tests", + "version": "1.0.0", + "description": "K6 performance tests written in TypeScript", + "scripts": { + "build": "webpack", + "build:watch": "webpack --watch", + "clean": "rm -rf dist", + "test:basic-load": "k6 run dist/basic-load.js", + "test:simple": "k6 run dist/simple-iteration.js", + "test:real-user": "k6 run dist/real-user-simulation.js", + "test:multi": "k6 run dist/multi-scenario.js", + "test:all": "npm run build && npm run test:basic-load && npm run test:simple && npm run test:real-user && npm run test:multi" + }, + "devDependencies": { + "@types/k6": "^0.47.0", + "typescript": "^5.0.0", + "webpack": "^5.88.0", + "webpack-cli": "^5.1.0", + "ts-loader": "^9.4.0" + }, + "keywords": ["k6", "performance", "testing", "typescript"], + "author": "", + "license": "MIT" +} diff --git a/performance/src/config/environment.ts b/performance/src/config/environment.ts new file mode 100644 index 0000000..46abfdb --- /dev/null +++ b/performance/src/config/environment.ts @@ -0,0 +1,17 @@ +export const config = { + baseUrl: __ENV.API_BASE_URL || 'http://localhost:8080/api/v1', + maxUsers: parseInt(__ENV.MAX_USERS || '1'), + testDuration: __ENV.TEST_DURATION || '2m', + + // Thresholds + httpReqDurationP95: parseInt(__ENV.HTTP_REQ_DURATION_P95 || '1000'), + httpReqFailedRate: parseFloat(__ENV.HTTP_REQ_FAILED_RATE || '0.01'), + + // Report settings + enableHtmlReport: (__ENV.ENABLE_HTML_REPORT || 'true') === 'true', +}; + +export const thresholds = { + http_req_failed: [`rate<${config.httpReqFailedRate}`], + http_req_duration: [`p(95)<${config.httpReqDurationP95}`], +}; diff --git a/performance/src/lib/api-client.ts b/performance/src/lib/api-client.ts new file mode 100644 index 0000000..b1a6cd4 --- /dev/null +++ b/performance/src/lib/api-client.ts @@ -0,0 +1,79 @@ +import http from 'k6/http'; +import { UserToken, RequestResult, QueryParams } from './types'; +import { config } from '../config/environment'; +import { refreshAccessToken } from './auth'; +import { buildQueryString } from './utils'; + +export class ApiClient { + private baseUrl: string; + + constructor(baseUrl: string = config.baseUrl) { + this.baseUrl = baseUrl; + } + + get(endpoint: string, token: UserToken, params?: QueryParams): RequestResult { + const queryString = params ? buildQueryString(params) : ''; + const url = `${this.baseUrl}${endpoint}${queryString}`; + + let response = http.get(url, { + headers: { Authorization: `Bearer ${token.accessToken}` }, + tags: { endpoint, method: 'GET' } + }); + + // 토큰 만료 시 자동 갱신 + if (response.status === 401) { + const newToken = refreshAccessToken(token.refreshToken); + if (newToken) { + response = http.get(url, { + headers: { Authorization: `Bearer ${newToken}` }, + tags: { endpoint, method: 'GET', retry: 'true' } + }); + return { + response, + success: response.status === 200, + newToken + }; + } + } + + return { + response, + success: response.status === 200 + }; + } + + patch(endpoint: string, data: any, token: UserToken): RequestResult { + const url = `${this.baseUrl}${endpoint}`; + + let response = http.patch(url, JSON.stringify(data), { + headers: { + 'Content-Type': 'application/json', + Authorization: `Bearer ${token.accessToken}` + }, + tags: { endpoint, method: 'PATCH' } + }); + + if (response.status === 401) { + const newToken = refreshAccessToken(token.refreshToken); + if (newToken) { + response = http.patch(url, JSON.stringify(data), { + headers: { + 'Content-Type': 'application/json', + Authorization: `Bearer ${newToken}` + }, + tags: { endpoint, method: 'PATCH', retry: 'true' } + }); + return { + response, + success: response.status === 200 || response.status === 409, + newToken + }; + } + } + + return { + response, + success: response.status === 200 || response.status === 409 + }; + } +} diff --git a/performance/src/lib/auth.ts b/performance/src/lib/auth.ts new file mode 100644 index 0000000..8cd7747 --- /dev/null +++ b/performance/src/lib/auth.ts @@ -0,0 +1,60 @@ +import http from 'k6/http'; +import { check } from 'k6'; +import { UserToken } from './types'; +import { config } from '../config/environment'; + +export function setupUsers(userIds: number[]): UserToken[] { + console.log('테스트 셋업 시작: 사용자 로그인 및 토큰 획득'); + + const tokens: UserToken[] = []; + + for (const userId of userIds) { + const response = http.post( + `${config.baseUrl}/auth/test/login`, + JSON.stringify({ user_id: userId }), + { headers: { 'Content-Type': 'application/json' } } + ); + + const success = check(response, { + 'logged in successfully': (r) => r.status === 200 && r.json('payload.tokens.access_token'), + }); + + if (success) { + tokens.push({ + user_id: userId, + accessToken: response.json('payload.tokens.access_token'), + refreshToken: response.json('payload.tokens.refresh_token'), + }); + } else { + console.error(`사용자 ${userId} 로그인 실패: ${response.status}`); + } + } + + console.log(`셋업 완료: ${tokens.length}개의 토큰 획득`); + return tokens; +} + +export function refreshAccessToken(refreshToken: string): string | null { + const response = http.post( + `${config.baseUrl}/auth/refresh`, + JSON.stringify({ refresh_token: refreshToken }), + { + headers: { + 'Content-Type': 'application/json', + Authorization: `Bearer ${refreshToken}` + }, + tags: { endpoint: '/auth/refresh', method: 'POST' } + } + ); + + const success = check(response, { + 'token refresh successful': (r) => r.status === 200 && r.json('payload.tokens.access_token') + }); + + if (success) { + return response.json('payload.tokens.access_token'); + } + + console.error(`토큰 갱신 실패: ${response.status}`); + return null; +} diff --git a/performance/src/lib/types.ts b/performance/src/lib/types.ts new file mode 100644 index 0000000..7251d87 --- /dev/null +++ b/performance/src/lib/types.ts @@ -0,0 +1,28 @@ +export interface UserToken { + user_id: number; + accessToken: string; + refreshToken: string; +} + +export interface TestSetupData { + tokens: UserToken[]; +} + +export interface QueryParams { + page?: number; + size?: number; + startDate?: string; + endDate?: string; +} + +export interface ApiResponse { + status: number; + body: any; + json: (path?: string) => any; +} + +export interface RequestResult { + response: ApiResponse; + success: boolean; + newToken?: string; +} diff --git a/performance/src/lib/utils.ts b/performance/src/lib/utils.ts new file mode 100644 index 0000000..500860e --- /dev/null +++ b/performance/src/lib/utils.ts @@ -0,0 +1,30 @@ +export function randomIntBetween(min: number, max: number): number { + return Math.floor(Math.random() * (max - min + 1)) + min; +} + +export function randomFloatBetween(min: number, max: number): number { + return Math.random() * (max - min) + min; +} + +export function formatDate(date: Date): string { + return date.toISOString().slice(0, 10); +} + +export function getRandomDateInPast(daysAgo: number = 30): Date { + const date = new Date(); + date.setDate(date.getDate() - randomIntBetween(0, daysAgo)); + return date; +} + +export function generateUserIds(count: number): number[] { + return Array.from({ length: count }, (_, i) => i + 1); +} + +export function buildQueryString(params: Record): string { + const queryString = Object.entries(params) + .filter(([_, value]) => value !== undefined && value !== null) + .map(([key, value]) => `${key}=${encodeURIComponent(value)}`) + .join('&'); + + return queryString ? `?${queryString}` : ''; +} diff --git a/performance/src/scenarios/real-user-patterns.ts b/performance/src/scenarios/real-user-patterns.ts new file mode 100644 index 0000000..edbe331 --- /dev/null +++ b/performance/src/scenarios/real-user-patterns.ts @@ -0,0 +1,59 @@ +import { sleep } from 'k6'; +import { UserToken } from '../lib/types'; +import { RecordsBrowsingScenarios } from './records-browsing'; +import { UserProfileScenarios } from './user-profile'; +import { randomIntBetween, randomFloatBetween } from '../lib/utils'; + +export class RealUserPatterns { + private recordsScenarios: RecordsBrowsingScenarios; + private userScenarios: UserProfileScenarios; + + constructor() { + this.recordsScenarios = new RecordsBrowsingScenarios(); + this.userScenarios = new UserProfileScenarios(); + } + + // 가벼운 브라우징 세션 (짧은 세션, 적은 요청) + lightBrowsing(tokens: UserToken[]): void { + const sessionStart = Date.now(); + + this.userScenarios.checkMyPage(tokens); + sleep(randomFloatBetween(2, 5)); + + this.recordsScenarios.simpleRecordsQuery(tokens); + sleep(randomFloatBetween(1, 3)); + + } + + // 일반적인 브라우징 세션 (중간 길이, 여러 페이지 조회) + browsing_session(tokens: UserToken[]): void { + this.userScenarios.checkMyPage(tokens); + sleep(randomFloatBetween(1, 3)); + + const pageCount = randomIntBetween(2, 4); + this.recordsScenarios.pagingBrowsing(tokens, pageCount); + } + + // 활발한 사용자 세션 (중간-높은 활동) + activeUserSession(tokens: UserToken[]): void { + this.userScenarios.checkMyPage(tokens); + sleep(randomFloatBetween(1, 2)); + + const pageCount = randomIntBetween(3, 6); + this.recordsScenarios.pagingBrowsing(tokens, pageCount); + + this.userScenarios.updateEgg(tokens, 0.3); + } + + // 헤비 유저 세션 (긴 세션, 많은 요청) + heavyUserSession(tokens: UserToken[]): void { + this.userScenarios.checkMyPage(tokens); + sleep(randomFloatBetween(0.5, 1.5)); + + const pageCount = randomIntBetween(5, 10); + this.recordsScenarios.pagingBrowsing(tokens, pageCount); + + this.userScenarios.heavyUserEggUpdate(tokens); + this.userScenarios.checkMyPage(tokens); + } +} diff --git a/performance/src/scenarios/records-browsing.ts b/performance/src/scenarios/records-browsing.ts new file mode 100644 index 0000000..1cf3684 --- /dev/null +++ b/performance/src/scenarios/records-browsing.ts @@ -0,0 +1,103 @@ +import { sleep, check } from 'k6'; +import { UserToken, QueryParams } from '../lib/types'; +import { ApiClient } from '../lib/api-client'; +import { randomIntBetween, getRandomDateInPast, formatDate, randomFloatBetween } from '../lib/utils'; + +export class RecordsBrowsingScenarios { + private apiClient: ApiClient; + + constructor() { + this.apiClient = new ApiClient(); + } + + private generateQueryParams(maxDaysBack: number = 30): QueryParams { + + const params: QueryParams = { + page: randomIntBetween(0, 5), + size: randomIntBetween(1, 20), + }; + + let startDate = getRandomDateInPast(maxDaysBack); + let endDate = getRandomDateInPast(maxDaysBack); + + if (startDate > endDate) { + [startDate, endDate] = [endDate, startDate]; + } + + params.startDate = formatDate(startDate); + params.endDate = formatDate(endDate); + + return params; + } + + // 기본 레코드 조회 (k6.js 스타일) + basicRecordsQuery(tokens: UserToken[]): void { + const vuIndex = (__VU - 1) % tokens.length; + const user = tokens[vuIndex]; + let accessToken = user.accessToken; + + const params = this.generateQueryParams(30); + + const result = this.apiClient.get('/records/me', user, params); + + // 토큰이 업데이트된 경우 반영 + if (result.newToken) { + tokens[vuIndex].accessToken = result.newToken; + } + + check(result.response, { + 'status is 200': (r) => r.status === 200, + }); + + if (!result.success) { + console.error(`사용자 ${user.user_id} 요청 실패: ${result.response.status}`); + } + + // 랜덤 지연 추가 (실제 사용자 행동 시뮬레이션) + sleep(randomFloatBetween(0.1, 2)); + } + + // 간단한 레코드 조회 (k6-v2.js 스타일) + simpleRecordsQuery(tokens: UserToken[]): void { + const vuIndex = (__VU - 1) % tokens.length; + const user = tokens[vuIndex]; + + const params = this.generateQueryParams(60); + + const result = this.apiClient.get('/records/me', user, params); + + if (result.newToken) { + tokens[vuIndex].accessToken = result.newToken; + } + + sleep(1); + + check(result.response, { + 'status is 200': (r) => r.status === 200, + }); + } + + // 페이징 브라우징 (여러 페이지 조회) + pagingBrowsing(tokens: UserToken[], pageCount: number = 3): void { + const vuIndex = (__VU - 1) % tokens.length; + const user = tokens[vuIndex]; + + for (let i = 0; i < pageCount; i++) { + const params = this.generateQueryParams(60); + params.page = i; + params.size = randomIntBetween(5, 20); + + const result = this.apiClient.get('/records/me', user, params); + + if (result.newToken) { + tokens[vuIndex].accessToken = result.newToken; + } + + check(result.response, { + 'request success': (r) => r.status === 200, + }); + + sleep(randomFloatBetween(1, 3)); // 사용자가 데이터를 읽는 시간 + } + } +} diff --git a/performance/src/scenarios/user-profile.ts b/performance/src/scenarios/user-profile.ts new file mode 100644 index 0000000..8f4fd30 --- /dev/null +++ b/performance/src/scenarios/user-profile.ts @@ -0,0 +1,78 @@ +import { sleep, check } from 'k6'; +import { UserToken } from '../lib/types'; +import { ApiClient } from '../lib/api-client'; +import { randomIntBetween, randomFloatBetween } from '../lib/utils'; + +export class UserProfileScenarios { + private apiClient: ApiClient; + + constructor() { + this.apiClient = new ApiClient(); + } + + // 마이페이지 조회 + checkMyPage(tokens: UserToken[]): void { + const vuIndex = (__VU - 1) % tokens.length; + const user = tokens[vuIndex]; + + const result = this.apiClient.get('/users/me', user); + + if (result.newToken) { + tokens[vuIndex].accessToken = result.newToken; + } + + check(result.response, { + 'status is 200': (r) => r.status === 200, + }); + } + + // 알 업데이트 (k6-15min.js에서 사용) + updateEgg(tokens: UserToken[], probability: number = 0.3): void { + if (Math.random() > probability) return; + + const vuIndex = (__VU - 1) % tokens.length; + const user = tokens[vuIndex]; + const eggId = user.user_id; + + const payload = { + love_point_amount: randomIntBetween(1, 3) + }; + + const result = this.apiClient.patch(`/users/eggs/${eggId}`, payload, user); + + if (result.newToken) { + tokens[vuIndex].accessToken = result.newToken; + } + + check(result.response, { + 'egg update success': (r) => r.status === 200 || r.status === 409, + }); + + sleep(randomFloatBetween(0.5, 2)); + } + + // 헤비 유저용 알 업데이트 (더 높은 확률, 더 많은 포인트) + heavyUserEggUpdate(tokens: UserToken[]): void { + if (Math.random() > 0.6) return; + + const vuIndex = (__VU - 1) % tokens.length; + const user = tokens[vuIndex]; + const eggId = user.user_id; + + const payload = { + love_point_amount: randomIntBetween(1, 5) + }; + + const result = this.apiClient.patch(`/users/eggs/${eggId}`, payload, user); + + if (result.newToken) { + tokens[vuIndex].accessToken = result.newToken; + } + + check(result.response, { + 'egg update success': (r) => r.status === 200 || r.status === 409, + }); + + sleep(randomFloatBetween(0.5, 1)); + } +} diff --git a/performance/src/tests/basic-load.ts b/performance/src/tests/basic-load.ts new file mode 100644 index 0000000..36bee2a --- /dev/null +++ b/performance/src/tests/basic-load.ts @@ -0,0 +1,61 @@ +import { Options } from 'k6/options'; +import { group } from 'k6'; +import { config, thresholds } from '../config/environment'; +import { setupUsers } from '../lib/auth'; +import { generateUserIds } from '../lib/utils'; +import { RecordsBrowsingScenarios } from '../scenarios/records-browsing'; +import { TestSetupData } from '../lib/types'; + +export const options: Options = { + scenarios: { + shared_iter_scenario: { + executor: 'shared-iterations', + vus: 50, + iterations: 1000, + startTime: '0s', + }, + per_vu_scenario: { + executor: 'per-vu-iterations', + vus: 10, + iterations: 10, + startTime: '10s', + }, + }, + thresholds, +}; + +export function setup(): TestSetupData { + const userIds = generateUserIds(config.maxUsers); + const tokens = setupUsers(userIds); + return { tokens }; +} + +export default function(data: TestSetupData) { + const recordsScenarios = new RecordsBrowsingScenarios(); + + group('get records', function () { + recordsScenarios.basicRecordsQuery(data.tokens); + }); +} + +export function handleSummary(data: any) { + console.log('테스트 완료, 결과 생성 중...'); + + const textSummary = ` +========== API 성능 측정 결과 ========== + +총 요청 수: ${data.metrics.http_reqs?.values.count || 0} +총 실패 수: ${data.metrics.http_req_failed?.values.count || 0} +평균 응답 시간: ${data.metrics.http_req_duration?.values.avg?.toFixed(2) || 0} ms +최대 응답 시간: ${data.metrics.http_req_duration?.values.max?.toFixed(2) || 0} ms +p95 응답 시간: ${data.metrics.http_req_duration?.values['p(95)']?.toFixed(2) || 0} ms + +================================== + `; + + return { + stdout: textSummary, + 'summary.json': JSON.stringify(data, null, 2), + 'summary.txt': textSummary, + }; +} diff --git a/performance/src/tests/multi-scenario.ts b/performance/src/tests/multi-scenario.ts new file mode 100644 index 0000000..fe376d7 --- /dev/null +++ b/performance/src/tests/multi-scenario.ts @@ -0,0 +1,55 @@ +import { Options } from 'k6/options'; +import { config, thresholds } from '../config/environment'; +import { setupUsers } from '../lib/auth'; +import { generateUserIds } from '../lib/utils'; +import { RecordsBrowsingScenarios } from '../scenarios/records-browsing'; +import { UserProfileScenarios } from '../scenarios/user-profile'; +import { TestSetupData } from '../lib/types'; + +export const options: Options = { + thresholds, + scenarios: { + normal: { + executor: 'per-vu-iterations', + vus: 50, // 50명의 가상 사용자 + iterations: 400, // 각 가상 사용자가 400번의 요청을 수행 + maxDuration: '2m', // 테스트 전체 지속 시간 + exec: 'normal' // 기록 범위 조회 API + }, + + mypage: { + executor: 'constant-arrival-rate', + rate: 10, + timeUnit: '4s', // 4초 당 10회 요청 + duration: '2m', // 2분 지속 + preAllocatedVUs: 4, + maxVUs: 50, // 최대 50명의 가상 사용자 + exec: 'mypage', // 마이페이지 조회 API + }, + }, +}; + +export function setup(): TestSetupData { + const userIds = generateUserIds(config.maxUsers); + const tokens = setupUsers(userIds); + return { tokens }; +} + +export default function(data: TestSetupData) { + // 기본 함수는 비워둠 (시나리오별 exec 함수 사용) +} + +export function mypage(data: TestSetupData) { + const userScenarios = new UserProfileScenarios(); + userScenarios.checkMyPage(data.tokens); +} + +export function normal(data: TestSetupData) { + const recordsScenarios = new RecordsBrowsingScenarios(); + recordsScenarios.simpleRecordsQuery(data.tokens); +} + +export function teardown() { + const totalDurationInSeconds = 120; // 2분 + console.log(`\n---\n📊 다중 시나리오 테스트 완료\n---`); +} diff --git a/performance/src/tests/real-user-simulation.ts b/performance/src/tests/real-user-simulation.ts new file mode 100644 index 0000000..01088d6 --- /dev/null +++ b/performance/src/tests/real-user-simulation.ts @@ -0,0 +1,101 @@ +import { Options } from 'k6/options'; +import { config, thresholds } from '../config/environment'; +import { setupUsers } from '../lib/auth'; +import { generateUserIds } from '../lib/utils'; +import { RealUserPatterns } from '../scenarios/real-user-patterns'; +import { TestSetupData } from '../lib/types'; + +export const options: Options = { + thresholds: { + http_req_failed: ['rate<0.05'], + http_req_duration: ['p(95)<2000', 'p(99)<5000'], + }, + + scenarios: { + // 아침 출근시간 (7-9시) 시뮬레이션 - 점진적 증가 + morning_rush: { + executor: 'ramping-arrival-rate', + startTime: '0s', + startRate: 5, + stages: [ + { target: 15, duration: '2m' }, + { target: 30, duration: '3m' }, + { target: 20, duration: '2m' }, + ], + preAllocatedVUs: 10, + maxVUs: 25, + exec: 'browsing_session', + }, + + // 점심시간 (12-1시) 활발한 사용 + lunch_time: { + executor: 'constant-arrival-rate', + startTime: '7m', + rate: 25, + timeUnit: '1s', + duration: '4m', + preAllocatedVUs: 15, + maxVUs: 20, + exec: 'active_user_session', + }, + + // 저녁 피크 시간 (6-8시) - 가장 높은 부하 + evening_peak: { + executor: 'ramping-arrival-rate', + startTime: '11m', + startRate: 20, + stages: [ + { target: 40, duration: '1m' }, + { target: 50, duration: '3m' }, + { target: 35, duration: '2m' }, + { target: 15, duration: '2m' }, + ], + preAllocatedVUs: 20, + maxVUs: 35, + exec: 'heavy_user_session', + }, + + // 야간 시간 - 낮은 부하 유지 + night_time: { + executor: 'constant-arrival-rate', + startTime: '19m', + rate: 8, + timeUnit: '1s', + duration: '3m', + preAllocatedVUs: 5, + maxVUs: 10, + exec: 'light_browsing', + }, + }, +}; + +const userPatterns = new RealUserPatterns(); + +export function setup(): TestSetupData { + const userIds = generateUserIds(config.maxUsers); + const tokens = setupUsers(userIds); + return { tokens }; +} + +export function light_browsing(data: TestSetupData) { + userPatterns.lightBrowsing(data.tokens); +} + +export function browsing_session(data: TestSetupData) { + userPatterns.browsing_session(data.tokens); +} + +export function active_user_session(data: TestSetupData) { + userPatterns.activeUserSession(data.tokens); +} + +export function heavy_user_session(data: TestSetupData) { + userPatterns.heavyUserSession(data.tokens); +} + +export function teardown() { + const totalDuration = 22 * 60; + console.log(`\n=== 실제 사용자 패턴 테스트 결과 ===`); + console.log(`테스트 시간: ${totalDuration}초 (${totalDuration/60}분)`); + console.log(`==========================================\n`); +} diff --git a/performance/src/tests/simple-iteration.ts b/performance/src/tests/simple-iteration.ts new file mode 100644 index 0000000..fa677cc --- /dev/null +++ b/performance/src/tests/simple-iteration.ts @@ -0,0 +1,33 @@ +import { Options } from 'k6/options'; +import { config, thresholds } from '../config/environment'; +import { setupUsers } from '../lib/auth'; +import { generateUserIds } from '../lib/utils'; +import { RecordsBrowsingScenarios } from '../scenarios/records-browsing'; +import { TestSetupData } from '../lib/types'; + +export const options: Options = { + scenarios: { + default: { + executor: 'per-vu-iterations', + vus: 1, + iterations: 100, + maxDuration: config.testDuration, + } + }, + thresholds, +}; + +export function setup(): TestSetupData { + const userIds = generateUserIds(config.maxUsers); + const tokens = setupUsers(userIds); + return { tokens }; +} + +export default function(data: TestSetupData) { + const recordsScenarios = new RecordsBrowsingScenarios(); + recordsScenarios.simpleRecordsQuery(data.tokens); +} + +export function teardown() { + console.log(`\n---\n 간단한 반복 테스트 완료 (지속시간: ${config.testDuration})\n---`); +}