zioinfo-mail/workspace/guardia-itsm/routers/jmeter.py
DESKTOP-TKLFCPR\ython cfe2901a55 refactor(structure): consolidate all projects under workspace/
- itsm/    -> workspace/guardia-itsm/
- manager/ -> workspace/guardia-manager/
- app/     -> workspace/guardia-messenger/
- manual/  -> workspace/guardia-docs/

workspace/zioinfo-web/ unchanged.
git mv preserves full commit history.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-31 23:50:56 +09:00

432 lines
17 KiB
Python

"""
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"<tr><td>{k}</td><td>{v.get('count','-')}</td><td>{v.get('avg_ms','-')} ms</td>"
f"<td>{v.get('errors',v.get('error_rate','-'))}</td></tr>"
for k, v in by_ep.items()
)
html = f"""<!DOCTYPE html><html lang="ko"><head><meta charset="UTF-8">
<title>GUARDiA 성능 테스트 보고서</title>
<style>
body{{font-family:Arial,sans-serif;margin:30px;color:#333;font-size:13px}}
h1{{color:#1a365d}} .kpi{{display:flex;gap:16px;flex-wrap:wrap;margin:16px 0}}
.kcard{{background:#ebf8ff;border-radius:8px;padding:12px 18px;text-align:center;min-width:120px}}
.kcard .v{{font-size:22px;font-weight:bold;color:#2b6cb0}}
.kcard .l{{font-size:11px;color:#718096}}
table{{border-collapse:collapse;width:100%;margin:12px 0}}
th{{background:#2d3748;color:#fff;padding:8px}} td{{padding:7px 8px;border-bottom:1px solid #e2e8f0}}
</style></head><body>
<h1>⚡ 성능 테스트 보고서</h1>
<p>생성일시: {r['created_at']} | 유형: {r['type']}</p>
<div class="kpi">
<div class="kcard"><div class="v">{s['tps']}</div><div class="l">TPS</div></div>
<div class="kcard"><div class="v">{s['avg_response_ms']} ms</div><div class="l">평균 응답</div></div>
<div class="kcard"><div class="v">{s['p95_ms']} ms</div><div class="l">P95</div></div>
<div class="kcard"><div class="v">{s['p99_ms']} ms</div><div class="l">P99</div></div>
<div class="kcard"><div class="v">{s['total_samples']}</div><div class="l">총 요청</div></div>
<div class="kcard"><div class="v" style="color:{err_color}">{s['error_rate_pct']}%</div><div class="l">에러율</div></div>
</div>
<h2>응답시간 분포</h2>
<table><tr><th>지표</th><th>값</th></tr>
<tr><td>최소</td><td>{s['min_response_ms']} ms</td></tr>
<tr><td>평균</td><td>{s['avg_response_ms']} ms</td></tr>
<tr><td>P50</td><td>{s['p50_ms']} ms</td></tr>
<tr><td>P90</td><td>{s['p90_ms']} ms</td></tr>
<tr><td>P95</td><td>{s['p95_ms']} ms</td></tr>
<tr><td>P99</td><td>{s['p99_ms']} ms</td></tr>
<tr><td>최대</td><td>{s['max_response_ms']} ms</td></tr>
</table>
<h2>엔드포인트별 결과</h2>
<table><tr><th>엔드포인트</th><th>요청 수</th><th>평균 응답</th><th>오류</th></tr>
{ep_rows}
</table>
<p style="margin-top:24px;color:#718096;font-size:11px">
Copyright &copy; 2026 GUARDiA All Rights Reserved.
</p></body></html>"""
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"'},
)