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:
민헌 2026-06-18 11:16:55 +09:00
parent 2832162672
commit 23cb8291bc
4 changed files with 266 additions and 30 deletions

View File

@ -1,74 +1,101 @@
"""Negosium 인증 서버 부하 테스트.
"""인증 서버 부하 테스트 (self-register 방식, 사전 시드 불필요).
실행:
pip install locust
locust -f loadtest/locustfile.py --host http://localhost:9300
# 웹 UI: http://localhost:8089 에서 사용자 수/spawn rate 입력
각 가상 유저가 on_start 에서 자기 계정을 생성(/create)하고 로그인한 뒤,
me/login/refresh/healthz 를 가중치대로 반복한다.
# 헤드리스(CI) 예시 - 100 VU, 10/s 증가, 2분:
locust -f loadtest/locustfile.py --host http://localhost:9300 \
--headless -u 100 -r 10 -t 2m
supplier 의 /create 는 supplier_id(소속 공급사)가 필수이므로, run_local_locust.sh 가
부하용 공급사(부하테스트공급사) 1건을 보장하고 그 supplier_id 를 LOAD_SUPPLIER_ID 로 넘긴다.
직접 실행 시:
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 가 낮으면
거의 확실히 bcrypt cost 가 병목이다 (DB 아님).
- 부하를 올리며 서버 측에서 PostgreSQL 커넥션 수를 함께 모니터링하라:
SELECT count(*) FROM pg_stat_activity;
(pool_size + max_overflow) x 2(R/W) x 워커수 가 max_connections 를 넘으면
max_connections 초과 시 실패한다.
- /create, /login 은 bcrypt(CPU 바운드) + DB read/write 라 가장 무겁다.
- /me, /refresh 는 su_id DB 존재/활성 검증(read 2회)을 한다 — 순수 JWT 경로가 아니다.
- 논리 DB 가 USER/PARTNER 2개라 같은 negosium_db 에 엔진 풀이 4벌(R/W×2) 잡힌다.
SELECT count(*) FROM pg_stat_activity WHERE datname = 'negosium_db';
"""
import os
import random
from locust import HttpUser, between, events, task
LOAD_SUPPLIER_ID = os.environ.get("LOAD_SUPPLIER_ID", "")
LOAD_PW = "loadpw1234"
# 각 시뮬레이션 유저는 고유 계정을 만들어 로그인 흐름을 반복한다.
class AuthUser(HttpUser):
wait_time = between(0.5, 2.0)
def on_start(self):
# 유저별 고유 계정 생성 후 1회 로그인하여 토큰 확보.
self.user_id = f"load_{random.randint(0, 1_000_000_000)}"
self.password = "pw1234"
self.token = None
self.client.post(
# 유저마다 고유 계정을 생성(self-register)하고 로그인해 토큰을 확보한다.
self.login_id = f"load_user_{random.randint(0, 1_000_000_000)}"
self.access_token = None
self.refresh_token = None
with self.client.post(
"/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",
)
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()
def _login(self):
with self.client.post(
"/v1/auth/login",
json={"id": self.user_id, "pw": self.password},
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.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()
else:
resp.failure(f"login failed: {resp.status_code} {resp.text[:120]}")
@task(5)
def me(self):
# JWT 검증만 하는 경량 경로 (DB 無). bcrypt 경로와 처리량 비교용.
if not self.token:
# 토큰 검증 + su_id/공급사명 DB 조회(2 read). 보호 엔드포인트 처리량 측정.
if not self.access_token:
return
self.client.get(
"/v1/auth/me",
headers={"Authorization": f"Bearer {self.token}"},
headers={"Authorization": f"Bearer {self.access_token}"},
name="GET /v1/auth/me",
)
@task(2)
def login(self):
# bcrypt + DB write 가 포함된 무거운 경로.
# bcrypt + DB read 2 + DB write 가 포함된 무거운 경로.
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)
def healthz(self):
# 베이스라인 (앱 오버헤드 측정).
@ -77,4 +104,6 @@ class AuthUser(HttpUser):
@events.test_start.add_listener
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
View 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
View 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
View 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