refactor(lps): 환경설정 TOML 단일화 — .env 제거, 시크릿도 config.local.toml 로 통합

설정이 .env(시크릿)+toml(설정)로 갈려 있던 것을 config.local.toml 하나로 통합.
DB 비번이 이미 toml 에 있어 분리 기준이 임의적이었고, backend(하우스 패턴)도
toml 단일이라 일관성 확보. 환경별로 바뀌는 값(DB_HOST 등)만 env override 유지.

- config_models: NaverConfig(keys 로테이션)·OpenAIConfig·DecodoConfig 추가
- server_configs: 3개 로드, load_dotenv 제거(python-dotenv 의존성도 제거)
- proxy/naver/similarity/keyword/worker_main: os.environ → config 객체 참조
- config.local.toml.example: [NaverConfig]/[OpenAIConfig]/[DecodoConfig] 섹션
- .env/.env.example 삭제, README/주석 갱신 (배포는 toml 마운트 or env override)
- tests: DecodoConfig 기반으로 갱신 → 전체 44/44

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
민헌 2026-07-09 10:06:00 +09:00
parent 2481c79e3f
commit 7911ba1745
12 changed files with 103 additions and 86 deletions

View File

@ -1,25 +0,0 @@
# LPS 환경변수 (시크릿). 복사해서 .env 로 만들고 값을 채우세요:
# cp .env.example .env
# .env 는 커밋되지 않습니다(.gitignore). 값은 큰따옴표 안에 넣으세요.
# 앱/워커 기동 시 config/server_configs 가 이 파일을 환경변수로 로드합니다.
# ── 네이버 쇼핑 검색 오픈 API ─────────────────────────────────────────
# https://developers.naver.com/apps → 애플리케이션 등록 → 사용 API '검색'
NAVER_CLIENT_ID=""
NAVER_CLIENT_SECRET=""
# 로테이션(선택): 429/403 쿼터 회피용 추가 키는 _2, _3 ... 로 늘리면 자동 포함
# NAVER_CLIENT_ID_2=""
# NAVER_CLIENT_SECRET_2=""
# ── AI (상품 유사도 판정 / 검색어 생성) — OpenAI ──────────────────────
OPENAI_API_KEY=""
# ── DECODO residential 프록시 (쿠팡 전용, 포트 기반 sticky+주기 회전) ──
# DECODO 대시보드 Residential > Proxy setup 값. HOST/USER/PASS + 포트범위 다 채우면 활성.
# IP 회전 = 포트 순환(각 포트가 sticky 세션). SESSION_MINUTES 는 대시보드 Sticky 지속시간과 맞춘다.
DECODO_HOST="" # 예: gate.decodo.com
DECODO_USERNAME="" # 대시보드 USERNAME (예: sppd6a3ze3)
DECODO_PASSWORD="" # 대시보드 PASSWORD
DECODO_PORT_START="10001" # 엔드포인트 포트 시작
DECODO_PORT_END="10010" # 엔드포인트 포트 끝
DECODO_SESSION_MINUTES="10" # 대시보드 Sticky 지속시간(분)과 일치 → 이 주기로 포트/ IP 회전

View File

@ -37,11 +37,11 @@ lps/
## 로컬 실행
```bash
cp config/config.local.toml.example config/config.local.toml # DB 등 비-시크릿 설정
cp .env.example .env # 시크릿(API 키) — NAVER/OPENAI 등
cp config/config.local.toml.example config/config.local.toml # 설정+시크릿 전부(포트/DB/API 키)
./run_local_server.sh # → http://localhost:9600/docs
```
> `config.local.toml` = 비밀 아닌 설정(포트/DB), `.env` = 시크릿(API 키). 둘 다 git 미추적.
> `config.local.toml` 한 파일에 설정과 시크릿(API 키)을 통합 관리(git 미추적).
> 배포는 이 파일을 마운트하거나, 환경별로 바뀌는 값만 env override(DB_HOST 등).
> 워커는 별도 프로세스: `python worker_main.py`
## 테스트

View File

@ -32,3 +32,29 @@ show_log = false
pool_size = 10
max_overflow = 20
sslmode = "" # 로컬: "" / 관리형 DB: "require"|"verify-ca"|"verify-full"
# ── 시크릿(API 키 등)도 이 파일에서 통합 관리 (미커밋). 배포는 이 파일 마운트 권장. ──
# 네이버 쇼핑 오픈API (https://developers.naver.com/apps). 여러 개면 429/403 로테이션 자동 포함.
[NaverConfig]
[[NaverConfig.keys]]
id = "<NAVER_CLIENT_ID>"
secret = "<NAVER_CLIENT_SECRET>"
# 추가 키는 아래처럼 블록을 더 넣으면 됨:
# [[NaverConfig.keys]]
# id = "..."
# secret = "..."
# AI 유사도 판정/검색어 생성 (OpenAI)
[OpenAIConfig]
api_key = "<OPENAI_API_KEY>"
model = "gpt-4o-mini"
# DECODO residential 프록시 (쿠팡 전용, 포트기반 sticky). 값 다 채우면 활성(비면 프록시 미사용).
[DecodoConfig]
host = "" # 예: gate.decodo.com
username = "" # 대시보드 USERNAME (예: sppd6a3ze3)
password = "" # 대시보드 PASSWORD
port_start = 0 # 예: 10001
port_end = 0 # 예: 10010
session_minutes = 10 # 대시보드 Sticky 지속시간(분)과 일치

View File

@ -1,3 +1,5 @@
from pydantic import BaseModel
from config.config_loader import ConfigModel
@ -36,3 +38,36 @@ class MainDBConfig(ConfigModel):
max_overflow: int = 20
# SSL/TLS 모드: ""/"disable"=미사용(로컬), "require"/"verify-ca"/"verify-full"=관리형 DB(RDS/Aurora/Azure).
sslmode: str = ""
# ── 시크릿(API 키 등)도 TOML 로 통합 관리. config.local.toml 은 미커밋(*.toml). ──
# 배포는 이 파일을 마운트하거나(권장), 환경별로 바뀌는 값만 env override 한다(DB_HOST 등).
class NaverKey(BaseModel):
id: str = ""
secret: str = ""
class NaverConfig(ConfigModel):
"""네이버 쇼핑 오픈API 키. 여러 개면 429/403 로테이션에 자동 포함."""
keys: list[NaverKey] = []
class OpenAIConfig(ConfigModel):
"""AI 유사도 판정/검색어 생성용 OpenAI."""
api_key: str = ""
model: str = "gpt-4o-mini"
class DecodoConfig(ConfigModel):
"""DECODO residential 프록시(쿠팡 전용, 포트기반 sticky). 값이 다 차야 활성."""
host: str = ""
username: str = ""
password: str = ""
port_start: int = 0
port_end: int = 0
session_minutes: int = 10

View File

@ -1,13 +1,9 @@
import os
from dotenv import load_dotenv
from config.config_loader import Configs
from config.config_models import WebServerConfig, LogConfig, MainDBConfig
# 시크릿(.env: API 키 등)을 환경변수로 로드. lps 루트의 .env 를 읽는다(없으면 조용히 무시).
# TOML(비밀 아님) 과 env(시크릿) 를 분리 — 키는 코드/커밋에 넣지 않는다.
load_dotenv(os.path.join(os.path.dirname(__file__), "..", ".env"))
from config.config_models import (
WebServerConfig, LogConfig, MainDBConfig, NaverConfig, OpenAIConfig, DecodoConfig,
)
# 실행 환경 결정 (기본 local). 환경변수 APP_ENV 로 변경.
APP_ENV = os.environ.get("APP_ENV", "local")
@ -24,6 +20,10 @@ configs = Configs(_config_file)
web_server_config: WebServerConfig = configs.get(WebServerConfig)
log_config: LogConfig = configs.get(LogConfig)
main_db_config: MainDBConfig = configs.get(MainDBConfig)
# 시크릿 포함 설정도 TOML 로 통합. 섹션이 없으면 기본값(빈/비활성).
naver_config: NaverConfig = configs.get(NaverConfig) or NaverConfig()
openai_config: OpenAIConfig = configs.get(OpenAIConfig) or OpenAIConfig()
decodo_config: DecodoConfig = configs.get(DecodoConfig) or DecodoConfig()
# DB 접속 env override (config.local.toml 유지, 도커에서 host 만 교체). 로컬은 env 미설정 → toml 그대로.

View File

@ -5,7 +5,6 @@ greenlet # SQLAlchemy async 의 sync/async 브리지에 필수 (일부 환경
asyncpg
orjson
pydantic>=2.0
python-dotenv # .env(시크릿: API 키) 로드 — server_configs 에서 환경변수로 주입
httpx # 외부 사이트/오픈API(최저가 조회) 호출용 async HTTP 클라이언트
# --- 크롤링(LPS) — 안티봇 대응은 어댑터 안에 격리. 트레드밀 대비 버전 pin. ---
@ -14,4 +13,4 @@ selectolax==0.4.10 # 빠른 C 파서(쿠팡 HTML). bs4 대비 최대 30배
patchright # 스텔스 Playwright 포크. 쿠팡 Akamai JS 챌린지 통과(실제 Chrome, channel=chrome)
# ※ nodriver 는 Python 3.14 소스인코딩 버그로 미채택 → Patchright 로 대체
# ※ 실행엔 시스템 Google Chrome 필요(로컬) / 배포 이미지엔 chromium 설치 필요
openai # AI 유사도 판정(같은 상품 매칭) — OPENAI_API_KEY(.env). structured output 사용
openai # AI 유사도 판정(같은 상품 매칭) — [OpenAIConfig].api_key(config.local.toml). structured output 사용

View File

@ -6,12 +6,11 @@
레퍼런스 keyword_maker 규칙(모델명 우선, 일반 카테고리어 제거) structured output 으로 이식.
"""
import os
from openai import AsyncOpenAI
from pydantic import BaseModel, Field
from common.logger import LOG
from config.server_configs import openai_config
_SYSTEM = (
"너는 커머스 검색어 생성기다. 상품 정보를 받아 쇼핑몰 검색창에 넣을 한국어 검색어 2개를 만든다.\n"
@ -28,9 +27,9 @@ class Keywords(BaseModel):
class KeywordGenerator:
def __init__(self, model: str = "gpt-4o-mini", api_key: str | None = None):
self._model = model
self._client = AsyncOpenAI(api_key=api_key or os.environ.get("OPENAI_API_KEY"))
def __init__(self, model: str | None = None, api_key: str | None = None):
self._model = model or openai_config.model
self._client = AsyncOpenAI(api_key=api_key or openai_config.api_key)
async def generate(self, target: dict) -> Keywords:
user = (

View File

@ -5,12 +5,11 @@ LLM 판단으로 대체 — 액세서리/호환부품/다른 상품/명백히
structured output(Pydantic)으로 정규식 파싱 없이 안정적으로 결과를 받는다.
"""
import os
from openai import AsyncOpenAI
from pydantic import BaseModel, Field
from common.logger import LOG
from config.server_configs import openai_config
from services.search.contract import NormalizedProduct
_SYSTEM = (
@ -35,9 +34,9 @@ class JudgmentList(BaseModel):
class SimilarityJudge:
def __init__(self, model: str = "gpt-4o-mini", api_key: str | None = None):
self._model = model
self._client = AsyncOpenAI(api_key=api_key or os.environ.get("OPENAI_API_KEY"))
def __init__(self, model: str | None = None, api_key: str | None = None):
self._model = model or openai_config.model
self._client = AsyncOpenAI(api_key=api_key or openai_config.api_key)
async def judge(self, target: dict, candidates: list[NormalizedProduct]) -> list[Judgment]:
"""후보별 동일상품 여부 판정. candidates 와 같은 순서/길이로 Judgment 리스트 반환."""

View File

@ -2,14 +2,13 @@
공식 오픈 API(https://openapi.naver.com/v1/search/shop.json) 크롤링/브라우저 불필요.
레퍼런스의 핵심 자산인 ** 로테이션** 이식: 429/403(쿼터/차단) 다음 키로 순환 재시도.
키는 .env(NAVER_CLIENT_ID/SECRET, +_2.._10)에서 로드 server_configs load_dotenv 주입.
키는 config.local.toml [NaverConfig].keys 에서 로드(여러 개면 로테이션).
"""
import os
import httpx
from common.logger import LOG
from config.server_configs import naver_config
from services.search.contract import SearchAdapter, NormalizedProduct, AdapterError, AdapterHealth
from services.search.rate_limiter import RateLimiter
from services.search.naver.transform import transform_items
@ -20,16 +19,8 @@ _MAX_DISPLAY = 100
def load_naver_keys() -> list[tuple[str, str]]:
"""(client_id, client_secret) 쌍 목록. 기본(무접미) + _2.._10 로테이션 키."""
keys: list[tuple[str, str]] = []
cid, csec = os.environ.get("NAVER_CLIENT_ID"), os.environ.get("NAVER_CLIENT_SECRET")
if cid and csec:
keys.append((cid, csec))
for i in range(2, 11):
cid, csec = os.environ.get(f"NAVER_CLIENT_ID_{i}"), os.environ.get(f"NAVER_CLIENT_SECRET_{i}")
if cid and csec:
keys.append((cid, csec))
return keys
"""(client_id, client_secret) 쌍 목록. TOML [NaverConfig].keys 에서 로드(여러 개면 로테이션)."""
return [(k.id, k.secret) for k in naver_config.keys if k.id and k.secret]
class NaverAdapter(SearchAdapter):
@ -52,7 +43,7 @@ class NaverAdapter(SearchAdapter):
async def search(self, query: str, limit: int = 40) -> list[NormalizedProduct]:
if not self._keys:
raise AdapterError("네이버 API 키 없음(.env NAVER_CLIENT_ID/SECRET)", source=self.source)
raise AdapterError("네이버 API 키 없음(config.local.toml [NaverConfig].keys)", source=self.source)
collected: list[dict] = []
async with httpx.AsyncClient(timeout=self._timeout) as client:

View File

@ -6,31 +6,23 @@ Decodo residential 은 **포트 기반 sticky** 모델이다:
요청 IP 변경은 Akamai '쿠키-IP 불일치' 재챌린지하므로 금물.
시간창(now // window)으로 포트를 고른다: 안에선 같은 포트=같은 IP, 창이 지나면 다음 포트= IP.
자격증명/엔드포인트는 .env(DECODO_*) 에서 로드(시크릿).
자격증명/엔드포인트는 config.local.toml [DecodoConfig] 에서 로드(시크릿).
"""
import os
import time
_UNSET = object() # 미지정(→env 사용) vs 명시적 빈값("" 등, 그대로 사용) 구분
def _int(v, default=None):
try:
return int(v)
except (TypeError, ValueError):
return default
from config.server_configs import decodo_config
class DecodoProxy:
def __init__(self, host=_UNSET, username=_UNSET, password=_UNSET, port_start=_UNSET, port_end=_UNSET, session_minutes=_UNSET):
env = os.environ.get
self.host = env("DECODO_HOST") if host is _UNSET else host
self.username = env("DECODO_USERNAME") if username is _UNSET else username
self.password = env("DECODO_PASSWORD") if password is _UNSET else password
self.port_start = _int(env("DECODO_PORT_START") if port_start is _UNSET else port_start)
self.port_end = _int(env("DECODO_PORT_END") if port_end is _UNSET else port_end)
self.session_minutes = _int(env("DECODO_SESSION_MINUTES") if session_minutes is _UNSET else session_minutes, 10)
def __init__(self, cfg=None):
cfg = cfg if cfg is not None else decodo_config
self.host = cfg.host
self.username = cfg.username
self.password = cfg.password
self.port_start = cfg.port_start
self.port_end = cfg.port_end
self.session_minutes = cfg.session_minutes or 10
@property
def enabled(self) -> bool:

View File

@ -1,18 +1,19 @@
"""DECODO 프록시 제공자 테스트 (포트 기반 sticky, 순수·네트워크 불필요)."""
from config.config_models import DecodoConfig
from services.search.proxy import DecodoProxy
def _p(**kw):
base = dict(host="gate.decodo.com", username="user1", password="pw", port_start=10001, port_end=10010, session_minutes=10)
base.update(kw)
return DecodoProxy(**base)
return DecodoProxy(DecodoConfig(**base))
def test_disabled_when_credentials_missing():
assert DecodoProxy(host="", username="", password="", port_start=None, port_end=None).enabled is False
assert DecodoProxy(DecodoConfig()).enabled is False # 전부 비어있음
assert _p(password="").enabled is False
assert _p(port_end=None).enabled is False
assert _p(port_end=0).enabled is False # 포트 미설정
assert _p().enabled is True
@ -25,7 +26,7 @@ def test_playwright_proxy_shape():
def test_disabled_returns_none():
assert DecodoProxy(host="", username="", password="", port_start=None, port_end=None).playwright_proxy() is None
assert DecodoProxy(DecodoConfig()).playwright_proxy() is None
def test_port_selected_within_range_and_stable_in_window():

View File

@ -9,7 +9,7 @@ import asyncio
import os
from common.logger import LOG
from config.server_configs import web_server_config
from config.server_configs import web_server_config, openai_config
from crud.job_crud import JobQueue
from crud.negative_cache import NegativeCache
from services.search.proxy import DecodoProxy
@ -31,8 +31,8 @@ async def main(concurrency: int = 1):
proxy = DecodoProxy()
LOG.i(f"DECODO 프록시: {'ON(sticky ' + str(proxy.session_minutes) + '분 회전)' if proxy.enabled else 'OFF(.env 미설정)'}")
adapters = {"coupang": CoupangAdapter(headless=False, proxy=proxy), "naver": NaverAdapter()}
# OPENAI_API_KEY 있으면 '같은 상품' AI 판정 + 재검색어 생성 활성화
has_openai = bool(os.environ.get("OPENAI_API_KEY"))
# OpenAI 키 있으면 '같은 상품' AI 판정 + 재검색어 생성 활성화
has_openai = bool(openai_config.api_key)
judge = SimilarityJudge() if has_openai else None
keyword_gen = KeywordGenerator() if has_openai else None
LOG.i(f"AI(판정+검색어생성): {'ON' if has_openai else 'OFF(키 없음)'}")