chore(backend): 로컬 실행/부하테스트 스크립트 추가
- run_local_server.sh / run_local_locust.sh / run_local_pgwatch.sh (대화형, 메뉴 선택 방식) - loadtest: self-register 방식 locustfile (가상 유저가 on_start 에서 계정 생성 → login/me/refresh) - locust 스크립트가 부하용 공급사 보장·이전 부하계정/토큰 정리·LOAD_SUPPLIER_ID 주입을 자동 처리 Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
2832162672
commit
23cb8291bc
@ -1,74 +1,101 @@
|
|||||||
"""Negosium 인증 서버 부하 테스트.
|
"""인증 서버 부하 테스트 (self-register 방식, 사전 시드 불필요).
|
||||||
|
|
||||||
실행:
|
각 가상 유저가 on_start 에서 자기 계정을 생성(/create)하고 로그인한 뒤,
|
||||||
pip install locust
|
me/login/refresh/healthz 를 가중치대로 반복한다.
|
||||||
locust -f loadtest/locustfile.py --host http://localhost:9300
|
|
||||||
# 웹 UI: http://localhost:8089 에서 사용자 수/spawn rate 입력
|
|
||||||
|
|
||||||
# 헤드리스(CI) 예시 - 100 VU, 10/s 증가, 2분:
|
supplier 의 /create 는 supplier_id(소속 공급사)가 필수이므로, run_local_locust.sh 가
|
||||||
locust -f loadtest/locustfile.py --host http://localhost:9300 \
|
부하용 공급사(부하테스트공급사) 1건을 보장하고 그 supplier_id 를 LOAD_SUPPLIER_ID 로 넘긴다.
|
||||||
--headless -u 100 -r 10 -t 2m
|
|
||||||
|
직접 실행 시:
|
||||||
|
LOAD_SUPPLIER_ID=<공급사 uuid> locust -f loadtest/locustfile.py --host http://localhost:9300
|
||||||
|
|
||||||
|
정리(테스트 후, self-register 로 쌓인 계정 삭제):
|
||||||
|
psql ... -c "DELETE FROM supplier.supplier_users WHERE id LIKE 'load_user_%';"
|
||||||
|
|
||||||
주의:
|
주의:
|
||||||
- /login 은 bcrypt 검증(CPU 바운드)이 들어가 가장 무겁다. RPS 가 낮으면
|
- /create, /login 은 bcrypt(CPU 바운드) + DB read/write 라 가장 무겁다.
|
||||||
거의 확실히 bcrypt cost 가 병목이다 (DB 아님).
|
- /me, /refresh 는 su_id DB 존재/활성 검증(read 2회)을 한다 — 순수 JWT 경로가 아니다.
|
||||||
- 부하를 올리며 서버 측에서 PostgreSQL 커넥션 수를 함께 모니터링하라:
|
- 논리 DB 가 USER/PARTNER 2개라 같은 negosium_db 에 엔진 풀이 4벌(R/W×2) 잡힌다.
|
||||||
SELECT count(*) FROM pg_stat_activity;
|
SELECT count(*) FROM pg_stat_activity WHERE datname = 'negosium_db';
|
||||||
(pool_size + max_overflow) x 2(R/W) x 워커수 가 max_connections 를 넘으면
|
|
||||||
max_connections 초과 시 실패한다.
|
|
||||||
"""
|
"""
|
||||||
|
|
||||||
|
import os
|
||||||
import random
|
import random
|
||||||
|
|
||||||
from locust import HttpUser, between, events, task
|
from locust import HttpUser, between, events, task
|
||||||
|
|
||||||
|
LOAD_SUPPLIER_ID = os.environ.get("LOAD_SUPPLIER_ID", "")
|
||||||
|
LOAD_PW = "loadpw1234"
|
||||||
|
|
||||||
|
|
||||||
# 각 시뮬레이션 유저는 고유 계정을 만들어 로그인 흐름을 반복한다.
|
|
||||||
class AuthUser(HttpUser):
|
class AuthUser(HttpUser):
|
||||||
wait_time = between(0.5, 2.0)
|
wait_time = between(0.5, 2.0)
|
||||||
|
|
||||||
def on_start(self):
|
def on_start(self):
|
||||||
# 유저별 고유 계정 생성 후 1회 로그인하여 토큰 확보.
|
# 유저마다 고유 계정을 생성(self-register)하고 로그인해 토큰을 확보한다.
|
||||||
self.user_id = f"load_{random.randint(0, 1_000_000_000)}"
|
self.login_id = f"load_user_{random.randint(0, 1_000_000_000)}"
|
||||||
self.password = "pw1234"
|
self.access_token = None
|
||||||
self.token = None
|
self.refresh_token = None
|
||||||
|
with self.client.post(
|
||||||
self.client.post(
|
|
||||||
"/v1/auth/create",
|
"/v1/auth/create",
|
||||||
json={"id": self.user_id, "pw": self.password, "nickname": "load"},
|
json={"supplier_id": LOAD_SUPPLIER_ID, "id": self.login_id, "pw": LOAD_PW},
|
||||||
name="POST /v1/auth/create",
|
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]}")
|
||||||
self._login()
|
self._login()
|
||||||
|
|
||||||
def _login(self):
|
def _login(self):
|
||||||
with self.client.post(
|
with self.client.post(
|
||||||
"/v1/auth/login",
|
"/v1/auth/login",
|
||||||
json={"id": self.user_id, "pw": self.password},
|
json={"id": self.login_id, "pw": LOAD_PW},
|
||||||
name="POST /v1/auth/login",
|
name="POST /v1/auth/login",
|
||||||
catch_response=True,
|
catch_response=True,
|
||||||
) as resp:
|
) as resp:
|
||||||
if resp.status_code == 200 and resp.json().get("result", {}).get("success"):
|
if resp.status_code == 200 and resp.json().get("result", {}).get("success"):
|
||||||
self.token = resp.json().get("access_token")
|
body = resp.json()
|
||||||
|
self.access_token = body.get("access_token")
|
||||||
|
self.refresh_token = body.get("refresh_token")
|
||||||
resp.success()
|
resp.success()
|
||||||
else:
|
else:
|
||||||
resp.failure(f"login failed: {resp.status_code} {resp.text[:120]}")
|
resp.failure(f"login failed: {resp.status_code} {resp.text[:120]}")
|
||||||
|
|
||||||
@task(5)
|
@task(5)
|
||||||
def me(self):
|
def me(self):
|
||||||
# JWT 검증만 하는 경량 경로 (DB 無). bcrypt 경로와 처리량 비교용.
|
# 토큰 검증 + su_id/공급사명 DB 조회(2 read). 보호 엔드포인트 처리량 측정.
|
||||||
if not self.token:
|
if not self.access_token:
|
||||||
return
|
return
|
||||||
self.client.get(
|
self.client.get(
|
||||||
"/v1/auth/me",
|
"/v1/auth/me",
|
||||||
headers={"Authorization": f"Bearer {self.token}"},
|
headers={"Authorization": f"Bearer {self.access_token}"},
|
||||||
name="GET /v1/auth/me",
|
name="GET /v1/auth/me",
|
||||||
)
|
)
|
||||||
|
|
||||||
@task(2)
|
@task(2)
|
||||||
def login(self):
|
def login(self):
|
||||||
# bcrypt + DB write 가 포함된 무거운 경로.
|
# bcrypt + DB read 2 + DB write 가 포함된 무거운 경로.
|
||||||
self._login()
|
self._login()
|
||||||
|
|
||||||
|
@task(1)
|
||||||
|
def refresh(self):
|
||||||
|
# refresh 토큰 검증 + su_id DB 존재/활성 확인 후 access 재발급.
|
||||||
|
if not self.refresh_token:
|
||||||
|
return
|
||||||
|
with self.client.post(
|
||||||
|
"/v1/auth/refresh_token",
|
||||||
|
headers={"Authorization": f"Bearer {self.refresh_token}"},
|
||||||
|
name="POST /v1/auth/refresh_token",
|
||||||
|
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"refresh failed: {resp.status_code} {resp.text[:120]}")
|
||||||
|
|
||||||
@task(1)
|
@task(1)
|
||||||
def healthz(self):
|
def healthz(self):
|
||||||
# 베이스라인 (앱 오버헤드 측정).
|
# 베이스라인 (앱 오버헤드 측정).
|
||||||
@ -77,4 +104,6 @@ class AuthUser(HttpUser):
|
|||||||
|
|
||||||
@events.test_start.add_listener
|
@events.test_start.add_listener
|
||||||
def _on_start(environment, **kwargs):
|
def _on_start(environment, **kwargs):
|
||||||
print("부하 테스트 시작 - PostgreSQL 커넥션 수 모니터링 권장 (pg_stat_activity)")
|
if not LOAD_SUPPLIER_ID:
|
||||||
|
print("[warn] LOAD_SUPPLIER_ID 가 비어있습니다 — /create 가 전부 실패합니다. run_local_locust.sh 로 실행하세요.")
|
||||||
|
print("부하 테스트 시작 - self-register 방식. PostgreSQL 커넥션 수 모니터링 권장 (pg_stat_activity)")
|
||||||
|
|||||||
88
backend/run_local_locust.sh
Executable file
88
backend/run_local_locust.sh
Executable file
@ -0,0 +1,88 @@
|
|||||||
|
#!/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}"
|
||||||
|
|
||||||
|
# locust 설치 보장
|
||||||
|
if [[ ! -x "$LOCUST" ]]; then
|
||||||
|
echo "[setup] locust 설치..."
|
||||||
|
"$VENV/bin/python" -m pip install -q locust
|
||||||
|
fi
|
||||||
|
|
||||||
|
# 1) locust 파일 선택
|
||||||
|
FILES=()
|
||||||
|
while IFS= read -r f; do
|
||||||
|
FILES+=("$f")
|
||||||
|
done < <(ls -1 loadtest/*.py 2>/dev/null | grep -v __pycache__ || true)
|
||||||
|
if [[ ${#FILES[@]} -eq 0 ]]; then
|
||||||
|
echo "[error] loadtest/ 에 locust 파일이 없습니다."
|
||||||
|
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
|
||||||
|
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 "
|
||||||
|
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>/dev/null | tr -d '[:space:]')"
|
||||||
|
if [[ -z "$LOAD_SUPPLIER_ID" ]]; then
|
||||||
|
echo "[error] 부하용 공급사 supplier_id 를 확보하지 못했습니다 (DB 접속/스키마 확인)."
|
||||||
|
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, 이전 부하계정 정리됨)"
|
||||||
|
|
||||||
|
# 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" "$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"
|
||||||
|
fi
|
||||||
62
backend/run_local_pgwatch.sh
Executable file
62
backend/run_local_pgwatch.sh
Executable file
@ -0,0 +1,62 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
#
|
||||||
|
# PostgreSQL 커넥션 모니터 (로그 방식). negosium_db 커넥션을 한 줄씩 쌓아가며 본다.
|
||||||
|
# 부하테스트(run_local_locust.sh) 중 별도 터미널에서 띄워 풀 사용량 추세를 관찰한다.
|
||||||
|
# (화면을 덮어쓰지 않으므로 스크롤로 이력을 그대로 볼 수 있다)
|
||||||
|
#
|
||||||
|
set -uo pipefail
|
||||||
|
cd "$(dirname "$0")" # backend/
|
||||||
|
|
||||||
|
# DB 접속 (local 기본값, 환경변수로 override 가능)
|
||||||
|
PGHOST="${PGHOST:-127.0.0.1}"
|
||||||
|
PGPORT="${PGPORT:-5432}"
|
||||||
|
PGUSER="${PGUSER:-postgres}"
|
||||||
|
PGDATABASE="${PGDATABASE:-negosium_db}"
|
||||||
|
|
||||||
|
q() { psql -h "$PGHOST" -p "$PGPORT" -U "$PGUSER" -d "$PGDATABASE" "$@"; }
|
||||||
|
|
||||||
|
# 접속 확인
|
||||||
|
if ! q -tAc "SELECT 1" >/dev/null 2>&1; then
|
||||||
|
echo "[error] DB 접속 실패: $PGUSER@$PGHOST:$PGPORT/$PGDATABASE"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
MAXCONN="$(q -tAc "SHOW max_connections;" 2>/dev/null | tr -d '[:space:]')"
|
||||||
|
MAXCONN="${MAXCONN:-0}"
|
||||||
|
|
||||||
|
read -rp "갱신 간격(초) [1]: " ITV; ITV="${ITV:-1}"
|
||||||
|
read -rp "로그 파일로도 저장 (경로, 비우면 화면만): " LOGF
|
||||||
|
|
||||||
|
echo "DB=$PGDATABASE max_connections=$MAXCONN (Ctrl+C 로 종료)"
|
||||||
|
echo " - total: 전체 커넥션 / active: 실행 중 / idle: 풀 유휴 / idle_tx: 트랜잭션 유휴(누수 의심)"
|
||||||
|
HEADER="시각 total active idle idle_tx"
|
||||||
|
echo "$HEADER"
|
||||||
|
[[ -n "$LOGF" ]] && { echo "# $HEADER" >>"$LOGF"; }
|
||||||
|
|
||||||
|
emit() { # 화면 + (옵션)파일
|
||||||
|
echo "$1"
|
||||||
|
[[ -n "$LOGF" ]] && echo "$1" >>"$LOGF"
|
||||||
|
}
|
||||||
|
|
||||||
|
# 우리 앱 풀 산식(참고): USER/PARTNER × R/W = 엔진 4벌 × (pool_size+max_overflow) = 최대 120 / 워커
|
||||||
|
while true; do
|
||||||
|
TS="$(date '+%H:%M:%S')"
|
||||||
|
# 상태별 카운트를 한 쿼리로(모니터 자신은 제외). 출력: "total active idle idle_tx"
|
||||||
|
ROW="$(q -tAF' ' -c "SELECT count(*),
|
||||||
|
count(*) FILTER (WHERE state='active'),
|
||||||
|
count(*) FILTER (WHERE state='idle'),
|
||||||
|
count(*) FILTER (WHERE state='idle in transaction')
|
||||||
|
FROM pg_stat_activity
|
||||||
|
WHERE datname='$PGDATABASE' AND pid <> pg_backend_pid();" 2>/dev/null)"
|
||||||
|
read -r TOTAL ACTIVE IDLE IDLETX <<<"${ROW:-0 0 0 0}"
|
||||||
|
TOTAL="${TOTAL:-0}"; ACTIVE="${ACTIVE:-0}"; IDLE="${IDLE:-0}"; IDLETX="${IDLETX:-0}"
|
||||||
|
|
||||||
|
PCT=0
|
||||||
|
if [[ "$MAXCONN" =~ ^[0-9]+$ && "$MAXCONN" -gt 0 ]]; then PCT=$(( TOTAL * 100 / MAXCONN )); fi
|
||||||
|
WARN=""
|
||||||
|
if (( PCT >= 80 )); then WARN=" <- max 임박!"; fi
|
||||||
|
|
||||||
|
emit "$(printf '%s %3s/%s (%3s%%) %5s %4s %4s%s' \
|
||||||
|
"$TS" "$TOTAL" "$MAXCONN" "$PCT" "$ACTIVE" "$IDLE" "$IDLETX" "$WARN")"
|
||||||
|
|
||||||
|
sleep "$ITV"
|
||||||
|
done
|
||||||
57
backend/run_local_server.sh
Executable file
57
backend/run_local_server.sh
Executable file
@ -0,0 +1,57 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
#
|
||||||
|
# 로컬 백엔드 서버 실행 (대화형). 실행하면 모드를 골라 입력한다.
|
||||||
|
# 최초 실행 시 venv 생성 + 의존성 설치까지 자동으로 한다.
|
||||||
|
#
|
||||||
|
set -euo pipefail
|
||||||
|
cd "$(dirname "$0")" # backend/
|
||||||
|
|
||||||
|
VENV=".venv"
|
||||||
|
PY="$VENV/bin/python"
|
||||||
|
PORT=9300
|
||||||
|
|
||||||
|
# 1) venv + 의존성 보장
|
||||||
|
if [[ ! -d "$VENV" ]]; then
|
||||||
|
echo "[setup] venv 생성 + 의존성 설치..."
|
||||||
|
python3 -m venv "$VENV"
|
||||||
|
"$PY" -m pip install -q --upgrade pip
|
||||||
|
"$PY" -m pip install -q -r requirements.txt
|
||||||
|
fi
|
||||||
|
|
||||||
|
# 2) config 보장
|
||||||
|
if [[ ! -f config/config.local.toml ]]; then
|
||||||
|
echo "[error] config/config.local.toml 이 없습니다. 아래로 생성 후 값을 채우세요:"
|
||||||
|
echo " cp config/config.local.toml.example config/config.local.toml"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
# 3) 모드 선택
|
||||||
|
echo "── 실행 모드 선택 ──"
|
||||||
|
echo " 1) 일반 실행 (web_main.py)"
|
||||||
|
echo " 2) 자동 재시작 (uvicorn --reload, 개발용)"
|
||||||
|
echo " 3) 의존성 재설치"
|
||||||
|
echo " q) 취소"
|
||||||
|
read -rp "선택 [1]: " choice
|
||||||
|
choice="${choice:-1}"
|
||||||
|
|
||||||
|
case "$choice" in
|
||||||
|
3) echo "[setup] 의존성 재설치..."; "$PY" -m pip install -q -r requirements.txt; echo "완료"; exit 0 ;;
|
||||||
|
q|Q) echo "취소합니다."; exit 0 ;;
|
||||||
|
esac
|
||||||
|
|
||||||
|
# 4) 포트 정리 (이미 떠 있으면 종료)
|
||||||
|
if lsof -ti:"$PORT" >/dev/null 2>&1; then
|
||||||
|
echo "[info] 포트 $PORT 사용 중 → 기존 프로세스 종료"
|
||||||
|
lsof -ti:"$PORT" | xargs kill 2>/dev/null || true
|
||||||
|
sleep 1
|
||||||
|
fi
|
||||||
|
export APP_ENV=local
|
||||||
|
|
||||||
|
# 5) 실행
|
||||||
|
case "$choice" in
|
||||||
|
1) echo "[run] web_main.py → http://localhost:$PORT/docs"
|
||||||
|
exec "$PY" web_main.py ;;
|
||||||
|
2) echo "[run] uvicorn --reload → http://localhost:$PORT/docs"
|
||||||
|
exec "$VENV/bin/uvicorn" router.router:app --host 0.0.0.0 --port "$PORT" --reload ;;
|
||||||
|
*) echo "[error] 알 수 없는 선택: $choice"; exit 1 ;;
|
||||||
|
esac
|
||||||
Loading…
Reference in New Issue
Block a user