o2o-negosium-original/lps/docs/architecture.md
민헌 5e485e9b6e feat(lps): 오픈마켓 폴백 기본 비활성(LPS_FALLBACKS 토글) — 협의 결정 B안 적용
2026-07-10 협의: 검색은 네이버+쿠팡만. G마켓·옥션·11번가 폴백은 최종 최저가
기여 0회에 검색당 최대 15s·프록시 비용 ~87%를 차지해 로직에서 제외.
주석처리 대신 env 토글로 코드·테스트는 살려둔다(부패 방지·env 한 줄 재가동).

- worker_main: LPS_FALLBACKS(기본 빈값=OFF)로만 폴백 어댑터 생성, 잘못된 값 경고,
  기동 로그에 폴백 상태 표기. 핸들러는 빈 폴백을 원래 정상 처리라 로직 변경 없음
- run_local_worker.sh: 폴백 여부 대화형 질문 추가(기본 비활성)
- compose: LPS_FALLBACKS 주석 env(재가동용)
- decision-openmarket-crawler.md: 결정(B)·근거·재가동 절차(라이브 스모크 선행) 확정 기록
- README·architecture·operations: 기본 비활성 반영

검증: 전체 테스트 106 passed(폴백 로직 테스트는 fake 주입이라 계속 유효),
워커 실기동 로그 '오픈마켓 폴백: OFF' 확인

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-10 10:14:39 +09:00

9.6 KiB
Raw Blame History

아키텍처 — 어떻게 동작하는가

← README로

1. 구성요소 (한눈에)

구성요소 파일 역할
API 서버 web_main.py 검색 요청을 받아 큐에 적재만 함(빠르게 응답). 상태·이력 조회 제공
큐(대기줄) crud/job_crud.py + job 테이블 할 일을 순서대로 안전하게 보관 (PostgreSQL 사용)
워커(일꾼) worker_main.py, worker/ 큐에서 하나씩 꺼내 실제 검색·판정·저장 수행
소스 어댑터 services/search/ 네이버·쿠팡에서 상품 수집 (소스별 방식 캡슐화)
오픈마켓 폴백 services/search/{esm,st11}/ G마켓·옥션·11번가 크롤 — 네이버가 그 몰을 커버 못 했을 때만 (BrowserSearchAdapter 공유). 기본 비활성(LPS_FALLBACKS)
파이프라인 services/pipeline/ 수집 결과를 필터·이상치 제거·최저가 정렬
AI services/ai/ "같은 상품" 판정 + 검색어 생성 (OpenAI)

API와 워커를 분리한 이유: 요청 접수는 즉시(가벼움), 실제 검색은 무거움(브라우저·AI). 분리하면 요청이 밀리지 않고, 워커만 따로 늘릴 수 있습니다.

2. 처리 파이프라인 (워커가 하는 일)

한 건의 검색 작업은 아래 단계를 거칩니다. 각 단계의 통과 건수는 stages로 기록됩니다(관측).

① 네거티브 캐시 확인
   최근 "없음"으로 확인된 상품이면 → 재검색 생략(비용 절약)

② 소스 검색 (재정제 루프, 최대 3라운드)
   라운드1: 원본 검색어  →  라운드2: AI 정밀 검색어  →  라운드3: 광역 검색어
   각 라운드에서 네이버 ∥ 쿠팡 동시 검색 후 병합

③ 필터
   - mall 필터 (필요 시 특정 쇼핑몰만/제외)
   - 가격 밴드 (요청에 현재가가 있으면 ±범위 밖 제거)

④ 이상치 제거 (IQR)
   비정상적으로 싸거나 비싼 항목 제거 (오매칭·묶음 등)

⑤ AI 같은 상품 판정
   후보 중 "찾는 상품과 동일한 것"만 선별 (액세서리·다른 규격 제외)
   → 매칭 있으면: ⑤-1 오픈마켓 폴백 → 최저가순 정렬 → 상위 N개 반환 (found)
   → 매칭 0건 + 소스 정상: 다음 라운드로
   → 매칭 0건 + 소스 차단: 작업 실패 처리(뒤에서 재시도)

⑤-1 오픈마켓 폴백 (매칭 성공 시 · **기본 비활성 — LPS_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/터널) 도 사이트 차단과 구분해 회전.
  • 시작 프리플라이트 + 웜업: 기동 시 살아있는 프록시 포트를 선점(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(처리량 우선). env DB_CONNECTION_BUDGET. 더 큰 처리량은 예산↑ + PG max_connections↑ 또는 pgbouncer.
  • 상세·벤치 결과: ../loadtest/README.md.

7. 최저가 이력 (그래프)

  • 트리거 기반: 자동 배치 없이 실제 조회된 상품만 그 시점에 기록 → 트래픽·비용 절약.
  • 검색할 때마다 네이버/쿠팡/최종 최저가 + 몰별 스냅샷(by_mall) 을 남깁니다.
  • 그래프: X축 = 조회 시각(불규칙), Y축 = 가격, 3개 선(+몰별). (한쪽 소스에 없던 시점은 선이 비어있음 — 정상)

8. 설계 원칙 (참고)

  • PostgreSQL을 큐로 제대로 사용: 별도 브로커 없이 원자적 할당 + 자동 복구로 유실·중복 없이 처리.
  • 소스 어댑터 패턴: 소스별 수집·안티봇 차이를 어댑터 안에 가두고, 코어는 정규화된 결과만 다룸 → 새 쇼핑몰 추가가 쉬움.
  • AI로 매칭: 상품명 형식이 제각각이라 규칙 고정 파싱 대신 AI가 "같은 상품인지" 판단.
  • 오픈마켓은 네이버 폴백: 네이버가 이미 커버하는 몰은 재크롤하지 않고, 못 덮은 몰만 크롤(비용 절약).

더 깊은 내부 구현은 각 파일 상단 주석에 정리되어 있습니다.