From 6ddb2c450464c3a6bf23b4a60b3a4f93d9e6de37 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EB=AF=BC=ED=97=8C?= Date: Fri, 7 Aug 2026 11:25:22 +0900 Subject: [PATCH] =?UTF-8?q?refactor(postgres-init):=20LPS=202026-08=20?= =?UTF-8?q?=EB=A7=88=EC=9D=B4=EA=B7=B8=EB=A0=88=EC=9D=B4=EC=85=98=206=5F?= =?UTF-8?q?=C2=B77=5F=20=ED=86=B5=ED=95=A9=20+=20=EC=A3=BC=EC=84=9D=20?= =?UTF-8?q?=EC=8A=A4=ED=83=80=EC=9D=BC=20=EC=A0=95=EB=A6=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 같은 DB(lps_db)의 같은 시기 추가분이 두 파일로 갈려 있어 순서·누락을 신경 써야 했다. 6_ 하나로 합친다(7_ 삭제) — 실행이 한 번이면 '어디까지 돌렸더라'를 기억할 일이 없다. - 주석을 3_lps_dbeaver.sql 스타일로 통일: 객체 위 한 줄 설명 + 컬럼 인라인 주석 정렬. 기존 6_·7_ 의 긴 배경 산문은 걷어냈다 — 배경은 lps/docs/result-states.md 가 소스고, 이 파일은 '무엇을 만드는가'만 답하면 된다. - 내용은 그대로: proxy_port(+LRU 인덱스), price_history 신뢰 신호 2·배송 3, 몰별 확인 상태 sources/partial(+부분 인덱스), 적용 확인 SELECT. 검증: 기존 lps_db 재실행(멱등 — NOTICE 만) + 빈 DB 에 3_ → 6_ 신규 설치 후 price_history 신규 7컬럼·테이블 6종 전부 확인. 테스트 DB 는 정리. Co-Authored-By: Claude Opus 5 --- lps/docs/result-states.md | 2 +- .../dbeaver/6_lps_2026-08_dbeaver.sql | 70 +++++++++---------- .../dbeaver/7_lps_source_state_dbeaver.sql | 32 --------- 3 files changed, 34 insertions(+), 70 deletions(-) delete mode 100644 postgres-init/dbeaver/7_lps_source_state_dbeaver.sql diff --git a/lps/docs/result-states.md b/lps/docs/result-states.md index 3cd8069..3dc3e10 100644 --- a/lps/docs/result-states.md +++ b/lps/docs/result-states.md @@ -182,7 +182,7 @@ per_source[src] = {"error": f"{type(res).__name__}: {res}"} # ← blocked/fata 둔다: 소비자가 '어떤 상태가 확인된 것인가'라는 **판단 규칙까지 알아야 하면 상태 정의가 두 곳으로 흩어진다**. 판단은 LPS 가 끝내고 소비자는 사실 하나만 읽는다. - 부분 인덱스 `ix_price_history_partial` — '확인 못한 결과'만 뽑는 운영 점검용(작게 유지된다). -- 마이그레이션: `postgres-init/dbeaver/7_lps_source_state_dbeaver.sql` (**운영 적용 필요**) +- 마이그레이션: `postgres-init/dbeaver/6_lps_2026-08_dbeaver.sql` (2026-08 추가분 통합, **운영 적용 필요**) 검증(실 DB): 쿠팡 차단과 쿠팡 0건은 `by_mall` 이 둘 다 `['naver']` 로 같지만 `partial`(true/false)과 `sources.coupang.state`(blocked/empty)가 두 경우를 갈라낸다. 테스트 3건 추가. diff --git a/postgres-init/dbeaver/6_lps_2026-08_dbeaver.sql b/postgres-init/dbeaver/6_lps_2026-08_dbeaver.sql index 20ead13..aa15c95 100644 --- a/postgres-init/dbeaver/6_lps_2026-08_dbeaver.sql +++ b/postgres-init/dbeaver/6_lps_2026-08_dbeaver.sql @@ -1,49 +1,45 @@ --- LPS 2026-08 스키마 추가분 — **lps_db 에 연결해서 실행**. 재실행 안전(IF NOT EXISTS). --- --- 3_lps_dbeaver.sql 이후에 늘어난 것들이다. 이미 만들어진 DB(dev·운영)는 모델이 바뀌어도 --- 자동으로 따라오지 않으므로 이 파일로 맞춘다. 신규 설치는 3 → 6 순서로 실행하면 된다. --- --- ⚠️ 안 돌리면 워커가 기동 중 죽는다 — proxy_port 를 기동 시 반드시 만지기 때문이다 --- (실측 2026-08-06 운영: UndefinedTableError: relation "proxy_port" does not exist → 크래시 루프). +-- LPS 2026-08 추가분 — lps_db 에 연결해서 실행. 재실행 안전(IF NOT EXISTS). +-- 3_lps_dbeaver.sql 이후 늘어난 스키마. 신규 설치는 3 → 6 순서, 기존 DB(dev·운영)는 이 파일만. +-- 안 돌리면 워커가 기동 중 죽는다(proxy_port 를 기동 시 반드시 만진다). --- ── 1) 프록시 포트(=IP 세션) 임대 장부 ──────────────────────────────────────── --- 한 DECODO 계정을 여러 워커 **프로세스**가 나눠 쓴다. 인메모리로 관리하면 서로의 임대·차단을 --- 몰라 같은 IP 를 동시에 잡거나(요청이 몰려 그 IP 가 빨리 탄다) 방금 태운 IP 를 곧바로 재사용한다. --- 그래서 잡 큐와 같은 방식(FOR UPDATE SKIP LOCKED)으로 DB 에서 배타 임대한다. +-- 프록시 포트(=IP 세션) 임대 장부 — 한 DECODO 계정을 여러 워커 프로세스가 나눠 쓴다. +-- 인메모리면 서로의 임대·차단을 몰라 같은 IP 를 동시에 잡거나 태운 IP 를 곧바로 재사용한다. CREATE TABLE IF NOT EXISTS proxy_port ( - host VARCHAR(80) NOT NULL, -- 게이트웨이(gate/kr — 같은 번호라도 IP 가 다름) - port INTEGER NOT NULL, - owner VARCHAR(80) NULL, -- 현재 임대자(소스-PID-워커) - leased_until TIMESTAMPTZ NULL, -- 임대 만료(=sticky 수명). 프로세스가 죽어도 자동 회수 - rest_until TIMESTAMPTZ NULL, -- 휴식 만료(예산 선제 회전 — 탄 게 아님) - cooldown_until TIMESTAMPTZ NULL, -- 쿨다운 만료(차단 — 전역 격리) - last_used_at TIMESTAMPTZ NULL, -- 마지막 임대 시각(LRU 회전 기준) - last_reason VARCHAR(40) NULL, -- acquire/release/rest/block - use_count INTEGER NOT NULL DEFAULT 0, - burn_count INTEGER NOT NULL DEFAULT 0, -- 누적 차단(불량 IP 슬롯 식별) - updated_at TIMESTAMPTZ NOT NULL DEFAULT now(), + host VARCHAR(80) NOT NULL, -- 게이트웨이(같은 번호라도 다르면 다른 IP) + port INTEGER NOT NULL, + owner VARCHAR(80) NULL, -- 현재 임대자(소스-PID-워커) + leased_until TIMESTAMPTZ NULL, -- 임대 만료(=sticky 수명). 죽어도 자동 회수 + rest_until TIMESTAMPTZ NULL, -- 휴식 만료(예산 선제 회전 — 탄 게 아님) + cooldown_until TIMESTAMPTZ NULL, -- 쿨다운 만료(차단 — 전역 격리) + last_used_at TIMESTAMPTZ NULL, -- LRU 회전 기준 + last_reason VARCHAR(40) NULL, -- acquire/release/rest/block + use_count INTEGER NOT NULL DEFAULT 0, + burn_count INTEGER NOT NULL DEFAULT 0, -- 누적 차단(불량 IP 슬롯 식별) + updated_at TIMESTAMPTZ NOT NULL DEFAULT now(), PRIMARY KEY (host, port) ); --- LRU 배정용 — '가장 오래 안 쓴 IP' 를 게이트웨이별로 뽑는다 CREATE INDEX IF NOT EXISTS ix_proxy_port_pick ON proxy_port (host, last_used_at); --- ── 2) price_history — 최저가 오퍼의 신뢰 신호 ──────────────────────────────── --- 최저가는 '가장 싼 값'이 아니라 '실제로 살 수 있는 가장 싼 값'이어야 한다. 리뷰·평점이 전혀 없는 --- 오퍼는 재고 없는 미끼가격일 수 있고, 그걸 최저가로 보고하면 사용자는 그 가격에 살 수 없다. +-- 최저가 오퍼의 신뢰 신호 — 리뷰·평점 없는 오퍼는 재고 없는 미끼가격일 수 있다. -- NULL(정보 없음)과 0(리뷰 0개)은 뜻이 다르므로 기본값을 두지 않는다. -ALTER TABLE price_history ADD COLUMN IF NOT EXISTS final_rating NUMERIC(3,2); -ALTER TABLE price_history ADD COLUMN IF NOT EXISTS final_review_count INTEGER; +ALTER TABLE price_history ADD COLUMN IF NOT EXISTS final_rating NUMERIC(3,2); -- 평점(5점 만점) +ALTER TABLE price_history ADD COLUMN IF NOT EXISTS final_review_count INTEGER; -- 리뷰 수 --- ── 3) price_history — 최저가 오퍼의 배송 정보 ──────────────────────────────── --- 순위는 상품가로 매긴다(배송 주체가 로켓/판매자로켓/네이버 판매자로 갈리면 배송비 숫자만으로는 --- 비교가 무의미하다). 그래도 기록은 남겨야 나중에 '배송비를 더하면 순위가 뒤집히는 비율'을 --- 데이터로 판단할 수 있다. fee 는 0=무료 / NULL=미확인(로켓처럼 조건부 무료라 금액 표기가 없는 경우). -ALTER TABLE price_history ADD COLUMN IF NOT EXISTS final_shipping_fee INTEGER; -ALTER TABLE price_history ADD COLUMN IF NOT EXISTS final_shipping_type VARCHAR(20); -ALTER TABLE price_history ADD COLUMN IF NOT EXISTS final_shipping_label VARCHAR(120); +-- 최저가 오퍼의 배송 정보 — 순위는 상품가로 매기지만(배송 주체가 다르면 금액 비교가 무의미) +-- 기록은 남긴다. 나중에 '배송비를 더하면 순위가 뒤집히나'를 데이터로 물을 수 있어야 한다. +ALTER TABLE price_history ADD COLUMN IF NOT EXISTS final_shipping_fee INTEGER; -- 0=무료, NULL=미확인(조건부) +ALTER TABLE price_history ADD COLUMN IF NOT EXISTS final_shipping_type VARCHAR(20); -- free/paid/rocket/rocket_merchant +ALTER TABLE price_history ADD COLUMN IF NOT EXISTS final_shipping_label VARCHAR(120); -- 화면 문구 원문 --- ── 검증 ────────────────────────────────────────────────────────────────────── --- 워커가 기동 시 요구하는 테이블이 다 있는지 확인한다(전부 OK 여야 정상 기동). +-- 몰별 확인 상태 — by_mall 은 **가격이 있는 몰만** 담아, 빠진 몰이 '거기엔 없더라'인지 +-- '거기를 못 봤다'인지 알 수 없었다. 안 본 걸 없다고 말하지 않으려면 이 값이 필요하다. +-- {"naver": {"state": "matched", "count": 40}, "coupang": {"state": "blocked", "error": "..."}} +-- state: matched/no_match/empty/blocked/env_blocked/unavailable/skipped (lps/docs/result-states.md) +ALTER TABLE price_history ADD COLUMN IF NOT EXISTS sources JSONB; +ALTER TABLE price_history ADD COLUMN IF NOT EXISTS partial BOOLEAN NOT NULL DEFAULT FALSE; -- 못 본 몰이 있어 최종이 아님 +CREATE INDEX IF NOT EXISTS ix_price_history_partial ON price_history (triggered_at) WHERE partial; + +-- 적용 확인 — 워커가 기동 시 요구하는 테이블 6종이 전부 OK 여야 정상 기동한다. SELECT 'proxy_port' AS relation, CASE WHEN to_regclass('public.proxy_port') IS NULL THEN 'MISSING' ELSE 'OK' END AS status UNION ALL SELECT 'ip_session', CASE WHEN to_regclass('public.ip_session') IS NULL THEN 'MISSING' ELSE 'OK' END UNION ALL SELECT 'price_history', CASE WHEN to_regclass('public.price_history') IS NULL THEN 'MISSING' ELSE 'OK' END diff --git a/postgres-init/dbeaver/7_lps_source_state_dbeaver.sql b/postgres-init/dbeaver/7_lps_source_state_dbeaver.sql deleted file mode 100644 index a6ed45f..0000000 --- a/postgres-init/dbeaver/7_lps_source_state_dbeaver.sql +++ /dev/null @@ -1,32 +0,0 @@ --- LPS 몰별 확인 상태 — **lps_db 에 연결해서 실행**. 재실행 안전(IF NOT EXISTS). --- --- 왜 필요한가: '그 몰에 더 싼 게 없었다'와 '그 몰이 막혀서 못 봤다'가 지금 화면에서 똑같이 --- '–' 로 보인다. 사용자는 앞쪽으로 읽지만 실제로는 뒤쪽일 수 있다 — 안 본 걸 없다고 말하는 셈이다. --- 크롤러는 그 차이를 이미 알고 있는데(SourceState), 담을 자리가 없어 화면까지 못 갔다. --- --- 상태 정의와 표기 규칙은 lps/docs/result-states.md 가 소스다. - --- 몰별 확인 상태 — {"naver": {"state": "matched", "count": 40}, --- "coupang": {"state": "blocked", "error": "AdapterError: ..."}} --- 열린 스키마(JSONB)로 둔다: 몰이 늘거나 상태에 근거를 덧붙여도 마이그레이션이 필요 없다. --- state 값: matched / no_match / empty / blocked / env_blocked / unavailable / skipped -ALTER TABLE price_history ADD COLUMN IF NOT EXISTS sources JSONB; - --- 결과가 **완전한가**. true = 못 본 몰이 있어 이 값이 최종이 아니다. --- sources 에서 유도할 수 있지만 굳이 컬럼으로 둔다 — 소비자(negodata)가 '어떤 상태가 확인된 --- 것인가'라는 판단 규칙까지 알아야 하면 상태 정의가 두 곳으로 흩어진다. 판단은 LPS 가 하고, --- 소비자는 사실 하나만 읽게 한다. -ALTER TABLE price_history ADD COLUMN IF NOT EXISTS partial BOOLEAN NOT NULL DEFAULT FALSE; - --- '확인 못한 결과'만 빠르게 뽑기 위한 부분 인덱스(운영 점검·알림용). --- 전체가 아니라 partial=true 행만 담아 인덱스가 작게 유지된다. -CREATE INDEX IF NOT EXISTS ix_price_history_partial - ON price_history (triggered_at) WHERE partial; - --- ── 검증 ────────────────────────────────────────────────────────────────────── -SELECT column_name, data_type, - CASE WHEN column_name IN ('sources','partial') THEN '이번 추가' ELSE '' END AS note - FROM information_schema.columns - WHERE table_name = 'price_history' - AND column_name IN ('outcome','by_mall','sources','partial') - ORDER BY column_name;