"""LPS 부하 테스트 — 여러 상품을 한 번에 제출하고 처리량·지연·비용을 집계한다. 여러 상품 동시 검색을 재현한다. 워커 동시성(WORKER_CONCURRENCY)만큼 상품이 병렬 처리된다. python loadtest.py # 카탈로그 앞 6개 N=100 python loadtest.py # 상품 100개(loadtest/catalog.json 전체) BASE=http://localhost:9600 python loadtest.py 상품셋 = loadtest/catalog.json (100종, 실존 상품): 규격만(식품·생활용품) / 규격+모델(가전·디지털) / 이름만 / 회사 포함 / 일부 기준가(price→가격밴드 필터 경로)를 섞어 실제 입력 분포를 재현한다. N > 카탈로그 크기면 반복 확장. product_code 는 부하테스트 전용 프리픽스(LT###, 매회 유니크라 dedupe·네거티브캐시에 안 걸림). 측정: 벽시계 총시간, 처리량(상품/분), 상품별·집계 지연(p50/p95), AI·DECODO·총비용. 워커가 떠 있어야 하고, 워커 동시성이 낮으면 상품들이 큐에서 순차 대기한다(그게 부하의 핵심 관측). """ import asyncio import json import os import pathlib import time import httpx BASE = os.environ.get("BASE", "http://localhost:9600") N = int(os.environ.get("N", "6")) _CATALOG = json.loads( (pathlib.Path(__file__).parent / "loadtest" / "catalog.json").read_text(encoding="utf-8") ) def _products(n): out = [] for i in range(n): base = _CATALOG[i % len(_CATALOG)] out.append({"product_code": f"LT{i:03d}", "job_type": "batch", **base}) return out def _composition(products) -> str: """상품셋 구성 요약 — 어떤 입력 분포로 테스트하는지 리포트에 남긴다.""" model = sum(1 for p in products if p.get("model")) spec_only = sum(1 for p in products if p.get("specification") and not p.get("model")) name_only = sum(1 for p in products if not p.get("specification") and not p.get("model")) priced = sum(1 for p in products if p.get("price")) return f"모델포함 {model} · 규격만 {spec_only} · 이름만 {name_only} · 기준가 포함 {priced}" def _pct(values, p): if not values: return 0 s = sorted(values) k = min(len(s) - 1, int(round((p / 100) * (len(s) - 1)))) return s[k] async def main(): products = _products(N) print(f"■ 부하 테스트: 상품 {N}개 → {BASE} (워커 동시성만큼 병렬 처리)") print(f" 구성: {_composition(products)}\n") async with httpx.AsyncClient(timeout=30) as c: # 큐/헬스 사전 확인 try: stats = (await c.get(f"{BASE}/v1/lps/queue/stats")).json()["counts"] print(f"시작 큐 상태: {stats}") except Exception as e: print(f"API 접속 실패({e}). 서버가 떠 있나요?"); return t0 = time.monotonic() r = (await c.post(f"{BASE}/v1/lps/search", json={"data": products})).json() jobs = [it["job_id"] for it in r.get("items", []) if it.get("job_id")] print(f"접수: {len(jobs)}건 (중복 스킵 {N - len(jobs)})\n폴링 중…\n") results = {} while len(results) < len(jobs): await asyncio.sleep(2) pend = [j for j in jobs if j not in results] got = await asyncio.gather(*[c.get(f"{BASE}/v1/lps/jobs/{j}") for j in pend]) for j, resp in zip(pend, got): d = resp.json() if d.get("status") in ("DONE", "DEAD"): results[j] = d done = len(results) print(f"\r 진행 {done}/{len(jobs)} ({time.monotonic()-t0:.0f}s 경과)", end="", flush=True) wall = time.monotonic() - t0 print("\n") # 집계 durs, ai_costs, proxy_costs, totals = [], [], [], [] found = dead = 0 print(f"{'상품코드':<8} {'상태':<5} {'결과':<10} {'소요':>6} {'AI$':>9} {'DECODO$':>9} {'총$':>9}") print("-" * 66) for j in jobs: d = results[j] o = d.get("output") or {} m = o.get("metrics") or {} cost = m.get("cost") or {} code = (o.get("query") or "")[:8] st = d.get("status", "?") dur = (m.get("duration_ms") or 0) / 1000 if st == "DONE": found += 1 if o.get("outcome") == "found" else 0 durs.append(dur); ai_costs.append(cost.get("ai_usd", 0)) proxy_costs.append(cost.get("proxy_usd", 0)); totals.append(cost.get("total_usd", 0)) else: dead += 1 print(f"{code:<8} {st:<5} {o.get('outcome','-'):<10} {dur:>5.1f}s " f"{cost.get('ai_usd',0):>9.6f} {cost.get('proxy_usd',0):>9.6f} {cost.get('total_usd',0):>9.6f}") print("-" * 66) print(f"\n■ 집계 ({len(jobs)}건, DEAD {dead})") print(f" 벽시계 총시간 : {wall:.1f}s 처리량: {len(jobs)/wall*60:.1f} 상품/분") print(f" 상품 지연 : p50 {_pct(durs,50):.1f}s · p95 {_pct(durs,95):.1f}s · max {max(durs) if durs else 0:.1f}s") print(f" ※ 순차합 대비 병렬: 상품별 소요 합 {sum(durs):.0f}s → 벽시계 {wall:.0f}s (동시성 효과)") print(f" 비용 합계 : AI ${sum(ai_costs):.5f} + DECODO ${sum(proxy_costs):.5f} = ${sum(totals):.5f}") print(f" 상품당 평균 비용 : ${(sum(totals)/len(totals)) if totals else 0:.6f}") print(f" 1,000건 추정 비용: ${(sum(totals)/len(totals)*1000) if totals else 0:.2f}") if __name__ == "__main__": asyncio.run(main())