feat(lps): process_count 기반 커넥션 풀 자동 산정 — 멀티코어 풀 오버서브스크립션 방지

connection_budget(기본 40)을 두고, 기동 시 process_count 에 맞춰
pool_size/max_overflow 를 역산: (pool+overflow)×2엔진×process_count ≤ budget.
워커를 늘려도 config 가 스스로 예산을 지켜 커넥션 고갈→요청 실패를 예방.

- config_models: MainDBConfig.connection_budget 추가
- server_configs: _autosize_pool(process_count 확정 후 산정) + _apply_pool_env_override
  (우선순위 = 명시 DB_POOL_SIZE > 자동 산정 > toml pool)
- web_main: 기동 로그에 실효 풀/총커넥션/예산 출력
- env: DB_CONNECTION_BUDGET override, docker-compose 에 knob 노출
- tests: test_pool_autosize 10케이스(예산 준수·분할비·비활성·infeasible 바닥)
- docs: operations 2-1 멀티코어/풀 섹션 + loadtest README 자동산정 표

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
민헌 2026-07-09 23:59:05 +09:00
parent a63793daf0
commit 9c2432c818
8 changed files with 149 additions and 10 deletions

View File

@ -124,6 +124,10 @@ services:
APP_ENV: local
DB_HOST: host.docker.internal # 컨테이너→호스트 DB (config.local.toml의 127.0.0.1 override)
PYTHONUNBUFFERED: "1"
# 멀티코어: PROCESS_COUNT(uvicorn 워커=코어수)를 올리면 커넥션 풀은 자동 산정된다.
# (pool+overflow)×2엔진×PROCESS_COUNT ≤ DB_CONNECTION_BUDGET 를 config 가 스스로 보장.
# PROCESS_COUNT: "4"
# DB_CONNECTION_BUDGET: "40" # 전용 PG(max_connections≈100)면 90 근처로 상향
ports:
- "9600:9600"
extra_hosts:

View File

@ -37,8 +37,13 @@ read_port = 5432
read_id = "<DB_USER>"
read_pw = "<DB_PASSWORD>"
show_log = false
pool_size = 10
max_overflow = 20
pool_size = 10 # connection_budget>0 이면 무시(자동 산정). budget=0 일 때만 이 값 사용.
max_overflow = 20 # 〃
# 커넥션 예산(자동 산정). process_count 에 맞춰 pool_size/max_overflow 를 자동 계산:
# (pool+overflow) × 2엔진 × process_count ≤ connection_budget.
# 'lps API 가 쓸 총 커넥션 상한' — 공유 PG(max_connections)·동거 서비스(worker 등)를 고려한 값.
# 예) 전용 PG(max_connections=100)면 90 근처, 공유 PG면 40 권장. 0 이면 자동 끔(위 pool 값 사용).
connection_budget = 40 # env DB_CONNECTION_BUDGET 로 override
sslmode = "" # 로컬: "" / 관리형 DB: "require"|"verify-ca"|"verify-full"
# ── 시크릿(API 키 등)도 이 파일에서 통합 관리 (미커밋). 배포는 이 파일 마운트 권장. ──

View File

@ -32,10 +32,14 @@ class MainDBConfig(ConfigModel):
read_id: str = ""
read_pw: str = ""
show_log: bool = False
# 커넥션 풀 사이징. 실제 동시 커넥션 상한 = (pool_size + max_overflow) x 엔진수(R/W=2) x 워커수.
# PostgreSQL max_connections 를 넘지 않도록 설정해야 한다. (예: 10+20=30 x 2 x 5워커 = 300)
# 커넥션 풀 사이징. 실제 동시 커넥션 = (pool_size + max_overflow) x 엔진수(R/W=2) x process_count.
pool_size: int = 10
max_overflow: int = 20
# 커넥션 예산(자동 산정). >0 이면 process_count 에 맞춰 pool_size/max_overflow 를 자동 계산한다:
# (pool+overflow)x2xprocess_count ≤ connection_budget 가 되도록. (이때 위 pool_size/max_overflow 는 무시)
# PG max_connections·공유 DB 동거 서비스(worker·negosium 등)를 고려한 'lps API 가 쓸 총 커넥션 상한'.
# 0 이면 자동 산정 끔(위 pool_size/max_overflow 그대로 사용). env DB_CONNECTION_BUDGET 로 override.
connection_budget: int = 40
# SSL/TLS 모드: ""/"disable"=미사용(로컬), "require"/"verify-ca"/"verify-full"=관리형 DB(RDS/Aurora/Azure).
sslmode: str = ""

View File

@ -39,7 +39,35 @@ 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 를 넘지 않게 조절.
# 커넥션 예산 override (자동 산정용). env DB_CONNECTION_BUDGET.
if os.environ.get("DB_CONNECTION_BUDGET"):
cfg.connection_budget = int(os.environ["DB_CONNECTION_BUDGET"])
def _autosize_pool(cfg: MainDBConfig, process_count: int):
"""process_count 기반 커넥션 풀 자동 산정.
실제 동시 커넥션 = (pool_size + max_overflow) × 2엔진(R/W) × process_count.
이 값이 connection_budget 를 넘지 않도록 pool_size/max_overflow 를 역산한다.
budget<=0 이면 비활성(toml 의 pool_size/max_overflow 그대로 사용).
반환: 자동 산정을 수행했으면 (pool_size, max_overflow), 아니면 None.
"""
budget = cfg.connection_budget
if budget <= 0:
return None
pc = max(1, process_count)
# 워커·엔진당 커넥션 = 예산 / (2엔진 × process_count). 최소 1(pool_size>=1) 보장.
# (예산 준수가 최우선 — process_count 가 너무 크면 엔진당 1커넥션까지 줄여서라도 예산을 넘지 않는다)
per_engine = max(1, budget // (2 * pc))
# 정상(pool) 60% + 버스트(overflow) 40% 로 분할.
cfg.pool_size = max(1, round(per_engine * 0.6))
cfg.max_overflow = max(0, per_engine - cfg.pool_size)
return cfg.pool_size, cfg.max_overflow
def _apply_pool_env_override(cfg: MainDBConfig):
"""명시적 풀 override — 자동 산정보다 우선(테스트·특수 배포용)."""
if os.environ.get("DB_POOL_SIZE"):
cfg.pool_size = int(os.environ["DB_POOL_SIZE"])
if os.environ.get("DB_MAX_OVERFLOW"):
@ -72,3 +100,9 @@ _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"])
# 커넥션 풀 자동 산정: process_count(위에서 확정) 기준으로 예산 안에 맞춘다.
# → 멀티워커 배포 시 풀 오버서브스크립션(→커넥션 고갈)을 config 가 스스로 방지.
_pool_autosized = _autosize_pool(main_db_config, web_server_config.process_count)
# 명시적 DB_POOL_SIZE/DB_MAX_OVERFLOW 는 자동 산정보다 우선(최종 override).
_apply_pool_env_override(main_db_config)

View File

@ -50,6 +50,22 @@ N=8 python loadtest.py # 상품 8개 제출→처리량·지연(p50/p95)·AI/
```
> 워커 동시성만큼 병렬 처리됩니다(동시성 낮으면 큐에서 순차 대기 — 그게 부하 관측 포인트).
## 2-1. 멀티코어 스케일 & 커넥션 풀 (자동)
API 서버는 asyncio(스레드 1개)라 **1 프로세스 = 1 코어**입니다. 처리량을 코어만큼 올리려면
`process_count`(uvicorn 워커 수)를 늘립니다 — 이때 **DB 커넥션 풀은 config 가 자동으로 맞춰줍니다**.
```
실제 동시 커넥션 = (pool_size + max_overflow) × 2엔진(R/W) × process_count
config 가 보장: 위 값 ≤ connection_budget (기본 40)
```
- `process_count` 를 올리면 `pool_size/max_overflow` 가 **자동으로 축소**되어 예산을 넘지 않습니다.
(수동 튜닝 불필요 — 예전엔 이걸 안 맞춰서 워커↑ 시 커넥션 고갈→요청 실패가 났음)
- 기동 로그에서 실효값 확인: `DB Pool : pool_size=.. max_overflow=.. × 2engine × Nworkers = M conns (budget=..)`
- **예산 조정**: 공유 PG 는 40 유지, 전용 PG(`max_connections≈100`)면 `DB_CONNECTION_BUDGET=90` 으로 상향.
- env 로 조절(코드/toml 수정 없이): `PROCESS_COUNT`, `DB_CONNECTION_BUDGET`, (특수 시)`DB_POOL_SIZE`/`DB_MAX_OVERFLOW`.
- 부하 한계 측정은 [`loadtest/README.md`](../loadtest/README.md) 참고(Locust 멀티코어 벤치).
## 3. 로그 보는 법 (워커 터미널)
| 로그 | 의미 |

View File

@ -48,7 +48,21 @@ API 는 asyncio(스레드 1개)라 단일 프로세스=단일 코어. uvicorn `w
```
(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
**이 규칙은 이제 config 가 자동으로 지킨다** — `MainDBConfig.connection_budget`(기본 40)을 두면,
기동 시 `process_count` 에 맞춰 `pool_size/max_overflow` 를 역산한다:
`(pool+overflow)×2×process_count ≤ connection_budget`. 워커를 늘려도 커넥션이 예산을 넘지 않는다.
(`server_configs._autosize_pool`. 기동 로그 `DB Pool : ... = N conns (budget=…)` 로 실효값 확인)
| process_count | 자동 산정(예산 40) | 총 커넥션 |
|:---:|:---:|---:|
| 1 | pool 12 / overflow 8 | 40 |
| 2 | pool 6 / overflow 4 | 40 |
| 4 | pool 3 / overflow 2 | 40 |
| 8 | pool 1 / overflow 1 | 32 |
- **예산 설정**: 전용 PG(max_connections=100)면 `connection_budget≈90`, 공유 PG면 40 권장. `env DB_CONNECTION_BUDGET`.
(공유 PG 기본 40 은 API + worker + 타 서비스가 100 안에 공존하도록 잡은 안전값 → 처리량보다 안정 우선)
- **override 우선순위**: 명시 `DB_POOL_SIZE`/`DB_MAX_OVERFLOW` > 자동 산정(budget>0) > toml `pool_size/max_overflow`(budget=0)
- 더 큰 처리량이 필요하면: 예산 상향 + **PG `max_connections` 상향** 또는 **pgbouncer**(커넥션 풀러) 도입
- **부하 한계(이 머신)**: 예산 96(≈max_connections)·4워커 ~2,700 RPS, p95 ~900ms, 실패 0

View File

@ -0,0 +1,57 @@
"""커넥션 풀 자동 산정(_autosize_pool) — process_count 기준으로 예산을 넘지 않아야 한다."""
import pytest
from config.config_models import MainDBConfig
from config.server_configs import _autosize_pool
def _conns(cfg: MainDBConfig, pc: int) -> int:
# 실제 동시 커넥션 = (pool + overflow) × 2엔진(R/W) × process_count
return (cfg.pool_size + cfg.max_overflow) * 2 * pc
@pytest.mark.parametrize("pc", [1, 2, 4, 8, 16])
def test_autosize_within_budget(pc):
cfg = MainDBConfig(connection_budget=40)
_autosize_pool(cfg, pc)
assert _conns(cfg, pc) <= 40
assert cfg.pool_size >= 1
assert cfg.max_overflow >= 0
def test_autosize_uses_budget_efficiently():
# 예산을 지나치게 낭비하지 않아야(넉넉한 예산일 때 절반 이상 활용)
cfg = MainDBConfig(connection_budget=96)
_autosize_pool(cfg, 4)
assert _conns(cfg, 4) <= 96
assert _conns(cfg, 4) >= 96 // 2
def test_autosize_split_ratio():
# pool(정상) 이 overflow(버스트)보다 크거나 같게 분할
cfg = MainDBConfig(connection_budget=96)
_autosize_pool(cfg, 1)
assert cfg.pool_size >= cfg.max_overflow
def test_autosize_disabled_when_budget_zero():
# budget<=0 이면 자동 산정 끔 → toml/기본 pool 값 유지, None 반환
cfg = MainDBConfig(connection_budget=0, pool_size=10, max_overflow=20)
assert _autosize_pool(cfg, 4) is None
assert (cfg.pool_size, cfg.max_overflow) == (10, 20)
def test_autosize_returns_computed_pair():
cfg = MainDBConfig(connection_budget=40)
result = _autosize_pool(cfg, 2)
assert result == (cfg.pool_size, cfg.max_overflow)
def test_autosize_infeasible_process_count_floors_at_one():
# process_count×2 > budget 이면 예산 준수가 물리적으로 불가능(워커당 최소 1커넥션 필요).
# 이때는 pool_size=1/overflow=0(엔진당 1커넥션)까지 줄이는 게 한계 — 0 풀은 만들지 않는다.
cfg = MainDBConfig(connection_budget=40)
_autosize_pool(cfg, 64)
assert cfg.pool_size == 1
assert cfg.max_overflow == 0

View File

@ -9,7 +9,7 @@
import uvicorn
from common.logger import LOG
from config.server_configs import web_server_config
from config.server_configs import web_server_config, main_db_config
LOG.SetPrefix(web_server_config.server_name)
@ -20,6 +20,11 @@ if __name__ == "__main__":
LOG.i(f"Server Name : {web_server_config.server_name}")
LOG.i(f"Server Port : {web_server_config.port}")
LOG.i(f"API Server start time : {router.router.API_SERVER_START_TIME}")
# 실효 커넥션 풀(자동 산정 결과) — 멀티워커 시 커넥션 예산 준수 여부 확인용.
_pc = web_server_config.process_count
_conn = (main_db_config.pool_size + main_db_config.max_overflow) * 2 * _pc
LOG.i(f"DB Pool : pool_size={main_db_config.pool_size} max_overflow={main_db_config.max_overflow} "
f"× 2engine × {_pc}workers = {_conn} conns (budget={main_db_config.connection_budget})")
if web_server_config.is_ssl:
uvicorn.run(