vignette/scripts/load-test-sessions.py
2026-07-15 21:31:30 +09:00

175 lines
6.5 KiB
Python

"""P3 파일럿 대비 동시 세션 부하 테스트 (2026-07-13 한신대 회의).
20명 동시 사용 시나리오: 학습자 N명이 동시에 dev-login → 온보딩 → 세션 생성.
세션 동시 생성 시 RAG warm 동시성 이슈 이력이 있어(회의 P3), 생성 경로의
동시 지연/오류를 실측한다. 기본은 세션 생성까지만이고, --with-turn 을 주면
학습자당 텍스트 턴 1회(/turn 동기 경로)까지 실행한다(엔진 부하 주의).
사용 (dev 스택 기동 후, 저장소 루트):
py -3.11 scripts/load-test-sessions.py --base-url http://127.0.0.1:8000 --users 20
py -3.11 scripts/load-test-sessions.py --users 20 --with-turn --json
주의:
- dev 전용(dev-login 필요). prod(8001)에는 절대 돌리지 않는다.
- --with-turn 은 실제 LLM 생성을 유발한다(claude_cli 상주 풀 부하·비용).
"""
from __future__ import annotations
import argparse
import asyncio
import json
import statistics
import sys
import time
import httpx
ONBOARDING_BODY = {
"legal_name": "부하 테스트",
"affiliation": "한신대학교",
"department": "심리학과",
"grade_level": "석사 1년",
"phone": "010-0000-0000",
"contact_address": "부하 테스트 주소",
"nickname": "loadtester",
"self_introduction": "P3 동시 세션 부하 테스트 계정입니다.",
"terms_accepted": True,
"privacy_accepted": True,
}
async def _one_learner(
base_url: str,
index: int,
*,
persona_code: str,
with_turn: bool,
) -> dict:
result: dict = {"index": index, "ok": False}
async with httpx.AsyncClient(base_url=base_url, timeout=120.0) as client:
try:
t0 = time.perf_counter()
r = await client.post(
"/auth/dev-login",
json={
"email": f"loadtest-{index:02d}@hs.ac.kr",
"role": "learner",
"display_name": f"부하테스트{index:02d}",
},
)
r.raise_for_status()
result["login_ms"] = round((time.perf_counter() - t0) * 1000)
me = (await client.get("/auth/me")).json()
if not me.get("onboarding_completed_at"):
r = await client.post("/users/me/onboarding", json=ONBOARDING_BODY)
r.raise_for_status()
if not me.get("consent_at"):
r = await client.post("/auth/consent", json={"accepted": True})
if r.status_code >= 400 and r.status_code != 404:
r.raise_for_status()
t1 = time.perf_counter()
r = await client.post(
"/sessions",
json={
"persona_code": persona_code,
"theory_mode": "humanistic",
"goal_stages": ["라포", "탐색"],
},
)
r.raise_for_status()
payload = r.json()
result["create_ms"] = round((time.perf_counter() - t1) * 1000)
result["session_id"] = payload["session_id"]
result["degraded"] = bool(payload.get("degraded"))
if with_turn:
t2 = time.perf_counter()
r = await client.post(
f"/sessions/{payload['session_id']}/turn",
json={"text": "안녕하세요, 오늘 이렇게 시간 내줘서 고마워요. 요즘 어떻게 지냈어요?"},
)
r.raise_for_status()
result["turn_ms"] = round((time.perf_counter() - t2) * 1000)
result["ok"] = True
except httpx.HTTPStatusError as exc:
result["error"] = f"{exc.response.status_code} {exc.response.text[:180]}"
except Exception as exc: # noqa: BLE001 — 부하 리포트용 수집
result["error"] = f"{type(exc).__name__}: {exc}"
return result
def _percentiles(values: list[int]) -> dict:
if not values:
return {}
ordered = sorted(values)
return {
"min": ordered[0],
"p50": ordered[len(ordered) // 2],
"p95": ordered[min(len(ordered) - 1, int(len(ordered) * 0.95))],
"max": ordered[-1],
"avg": round(statistics.mean(ordered)),
}
async def main() -> int:
parser = argparse.ArgumentParser(description="P3 동시 세션 부하 테스트 (dev 전용)")
parser.add_argument("--base-url", default="http://127.0.0.1:8000")
parser.add_argument("--users", type=int, default=20)
parser.add_argument("--persona", default="P4")
parser.add_argument("--with-turn", action="store_true")
parser.add_argument("--json", action="store_true", dest="as_json")
args = parser.parse_args()
if "8001" in args.base_url or "chanpaca" in args.base_url:
print("거부: prod 대상 부하 테스트는 금지되어 있습니다.", file=sys.stderr)
return 2
started = time.perf_counter()
results = await asyncio.gather(
*(
_one_learner(
args.base_url,
index,
persona_code=args.persona,
with_turn=args.with_turn,
)
for index in range(1, args.users + 1)
)
)
wall_seconds = round(time.perf_counter() - started, 2)
ok = [r for r in results if r.get("ok")]
failed = [r for r in results if not r.get("ok")]
report = {
"target": args.base_url,
"users": args.users,
"with_turn": args.with_turn,
"wall_seconds": wall_seconds,
"ok": len(ok),
"failed": len(failed),
"degraded_sessions": sum(1 for r in ok if r.get("degraded")),
"create_ms": _percentiles([r["create_ms"] for r in ok if "create_ms" in r]),
"turn_ms": _percentiles([r["turn_ms"] for r in ok if "turn_ms" in r]),
"errors": [
{"index": r["index"], "error": r.get("error", "")} for r in failed
][:10],
}
if args.as_json:
print(json.dumps(report, ensure_ascii=False, indent=2))
else:
print(f"대상 {report['target']} · {report['users']}명 동시 · 총 {wall_seconds}s")
print(f"성공 {report['ok']} / 실패 {report['failed']} / degraded {report['degraded_sessions']}")
print(f"세션 생성(ms): {report['create_ms']}")
if report["turn_ms"]:
print(f"턴 왕복(ms): {report['turn_ms']}")
for err in report["errors"]:
print(f" - #{err['index']}: {err['error']}")
return 0 if not failed else 1
if __name__ == "__main__":
raise SystemExit(asyncio.run(main()))