o2o-negosium-original/backend/run_local_locust.sh
민헌 174bcc9f9f test(loadtest): 협상 도메인 부하 시나리오 + run_local_locust 개선
- 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 <noreply@anthropic.com>
2026-07-07 14:36:48 +09:00

173 lines
8.5 KiB
Bash
Executable File

#!/usr/bin/env bash
#
# 로컬 부하테스트(locust) 실행 (대화형). 실행하면 파일/시드/방식을 골라 입력한다.
# loadtest/ 아래 locust 파일이 늘어나도 목록에서 선택만 하면 된다.
#
set -euo pipefail
cd "$(dirname "$0")" # backend/
VENV=".venv"
LOCUST="$VENV/bin/locust"
HOST="${LOCUST_HOST:-http://localhost:9300}"
# 시드용 DB 접속 (local 기본값, 환경변수로 override 가능)
PGHOST="${PGHOST:-127.0.0.1}"
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 파일 선택 (glob 로 수집; *.py 는 __pycache__ 하위를 매칭하지 않아 자연히 제외)
FILES=()
for f in loadtest/*.py; do
[[ -e "$f" ]] && FILES+=("$f") # 매칭이 없으면 glob 원문이 오므로 존재 확인
done
if [[ ${#FILES[@]} -eq 0 ]]; then
echo "[error] loadtest/ 에 locust 파일이 없습니다." >&2
exit 1
fi
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)
# 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 (
INSERT INTO partner.suppliers (supplier_id, company_id, user_id, name)
SELECT gen_random_uuid(), gen_random_uuid(), gen_random_uuid(), '부하테스트공급사'
WHERE NOT EXISTS (SELECT 1 FROM ex)
RETURNING supplier_id
)
SELECT supplier_id FROM ins UNION ALL SELECT supplier_id FROM ex LIMIT 1;
" 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 를 확보하지 못했습니다 (스키마 확인)." >&2
exit 1
fi
# 이전 실행에서 self-register 로 쌓인 계정/토큰 정리(누적 방지). 공급사 행은 재사용하므로 남긴다.
psql -h "$PGHOST" -p "$PGPORT" -U "$PGUSER" -d "$PGDATABASE" -q \
-c "DELETE FROM supplier.supplier_user_tokens WHERE su_id IN (SELECT su_id FROM supplier.supplier_users WHERE id LIKE 'load_user_%');
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)"
echo " 2) 헤드리스 (값 입력)"
read -rp "선택 [1]: " mode
mode="${mode:-1}"
if [[ "$mode" == "2" ]]; then
read -rp "동시 사용자 수 [50]: " VU; VU="${VU:-50}"
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" 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" LOAD_NEGO_POOL_FILE="$LOAD_NEGO_POOL_FILE" \
"$LOCUST" -f "$FILE" --host "$HOST"
fi