test(lps): Locust API 부하 테스트 + 멀티코어 벤치 (풀 병목 발견·증명)

API(enqueue/조회, asyncpg I/O 바운드) 부하 테스트로 멀티코어 활용·한계 측정.

- loadtest/locustfile.py: enqueue(고유코드 write)+조회 가중 부하.
- loadtest/bench_multicore.sh: PROCESS_COUNT 1→N 자동 비교(--processes 로 부하생성기도 멀티프로세스).
- server_configs: PROCESS_COUNT / DB_POOL_SIZE / DB_MAX_OVERFLOW env override(코드·toml 수정 없이 튜닝).
- loadtest/README.md: 발견 문서화.

발견(11코어·1500users): 기본 풀(10/20)로 워커 늘리면 실패 폭증(1w=0 → 4w=10336) —
(pool+overflow)×2엔진×workers=240 > PG max_connections=100 커넥션 고갈(SQLAlchemy pool checkout 실패).
증명: 풀 8/4(96<100)로 PC=4 재실행 → RPS 1336→2705(2배), 실패 0. 코드는 멀티코어 활용 가능,
막는 건 풀 오버서브스크립션. 규칙: (pool+overflow)×2×process_count ≤ max_connections.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
민헌 2026-07-09 23:45:25 +09:00
parent eba78dbbb9
commit a63793daf0
4 changed files with 195 additions and 0 deletions

View File

@ -39,6 +39,11 @@ def _apply_db_env_override(cfg: MainDBConfig):
cfg.write_pw = cfg.read_pw = os.environ["DB_PASSWORD"]
if os.environ.get("DB_NAME"):
cfg.name = os.environ["DB_NAME"]
# 커넥션 풀 사이징 override — 멀티워커 시 (pool+overflow)×2엔진×workers 가 PG max_connections 를 넘지 않게 조절.
if os.environ.get("DB_POOL_SIZE"):
cfg.pool_size = int(os.environ["DB_POOL_SIZE"])
if os.environ.get("DB_MAX_OVERFLOW"):
cfg.max_overflow = int(os.environ["DB_MAX_OVERFLOW"])
# 시크릿 env override — 프로덕션에선 API 키를 이미지에 굽지 않고 env(또는 시크릿매니저)로 주입한다.
@ -63,3 +68,7 @@ def _apply_secret_env_override():
_apply_db_env_override(main_db_config)
_apply_secret_env_override()
# uvicorn 워커 수(멀티코어) env override — 부하테스트에서 1↔N 비교용(코드/toml 수정 없이).
if os.environ.get("PROCESS_COUNT"):
web_server_config.process_count = int(os.environ["PROCESS_COUNT"])

54
lps/loadtest/README.md Normal file
View File

@ -0,0 +1,54 @@
# LPS 부하 테스트 (Locust)
**대상 = API 서버**(web_main). 워커(브라우저 크롤)는 프록시/브라우저에 처리량이 묶여 Locust 대상이 아니다.
API 는 요청을 받아 `job` 테이블에 적재만 하고 즉시 응답한다(**asyncpg I/O 바운드**, bcrypt 없음).
## 파일
- `locustfile.py` — enqueue(POST /search, 고유코드 write) + 조회(jobs/stats/ops/readyz) 가중 부하
- `bench_multicore.sh`**PROCESS_COUNT 1→N 자동 비교**(멀티코어 스케일링 측정, 대화형)
## 실행
```bash
# 웹 UI (:8089)
.venv/bin/locust -f loadtest/locustfile.py --host http://localhost:9600
# 헤드리스
.venv/bin/locust -f loadtest/locustfile.py --host http://localhost:9600 --headless --processes 4 -u 1500 -r 150 -t 1m
# 멀티코어 자동 벤치 (PROCESS_COUNT 1/2/4)
LOCUST_PROCESSES=4 ./loadtest/bench_multicore.sh
```
> ⚠️ **워커는 끄고** 실행(부하 중 실제 크롤=프록시/AI 비용). 잡은 PENDING 으로 쌓임 → 끝나면 정리:
> `psql -h 127.0.0.1 -U postgres -d lps_db -c "TRUNCATE job;"`
> 부하 중 커넥션 관측: `SELECT count(*) FROM pg_stat_activity WHERE datname='lps_db';`
## 핵심 발견 (2026-07-09, 11코어 맥 · 1500 users · 20s)
**1) 멀티코어는 되지만 DB 커넥션 풀이 발목을 잡는다.**
API 는 asyncio(스레드 1개)라 단일 프로세스=단일 코어. uvicorn `workers`(=`process_count`)를 늘리면
코어만큼 스케일해야 하는데, **기본 풀(pool_size=10, max_overflow=20)에선 워커를 늘릴수록 오히려 실패 폭증**:
| workers | RPS | 실패 | 원인 |
|:---:|---:|---:|---|
| 1 | 1,137 | 0 | 정상(단일 코어) |
| 2 | 1,390 | 2,997 | 커넥션 초과 시작 |
| 4 | 1,336 | 10,336 | **커넥션 고갈**(SQLAlchemy pool checkout 실패) |
원인: **`(pool_size+max_overflow) × 2엔진(R/W) × workers` 가 PG `max_connections`(기본 100)를 초과**.
4워커면 (10+20)×2×4 = **240 > 100** → 워커 3~4가 커넥션 못 받아 요청 실패.
**2) 풀을 올바르게 잡으면 멀티코어가 제대로 작동한다.**
| PC=4 설정 | RPS | 실패 |
|---|---:|---:|
| 풀 10/20 (240 요구) | 1,336 | 10,336 |
| **풀 8/4 (96 요구)** | **2,705** | **0** |
풀만 줄이니 **RPS 2배 + 실패 0**. 즉 코드는 멀티코어를 활용할 수 있고, **막는 건 풀 오버서브스크립션**이다.
## 튜닝 규칙 (프로덕션)
```
(pool_size + max_overflow) × 2 × process_count ≤ PG max_connections
```
- 예) max_connections=100, process_count=4 → (pool+overflow) ≤ 12 → **pool_size=8, max_overflow=4**
- env 로 조절(코드 수정 없이): `DB_POOL_SIZE`, `DB_MAX_OVERFLOW`, `PROCESS_COUNT`
- 더 큰 처리량이 필요하면: **PG `max_connections` 상향** 또는 **pgbouncer**(커넥션 풀러) 도입
- **부하 한계(이 머신)**: 풀 정상화 시 4워커 ~2,700 RPS, p95 ~900ms, 실패 0

69
lps/loadtest/bench_multicore.sh Executable file
View File

@ -0,0 +1,69 @@
#!/usr/bin/env bash
#
# 멀티코어 스케일링 벤치 (대화형).
# PROCESS_COUNT 를 1→N 으로 바꿔가며 API 를 띄우고, 각각 locust 헤드리스로 RPS/지연을 측정해 비교한다.
# API 는 asyncio(스레드 1개)라 단일 프로세스=단일 코어 → workers 를 늘리면 코어만큼 스케일해야 정상.
#
# 주의:
# - **워커는 반드시 OFF** (부하 중 실제 크롤=프록시/AI 비용). 잡은 PENDING 으로 쌓임 → 끝나면 TRUNCATE.
# - 커넥션 한계: (pool_size+max_overflow)×2엔진×workers 가 PG max_connections 를 넘으면 거기서 막힌다.
set -euo pipefail
cd "$(dirname "$0")/.." # lps/
VENV=".venv"; PY="$VENV/bin/python"; LOCUST="$VENV/bin/locust"
HOST="http://localhost:9600"; PORT=9600
command -v psql >/dev/null || true
[[ -x "$LOCUST" ]] || { echo "[setup] locust 설치..."; "$PY" -m pip install -q locust; }
CORES=$("$PY" -c "import os;print(os.cpu_count())")
echo "── 멀티코어 부하 벤치 ── (CPU 코어: $CORES)"
# 워커 실행 중이면 경고
if pgrep -f worker_main.py >/dev/null; then
echo "[warn] 워커(worker_main.py)가 실행 중입니다 — 부하 중 실제 크롤 비용 발생. 중단 권장:"
read -rp " 워커를 종료할까요? [Y/n]: " k; [[ "${k:-Y}" =~ ^[Nn] ]] || pkill -f worker_main.py || true
fi
read -rp "동시 유저 수(-u) [200]: " USERS; USERS="${USERS:-200}"
read -rp "spawn rate(-r) [20]: " SPAWN; SPAWN="${SPAWN:-20}"
read -rp "각 단계 지속(-t) [45s]: " DUR; DUR="${DUR:-45s}"
read -rp "PROCESS_COUNT 목록(공백구분) [1 2 4]: " PCS; PCS="${PCS:-1 2 4}"
# PG max_connections (참고)
MAXC=$(psql -h 127.0.0.1 -U postgres -tAc "SHOW max_connections;" 2>/dev/null || echo "?")
echo "PG max_connections=$MAXC · 풀 추정=(pool+overflow)×2×workers"
echo ""
RESULTS=()
for PC in $PCS; do
# 기존 API 종료
lsof -ti:"$PORT" 2>/dev/null | xargs kill 2>/dev/null || true; sleep 1
echo "▶ PROCESS_COUNT=$PC 로 API 기동..."
PROCESS_COUNT="$PC" APP_ENV=local "$PY" web_main.py > "/tmp/lps_api_pc${PC}.log" 2>&1 &
APIPID=$!
# 기동 대기(헬스)
for _ in $(seq 1 20); do curl -s "$HOST/healthz" >/dev/null 2>&1 && break; sleep 1; done
# locust 헤드리스 (--processes 로 부하 생성기도 멀티프로세스 → locust 가 병목되지 않게)
"$LOCUST" -f loadtest/locustfile.py --host "$HOST" --headless --processes "${LOCUST_PROCESSES:-4}" \
-u "$USERS" -r "$SPAWN" -t "$DUR" --csv "/tmp/lps_locust_pc${PC}" --only-summary >/dev/null 2>&1 || true
kill "$APIPID" 2>/dev/null || true; sleep 1
# Aggregated 행 파싱: $10=Req/s $17=95% $3=count $4=fail
read -r RPS P95 CNT FAIL < <(awk -F, '$2=="Aggregated"{print $10, $17, $3, $4}' "/tmp/lps_locust_pc${PC}_stats.csv" 2>/dev/null || echo "0 0 0 0")
RESULTS+=("$PC|${RPS:-0}|${P95:-0}|${CNT:-0}|${FAIL:-0}")
printf " → RPS=%.0f p95=%sms 요청=%s 실패=%s\n\n" "${RPS:-0}" "${P95:-0}" "${CNT:-0}" "${FAIL:-0}"
done
echo "════════ 결과 (동시유저 $USERS, $DUR) ════════"
printf "%-8s %-10s %-10s %-10s %-8s %-10s\n" "workers" "RPS" "p95(ms)" "요청" "실패" "vs 1코어"
BASE=""
for r in "${RESULTS[@]}"; do
IFS='|' read -r pc rps p95 cnt fail <<< "$r"
[[ -z "$BASE" ]] && BASE="$rps"
SCALE=$("$PY" -c "print(f'{(${rps:-0}/${BASE:-1}):.2f}x')" 2>/dev/null || echo "-")
printf "%-8s %-10.0f %-10s %-10s %-8s %-10s\n" "$pc" "${rps:-0}" "${p95:-0}" "${cnt:-0}" "${fail:-0}" "$SCALE"
done
echo ""
echo "해석: RPS 가 workers 에 비례해 오르면 멀티코어 활용 정상. 어느 지점부터 안 오르고 실패가 늘면"
echo " 거기가 한계 — 보통 DB 커넥션(max_connections=$MAXC) 또는 write 경합. 풀/PG 튜닝 대상."
echo "정리: psql -h 127.0.0.1 -U postgres -d lps_db -c 'TRUNCATE job;' (쌓인 부하 잡 제거)"

View File

@ -0,0 +1,63 @@
"""LPS **API 서버** 부하 테스트 (enqueue/조회 경로).
부하 대상은 API(web_main) 워커(브라우저 크롤) 프록시/브라우저에 묶여 처리량이 결정되므로
Locust 대상이 아니다. API 요청을 받아 job 테이블에 적재만 하고 즉시 응답한다(asyncpg I/O 바운드).
측정 목적
1) **멀티코어 활용**: API asyncio(스레드 1) 단일 프로세스=단일 코어. uvicorn workers(=process_count)
1N 으로 올리며 RPS 스케일하는지 본다. (bench_multicore.sh 자동 비교)
2) **부하 한계**: enqueue job write(+dedupe unique index). 한계는 대개 DB(커넥션 ·write 경합).
(pool_size+max_overflow)×2엔진×workers PG max_connections 넘으면 거기서 막힌다.
**워커는 끄고** 실행하라(부하 실제 크롤=프록시/AI 비용). 잡은 PENDING 으로 쌓였다가 끝나면 정리:
psql -h 127.0.0.1 -U postgres -d lps_db -c "TRUNCATE job;"
-- 부하 커넥션 관측: SELECT count(*) FROM pg_stat_activity WHERE datname='lps_db';
실행
locust -f loadtest/locustfile.py --host http://localhost:9600 # 웹 UI(:8089)
locust -f loadtest/locustfile.py --host http://localhost:9600 --headless -u 200 -r 20 -t 2m
"""
import random
from locust import HttpUser, between, task
class LpsApiUser(HttpUser):
# 실제 클라이언트처럼 짧게 쉬며 반복(과도한 wait 없이 API 한계를 본다)
wait_time = between(0.05, 0.3)
def on_start(self):
self.last_job = None
@task(6)
def enqueue_search(self):
# 고유 product_code → 실제 INSERT(활성 중복 dedupe 회피). 부하의 핵심 write 경로.
code = f"LOAD-{random.randint(0, 2_000_000_000)}"
body = {"data": [{"product_code": code, "product_name": "부하테스트 상품",
"specification": "1박스", "job_type": "batch"}]}
with self.client.post("/v1/lps/search", json=body, name="POST /search", catch_response=True) as r:
if r.status_code == 200 and r.json().get("accepted", 0) == 1:
self.last_job = (r.json().get("items") or [{}])[0].get("job_id")
r.success()
else:
r.failure(f"{r.status_code} {r.text[:120]}")
@task(3)
def poll_job(self): # 접수 후 상태 폴링(DB read)
if not self.last_job:
return
with self.client.get(f"/v1/lps/jobs/{self.last_job}", name="GET /jobs/{id}", catch_response=True) as r:
r.success() if r.status_code == 200 else r.failure(f"{r.status_code}")
@task(1)
def queue_stats(self):
self.client.get("/v1/lps/queue/stats", name="GET /queue/stats")
@task(1)
def ops(self):
self.client.get("/v1/lps/ops", name="GET /ops")
@task(1)
def readyz(self):
self.client.get("/readyz", name="GET /readyz")