docs(lps): README 에 크롤·매칭·IP 로테이션 실제 방식 추가 + 낡은 서술 정정

"어떻게 동작하나"가 워크플로우 그림 한 장뿐이라, 정작 이 시스템에서 어려운 세 가지
(안티봇 뚫는 법·같은 상품 고르는 법·IP 돌리는 법)를 README 만 봐서는 알 수 없었다.
기존 스타일(표·ASCII·비개발자 설명)을 유지해 '🔬 안을 열어보면' 절을 추가한다.

- 소스별 크롤: 네이버(모바일 msearch·WTM)와 쿠팡(Akamai)의 **정반대 전략**을 표로 대조.
  네이버는 리소스 차단을 끄고(요청 가로채기 자체가 탐지 신호) 한국 IP+ko-KR 로케일이 필수,
  쿠팡은 리소스를 막아 대역폭을 줄인다. 스크롤은 횟수가 아니라 '안 늘어남'이 종료 조건인 이유도.
- 같은 상품 판정: 필터 3단계 + AI 판정, '무시할 차이 / 불일치로 볼 차이' 기준표,
  후보를 10건씩 쪼개야 하는 이유(37건 일괄 → 전멸).
- IP 로테이션: 포트=sticky 세션, 게이트웨이 2개, DB 장부(SKIP LOCKED·LRU),
  포트 3상태(임대/휴식/쿨다운), 회전 계기 4종, **태우지 않는 경우**(구조적 차단·서킷브레이커·
  확신 없는 0건)와 예산을 IP 기준으로 세는 이유.

낡은 서술 정정:
- "네이버 쇼핑 API" → 오픈API 는 2026-07-31 종료, 지금은 둘 다 크롤
- 테이블 5종 → 6종(proxy_port), 알림 10룰 → 11룰(fatal_block)
- 요청 예산 "기본 3회" → 쿠팡 3·네이버 10
- config 설명 "배포는 env 주입" → 실제로는 config.local.toml 마운트, env 는 DB 접속점만
- 폴더 구조에 crud/port_lease·profile_slot 추가

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
민헌 2026-08-05 17:23:23 +09:00
parent 377389f495
commit 7b1bcb6d24

View File

@ -39,25 +39,111 @@
**핵심 포인트** **핵심 포인트**
- **즉시 응답 + 나중 처리**: 요청하면 바로 "접수번호(job_id)"를 주고, 실제 검색은 뒤에서 진행됩니다. (검색은 몇 초~수십 초 걸림) - **즉시 응답 + 나중 처리**: 요청하면 바로 "접수번호(job_id)"를 주고, 실제 검색은 뒤에서 진행됩니다. (검색은 몇 초~수십 초 걸림)
- **못 찾으면 검색어를 바꿔 재시도**: "맥심 커피"로 안 나오면 "맥심 모카골드 커피믹스"처럼 **AI가 검색어를 다듬어** 다시 시도하고, 그래도 없으면 "없음"으로 정리합니다. (무한 재시도 안 함) - **못 찾으면 검색어를 바꿔 재시도**: "맥심 커피"로 안 나오면 "맥심 모카골드 커피믹스"처럼 **AI가 검색어를 다듬어** 다시 시도하고, 그래도 없으면 "없음"으로 정리합니다. (무한 재시도 안 함)
- **차단 대응**: IP당 요청 예산(기본 3회)에 닿으면 **차단당하기 전에 IP를 선제 교체**하고(평판 보존 — 그 IP는 로테이션 복귀 시 재사용), 그래도 감지되면 그 포트를 쿨다운 격리 후 다른 IP로 재시도합니다. 시작 시 챌린지를 미리 풀어(웜업) 실 작업을 빠르게 합니다. - **차단 대응**: IP당 요청 예산(쿠팡 3회·네이버 10회)에 닿으면 **차단당하기 전에 IP를 선제 교체**하고(평판 보존 — 그 IP는 쉬었다가 복귀), 그래도 감지되면 그 포트를 쿨다운 격리 후 다른 IP로 재시도합니다. 회전해도 소용없는 차단(환경·설정 문제)은 **따로 알아보고 IP를 태우지 않습니다**. 시작 시 챌린지를 미리 풀어(웜업) 실 작업을 빠르게 합니다.
- **원가 투명**: 검색 1건이 쓴 AI 비용·프록시 대역폭·시간을 함께 기록합니다. - **원가 투명**: 검색 1건이 쓴 AI 비용·프록시 대역폭·시간을 함께 기록합니다.
--- ---
## 🔬 안을 열어보면 — 크롤 · 매칭 · IP 로테이션
### 1) 소스별 크롤링 방식
네이버·쿠팡 **둘 다 실제 Chrome(patchright)으로 긁습니다.** 막는 방식이 달라서 세부 전략은 정반대입니다.
| | 네이버 | 쿠팡 |
|---|---|---|
| 경로 | 모바일 `msearch.shopping.naver.com` | `www.coupang.com/np/search` |
| 왜 이 경로? | 쇼핑 검색 오픈API가 **2026-07-31 영구 종료**(404 `SE05`, 대체 없음). PC 경로는 405+캡차, 내부 API는 418 → 모바일만 열려 있음 | 애초에 공개 API 없음 |
| 막는 주체 | **WTM 캡차** | **Akamai Bot Manager** JS 행동 챌린지 |
| 리소스 차단 | ❌ **끈다** — 이미지만 막아도 즉시 캡차. '무엇을 막느냐'가 아니라 **요청 가로채기(CDP Fetch) 자체**가 탐지 신호다(검색당 ~3MB 감수) | ✅ 이미지·미디어·폰트·CSS 차단 — 파싱·챌린지에 불필요해서 대역폭만 줄어든다 |
| 필수 조건 | **한국 IP**(해외면 2.6KB 하드차단) + 브라우저 로케일 `ko-KR`·`Asia/Seoul` | 프로필 재사용으로 챌린지 쿠키 유지(한 번만 풀면 됨) |
| 결과 수집 | 무한스크롤 — 카드가 **더 안 늘 때까지** 바닥으로 내린다(횟수가 아니라 '안 늘어남'이 종료 조건, 상한 6회) → 상위 40건 | `listSize` 파라미터로 한 번에 → 상위 40건 |
> 로케일 두 줄이 캡차를 가릅니다 — 같은 한국 IP·같은 브라우저에서 `en-US`면 캡차, `ko-KR`이면 정상이었습니다(실측).
> 스크롤도 **횟수로 세면 안 됩니다**: 프록시 지연이 있으면 아직 아무것도 안 그려진 화면을 스크롤하고 끝나 40건 나올 페이지에서 14건만 건집니다.
카드 셀렉터는 클래스명이 webpack 해시(`product_price__O3ZGH`)라 **prefix 매칭**으로만 잡습니다 — 해시가 바뀌어도 안 깨집니다. 한 페이지에 광고·슈퍼적립·브랜드블록 카드가 유기 검색결과와 섞여 나오므로 유기 결과만 골라냅니다.
### 2) "같은 상품" 판정
수집한 카드를 그대로 쓰면 최저가가 오염됩니다 — 빨대·커버 같은 액세서리가 끼거나, 60롤 가격을 30롤 최저가로 쓰는 수량 왜곡이 생깁니다. 4단계로 좁힙니다.
```
수집 N건 ──▶ ① 몰 필터 ──▶ ② 가격 밴드 ──▶ ③ 이상치 제거 ──▶ ④ AI 같은상품 판정 ──▶ 최저가
(기준가 대비) (IQR) (gpt-4o-mini)
```
①~③은 규칙이고, 판단은 ④가 합니다. AI에게 주는 기준은 **무시할 차이**와 **불일치로 볼 차이**로 갈라놓았습니다:
| 무시한다 (같은 상품) | 불일치로 본다 (다른 상품) |
|---|---|
| 판매자·스토어, 색상/향, 사은품, 배송 문구, 상품명 수식어('무형광'·'프리미엄') | **종류**(프라이팬 ≠ 볶음팬 ≠ 웍팬), 브랜드·모델, 용량·크기, **수량**(30롤 1팩 ≠ 30롤 2팩), 액세서리·호환부품 |
> 예전엔 "포장 차이는 같은 상품"과 "규격이 다르면 불일치"가 같이 있어 모델이 어느 쪽으로도 답할 수 없었습니다. 최저가 관점에선 **수량이 다르면 다른 상품**입니다.
**후보는 반드시 10건씩 쪼개서 묻습니다.** 37건을 한 번에 넣으면 gpt-4o-mini가 전 항목에 같은 점수를 매기고 **전부 불일치**로 답합니다(`temperature=0`에서 3회 재현). 10건씩 나누면 같은 모델·같은 입력으로 12건이 매칭됐습니다. 배치는 병렬로 던지므로 지연은 1개분입니다.
매칭이 0건이면 **검색어를 바꿔 최대 3라운드**(원본 → 정밀 → 광역) 돌고, 그래도 없으면 '없음'으로 확정해 일정 시간 캐시합니다(무한 재시도 방지).
### 3) IP 프록시 로테이션
DECODO residential 프록시를 씁니다. 여기선 **포트 1개 = sticky 세션 1개**라, **IP를 바꾼다 = 포트를 바꾼다** 입니다.
```
gate.decodo.com:10001-10100 국가 무지정 → 쿠팡
kr.decodo.com :10001-10100 한국 전용 → 네이버 (해외 IP면 하드차단)
```
> 같은 포트 번호라도 **게이트웨이가 다르면 다른 IP**입니다(실측: port 10061 → gate=인도네시아 / kr=한국). 그래서 장부의 키는 (게이트웨이, 포트)입니다.
**누가 어떤 IP를 쓰는지는 DB(`proxy_port`)가 관리합니다** — 워커 프로세스가 여러 개여도 한 계정을 나눠 쓰기 때문입니다. 잡 큐와 같은 방식으로 `FOR UPDATE SKIP LOCKED`를 써서 **후보 선택과 임대를 한 문장에서** 끝냅니다(두 프로세스가 같은 IP를 동시에 잡을 수 없음). 배정은 **가장 오래 안 쓴 IP(LRU)** 순이라 프로세스가 몇 개든 알아서 골고루 돕니다.
포트는 세 가지 상태로 묶입니다:
| 상태 | 언제 | 기간 |
|---|---|---|
| **임대** | 지금 누가 쓰는 중 | sticky 수명(10분). 프로세스가 죽어도 만료로 자동 회수 — 별도 정리 프로세스 불필요 |
| **휴식** | 예산 도달로 **선제 교체**한 IP | sticky 수명. 탄 게 아니라 쉬는 것(곧바로 재사용되면 예산의 의미가 없어짐) |
| **쿨다운** | 차단이 확인된 IP | max(sticky, 30분). 누가 태웠든 **전역**으로 적용 |
IP를 바꾸는 계기는 넷입니다:
| 계기 | 처리 |
|---|---|
| **요청 예산 도달** (쿠팡 3회 / 네이버 10회) | 차단당하기 **전에** 선제 교체 — 평판 보존이 목적. 태우지 않고 휴식만 준다 |
| **차단 감지** | 그 포트를 쿨다운 격리하고 다른 IP로 인라인 재시도 |
| **프록시 전송오류** (407·터널 실패) | 사이트가 아니라 포트가 죽은 것 → 교체 후 재시도 |
| **sticky 수명 만료** | 제공자 쪽 세션도 끝났으므로 임대를 놓아주고 새 IP를 받는다 |
> 예산은 **브라우저가 아니라 IP를 기준으로** 셉니다. 유휴 브라우저 정리(120초)는 브라우저만 닫고 같은 IP로 돌아오기 때문에, 브라우저 기준으로 세면 카운터가 매번 초기화돼 예산이 영영 발화하지 않습니다.
**태우지 않는 경우가 두 가지** 있습니다. 회전해도 소용없는데 태우면 원인은 그대로인 채 풀만 마르기 때문입니다.
- **구조적 차단** — 해외 IP로 네이버에 접근한 경우처럼 IP를 바꿔도 결과가 같은 차단. 즉시 실패시키고 "설정을 고치라"고 알립니다.
- **환경 차단(서킷브레이커)** — 서로 다른 IP가 **연속 3개 모두 첫 요청부터** 막히면 IP로 설명되지 않습니다(평판 문제라면 몇 개는 통과하고, 과사용이라면 첫 요청이 아니라 뒤쪽에서 막힙니다). 소각을 멈추고 알린 뒤, 검색이 한 번 성공하면 자동으로 풀립니다.
> 이 판정이 없던 때는 전면 차단 상태에서 **잡 16건이면 100포트가 전부 30분 쿨다운**에 묶였습니다(웜업만으로 워커당 6포트). 지금은 판정 근거로 2개를 쓰고 멈춥니다.
또 **확신이 없으면 태우지 않습니다.** 결과 0건인데 알려진 차단 마커가 없으면 '페이지가 짧다'는 정황뿐이라, 진짜 검색결과 없음일 수 있습니다. 이럴 땐 IP 교체·재시도까지만 하고 30분 쿨다운은 걸지 않습니다.
> 더 깊은 내용은 [아키텍처](docs/architecture.md)·[운영 가이드](docs/operations.md)를 보세요.
---
## ✨ 주요 기능 ## ✨ 주요 기능
| 기능 | 설명 | | 기능 | 설명 |
|------|------| |------|------|
| 멀티 소스 검색 | 네이버 쇼핑 API + 쿠팡(Akamai 우회) 동시 검색·병합 | | 멀티 소스 검색 | 네이버 모바일 쇼핑(WTM 우회) + 쿠팡(Akamai 우회) 동시 크롤·병합. 오픈API는 2026-07-31 종료돼 **둘 다 크롤** |
| 오픈마켓 폴백 크롤 | 네이버가 못 덮은 몰만 G마켓·옥션(Cloudflare Turnstile 우회)·11번가 크롤 → 몰별 가격. **기본 비활성**(`[WorkerConfig].fallbacks`, [배경](docs/decision-openmarket-crawler.md)) | | 오픈마켓 폴백 크롤 | 네이버가 못 덮은 몰만 G마켓·옥션(Cloudflare Turnstile 우회)·11번가 크롤 → 몰별 가격. **기본 비활성**(`[WorkerConfig].fallbacks`, [배경](docs/decision-openmarket-crawler.md)) |
| AI 같은 상품 판정 | "진짜 그 상품"만 선별 (액세서리·다른 규격 제외) | | AI 같은 상품 판정 | "진짜 그 상품"만 선별 (액세서리·다른 규격·다른 수량 제외). 후보를 10건씩 쪼개 병렬 판정 |
| 검색어 자동 정제 | 0건이면 정밀/광역 검색어로 재시도 | | 검색어 자동 정제 | 0건이면 정밀/광역 검색어로 재시도 |
| 최저가 이력 그래프 | 조회 시점마다 네이버/쿠팡/최종 + 몰별(by_mall) 최저가를 시계열로 기록 | | 최저가 이력 그래프 | 조회 시점마다 네이버/쿠팡/최종 + 몰별(by_mall) 최저가를 시계열로 기록 |
| 검색 원가 계측 | 검색 1건의 AI 토큰·비용 + DECODO 대역폭(실측 CDP) + 시간을 집계 | | 검색 원가 계측 | 검색 1건의 AI 토큰·비용 + DECODO 대역폭(실측 CDP) + 시간을 집계 |
| 다중 상품 병렬 | 워커별 브라우저 세트로 여러 상품 동시 검색(`WORKER_CONCURRENCY`) | | 다중 상품 병렬 | 워커별 브라우저 세트로 여러 상품 동시 검색(`WORKER_CONCURRENCY`) |
| 안정적 큐 처리 | 작업 유실 없이 순서대로, 실패 시 자동 재시도 | | 안정적 큐 처리 | 작업 유실 없이 순서대로, 실패 시 자동 재시도 |
| 프록시 IP 선제 회전 | 요청 예산(기본 3회) 도달 시 **차단 전 선제 교체** + 불탄 포트 쿨다운 + 봇 감지·전송오류 즉시 순환 + 시작 웜업(DECODO). 예산 튜닝용 `ip_session` 관측 로그 | | 프록시 IP 로테이션 | 포트 임대를 **DB 장부(`proxy_port`)로 관리** — 프로세스가 여러 개여도 같은 IP 중복 사용 없음(LRU 배정). 예산 도달 시 차단 전 선제 교체 + 불탄 포트 쿨다운 + 전송오류 즉시 순환 + 시작 웜업. 예산 튜닝용 `ip_session` 관측 로그 |
| 임계 알림 | 큐·차단·DB풀·소스별 장기실패·비용 등 10룰 — 쿨다운(스팸 방지)·해소 알림, Slack 웹훅([룰 표](docs/operations.md)) | | 회전 무효 차단 감지 | 구조적 차단(해외 IP 등)과 **환경 차단 서킷브레이커**(서로 다른 IP 3개가 연속 첫 요청부터 차단)를 구분해 **포트를 태우지 않고** 즉시 알림 — 풀 고갈 방지 |
| 임계 알림 | 큐·차단·DB풀·소스별 장기실패·비용·회전무효 차단 등 11룰 — 쿨다운(스팸 방지)·해소 알림, Slack 웹훅([룰 표](docs/operations.md)) |
| API guard | `[WebServerConfig].api_keys` 설정 시 `/v1` 전체 X-API-Key 검증(개발은 빈값=개방 모드) | | API guard | `[WebServerConfig].api_keys` 설정 시 `/v1` 전체 X-API-Key 검증(개발은 빈값=개방 모드) |
--- ---
@ -108,7 +194,7 @@ docker compose up -d # negosium 스택 + lps-api·lps-worker·lps
| 문서 | 대상 | 내용 | | 문서 | 대상 | 내용 |
|------|------|------| |------|------|------|
| **[아키텍처](docs/architecture.md)** | 개발자/기획자 | 구성요소·파이프라인·안티봇(Akamai/Turnstile)·비용계측·동시성 | | **[아키텍처](docs/architecture.md)** | 개발자/기획자 | 구성요소·파이프라인·안티봇(Akamai/Turnstile)·비용계측·동시성 |
| **[데이터베이스](docs/database.md)** | 개발자/기획자 | 테이블 5종 구조와 코드값(+by_mall·ip_session) | | **[데이터베이스](docs/database.md)** | 개발자/기획자 | 테이블 6종 구조와 코드값(+by_mall·ip_session·proxy_port 장부) |
| **[API 사용법](docs/api.md)** | 연동 개발자 | 엔드포인트·요청/응답·metrics 예시 | | **[API 사용법](docs/api.md)** | 연동 개발자 | 엔드포인트·요청/응답·metrics 예시 |
| **[운영 가이드](docs/operations.md)** | 운영자/개발자 | 실행·병렬·관측(readyz/ops/알림)·**Docker 배포**·문제 해결 | | **[운영 가이드](docs/operations.md)** | 운영자/개발자 | 실행·병렬·관측(readyz/ops/알림)·**Docker 배포**·문제 해결 |
| **[크롤러 논의](docs/decision-openmarket-crawler.md)** | 팀 | 오픈마켓 크롤러 유지 여부(ROI) 의사결정 메모 | | **[크롤러 논의](docs/decision-openmarket-crawler.md)** | 팀 | 오픈마켓 크롤러 유지 여부(ROI) 의사결정 메모 |
@ -126,15 +212,16 @@ lps/
├── run_local_worker.sh # 로컬 워커 실행 (대화형: 동시성·프로필) ├── run_local_worker.sh # 로컬 워커 실행 (대화형: 동시성·프로필)
├── run_docker.sh # lps 서브셋 Docker 실행 (대화형: 설정 검증·guard 안전장치 + admin 포함) ├── run_docker.sh # lps 서브셋 Docker 실행 (대화형: 설정 검증·guard 안전장치 + admin 포함)
├── run_loadtest_gui.sh # 부하 테스트 Locust 웹 UI(:8089) 실행 (대화형) ├── run_loadtest_gui.sh # 부하 테스트 Locust 웹 UI(:8089) 실행 (대화형)
├── config/ # 설정(config.local.toml — 포트/DB/API키, 미커밋; 배포는 env 주입) ├── config/ # 설정·시크릿(config.local.toml 하나 — 미커밋. 컨테이너엔 마운트, env 는 DB 접속점만 override)
├── common/ # 공통(enums, DB 세션, 모델, 로거, alerts=임계 알림 관리자) ├── common/ # 공통(enums, DB 세션, 모델, 로거, alerts=임계 알림 관리자)
│ └── database/model/models.py # DB 테이블 정의 │ └── database/model/models.py # DB 테이블 정의
├── loadtest.py # 부하 테스트 (N개 상품 → 처리량·지연·비용 집계) ├── loadtest.py # 부하 테스트 (N개 상품 → 처리량·지연·비용 집계)
├── crud/ # DB 접근 (job_crud, price_history, negative_cache, bot_detection, ip_session) ├── crud/ # DB 접근 (job_crud, price_history, negative_cache, bot_detection, ip_session, port_lease=IP 임대 장부)
├── services/ ├── services/
│ ├── search/ # 소스 어댑터 (coupang, naver_shop=네이버 크롤, esm=G마켓·옥션, st11=11번가) │ ├── search/ # 소스 어댑터 (coupang, naver_shop=네이버 크롤, esm=G마켓·옥션, st11=11번가)
│ │ ├── browser_base.py # patchright 공통(수명·프록시회전·차단감지·CDP 바이트계측) │ │ ├── browser_base.py # patchright 공통(브라우저 수명·IP 세션·차단감지/서킷브레이커·CDP 바이트계측)
│ │ ├── proxy.py # DECODO(IP 회전·포트 쿨다운·프리플라이트) │ │ ├── proxy.py # DECODO(포트=IP 임대·회전·쿨다운/휴식·프리플라이트)
│ │ ├── profile_slot.py # Chrome 프로필 슬롯 배타 선점(프로세스 여러 개 대응)
│ │ └── card_parser.py # 오픈마켓 공용 카드 파서 │ │ └── card_parser.py # 오픈마켓 공용 카드 파서
│ ├── pipeline/ # 필터·이상치·최저가 정렬(+몰별 분해) │ ├── pipeline/ # 필터·이상치·최저가 정렬(+몰별 분해)
│ ├── ai/ # AI 유사도 판정·검색어 생성 (OpenAI) │ ├── ai/ # AI 유사도 판정·검색어 생성 (OpenAI)