설정이 .env(compose 주입)·config.toml·코드 곳곳의 os.environ 직독 3계층에 흩어져 관리가 어려웠다. TOML 하나로 통합한다(협의 결정). - 신설 [WorkerConfig](동시성·폴백·프로필·데드라인·유예·Chrome·하트비트), [AlertConfig](웹훅·쿨다운·임계 10종). [WebServerConfig].api_keys(guard), [DecodoConfig].ip_request_budget/port_cooldown_sec 추가 — 흩어져 있던 LPS_* env 20여 개를 섹션으로 흡수. - server_configs 의 env override 계층(DB_*·시크릿·NAVER_KEYS 등) 삭제. 남는 env 는 APP_ENV(부트스트랩)·PROCESS_COUNT/WORKER_CONCURRENCY(실행 스크립트 대화형 입력 전용)·LPS_LIVE(테스트 옵트인)뿐. - Docker: env 주입 → config.docker.toml 마운트 + APP_ENV=docker. 이미지 무시크릿 유지, 마운트 누락 시 FileNotFoundError 즉시 실패. .env.example 삭제, config.docker.toml.example 신설. - negodata 호출부: guard 키를 env 직독에서 [WebServerConfig].lps_api_key (+기존 관례대로 env override)로 이동. - 실행 스크립트: 프로필·폴백·예산 프롬프트 제거(toml 소스 안내), 동시성/프로세스 수만 임시 override 로 유지. - docs 7종·example toml 의 env 표기를 toml 키로 일괄 갱신. - 전체 145 passed + APP_ENV=docker 로딩·API 기동 스모크 확인. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
178 lines
15 KiB
Markdown
178 lines
15 KiB
Markdown
# 아키텍처 — 어떻게 동작하는가
|
||
|
||
[← README로](../README.md)
|
||
|
||
## 1. 구성요소 (한눈에)
|
||
|
||
| 구성요소 | 파일 | 역할 |
|
||
|---------|------|------|
|
||
| **API 서버** | `web_main.py` | 검색 요청을 받아 **큐에 적재**만 함(빠르게 응답). 상태·이력 조회 제공 |
|
||
| **큐(대기줄)** | `crud/job_crud.py` + `job` 테이블 | 할 일을 순서대로 안전하게 보관 (PostgreSQL 사용) |
|
||
| **워커(일꾼)** | `worker_main.py`, `worker/` | 큐에서 하나씩 꺼내 **실제 검색·판정·저장** 수행 |
|
||
| **소스 어댑터** | `services/search/` | 네이버·쿠팡에서 상품 수집 (소스별 방식 캡슐화) |
|
||
| **오픈마켓 폴백** | `services/search/{esm,st11}/` | G마켓·옥션·11번가 크롤 — 네이버가 그 몰을 커버 못 했을 때만 (BrowserSearchAdapter 공유). **기본 비활성**(`[WorkerConfig].fallbacks`) |
|
||
| **파이프라인** | `services/pipeline/` | 수집 결과를 필터·이상치 제거·최저가 정렬 |
|
||
| **AI** | `services/ai/` | "같은 상품" 판정 + 검색어 생성 (OpenAI) |
|
||
| **관측·알림** | `common/alerts.py` + 워커 ops-monitor | 큐·차단·DB풀·비용 등 10룰 임계 알림(쿨다운·해소 알림, Slack 웹훅) + 하트비트. API 도 자기 풀을 자체 감시. [룰 표](operations.md) |
|
||
| **API guard** | `router/v1/validator/auth.py` | `[WebServerConfig].api_keys` 설정 시 `/v1` 전체 X-API-Key 검증(개발은 빈값=개방) |
|
||
|
||
> **API와 워커를 분리**한 이유: 요청 접수는 즉시(가벼움), 실제 검색은 무거움(브라우저·AI). 분리하면 요청이 밀리지 않고, 워커만 따로 늘릴 수 있습니다.
|
||
|
||
## 2. 처리 파이프라인 (워커가 하는 일)
|
||
|
||
한 건의 검색 작업은 아래 단계를 거칩니다. 각 단계의 통과 건수는 `stages`로 기록됩니다(관측).
|
||
|
||
```
|
||
① 네거티브 캐시 확인
|
||
최근 "없음"으로 확인된 상품이면 → 재검색 생략(비용 절약)
|
||
|
||
② 소스 검색 (재정제 루프, 최대 3라운드)
|
||
라운드1: 원본 검색어 → 라운드2: AI 정밀 검색어 → 라운드3: 광역 검색어
|
||
각 라운드에서 네이버 ∥ 쿠팡 동시 검색 후 병합
|
||
|
||
③ 필터
|
||
- mall 필터 (필요 시 특정 쇼핑몰만/제외)
|
||
- 가격 밴드 (요청에 현재가가 있으면 ±범위 밖 제거)
|
||
|
||
④ 이상치 제거 (IQR)
|
||
비정상적으로 싸거나 비싼 항목 제거 (오매칭·묶음 등)
|
||
|
||
⑤ AI 같은 상품 판정
|
||
후보 중 "찾는 상품과 동일한 것"만 선별 (액세서리·다른 규격 제외)
|
||
→ 매칭 있으면: ⑤-1 오픈마켓 폴백 → 최저가순 정렬 → 상위 N개 반환 (found)
|
||
→ 매칭 0건 + 소스 정상: 다음 라운드로
|
||
→ 매칭 0건 + 소스 차단: 작업 실패 처리(뒤에서 재시도)
|
||
|
||
⑤-1 오픈마켓 폴백 (매칭 성공 시 · **기본 비활성 — [WorkerConfig].fallbacks 로 켬**)
|
||
네이버가 커버 못 한 몰(G마켓·옥션·11번가)만 실사이트 크롤 → 같은 상품 판정 → 병합
|
||
("네이버로 그 몰 값 확보 성공 → 그 값, 실패(몰 없음) → 크롤". 크롤 실패는 격리)
|
||
※ 2026-07-10 협의: 최종 최저가 기여 0회·시간/비용 과다로 로직에서 제외(코드 유지).
|
||
배경·재가동 절차는 decision-openmarket-crawler.md
|
||
|
||
⑥ 결과 저장
|
||
최저가 확정 + 최저가 이력 스냅샷 기록(몰별 by_mall 포함)
|
||
```
|
||
|
||
모든 라운드에서 못 찾으면 → **not_found(정상 종료)** + 네거티브 캐시에 기록.
|
||
|
||
## 3. 재시도 로직 — "왜 실패했나"에 따라 다르게
|
||
|
||
실패는 성격이 다르므로 **두 종류로 분리**해서 처리합니다. (섞으면 무한 재시도·오작동)
|
||
|
||
| 실패 종류 | 예시 | 대응 |
|
||
|----------|------|------|
|
||
| **기술적 실패** | 네트워크·쿠팡 차단·API 한도·AI 오류 | 잠시 후 재시도(지수 백오프), 여러 번 실패하면 **DEAD**(사람이 확인) |
|
||
| **검색어 문제** | "맥심 커피"가 너무 광범위 → 0건 | **검색어를 바꿔** 재시도(정밀→광역), 다 실패하면 **not_found** |
|
||
| **진짜 없는 상품** | 실제로 안 파는 상품 | 재시도 무의미 → **not_found로 정상 종료** (에러 아님) |
|
||
|
||
**무한 재시도 방지**: 기술적 재시도(횟수 상한)·검색어 재시도(라운드 상한) 둘 다 유한합니다. "못 찾음"은 **실패가 아니라 정상적인 답**으로 처리해 쌓이지 않습니다.
|
||
|
||
## 4. 안티봇 대응 (소스별로 다름)
|
||
|
||
브라우저 소스는 `BrowserSearchAdapter`(services/search/browser_base.py) 위에서 patchright(스텔스 Chrome)로 뚫고, 사이트별 차이는 훅으로 분리합니다.
|
||
|
||
| 소스 | 안티봇 | 대응 |
|
||
|------|--------|------|
|
||
| **쿠팡** | Akamai(JS 챌린지, 여러 flavor: 챌린지·Edge Access Denied·권한제한) | 실제 Chrome 통과 + 다종 마커 감지→IP 회전. 리소스 차단 OK(대역폭↓) |
|
||
| **G마켓·옥션**(ESM) | **Cloudflare Turnstile**('사람인지 확인' 체크박스) | patchright가 콜드 ~12초에 **자동 통과**, `cf_clearance` 쿠키로 이후 요청은 웜(~5초). 인터랙티브 체크박스는 best-effort 클릭 |
|
||
| **11번가** | 경량(모바일은 robot 차단→PC 사용) | PC 크롤. 지연 로딩 → 스크롤 트리거 |
|
||
| **네이버** | 없음(공식 오픈API) | httpx 직접 호출 + 키 로테이션 |
|
||
|
||
**핵심 메커니즘**
|
||
- **IP 회전(DECODO)**: 같은 IP로 계속 두드리면 차단 → 시간창 기반 sticky + 봇감지/전송오류 시 즉시 회전. 감지 이력(`bot_detection`)을 기록해 패턴 분석. **프록시 전송오류(407/터널)** 도 사이트 차단과 구분해 회전.
|
||
- **선제 회전(요청 예산)**: IP당 요청 수가 예산(`[DecodoConfig].ip_request_budget`, 기본 3 — 실측상 5회 부근 차단)에 닿으면 **차단당하기 전에** 회전. 선제 교체된 포트는 평판이 깨끗해 로테이션 복귀 시 재사용됩니다. 반면 **차단 감지된 포트는 쿨다운**(`[DecodoConfig].port_cooldown_sec`, 기본 max(sticky, 30분)) 동안 격리 — sticky 만료 후 복귀라 사실상 새 IP. 세션마다 `ip_session`(요청 수·종료 사유)을 남겨 예산 상한을 데이터로 튜닝합니다(쿼리는 database.md).
|
||
- **시작 프리플라이트 + 웜업**: 기동 시 살아있는 프록시 포트를 선점(egress IP 로그)하고, 챌린지 소스를 미리 1회 풀어 **쿠키를 선점**(나쁜 IP는 회전 재시도) → 실 작업은 웜(빠름).
|
||
- **동적 리소스 차단**: 이미지·폰트 등을 차단해 대역폭↓. 단 **Turnstile은 리소스 차단을 봇 신호로 감지**하므로, ESM은 챌린지 solving 중(콜드)엔 차단을 풀고 **cf_clearance 확보 후(웜)에만 차단**합니다.
|
||
- **폴백 데드라인**: 오픈마켓 크롤은 '보강'이라 각 크롤에 시간 상한(기본 15초)을 둬, 한 몰이 안 풀려도 전체 지연이 늘지 않게 합니다.
|
||
- **유휴 브라우저 정리**: 일정 시간(기본 120초) 검색이 없는 소스의 Chrome을 닫아 메모리를 회수(쿠키는 프로필에 남아 재기동해도 웜 유지).
|
||
|
||
## 5. 검색 원가 계측 (리소스·비용·시간)
|
||
|
||
검색 1건이 쓰는 것을 잡 단위로 집계해 `result.metrics`에 남깁니다(API/FE 노출).
|
||
- **AI**: 호출 수 + 토큰(prompt/completion) + 추정 비용($, 모델 단가)
|
||
- **크롤 대역폭**: CDP `Network.loadingFinished`의 **실제 전송 바이트**(DOM 크기가 아님). 프록시 경유분(네이버 직접 제외)으로 **DECODO 비용**($/GB) 산정
|
||
- **컴포넌트별 비용**: `cost = { ai_usd, proxy_usd, total_usd }`
|
||
- **시간**: 소스별 소요 + 전체
|
||
|
||
> 실측(2026-07): 상품당 ~$0.013 (AI ~$0.002 + **DECODO ~$0.011 = 87%**). 대역폭이 원가의 대부분 — 오픈마켓 크롤(브라우저 필수)이 주범.
|
||
|
||
## 6. 다중 상품 병렬 처리
|
||
|
||
- `POST /search`에 상품 리스트를 주면 상품마다 잡을 큐에 적재.
|
||
- 워커 동시성(`WORKER_CONCURRENCY=N`)만큼 상품을 **진짜 병렬** 처리 — 워커마다 **자기 브라우저 세트**(프로필 분리 + 다른 프록시 IP)를 가져 공유 lock 병목을 없앰. 권장 N=2~3(로컬, Chrome 최대 4×N개).
|
||
- 부하 측정: `loadtest.py`(e2e 처리량·p50/p95 지연·AI/DECODO/총비용) · `loadtest/`(Locust API 부하, 멀티코어 벤치).
|
||
|
||
### 6-1. API 멀티코어 스케일 & 커넥션 풀 자동산정
|
||
|
||
- **API 서버는 asyncio(스레드 1개) = 1 프로세스 1 코어**. 처리량을 코어만큼 올리려면 `process_count`(uvicorn 워커 수)를 늘린다.
|
||
- **함정**: 프로세스마다 독립 커넥션 풀을 열어 `(pool_size + max_overflow) × 2엔진(R/W) × process_count` 만큼 커넥션을 요구 → PG `max_connections`(기본 100)를 넘으면 **커넥션 고갈로 요청 실패 폭증**(부하테스트로 실증: 풀 10/20 · 4프로세스 = 240 요구 → 실패 1만+).
|
||
- **해결(자동)**: `MainDBConfig.connection_budget`(기본 40)를 두면 기동 시 `process_count`에 맞춰 `pool_size/max_overflow`를 **역산**해 `(pool+overflow)×2×process_count ≤ budget`을 스스로 보장(`server_configs._autosize_pool`). 워커를 늘려도 예산을 넘지 않는다. 기동 로그 `DB Pool : … = N conns (budget=…)`로 실효값 확인.
|
||
- **예산 가이드**: 공유 PG=40(API+worker+타 서비스 공존, 안정 우선) / 전용 PG(`max_connections≈100`)=90(처리량 우선). `[MainDBConfig].connection_budget`. 더 큰 처리량은 예산↑ + PG `max_connections`↑ 또는 pgbouncer.
|
||
- 상세·벤치 결과: [`../loadtest/README.md`](../loadtest/README.md).
|
||
|
||
### 6-2. 왜 브라우저는 워커당 1세트인가 (더 띄우면 안 되나?)
|
||
|
||
Chrome 프로세스 자체는 얼마든지 더 띄울 수 있다. 그런데도 워커:브라우저를 1:1로 두는 이유 —
|
||
정확히는 **"더 띄워봐야 이득이 0이고, 덜 띄우면(공유하면) 손해"**라서 1:1이 낭비도 병목도 없는 균형점이다.
|
||
|
||
- **희소 자원은 브라우저가 아니라 "신원(identity)"**. 쿠팡용 브라우저 1개 = Chrome 프로필(Akamai 쿠키,
|
||
cf_clearance) + DECODO sticky IP(포트) 묶음이다. Akamai 는 쿠키와 IP 를 묶어 보므로(불일치 시 재챌린지)
|
||
브라우저 추가 = 웜업 챌린지 1회 추가 + 프록시 포트 1개 소모 + 봇 감지 노출면 확대다. Chrome 은 공짜여도
|
||
**쓸 수 있는 신원은 공짜가 아니다.**
|
||
- **검색 1건은 브라우저 여러 개로 못 쪼갠다.** 검색은 "페이지 열고 → 렌더 대기 → 파싱"의 순차 작업.
|
||
병렬화 단위는 검색이 아니라 **잡(=상품)**이고, 잡의 동시 처리 주체가 워커다. 워커는 소스당 검색을
|
||
한 번에 하나만 하므로 소스당 브라우저 1개면 항상 꽉 채워 쓴다.
|
||
- **비율이 어긋나면**: 브라우저 > 워커 → 남는 브라우저는 놀면서 메모리·포트·웜업 비용만 차지(처리량 +0).
|
||
브라우저 < 워커(공유) → 어댑터가 브라우저 상태(페이지·봇감지 회전 상태머신) 때문에 검색을 락으로
|
||
직렬화하므로 사실상 순차가 된다(2026-07-10 스톨 사건이 정확히 이 모습 — 웜업이 락을 쥐자 그 워커의
|
||
검색 전체가 정지).
|
||
- **처리량 상한도 브라우저 수가 아니다.** 신원 하나당 요청 간격을 일부러 2~8s 랜덤으로 벌린다(등간격
|
||
기계 요청 = 탐지 신호). 즉 신원당 처리량은 설계상 캡 — 처리량을 올리는 유일한 방법은 신원(=워커)을
|
||
늘리는 것이다.
|
||
|
||
### 6-3. 워커 수 상한을 서버에서 파악하는 법
|
||
|
||
`워커 상한 = min( RAM캡, 포트캡, 실측 정체점 )` — 계산 가능한 하드캡 2개로 범위를 좁히고, 그 안에서 계단식 실측.
|
||
|
||
**하드캡(계산)**
|
||
|
||
- **RAM캡** = `(전체 RAM − OS/PG/API 여유분) × 0.7 ÷ 세트당 RSS`.
|
||
세트당 RSS 는 워커 N개로 부하를 건 상태에서 chrome 프로세스 그룹 RSS 합 ÷ N (모니터 :9700 의
|
||
프로세스 그룹, 정밀하게는 `ps` RSS 합산). headful Chrome 세트는 리소스 차단을 해도 세트당
|
||
수백 MB~1GB — 16GB 서버면 대략 8~12세트에서 걸린다. 폴백을 켜면 워커당 브라우저 최대 4개로 배수 증가.
|
||
- **포트캡** ≈ `DECODO 포트 수 ÷ 3`. 워커마다 다른 sticky 포트가 필요하고 봇 감지 시 다음 포트로
|
||
회전하므로 회전 여유분이 필수 — 워커 수가 포트 수에 근접하면 회전 후 옆 워커가 쓰던 IP 를 받는
|
||
충돌이 생긴다. `config` 의 `port_start~port_end`에서 바로 계산.
|
||
|
||
**소프트캡(실측 — 보통 이게 진짜 상한)**
|
||
|
||
`WORKER_CONCURRENCY` 3→6→9… 계단으로 올리며 매번 같은 부하(`N=30 loadtest.py` 등)를 걸고,
|
||
아래 신호 중 **먼저 오는 것**이 그 서버의 상한:
|
||
|
||
1. **처리량 정체** — 워커 2배인데 상품/분이 2배가 안 됨(리포트 숫자로 비교).
|
||
2. **봇 감지율 급증** — `blocks_1h`가 워커 수보다 가파르게 상승. 프록시 재시도 비용+차단 리스크라
|
||
CPU 보다 먼저 멈춰야 하는 신호.
|
||
3. **메모리 압박** — macOS `memory_pressure` / Linux swap 시작. Chrome 은 부족하면 느려지는 게 아니라 크래시.
|
||
4. **워커 파이썬 프로세스의 단일 코어 포화** — 워커 N개는 **파이썬 프로세스 1개 안의 asyncio 태스크**라
|
||
파이썬 쪽 일(파싱·AI 응답 처리·DB)은 코어 1개를 공유한다. 모니터에서 worker 프로세스가 코어 1개
|
||
기준 100%에 붙으면 그 이상은 무의미. (Chrome 들은 별도 프로세스라 나머지 코어로 퍼진다.)
|
||
5. **p95 지연 상승** — 상품당 지연이 워커 늘리기 전보다 나빠지면 경합(락·프록시·DB) 시작.
|
||
|
||
한 서버의 실측 상한을 넘는 처리량이 필요하면 워커 컨테이너를 **수평 확장**한다(§1 — 큐 기반이라
|
||
워커만 늘리면 됨. `Dockerfile.worker` + compose).
|
||
|
||
## 7. 최저가 이력 (그래프)
|
||
|
||
- **트리거 기반**: 자동 배치 없이 **실제 조회된 상품만** 그 시점에 기록 → 트래픽·비용 절약.
|
||
- 검색할 때마다 **네이버/쿠팡/최종 최저가** + **몰별 스냅샷(`by_mall`)** 을 남깁니다.
|
||
- 그래프: X축 = 조회 시각(불규칙), Y축 = 가격, 3개 선(+몰별). (한쪽 소스에 없던 시점은 선이 비어있음 — 정상)
|
||
|
||
## 8. 설계 원칙 (참고)
|
||
|
||
- **PostgreSQL을 큐로 제대로 사용**: 별도 브로커 없이 원자적 할당 + 자동 복구로 유실·중복 없이 처리.
|
||
- **소스 어댑터 패턴**: 소스별 수집·안티봇 차이를 어댑터 안에 가두고, 코어는 정규화된 결과만 다룸 → 새 쇼핑몰 추가가 쉬움.
|
||
- **AI로 매칭**: 상품명 형식이 제각각이라 규칙 고정 파싱 대신 AI가 "같은 상품인지" 판단.
|
||
- **오픈마켓은 네이버 폴백**: 네이버가 이미 커버하는 몰은 재크롤하지 않고, 못 덮은 몰만 크롤(비용 절약).
|
||
|
||
더 깊은 내부 구현은 각 파일 상단 주석에 정리되어 있습니다.
|