o2o-negosium-original/lps/docs/database.md
민헌 377389f495 fix(lps): IP 로테이션 안정성 검수 — 예산이 안 먹던 근본 원인 + 차단 시 풀 소각 차단
크롤·IP 로테이션을 검수하며 찾은 결함을 순서대로 고쳤다. 의심 지점은 모두 실제 코드 경로로
재현해 확인했다(브라우저·네트워크만 mock, 프록시·DB 장부는 실물).

**① 요청 예산이 사실상 발화하지 않았다 (핵심)**
유휴 정리(close_if_idle, 120s)는 브라우저만 닫고 임대는 두는데, 재기동 때마다 _ip_requests 를
0 으로 되돌렸다. 게다가 ensure_port 의 renew 가 임대 만료를 계속 뒤로 민다 — 검색이 유휴
임계보다 뜸하고 임대(10분)보다 잦으면 **한 IP 에 영원히 고정**된다(실측: 6회 검색이 전부 같은
포트·ip_req#1). 연속 검색에서는 정상 동작해 부하 테스트로는 안 잡히고, 수동 트리거처럼
드문드문한 실사용 패턴에서만 깨진다.
파급이 하나 더 있다 — bot_detection.ip_request_no 가 항상 1 로 찍혀, operations.md 가 명시한
'1 위주면 IP 평판 / 2 이상이면 예산 하향' 진단이 통째로 무너진다. 과거 "전량 ip_req#1 이라
IP 평판 문제" 결론은 이 착시일 수 있다(문서에 경고 추가).
→ IP 세션 상태를 브라우저 수명과 분리. **포트가 실제로 바뀔 때만** 리셋한다(_begin_ip_session).
   세션 종료 기록도 포트 변경·최종 close 시점으로 옮겼다(idle 사유 소멸).

**② 환경 차단이면 회복 못 하는데 풀을 계속 태웠다**
쿠팡은 fatal 마커가 없어 컨테이너 차단 같은 '회전 무효' 상황을 구분 못 했다. 실측으로
웜업 6포트 + 잡 1건당 6포트를 30분 쿨다운에 묶어 **잡 16건이면 100포트 고갈**. 실제 장부에도
9분간 11포트 연속 소각 이력이 남아 있다(gate 사용 21 / 소각 14).
→ 서킷브레이커: **서로 다른 IP 가 연속 3개 모두 첫 요청부터** 막히면 IP 문제가 아니라고 판정,
   태우기를 멈추고 fatal 로 알린다(env_block 마커 → 기존 fatal_block 알림이 집계).
   같은 IP 반복 차단·뒤쪽 요청 차단은 세지 않는다. 성공 1회로 자동 해제(타이머 불필요).
   결과: 전면 차단 시 소각이 판정 근거 2개에서 멈춘다(웜업 6→0, 잡 6→0).

**③ 종료가 임대를 반납하지 않았다**
close() 후에도 leased_until(최대 10분)까지 그 IP 를 아무도 못 썼다 — 재시작이 잦을수록 가용
풀이 줄었다. DecodoProxy.release() 추가, close() 에서만 호출(유휴 정리는 웜 쿠키·예산 유지를
위해 그대로 둔다).

**④ 시간창 재기동이 IP 를 안 바꿨다** — 로그만 'IP 회전'이었고 renew 로 같은 포트를 붙잡았다.
sticky 수명이 끝나면 같은 포트라도 IP 가 바뀌므로 명시적으로 놓아준다.

**⑤ '검색결과 없음'을 차단으로 오인해 IP 를 태울 수 있었다**
네이버 무결과 페이지 크기는 실측된 적이 없는데 short_html 폴백이 이를 차단으로 본다.
확신도로 대응을 갈랐다 — 알려진 마커만 태우고/서킷브레이커에 세고, 미지의 짧은 HTML 은
회전·재시도까지만. 판단 근거는 bot_detection 에 계속 쌓이므로 나중에 임계를 실측할 수 있다.

**⑥** available_ports() 가 장부 모드에서 늘 최대값을 반환하는 점을 문서화(관측 경로는 미사용).
세션 마감을 멱등하게 만들어 close() 중복 호출 시 이중 기록 방지.

테스트 14건 추가(전체 251 passed). mock 하니스도 실물을 타도록 고쳤다 — 회전 시 포트가 실제로
바뀌고, 재기동 판단·IP 세션 경계는 실제 코드를 그대로 쓴다(고정 포트 mock 은 이 버그를 못 봤다).

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-05 17:09:01 +09:00

8.9 KiB

데이터베이스 구조

← README로

  • DB 이름: lps_db (PostgreSQL, negosium_db와 별개)
  • 테이블 정의: common/database/model/models.py (SQLAlchemy) — 이 파일이 스키마의 단일 출처
  • 공통 규칙: 외래키(FK) 안 씀(무결성은 앱에서) · 코드값은 정수(SMALLINT) · 시각은 전부 TIMESTAMPTZ(UTC)

테이블 6종 한눈에

테이블 용도
job 작업 큐 — 검색 요청을 순서대로 보관·처리
price_history 최저가 이력 — 그래프용 시계열 스냅샷
search_negative 네거티브 캐시 — "없음"으로 확인된 상품을 일정 시간 기억
bot_detection 봇 감지 이력 — 쿠팡이 차단한 패턴 기록
ip_session IP 세션 종료 이력 — 요청 예산(선제 회전) 상한 튜닝 데이터
proxy_port 프록시 포트(IP 세션) 임대 장부 — 프로세스 간 공유 상태

1. job — 작업 큐

컬럼 뜻
job_id 작업 고유 ID (요청 시 반환되는 접수번호)
job_type 작업 종류 (1=검색, 2=외부전송)
status 상태 (아래 코드표)
priority 우선순위(낮을수록 먼저)
payload 요청 내용(상품 정보) JSON
result 처리 결과 JSON (최저가·단계·소스별 건수 등)
attempts / max_attempts 시도 횟수 / 최대
run_after 이 시각 이후 실행(재시도 대기용)
lease_until / worker_id 점유 만료 시각 / 처리 중인 워커 (죽으면 자동 회수)
last_error 마지막 오류 메시지
created_at / updated_at 생성/수정 시각

status 코드값 (JobStatus)

값 이름 뜻
1 PENDING 대기 중
2 RUNNING 처리 중
3 DONE 완료 (found/not_found 모두 포함)
4 DEAD 재시도 소진 실패 (사람 확인 필요)

job_type 코드값 (JobType): 1=SEARCH(검색), 2=OUTBOX(외부전송)


2. price_history — 최저가 이력 (그래프)

검색할 때마다 1행씩 쌓입니다. 특정 상품의 시계열을 뽑아 그래프로 그립니다.

컬럼 뜻
product_code 상품 식별 키(요청의 product_code)
triggered_at 검색 실행 시각 (그래프 X축)
outcome found / not_found
matched_count AI가 "같은 상품"으로 판정한 개수
naver_lowest / naver_name / naver_url 네이버 최저가 + 상품명/링크
coupang_lowest / coupang_name / coupang_url 쿠팡 최저가 + 상품명/링크
final_lowest 전체 최저가 (그래프 Y축 핵심)
final_source 최종 최저가가 나온 소스(naver/coupang/gmarket/auction/st11)
by_mall 몰별 최저가 스냅샷(JSONB, 열린 스키마) — [{mall, source, price, shipping_fee, shipping_type, url}, …]. G마켓·옥션·11번가 등이 늘어도 컬럼 추가 없이 담는다
job_id / created_at 검색 잡 연결 / 생성 시각

한쪽 소스에 그 상품이 없던 시점은 해당 컬럼이 null(그래프 선이 빈다 — 정상).


최저가 오퍼의 품질 정보 (2026-08-05 추가)

가격만으로는 '실제로 살 수 있는 값인지' 알 수 없어, 최종 최저가 오퍼의 근거를 함께 남긴다.

컬럼 뜻
final_rating / final_review_count 평점·리뷰 수. 둘 다 NULL 이면 미검증 오퍼(재고 없는 미끼가격일 수 있음). NULL(정보 없음)과 0(리뷰 0개)은 다른 뜻이라 기본값 없음
final_shipping_fee 0=무료, NULL=미확인(로켓처럼 조건부 무료라 화면에 금액이 없음)
final_shipping_type free / paid / rocket / rocket_merchant
final_shipping_label 화면 문구 원문(예: 내일(목) 도착 보장 · 와우는 무료배송 ∙ 무료반품 ∙ 새벽도착)

순위는 상품가 기준이다. 배송 주체가 다르면(쿠팡 로켓 / 판매자로켓 / 네이버 판매자) 배송비 비교가 무의미해서다 — 기록만 남겨 "배송비를 더하면 순위가 뒤집히는 비율"을 나중에 판단한다.

-- 리뷰·평점 없는 오퍼가 최저가로 잡힌 비율(유령상품 노출도)
SELECT count(*) FILTER (WHERE final_review_count IS NULL) * 100.0 / count(*) AS 미검증_퍼센트
  FROM price_history WHERE outcome = 'found';

3. search_negative — 네거티브 캐시

"검색해도 없더라"를 일정 시간(기본 24h) 기억해 재검색 낭비를 막습니다.

컬럼 뜻
key 상품 식별 키(보통 product_code)
until 이 시각까지 "없음"으로 간주 (지나면 다시 검색 허용)
reason 사유 메모
created_at 생성 시각

4. bot_detection — 봇 감지 이력

쿠팡이 차단(봇 감지)했을 때 기록. "어떤 IP로 몇 번째 요청에서 걸리나"를 분석합니다.

컬럼 뜻
source 소스(coupang)
query 감지 당시 검색어
ip_request_no 현재 IP(브라우저)로 몇 번째 요청이었나
proxy_port 사용 중이던 프록시 포트(=IP 세션)
elapsed_sec 브라우저 실행 후 경과(초)
marker 감지 근거(차단 페이지 마커)
headless / html_len 헤드리스 여부 / 응답 크기
created_at 감지 시각

분석 예시

-- IP당 평균 몇 요청 만에 감지되는지
SELECT avg(ip_request_no), count(*) FROM bot_detection;

5. ip_session — IP(프록시 포트) 세션 종료 이력

브라우저(=IP 세션)가 끝날 때마다 기록. bot_detection은 차단된 세션만 남지만, 여기엔 무사 종료(예산 선제 회전·시간창 만료 등)도 남아 요청 예산([DecodoConfig].ip_request_budget) 상한 튜닝의 원천 데이터가 됩니다.

컬럼 뜻
source 소스(coupang 등)
proxy_port 사용 포트(=IP 세션). 프록시 미사용이면 NULL
requests 이 IP로 보낸 요청 수
ok_count / blocked_count 성공 검색 수 / 차단 감지 수
elapsed_sec IP 세션 지속 시간(초) — 브라우저 수명이 아니라 그 IP 를 쥔 총 시간
end_reason 종료 사유 — budget(예산 선제) / block(차단) / proxy_error(포트 사망) / window(sticky 수명 만료) / shutdown(종료)
created_at 세션 종료 시각

IP 세션 ≠ 브라우저 수명 (2026-08-05 변경). 유휴 정리(120s)는 브라우저만 닫고 같은 IP 로 돌아오므로 세션이 끝나지 않는다 — 그래서 idle 사유는 더 이상 기록되지 않는다. 예전엔 재기동마다 카운터가 0 으로 리셋돼 한 IP 를 계속 쓰면서 requests 가 항상 1 로 남았다(예산이 영영 발화하지 않던 원인). 아래 튜닝 쿼리는 그 시점 이전 데이터엔 쓸 수 없다.

예산 튜닝 쿼리 — 차단이 나기 시작하는 요청 수 분포를 보고 상한을 조정:

-- 종료 사유별 분포(최근 7일): budget 이 대다수 + block 0 이면 예산을 1씩 올려볼 수 있고,
-- block 이 보이면 그 세션들의 requests 최솟값보다 예산을 낮게 유지한다.
SELECT end_reason, count(*), avg(requests)::numeric(5,1) AS avg_req, min(requests), max(requests)
  FROM ip_session WHERE created_at > now() - interval '7 days'
 GROUP BY end_reason ORDER BY count(*) DESC;

6. proxy_port — 프록시 포트(IP 세션) 임대 장부

한 DECODO 계정을 여러 워커 프로세스가 나눠 쓰므로 임대 상태를 DB 에 둔다(인메모리면 서로의 임대·차단을 몰라 같은 IP 를 동시에 잡거나 태운 IP 를 곧바로 재사용한다).

컬럼 뜻
host, port PK. 게이트웨이 + 포트 = sticky IP 세션 1개 (같은 번호라도 게이트웨이가 다르면 다른 IP)
owner 현재 임대자(소스-PID-워커)
leased_until 임대 만료(=sticky 수명). 프로세스가 죽어도 이 시각이 지나면 자동 회수
rest_until 휴식(선제 회전) 만료 — 탄 게 아니라 쉬는 것, 소진 시 가장 먼저 회수
cooldown_until 쿨다운(차단) 만료
last_used_at LRU 회전 기준 — 가장 오래 안 쓴 포트부터 배정
use_count, burn_count 누적 임대·차단(상습 불량 IP 슬롯 식별)
-- 게이트웨이별 현황(고갈 점검)
SELECT host,
       count(*) FILTER (WHERE leased_until   > now()) AS 임대,
       count(*) FILTER (WHERE rest_until     > now()) AS 휴식,
       count(*) FILTER (WHERE cooldown_until > now()) AS 쿨다운,
       sum(burn_count) AS 누적차단
  FROM proxy_port GROUP BY host;

스키마 생성/관리

  • 개발·테스트: SQLAlchemy 모델에서 create_all로 자동 생성.
  • DB 접속(로컬): psql -h 127.0.0.1 -U postgres -d lps_db (자세한 쿼리는 운영 가이드).