"""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()))