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 ```bash
cp config/config.local.toml.example config/config.local.toml # DB 등 비-시크릿 설정 cp config/config.local.toml.example config/config.local.toml # 설정+시크릿 전부(포트/DB/API 키)
cp .env.example .env # 시크릿(API 키) — NAVER/OPENAI 등
./run_local_server.sh # → http://localhost:9600/docs ./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` > 워커는 별도 프로세스: `python worker_main.py`
## 테스트 ## 테스트

View File

@ -32,3 +32,29 @@ show_log = false
pool_size = 10 pool_size = 10
max_overflow = 20 max_overflow = 20
sslmode = "" # 로컬: "" / 관리형 DB: "require"|"verify-ca"|"verify-full" 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 from config.config_loader import ConfigModel
@ -36,3 +38,36 @@ class MainDBConfig(ConfigModel):
max_overflow: int = 20 max_overflow: int = 20
# SSL/TLS 모드: ""/"disable"=미사용(로컬), "require"/"verify-ca"/"verify-full"=관리형 DB(RDS/Aurora/Azure). # SSL/TLS 모드: ""/"disable"=미사용(로컬), "require"/"verify-ca"/"verify-full"=관리형 DB(RDS/Aurora/Azure).
sslmode: str = "" 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 import os
from dotenv import load_dotenv
from config.config_loader import Configs from config.config_loader import Configs
from config.config_models import WebServerConfig, LogConfig, MainDBConfig from config.config_models import (
WebServerConfig, LogConfig, MainDBConfig, NaverConfig, OpenAIConfig, DecodoConfig,
# 시크릿(.env: API 키 등)을 환경변수로 로드. lps 루트의 .env 를 읽는다(없으면 조용히 무시). )
# TOML(비밀 아님) 과 env(시크릿) 를 분리 — 키는 코드/커밋에 넣지 않는다.
load_dotenv(os.path.join(os.path.dirname(__file__), "..", ".env"))
# 실행 환경 결정 (기본 local). 환경변수 APP_ENV 로 변경. # 실행 환경 결정 (기본 local). 환경변수 APP_ENV 로 변경.
APP_ENV = os.environ.get("APP_ENV", "local") APP_ENV = os.environ.get("APP_ENV", "local")
@ -24,6 +20,10 @@ configs = Configs(_config_file)
web_server_config: WebServerConfig = configs.get(WebServerConfig) web_server_config: WebServerConfig = configs.get(WebServerConfig)
log_config: LogConfig = configs.get(LogConfig) log_config: LogConfig = configs.get(LogConfig)
main_db_config: MainDBConfig = configs.get(MainDBConfig) 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 그대로. # DB 접속 env override (config.local.toml 유지, 도커에서 host 만 교체). 로컬은 env 미설정 → toml 그대로.

View File

@ -5,7 +5,6 @@ greenlet # SQLAlchemy async 의 sync/async 브리지에 필수 (일부 환경
asyncpg asyncpg
orjson orjson
pydantic>=2.0 pydantic>=2.0
python-dotenv # .env(시크릿: API 키) 로드 — server_configs 에서 환경변수로 주입
httpx # 외부 사이트/오픈API(최저가 조회) 호출용 async HTTP 클라이언트 httpx # 외부 사이트/오픈API(최저가 조회) 호출용 async HTTP 클라이언트
# --- 크롤링(LPS) — 안티봇 대응은 어댑터 안에 격리. 트레드밀 대비 버전 pin. --- # --- 크롤링(LPS) — 안티봇 대응은 어댑터 안에 격리. 트레드밀 대비 버전 pin. ---
@ -14,4 +13,4 @@ selectolax==0.4.10 # 빠른 C 파서(쿠팡 HTML). bs4 대비 최대 30배
patchright # 스텔스 Playwright 포크. 쿠팡 Akamai JS 챌린지 통과(실제 Chrome, channel=chrome) patchright # 스텔스 Playwright 포크. 쿠팡 Akamai JS 챌린지 통과(실제 Chrome, channel=chrome)
# ※ nodriver 는 Python 3.14 소스인코딩 버그로 미채택 → Patchright 로 대체 # ※ nodriver 는 Python 3.14 소스인코딩 버그로 미채택 → Patchright 로 대체
# ※ 실행엔 시스템 Google Chrome 필요(로컬) / 배포 이미지엔 chromium 설치 필요 # ※ 실행엔 시스템 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 으로 이식. 레퍼런스 keyword_maker 의 규칙(모델명 우선, 일반 카테고리어 제거)을 structured output 으로 이식.
""" """
import os
from openai import AsyncOpenAI from openai import AsyncOpenAI
from pydantic import BaseModel, Field from pydantic import BaseModel, Field
from common.logger import LOG from common.logger import LOG
from config.server_configs import openai_config
_SYSTEM = ( _SYSTEM = (
"너는 커머스 검색어 생성기다. 상품 정보를 받아 쇼핑몰 검색창에 넣을 한국어 검색어 2개를 만든다.\n" "너는 커머스 검색어 생성기다. 상품 정보를 받아 쇼핑몰 검색창에 넣을 한국어 검색어 2개를 만든다.\n"
@ -28,9 +27,9 @@ class Keywords(BaseModel):
class KeywordGenerator: class KeywordGenerator:
def __init__(self, model: str = "gpt-4o-mini", api_key: str | None = None): def __init__(self, model: str | None = None, api_key: str | None = None):
self._model = model self._model = model or openai_config.model
self._client = AsyncOpenAI(api_key=api_key or os.environ.get("OPENAI_API_KEY")) self._client = AsyncOpenAI(api_key=api_key or openai_config.api_key)
async def generate(self, target: dict) -> Keywords: async def generate(self, target: dict) -> Keywords:
user = ( user = (

View File

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

View File

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

View File

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

View File

@ -1,18 +1,19 @@
"""DECODO 프록시 제공자 테스트 (포트 기반 sticky, 순수·네트워크 불필요).""" """DECODO 프록시 제공자 테스트 (포트 기반 sticky, 순수·네트워크 불필요)."""
from config.config_models import DecodoConfig
from services.search.proxy import DecodoProxy from services.search.proxy import DecodoProxy
def _p(**kw): def _p(**kw):
base = dict(host="gate.decodo.com", username="user1", password="pw", port_start=10001, port_end=10010, session_minutes=10) base = dict(host="gate.decodo.com", username="user1", password="pw", port_start=10001, port_end=10010, session_minutes=10)
base.update(kw) base.update(kw)
return DecodoProxy(**base) return DecodoProxy(DecodoConfig(**base))
def test_disabled_when_credentials_missing(): 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(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 assert _p().enabled is True
@ -25,7 +26,7 @@ def test_playwright_proxy_shape():
def test_disabled_returns_none(): 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(): def test_port_selected_within_range_and_stable_in_window():

View File

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