From 174bcc9f9f5b7c0f3b2e95d78b85817ed2059e0a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EB=AF=BC=ED=97=8C?= Date: Tue, 7 Jul 2026 14:36:48 +0900 Subject: [PATCH] =?UTF-8?q?test(loadtest):=20=ED=98=91=EC=83=81=20?= =?UTF-8?q?=EB=8F=84=EB=A9=94=EC=9D=B8=20=EB=B6=80=ED=95=98=20=EC=8B=9C?= =?UTF-8?q?=EB=82=98=EB=A6=AC=EC=98=A4=20+=20run=5Flocal=5Flocust=20?= =?UTF-8?q?=EA=B0=9C=EC=84=A0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - negotiation_locustfile: 공급사 브라우징(list/chat init·messages) + 참여/거부 부하. reject 는 1회성이라 소비 전용 REJECT 풀(큐), participate 는 IN_PROGRESS no-op 이라 재사용 READ 풀. 시드 세션마다 chat 오프닝 행을 심어 agent 호출 회피. catch_response 로 result.success 검증. - run_local_locust.sh: · 단일 파일 자동 선택(불필요한 프롬프트 생략) + 다중 시 입력 검증 · .venv 존재 확인, glob 수집, psql 에러 노출(2>/dev/null 제거) · negotiation 선택 시 세션 풀(CTE+generate_series) 시딩 → 풀 파일 전달 · LOAD_READ_POOL / LOAD_REJECT_POOL 로 풀 크기 조절 검증: 10 users·20s 헤드리스에서 전 엔드포인트 0% 실패, agent 무호출 확인. Co-Authored-By: Claude Fable 5 --- backend/loadtest/negotiation_locustfile.py | 202 +++++++++++++++++++++ backend/run_local_locust.sh | 128 ++++++++++--- 2 files changed, 308 insertions(+), 22 deletions(-) create mode 100644 backend/loadtest/negotiation_locustfile.py diff --git a/backend/loadtest/negotiation_locustfile.py b/backend/loadtest/negotiation_locustfile.py new file mode 100644 index 0000000..dc12582 --- /dev/null +++ b/backend/loadtest/negotiation_locustfile.py @@ -0,0 +1,202 @@ +"""협상(negotiation) 도메인 부하 테스트 — 공급사 브라우징 + 참여/거부 시나리오. + +각 가상 유저가 on_start 에서 self-register + 로그인(auth locustfile 과 동일)한 뒤, +로그인 유저가 속한 부하 공급사의 협상 세션을 대상으로: + - 읽기: 목록 조회(list) / 채팅 진입(chat_init) / 대화 히스토리(chat_messages) + - 변경: 참여(participate) / 거부(reject) +를 가중치대로 반복한다. + +세션은 supplier backend 에 생성 API 가 없어(negodata 가 생성) DB 로 직접 시딩해야 한다. +run_local_locust.sh 가 부하 공급사에 세션 풀을 시딩하고 그 id 들을 풀 파일로 넘긴다(LOAD_NEGO_POOL_FILE): + - READ 풀 : IN_PROGRESS + open 견적 + chat 오프닝 행. 재사용(비소비) — list/init/messages/participate 대상. + - REJECT 풀: 위와 동일하나 reject 로 한 번씩만 소비(REJECTED 로 전이). 큐에서 pop, 유저 간 중복 없음. + +풀 파일 형식(run_local_locust.sh 가 생성): + READ:,,... + REJECT: + REJECT: + ... + +주의: +- participate 는 IN_PROGRESS 세션에 대해 성공 no-op(무변경)이라 지속 반복해도 안전하다 + (CREATED→IN_PROGRESS 쓰기 전이 자체는 pytest 가 검증). 여기선 엔드포인트 정상상태 비용을 측정. +- reject 는 1회성이라 REJECT 풀이 소진되면 해당 태스크는 건너뛴다(소진 사실을 로그로 남긴다 — silent cap 방지). +- chat_messages 는 빈 IN_PROGRESS 세션에서 agent 오프닝을 seed 하므로, 시드 세션마다 chat 행을 심어 agent 호출을 피한다. + +직접 실행 시: + LOAD_SUPPLIER_ID=<공급사 uuid> LOAD_NEGO_POOL_FILE=<풀 파일 경로> \ + locust -f loadtest/negotiation_locustfile.py --host http://localhost:9300 +""" + +import os +import queue +import random + +from locust import HttpUser, between, events, task + +LOAD_SUPPLIER_ID = os.environ.get("LOAD_SUPPLIER_ID", "") +LOAD_NEGO_POOL_FILE = os.environ.get("LOAD_NEGO_POOL_FILE", "") +LOAD_PW = "loadpw1234" + +# 풀 파일에서 로드되는 세션 id 저장소(프로세스 공유 — locust 는 유저를 단일 프로세스의 greenlet 으로 실행). +READ_IDS: list[str] = [] # 비소비(재사용): list/init/messages/participate 대상 +REJECT_QUEUE: "queue.Queue[str]" = queue.Queue() # 소비: reject 가 하나씩 pop + + +def _load_pool(path: str) -> None: + """풀 파일을 읽어 READ_IDS 리스트와 REJECT_QUEUE 큐를 채운다.""" + with open(path, encoding="utf-8") as f: + for line in f: + line = line.strip() + if line.startswith("READ:"): + READ_IDS.extend(x for x in line[len("READ:"):].split(",") if x) + elif line.startswith("REJECT:"): + sid = line[len("REJECT:"):].strip() + if sid: + REJECT_QUEUE.put(sid) + + +class NegotiationUser(HttpUser): + wait_time = between(0.5, 2.0) + + def on_start(self): + # 유저마다 고유 계정을 생성(self-register)하고 로그인해 토큰을 확보한다(auth 부하와 동일 패턴). + self.login_id = f"load_user_{random.randint(0, 1_000_000_000)}" + self.access_token = None + with self.client.post( + "/v1/auth/create", + json={"supplier_id": LOAD_SUPPLIER_ID, "id": self.login_id, "pw": LOAD_PW}, + name="POST /v1/auth/create", + catch_response=True, + ) as resp: + if resp.status_code == 200 and resp.json().get("result", {}).get("success"): + resp.success() + else: + resp.failure(f"create failed: {resp.status_code} {resp.text[:120]}") + with self.client.post( + "/v1/auth/login", + json={"id": self.login_id, "pw": LOAD_PW}, + name="POST /v1/auth/login", + catch_response=True, + ) as resp: + if resp.status_code == 200 and resp.json().get("result", {}).get("success"): + self.access_token = resp.json().get("access_token") + resp.success() + else: + resp.failure(f"login failed: {resp.status_code} {resp.text[:120]}") + + def _auth_header(self): + return {"Authorization": f"Bearer {self.access_token}"} + + @task(6) + def list_sessions(self): + # 공급사 세션 목록 — 여러 스키마를 앱 레벨에서 조인하는 무거운 조회. 랜덤 필터/정렬/페이지. + if not self.access_token: + return + params = {"order": random.choice(["asc", "desc"]), "page": random.randint(1, 3), "page_size": 20} + if random.random() < 0.5: + params["status"] = random.choice([1, 2, 3]) + if random.random() < 0.3: + params["qt_type"] = random.choice([1, 2]) + with self.client.get( + "/v1/negotiation/sessions", + params=params, + headers=self._auth_header(), + name="GET /v1/negotiation/sessions", + catch_response=True, + ) as resp: + if resp.status_code == 200 and resp.json().get("result", {}).get("success"): + resp.success() + else: + resp.failure(f"list failed: {resp.status_code} {resp.text[:120]}") + + @task(4) + def chat_init(self): + # 채팅 진입(상품·견적 메타). 읽기 풀에서 임의 세션 선택. + if not self.access_token or not READ_IDS: + return + sid = random.choice(READ_IDS) + with self.client.get( + f"/v1/negotiation/sessions/{sid}/chat/init", + headers=self._auth_header(), + name="GET /v1/negotiation/sessions/[id]/chat/init", + catch_response=True, + ) as resp: + if resp.status_code == 200 and resp.json().get("result", {}).get("success"): + resp.success() + else: + resp.failure(f"init failed: {resp.status_code} {resp.text[:120]}") + + @task(3) + def chat_messages(self): + # 대화 히스토리(재진입 복원). 시드된 오프닝 행이 있어 agent 를 호출하지 않는다. + if not self.access_token or not READ_IDS: + return + sid = random.choice(READ_IDS) + with self.client.get( + f"/v1/negotiation/sessions/{sid}/chat/messages", + headers=self._auth_header(), + name="GET /v1/negotiation/sessions/[id]/chat/messages", + catch_response=True, + ) as resp: + if resp.status_code == 200 and resp.json().get("result", {}).get("success"): + resp.success() + else: + resp.failure(f"messages failed: {resp.status_code} {resp.text[:120]}") + + @task(2) + def participate(self): + # 참여 — 읽기 풀은 이미 IN_PROGRESS 라 성공 no-op(무변경). 엔드포인트 정상상태 비용(인증+조회+검증) 측정. + if not self.access_token or not READ_IDS: + return + sid = random.choice(READ_IDS) + with self.client.post( + f"/v1/negotiation/sessions/{sid}/participate", + headers=self._auth_header(), + name="POST /v1/negotiation/sessions/[id]/participate", + catch_response=True, + ) as resp: + if resp.status_code == 200 and resp.json().get("result", {}).get("success"): + resp.success() + else: + resp.failure(f"participate failed: {resp.status_code} {resp.text[:120]}") + + @task(1) + def reject(self): + # 거부 — REJECT 풀에서 하나 pop 해 소비(IN_PROGRESS→REJECTED 쓰기 전이). 풀 소진 시 건너뛴다. + if not self.access_token: + return + try: + sid = REJECT_QUEUE.get_nowait() + except queue.Empty: + return # 풀 소진 — test_stop 에서 소진 사실을 알린다 + with self.client.post( + f"/v1/negotiation/sessions/{sid}/reject", + json={"reject_reason": "부하테스트 단종"}, + headers=self._auth_header(), + name="POST /v1/negotiation/sessions/[id]/reject", + catch_response=True, + ) as resp: + if resp.status_code == 200 and resp.json().get("result", {}).get("success"): + resp.success() + else: + resp.failure(f"reject failed: {resp.status_code} {resp.text[:120]}") + + +@events.test_start.add_listener +def _on_start(environment, **kwargs): + if not LOAD_SUPPLIER_ID: + print("[warn] LOAD_SUPPLIER_ID 가 비어있습니다 — self-register 가 전부 실패합니다. run_local_locust.sh 로 실행하세요.") + if LOAD_NEGO_POOL_FILE and os.path.exists(LOAD_NEGO_POOL_FILE): + _load_pool(LOAD_NEGO_POOL_FILE) + print(f"협상 부하 시작 — READ 풀 {len(READ_IDS)}건 / REJECT 풀 {REJECT_QUEUE.qsize()}건") + if not READ_IDS: + print("[warn] READ 풀이 비었습니다 — init/messages/participate 가 동작하지 않습니다. run_local_locust.sh 로 실행하세요.") + + +@events.test_stop.add_listener +def _on_stop(environment, **kwargs): + if REJECT_QUEUE.empty(): + print("[info] REJECT 풀 소진 — reject 태스크는 이후 건너뛰었습니다. 더 오래/세게 돌리려면 LOAD_REJECT_POOL 을 키우세요.") + else: + print(f"[info] REJECT 풀 잔여 {REJECT_QUEUE.qsize()}건.") diff --git a/backend/run_local_locust.sh b/backend/run_local_locust.sh index bbeea31..43303d1 100755 --- a/backend/run_local_locust.sh +++ b/backend/run_local_locust.sh @@ -15,39 +15,55 @@ PGPORT="${PGPORT:-5432}" PGUSER="${PGUSER:-postgres}" PGDATABASE="${PGDATABASE:-negosium_db}" +# 가상환경 보장 (없으면 친절한 안내 후 종료) +if [[ ! -x "$VENV/bin/python" ]]; then + echo "[error] $VENV 가상환경이 없습니다. backend/ 에서 먼저 만드세요:" >&2 + echo " python -m venv .venv && .venv/bin/pip install -r requirements.txt" >&2 + exit 1 +fi + # locust 설치 보장 if [[ ! -x "$LOCUST" ]]; then echo "[setup] locust 설치..." "$VENV/bin/python" -m pip install -q locust fi -# 1) locust 파일 선택 +# 1) locust 파일 선택 (glob 로 수집; *.py 는 __pycache__ 하위를 매칭하지 않아 자연히 제외) FILES=() -while IFS= read -r f; do - FILES+=("$f") -done < <(ls -1 loadtest/*.py 2>/dev/null | grep -v __pycache__ || true) +for f in loadtest/*.py; do + [[ -e "$f" ]] && FILES+=("$f") # 매칭이 없으면 glob 원문이 오므로 존재 확인 +done if [[ ${#FILES[@]} -eq 0 ]]; then - echo "[error] loadtest/ 에 locust 파일이 없습니다." + echo "[error] loadtest/ 에 locust 파일이 없습니다." >&2 exit 1 fi -echo "── locust 파일 선택 ──" -i=1 -for f in "${FILES[@]}"; do - echo " $i) ${f#loadtest/}" - i=$((i + 1)) -done -read -rp "선택 [1]: " fsel -fsel="${fsel:-1}" -FILE="${FILES[$((fsel - 1))]:-}" -if [[ -z "$FILE" ]]; then - echo "[error] 잘못된 선택: $fsel" - exit 1 + +if [[ ${#FILES[@]} -eq 1 ]]; then + FILE="${FILES[0]}" # 파일이 하나뿐이면 자동 선택(불필요한 프롬프트 생략) + echo "[info] locust 파일: ${FILE#loadtest/}" +else + echo "── locust 파일 선택 ──" + i=1 + for f in "${FILES[@]}"; do + echo " $i) ${f#loadtest/}" + i=$((i + 1)) + done + read -rp "선택 [1]: " fsel + fsel="${fsel:-1}" + # 숫자·범위 검증 (비숫자 입력이 산술 문맥에서 엉뚱한 인덱스가 되는 것을 차단) + if [[ ! "$fsel" =~ ^[0-9]+$ ]] || (( fsel < 1 || fsel > ${#FILES[@]} )); then + echo "[error] 잘못된 선택: $fsel" >&2 + exit 1 + fi + FILE="${FILES[$((fsel - 1))]}" fi # 2) 부하용 공급사 1건 보장 → supplier_id 를 LOAD_SUPPLIER_ID 로 넘긴다. # 유저는 사전 시드하지 않고 locust on_start 에서 self-register 한다(tbl 방식). # (없으면 만들고, 있으면 그대로 사용 = get-or-create) -LOAD_SUPPLIER_ID="$(psql -h "$PGHOST" -p "$PGPORT" -U "$PGUSER" -d "$PGDATABASE" -t -A -c " +# psql 실패 시 원본 에러를 그대로 노출한다(2>/dev/null 로 삼키면 원인 파악이 어려움). +# `if ! ...` 로 감싸 set -e 를 우회하고 친절한 에러 메시지로 안내한다. +if ! LOAD_SUPPLIER_ID="$(psql -h "$PGHOST" -p "$PGPORT" -U "$PGUSER" -d "$PGDATABASE" -t -A -c " WITH ex AS ( SELECT supplier_id FROM partner.suppliers WHERE name='부하테스트공급사' AND deleted=false LIMIT 1 ), ins AS ( @@ -57,9 +73,14 @@ WITH ex AS ( RETURNING supplier_id ) SELECT supplier_id FROM ins UNION ALL SELECT supplier_id FROM ex LIMIT 1; -" 2>/dev/null | tr -d '[:space:]')" +" 2>&1)"; then + echo "[error] 부하용 공급사 시딩 실패 (DB 접속/스키마 확인):" >&2 + echo "$LOAD_SUPPLIER_ID" >&2 + exit 1 +fi +LOAD_SUPPLIER_ID="$(printf '%s' "$LOAD_SUPPLIER_ID" | tr -d '[:space:]')" if [[ -z "$LOAD_SUPPLIER_ID" ]]; then - echo "[error] 부하용 공급사 supplier_id 를 확보하지 못했습니다 (DB 접속/스키마 확인)." + echo "[error] 부하용 공급사 supplier_id 를 확보하지 못했습니다 (스키마 확인)." >&2 exit 1 fi @@ -69,6 +90,67 @@ psql -h "$PGHOST" -p "$PGPORT" -U "$PGUSER" -d "$PGDATABASE" -q \ DELETE FROM supplier.supplier_users WHERE id LIKE 'load_user_%';" >/dev/null 2>&1 || true echo "[info] 부하용 공급사 supplier_id=$LOAD_SUPPLIER_ID (유저는 self-register, 이전 부하계정 정리됨)" +# 2-1) 협상 부하일 때만: 세션 풀 시딩(READ 재사용 + REJECT 소비용) → 풀 파일로 넘긴다. +# 세션은 supplier backend 에 생성 API 가 없어 DB 로 직접 시딩한다. 마킹 prefix 'LOADNEGO-'. +# 매 실행 재시딩(reject 가 소비하므로 항상 새 풀). N 은 환경변수로 조절. +LOAD_NEGO_POOL_FILE="" +if [[ "${FILE#loadtest/}" == "negotiation_locustfile.py" ]]; then + N_READ="${LOAD_READ_POOL:-30}" # 재사용 세션 수(list/init/messages/participate 대상) + N_REJECT="${LOAD_REJECT_POOL:-500}" # 소비 세션 수(reject 1건당 1개) + echo "[info] 협상 세션 풀 시딩 중 (READ=$N_READ, REJECT=$N_REJECT)..." + # 기존 부하 세션 정리(chats→sessions→quotations→items 순, no-FK 라 앱에서 순서 보장) + psql -h "$PGHOST" -p "$PGPORT" -U "$PGUSER" -d "$PGDATABASE" -q -c " + DELETE FROM negotiation.chats WHERE session_id IN (SELECT session_id FROM negotiation.sessions WHERE qt_number LIKE 'LOADNEGO-%'); + DELETE FROM negotiation.sessions WHERE qt_number LIKE 'LOADNEGO-%'; + DELETE FROM quotation.quotations WHERE number LIKE 'LOADNEGO-%'; + DELETE FROM partner.items WHERE code LIKE 'LOADNEGO-%';" >/dev/null 2>&1 || true + + # 세션마다 item+quotation(open, +6h)+session(IN_PROGRESS)+chat 오프닝 1행을 한 번에 시딩. + # 데이터 수정 CTE 는 참조 여부와 무관하게 모두 실행된다(Postgres). 최종 SELECT 로 (g, mark, session_id) 반환. + if ! POOL_RAW="$(psql -h "$PGHOST" -p "$PGPORT" -U "$PGUSER" -d "$PGDATABASE" -t -A -F',' -c " +WITH seq AS ( + SELECT g, + gen_random_uuid() AS item_id, gen_random_uuid() AS qt_id, + gen_random_uuid() AS session_id, gen_random_uuid() AS chat_id, + CASE WHEN g <= $N_READ THEN 'LOADNEGO-READ-'||g + ELSE 'LOADNEGO-REJECT-'||(g - $N_READ) END AS mark + FROM generate_series(1, $N_READ + $N_REJECT) g +), +i AS ( + INSERT INTO partner.items (item_id, company_id, user_id, name, code, price, model_name, manufacturer, spec, moq, lead_time, vat_yn, delivery_fee_yn) + SELECT item_id, gen_random_uuid(), gen_random_uuid(), '부하상품 '||g, mark, 100000, 'MODEL-'||g, '부하제조사', '규격A', '10', 3, true, false FROM seq +), +q AS ( + INSERT INTO quotation.quotations (qt_id, user_id, qt_setting_id, version_id, name, number, type, status, start_time, end_time, memo) + SELECT qt_id, gen_random_uuid(), gen_random_uuid(), gen_random_uuid(), '부하견적 '||g, mark, 1, 2, now(), now() + interval '6 hours', '부하메모' FROM seq +), +s AS ( + INSERT INTO negotiation.sessions (session_id, quotation_id, item_id, supplier_id, qt_number, qt_round, qt_type, target_price, status, end_time) + SELECT session_id, qt_id, item_id, '$LOAD_SUPPLIER_ID', mark, 1, 1, 100000, 2, now() + interval '6 hours' FROM seq +), +c AS ( + INSERT INTO negotiation.chats (chat_id, session_id, seq, sender, target_price, meta) + SELECT chat_id, session_id, 1, 1, 100000, + '{\"script\":\"부하 오프닝입니다.\",\"step\":\"서비스안내\",\"client_step\":\"서비스안내\",\"input_mode\":\"confirm\",\"input_options\":[\"네, 시작할게요\"]}'::jsonb + FROM seq +) +SELECT g, mark, session_id FROM seq ORDER BY g; +" 2>&1)"; then + echo "[error] 협상 세션 풀 시딩 실패:" >&2 + echo "$POOL_RAW" >&2 + exit 1 + fi + + # 풀 파일 작성: READ id 는 한 줄(콤마), REJECT id 는 줄당 하나. + LOAD_NEGO_POOL_FILE="$(mktemp -t nego_pool.XXXXXX)" + { + printf 'READ:' + printf '%s\n' "$POOL_RAW" | awk -F',' '$2 ~ /^LOADNEGO-READ-/ {printf "%s%s", sep, $3; sep=","} END{print ""}' + printf '%s\n' "$POOL_RAW" | awk -F',' '$2 ~ /^LOADNEGO-REJECT-/ {print "REJECT:" $3}' + } > "$LOAD_NEGO_POOL_FILE" + echo "[info] 세션 풀 파일: $LOAD_NEGO_POOL_FILE (READ=$N_READ, REJECT=$N_REJECT)" +fi + # 3) 실행 방식 선택 echo "── 실행 방식 ──" echo " 1) 웹 UI (브라우저에서 사용자 수 조절, http://localhost:8089)" @@ -81,8 +163,10 @@ if [[ "$mode" == "2" ]]; then read -rp "초당 증가 수 [10]: " RATE; RATE="${RATE:-10}" read -rp "지속 시간(예 30s/2m) [1m]: " DUR; DUR="${DUR:-1m}" echo "[run] $FILE headless -u $VU -r $RATE -t $DUR (host=$HOST)" - exec env LOAD_SUPPLIER_ID="$LOAD_SUPPLIER_ID" "$LOCUST" -f "$FILE" --host "$HOST" --headless -u "$VU" -r "$RATE" -t "$DUR" + exec env LOAD_SUPPLIER_ID="$LOAD_SUPPLIER_ID" LOAD_NEGO_POOL_FILE="$LOAD_NEGO_POOL_FILE" \ + "$LOCUST" -f "$FILE" --host "$HOST" --headless -u "$VU" -r "$RATE" -t "$DUR" else echo "[run] $FILE 웹 UI → http://localhost:8089 (host=$HOST)" - exec env LOAD_SUPPLIER_ID="$LOAD_SUPPLIER_ID" "$LOCUST" -f "$FILE" --host "$HOST" + exec env LOAD_SUPPLIER_ID="$LOAD_SUPPLIER_ID" LOAD_NEGO_POOL_FILE="$LOAD_NEGO_POOL_FILE" \ + "$LOCUST" -f "$FILE" --host "$HOST" fi