175 lines
6.5 KiB
Python
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()))
|