From 23cb8291bc2cc468f66c2f88329d49ce8d6d3c47 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EB=AF=BC=ED=97=8C?= Date: Thu, 18 Jun 2026 11:16:55 +0900 Subject: [PATCH] =?UTF-8?q?chore(backend):=20=EB=A1=9C=EC=BB=AC=20?= =?UTF-8?q?=EC=8B=A4=ED=96=89/=EB=B6=80=ED=95=98=ED=85=8C=EC=8A=A4?= =?UTF-8?q?=ED=8A=B8=20=EC=8A=A4=ED=81=AC=EB=A6=BD=ED=8A=B8=20=EC=B6=94?= =?UTF-8?q?=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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) --- backend/loadtest/locustfile.py | 89 ++++++++++++++++++++++------------ backend/run_local_locust.sh | 88 +++++++++++++++++++++++++++++++++ backend/run_local_pgwatch.sh | 62 +++++++++++++++++++++++ backend/run_local_server.sh | 57 ++++++++++++++++++++++ 4 files changed, 266 insertions(+), 30 deletions(-) create mode 100755 backend/run_local_locust.sh create mode 100755 backend/run_local_pgwatch.sh create mode 100755 backend/run_local_server.sh diff --git a/backend/loadtest/locustfile.py b/backend/loadtest/locustfile.py index af9f02f..900a06f 100644 --- a/backend/loadtest/locustfile.py +++ b/backend/loadtest/locustfile.py @@ -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)") diff --git a/backend/run_local_locust.sh b/backend/run_local_locust.sh new file mode 100755 index 0000000..bbeea31 --- /dev/null +++ b/backend/run_local_locust.sh @@ -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 diff --git a/backend/run_local_pgwatch.sh b/backend/run_local_pgwatch.sh new file mode 100755 index 0000000..7c3ef5b --- /dev/null +++ b/backend/run_local_pgwatch.sh @@ -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 diff --git a/backend/run_local_server.sh b/backend/run_local_server.sh new file mode 100755 index 0000000..d0b493e --- /dev/null +++ b/backend/run_local_server.sh @@ -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