o2o-negosium-original/lps/docs/database.md
민헌 3c5286aeec feat(lps): IP 선제 로테이션 — 요청 예산·포트 쿨다운·ip_session 관측
쿠팡 크롤 IP 를 '막힐 때까지' 쓰던 방식을 '막히기 전에 교체'로 전환한다.

- 요청 예산(LPS_IP_REQUEST_BUDGET, 기본 3): IP당 요청 수가 예산에 닿으면
  차단 전에 선제 회전. 실측상 5회 부근 차단 이력이 있어 보수적으로 3회.
  선제 교체된 포트는 평판이 깨끗해 로테이션 복귀 시 재사용된다.
- 포트 쿨다운(LPS_PORT_COOLDOWN_SEC, 기본 max(sticky,30분)): 차단 감지·
  전송오류 포트는 격리하고 _port() 가 건너뛴다. 전 포트 쿨다운이면 만료
  임박 포트 사용(가용성 우선). 포트 수는 config 범위에서 동적 산출.
- 차단 재시도 소진 시에도 회전 예약 — 불탄 포트로 다음 검색을 하지 않음.
- ip_session 테이블 신설: 세션마다 요청 수·성공/차단·종료 사유(budget/
  block/proxy_error/window/idle/shutdown)를 기록. bot_detection 과 달리
  무사 종료도 남아 예산 상한 튜닝의 원천 데이터가 된다(쿼리 database.md).
  models.py·migrations·init.sql(lps_db 섹션) 동행 갱신, dev DB 적용 완료.
- 테스트 17건 추가(쿨다운·예산 판정·세션 기록·CRUD), 전체 126 passed.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-13 16:35:44 +09:00

5.8 KiB

데이터베이스 구조

← README로

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

테이블 4종 한눈에

테이블 용도
job 작업 큐 — 검색 요청을 순서대로 보관·처리
price_history 최저가 이력 — 그래프용 시계열 스냅샷
search_negative 네거티브 캐시 — "없음"으로 확인된 상품을 일정 시간 기억
bot_detection 봇 감지 이력 — 쿠팡이 차단한 패턴 기록
ip_session 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(그래프 선이 빈다 — 정상).


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차단된 세션만 남지만, 여기엔 무사 종료(예산 선제 회전·시간창 만료 등)도 남아 요청 예산(LPS_IP_REQUEST_BUDGET) 상한 튜닝의 원천 데이터가 됩니다.

컬럼
source 소스(coupang 등)
proxy_port 사용 포트(=IP 세션). 프록시 미사용이면 NULL
requests 이 IP로 보낸 요청 수
ok_count / blocked_count 성공 검색 수 / 차단 감지 수
elapsed_sec 세션 지속 시간(초)
end_reason 종료 사유 — budget(예산 선제) / block(차단) / proxy_error(포트 사망) / window(시간창 만료) / idle(유휴 정리) / shutdown(종료)
created_at 세션 종료 시각

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

-- 종료 사유별 분포(최근 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;

스키마 생성/관리

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