- 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>
432 lines
17 KiB
Python
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 © 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"'},
|
|
)
|