""" JMeter 성능 테스트 연동 API 기능: 1. JMeter JTL 결과 파일 업로드 → 분석 및 보고서 생성 2. GUARDiA API 자동 성능 테스트 (내장 Python httpx 기반) 3. 결과 HTML/Excel 보고서 다운로드 엔드포인트: POST /api/perf/upload/jtl — JTL 파일 업로드 및 분석 POST /api/perf/run — GUARDiA API 자동 성능 테스트 GET /api/perf/results — 최근 테스트 결과 목록 GET /api/perf/results/{id} — 특정 결과 상세 GET /api/perf/results/{id}/html — HTML 보고서 GET /api/perf/results/{id}/excel — Excel 보고서 """ from __future__ import annotations import asyncio import io import csv import logging import time import uuid from datetime import datetime from typing import Any, Optional from fastapi import APIRouter, Depends, File, HTTPException, UploadFile from fastapi.responses import HTMLResponse, Response from pydantic import BaseModel from sqlalchemy.ext.asyncio import AsyncSession from core.auth import get_current_user from database import get_db from models import User, UserRole logger = logging.getLogger(__name__) router = APIRouter(prefix="/api/perf", tags=["performance"]) # 결과 인메모리 저장소 _results: dict[str, dict] = {} class PerfTestRequest(BaseModel): target_url: str = "http://localhost:8001" endpoints: list[str] = ["/", "/api/tasks", "/api/dashboard/me"] users: int = 10 # 동시 사용자 duration: int = 30 # 테스트 시간 (초) ramp_up: int = 5 # 사용자 증가 시간 (초) think_time: float = 0.1 # 요청 간 대기 (초) # ── JTL 파일 분석 ──────────────────────────────────────────────────────────── def _parse_jtl(content: str) -> dict[str, Any]: """JMeter JTL (CSV) 파일 파싱 및 통계 계산.""" reader = csv.DictReader(io.StringIO(content)) rows = list(reader) if not rows: return {"error": "JTL 파일에 데이터가 없습니다."} # 필드 이름 정규화 def get_field(row, *names): for n in names: if n in row: return row[n] return "" samples = [] for row in rows: try: elapsed = int(get_field(row, "elapsed", "Elapsed")) success = get_field(row, "success", "Success").lower() == "true" label = get_field(row, "label", "Label", "sampler_label") rc = get_field(row, "responseCode", "ResponseCode") ts = int(get_field(row, "timeStamp", "Timestamp") or 0) samples.append({ "elapsed": elapsed, "success": success, "label": label, "rc": rc, "ts": ts, }) except (ValueError, KeyError): continue if not samples: return {"error": "유효한 샘플 데이터 없음"} elapsed_list = [s["elapsed"] for s in samples] success_count = sum(1 for s in samples if s["success"]) error_count = len(samples) - success_count elapsed_sorted = sorted(elapsed_list) p_idx = lambda p: max(0, int(len(elapsed_sorted) * p / 100) - 1) # 초당 처리량 ts_list = [s["ts"] for s in samples if s["ts"] > 0] if ts_list: duration_ms = max(ts_list) - min(ts_list) tps = len(samples) / (duration_ms / 1000) if duration_ms > 0 else 0 else: tps = 0 # 레이블별 통계 by_label: dict = {} for s in samples: lbl = s["label"] if lbl not in by_label: by_label[lbl] = {"count": 0, "success": 0, "elapsed": []} by_label[lbl]["count"] += 1 by_label[lbl]["success"] += int(s["success"]) by_label[lbl]["elapsed"].append(s["elapsed"]) label_stats = {} for lbl, d in by_label.items(): es = sorted(d["elapsed"]) label_stats[lbl] = { "count": d["count"], "error_rate": round((d["count"] - d["success"]) / d["count"] * 100, 1), "avg_ms": round(sum(es) / len(es), 1) if es else 0, "p90_ms": es[max(0, int(len(es) * 0.9) - 1)] if es else 0, "p95_ms": es[max(0, int(len(es) * 0.95) - 1)] if es else 0, "max_ms": max(es) if es else 0, } return { "total_samples": len(samples), "success_count": success_count, "error_count": error_count, "error_rate_pct": round(error_count / len(samples) * 100, 2), "tps": round(tps, 2), "avg_response_ms": round(sum(elapsed_list) / len(elapsed_list), 1), "min_response_ms": min(elapsed_list), "max_response_ms": max(elapsed_list), "p50_ms": elapsed_sorted[p_idx(50)], "p90_ms": elapsed_sorted[p_idx(90)], "p95_ms": elapsed_sorted[p_idx(95)], "p99_ms": elapsed_sorted[p_idx(99)], "by_label": label_stats, } @router.post("/upload/jtl") async def upload_jtl( file: UploadFile = File(...), _u: User = Depends(get_current_user), ): """JMeter JTL 파일 업로드 및 통계 분석.""" if not file.filename.endswith(".jtl") and not file.filename.endswith(".csv"): raise HTTPException(400, "JTL 또는 CSV 파일만 지원합니다.") content = (await file.read()).decode("utf-8", errors="ignore") stats = _parse_jtl(content) if "error" in stats: raise HTTPException(400, stats["error"]) result_id = str(uuid.uuid4())[:8] _results[result_id] = { "id": result_id, "type": "jtl_upload", "filename": file.filename, "created_at":datetime.utcnow().isoformat(), "stats": stats, } return { "result_id": result_id, "message": f"JTL 분석 완료 — 총 {stats['total_samples']}개 샘플", "summary": { "tps": stats["tps"], "avg_ms": stats["avg_response_ms"], "p95_ms": stats["p95_ms"], "error_rate_pct": stats["error_rate_pct"], }, } # ── 내장 성능 테스트 ───────────────────────────────────────────────────────── @router.post("/run") async def run_performance_test( body: PerfTestRequest, cu: User = Depends(get_current_user), ): """GUARDiA API 자동 성능 테스트 (httpx 기반, JMeter 불필요).""" if cu.role not in (UserRole.ADMIN, UserRole.PM, UserRole.ENGINEER): raise HTTPException(403, "ADMIN/PM/ENGINEER만 성능 테스트를 실행할 수 있습니다.") import httpx result_id = str(uuid.uuid4())[:8] samples: list[dict] = [] async def hit_endpoint(client: httpx.AsyncClient, url: str) -> dict: t0 = time.monotonic() try: r = await client.get(url, timeout=10.0) elapsed_ms = round((time.monotonic() - t0) * 1000, 1) return {"url": url, "status": r.status_code, "elapsed_ms": elapsed_ms, "success": r.status_code < 400} except Exception as e: elapsed_ms = round((time.monotonic() - t0) * 1000, 1) return {"url": url, "status": 0, "elapsed_ms": elapsed_ms, "success": False, "error": str(e)[:50]} # 점진적 부하 증가 (ramp-up) start_time = time.monotonic() users_active = 0 ramp_interval = body.ramp_up / max(body.users, 1) async with httpx.AsyncClient(base_url=body.target_url) as client: while time.monotonic() - start_time < body.duration: # 현재 활성 사용자 수 계산 elapsed_total = time.monotonic() - start_time target_users = min(body.users, int(elapsed_total / ramp_interval) + 1) tasks = [] for _ in range(target_users): for ep in body.endpoints: tasks.append(hit_endpoint(client, ep)) results = await asyncio.gather(*tasks) samples.extend(results) await asyncio.sleep(body.think_time) if time.monotonic() - start_time >= body.duration: break # 통계 계산 elapsed_list = [s["elapsed_ms"] for s in samples] success_count = sum(1 for s in samples if s["success"]) el_sorted = sorted(elapsed_list) p_idx = lambda p: max(0, int(len(el_sorted) * p / 100) - 1) duration_sec = time.monotonic() - start_time stats = { "total_samples": len(samples), "success_count": success_count, "error_count": len(samples) - success_count, "error_rate_pct": round((len(samples) - success_count) / max(len(samples), 1) * 100, 2), "tps": round(len(samples) / duration_sec, 2), "avg_response_ms": round(sum(elapsed_list) / max(len(elapsed_list), 1), 1), "min_response_ms": min(elapsed_list) if elapsed_list else 0, "max_response_ms": max(elapsed_list) if elapsed_list else 0, "p50_ms": el_sorted[p_idx(50)] if el_sorted else 0, "p90_ms": el_sorted[p_idx(90)] if el_sorted else 0, "p95_ms": el_sorted[p_idx(95)] if el_sorted else 0, "p99_ms": el_sorted[p_idx(99)] if el_sorted else 0, "by_endpoint": { ep: { "count": sum(1 for s in samples if s["url"] == ep), "avg_ms": round( sum(s["elapsed_ms"] for s in samples if s["url"] == ep) / max(sum(1 for s in samples if s["url"] == ep), 1), 1 ), "errors": sum(1 for s in samples if s["url"] == ep and not s["success"]), } for ep in body.endpoints }, } _results[result_id] = { "id": result_id, "type": "auto_test", "target_url": body.target_url, "users": body.users, "duration": body.duration, "created_at": datetime.utcnow().isoformat(), "stats": stats, "config": body.model_dump(), } return { "result_id": result_id, "message": f"성능 테스트 완료 ({body.duration}초, {body.users}명)", "summary": { "tps": stats["tps"], "avg_ms": stats["avg_response_ms"], "p95_ms": stats["p95_ms"], "error_rate_pct": stats["error_rate_pct"], }, } # ── 결과 목록/상세 ─────────────────────────────────────────────────────────── @router.get("/results") async def list_results(_u: User = Depends(get_current_user)): return [ { "id": r["id"], "type": r["type"], "created_at": r["created_at"], "tps": r["stats"].get("tps", 0), "avg_ms": r["stats"].get("avg_response_ms", 0), "error_rate": r["stats"].get("error_rate_pct", 0), } for r in sorted(_results.values(), key=lambda x: x["created_at"], reverse=True) ] @router.get("/results/{rid}") async def get_result(rid: str, _u: User = Depends(get_current_user)): r = _results.get(rid) if not r: raise HTTPException(404, "결과를 찾을 수 없습니다.") return r # ── HTML 보고서 ────────────────────────────────────────────────────────────── @router.get("/results/{rid}/html", response_class=HTMLResponse) async def perf_html_report(rid: str, _u: User = Depends(get_current_user)): r = _results.get(rid) if not r: raise HTTPException(404, "결과를 찾을 수 없습니다.") s = r["stats"] err_color = "green" if s["error_rate_pct"] < 1 else ("orange" if s["error_rate_pct"] < 5 else "red") by_ep = s.get("by_endpoint") or s.get("by_label", {}) ep_rows = "".join( f"{k}{v.get('count','-')}{v.get('avg_ms','-')} ms" f"{v.get('errors',v.get('error_rate','-'))}" for k, v in by_ep.items() ) html = f""" GUARDiA 성능 테스트 보고서

⚡ 성능 테스트 보고서

생성일시: {r['created_at']} | 유형: {r['type']}

{s['tps']}
TPS
{s['avg_response_ms']} ms
평균 응답
{s['p95_ms']} ms
P95
{s['p99_ms']} ms
P99
{s['total_samples']}
총 요청
{s['error_rate_pct']}%
에러율

응답시간 분포

지표
최소{s['min_response_ms']} ms
평균{s['avg_response_ms']} ms
P50{s['p50_ms']} ms
P90{s['p90_ms']} ms
P95{s['p95_ms']} ms
P99{s['p99_ms']} ms
최대{s['max_response_ms']} ms

엔드포인트별 결과

{ep_rows}
엔드포인트요청 수평균 응답오류

Copyright © 2026 GUARDiA All Rights Reserved.

""" return HTMLResponse(html) # ── Excel 보고서 ───────────────────────────────────────────────────────────── @router.get("/results/{rid}/excel") async def perf_excel_report(rid: str, _u: User = Depends(get_current_user)): r = _results.get(rid) if not r: raise HTTPException(404, "결과를 찾을 수 없습니다.") try: import io, openpyxl from openpyxl.styles import Font, PatternFill except ImportError: raise HTTPException(500, "openpyxl 미설치") s = r["stats"] wb = openpyxl.Workbook() ws = wb.active ws.title = "성능 테스트 결과" header_fill = PatternFill("solid", fgColor="1a365d") header_font = Font(bold=True, color="FFFFFF") # 요약 시트 summary_rows = [ ("테스트 유형", r["type"]), ("생성일시", r["created_at"]), ("총 요청 수", s["total_samples"]), ("TPS", s["tps"]), ("평균 응답 (ms)", s["avg_response_ms"]), ("P50 (ms)", s["p50_ms"]), ("P90 (ms)", s["p90_ms"]), ("P95 (ms)", s["p95_ms"]), ("P99 (ms)", s["p99_ms"]), ("최대 응답 (ms)", s["max_response_ms"]), ("에러율 (%)", s["error_rate_pct"]), ("에러 수", s["error_count"]), ] for row, (k, v) in enumerate(summary_rows, 1): ws.cell(row=row, column=1, value=k).font = Font(bold=True) ws.cell(row=row, column=2, value=v) # 엔드포인트 시트 ws2 = wb.create_sheet("엔드포인트별") by_ep = s.get("by_endpoint") or s.get("by_label", {}) headers = ["엔드포인트/라벨", "요청 수", "평균 응답 (ms)", "오류"] for col, h in enumerate(headers, 1): c = ws2.cell(row=1, column=col, value=h) c.font = header_font; c.fill = header_fill for row, (k, v) in enumerate(by_ep.items(), 2): ws2.cell(row=row, column=1, value=k) ws2.cell(row=row, column=2, value=v.get("count", "-")) ws2.cell(row=row, column=3, value=v.get("avg_ms", "-")) ws2.cell(row=row, column=4, value=v.get("errors", v.get("error_rate", "-"))) buf = io.BytesIO() wb.save(buf) today = datetime.utcnow().strftime("%Y%m%d") return Response( content=buf.getvalue(), media_type="application/vnd.openxmlformats-officedocument.spreadsheetml.sheet", headers={"Content-Disposition": f'attachment; filename="GUARDiA_perf_{today}.xlsx"'}, )