From 495e16b95134b836fefc139a55afe6e8435b509b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EB=AF=BC=ED=97=8C?= Date: Wed, 5 Aug 2026 11:16:37 +0900 Subject: [PATCH] =?UTF-8?q?feat(lps):=20IP=20=EB=A5=BC=20=ED=83=9C?= =?UTF-8?q?=EC=9A=B0=EC=A7=80=20=EB=A7=90=EA=B3=A0=20=ED=95=9C=EA=B3=84=20?= =?UTF-8?q?=EC=A7=81=EC=A0=84=EA=B9=8C=EC=A7=80=20=EC=93=B0=EA=B3=A0=20?= =?UTF-8?q?=EC=89=AC=EA=B2=8C=20=E2=80=94=20=EB=84=A4=EC=9D=B4=EB=B2=84=20?= =?UTF-8?q?=EC=98=88=EC=82=B0=20=EC=83=81=ED=96=A5=20+=20=ED=9C=B4?= =?UTF-8?q?=EC=8B=9D=20=EA=B0=9C=EB=85=90?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 기존엔 네이버가 쿠팡 기준 예산(3회)을 그대로 썼다. 실측하니 체급이 다르다: 같은 KR IP 로 **12회 연속 검색까지 무차단**(IP 4개 전부 한계 미도달). 3회로 돌리면 불필요하게 4배 자주 회전해 KR 풀만 빨리 소모하고 회전마다 브라우저 재기동(~20s)이 붙는다. → [DecodoConfig].naver_ip_request_budget = 10 (실측 12 에 여유). 쿠팡은 3 유지. 그리고 선제 회전에 빠져 있던 조각을 채웠다 — **휴식(rest)**: 예산 도달로 놓은 포트를 곧바로 다른 워커가 집으면 그 IP 의 요청률이 도로 올라가 예산의 의미가 사라진다. release(rest_sec=...) 로 sticky 수명만큼 쉬게 한다. 차단으로 태우는 burn(30분)과는 별개 상태다: 휴식 탄 게 아님 · 짧음 · 소진 시 가장 먼저 회수 쿨다운 차단당함 · 김 · 휴식보다 나중에 회수 회전 종류(kind)를 browser_base → DecodoProxy.rotate(kind) 로 전달해 budget 일 때만 휴식을 건다. 라이브 검증(예산 3으로 낮춰 관찰): 6회 검색 = IP 2개만 사용, 3회마다 선제 회전, 놓은 포트는 휴식 1 · 쿨다운 0 · 차단 0. 즉 IP 를 태우지 않고 로테이션만으로 돌아간다. 테스트 6건 추가(휴식 재사용 금지·만료 복귀·burn 우선·회수 우선순위·budget vs block), 전체 202 passed. _MockProxy.rotate 가 kind 를 받도록 갱신. --- lps/config/config.local.toml.example | 1 + lps/config/config_models.py | 9 ++++- lps/services/search/browser_base.py | 2 +- lps/services/search/port_registry.py | 35 +++++++++++++---- lps/services/search/proxy.py | 15 +++++-- lps/tests/test_browser_base.py | 5 ++- lps/tests/test_port_registry.py | 58 ++++++++++++++++++++++++++++ lps/worker_main.py | 1 + 8 files changed, 111 insertions(+), 15 deletions(-) diff --git a/lps/config/config.local.toml.example b/lps/config/config.local.toml.example index 9a275c0..b1b83b6 100644 --- a/lps/config/config.local.toml.example +++ b/lps/config/config.local.toml.example @@ -115,4 +115,5 @@ port_end = 0 # 예: 10010 — 포트를 늘리면(계약 변경) 이 session_minutes = 10 # 대시보드 Sticky 지속시간(분)과 일치 cost_per_gb = 0.0 # DECODO 요금($/GB) — 검색 원가의 대역폭 비용 산정용(플랜에 맞게, 예 3.0) ip_request_budget = 3 # IP당 요청 예산 — 도달 시 차단 전 선제 회전(0=비활성). 튜닝은 docs/database.md +naver_ip_request_budget = 10 # 네이버는 IP 당 12회까지 무차단 실측 → 여유 둔 10 port_cooldown_sec = 0 # 차단 감지 포트 격리 초. 0=자동 max(sticky, 30분) diff --git a/lps/config/config_models.py b/lps/config/config_models.py index a26f908..c4b6e1f 100644 --- a/lps/config/config_models.py +++ b/lps/config/config_models.py @@ -107,10 +107,17 @@ class DecodoConfig(ConfigModel): session_minutes: int = 10 cost_per_gb: float = 0.0 # DECODO residential 요금($/GB) — 검색 원가의 대역폭 비용 산정용(플랜에 맞게 설정) # IP(포트 세션)당 요청 예산 — 도달 시 차단당하기 전에 선제 회전(평판 보존). 0=비활성. - # 실측상 5회 부근 차단 이력 → 보수적 3. 튜닝은 ip_session 분석(docs/database.md). + # 쿠팡(Akamai) 실측상 5회 부근 차단 이력 → 보수적 3. 튜닝은 ip_session 분석(docs/database.md). ip_request_budget: int = 3 + # 네이버는 같은 KR IP 로 **12회 연속 검색까지 무차단**(2026-08-05 실측, IP 4개 전부 한계 미도달). + # 쿠팡 기준 3을 그대로 쓰면 불필요하게 4배 자주 회전해 KR 풀만 빨리 소모하고 지연도 늘어난다 + # (회전 1회 = 브라우저 재기동 ~20s). 실측 12에 여유를 둔 10. 0=비활성(시간창 회전만). + naver_ip_request_budget: int = 10 # 차단 감지된 포트 격리 시간(초). 0=자동(max(sticky, 30분)) — sticky 만료 후 복귀라 사실상 새 IP. port_cooldown_sec: int = 0 + # 선제 회전(예산 도달)으로 놓은 포트의 휴식 시간(초). 0=자동(sticky 수명). + # 차단이 아니라 '쉬게 하는' 것이라 쿨다운보다 짧다 — 그래도 곧바로 재사용되면 안 된다. + port_rest_sec: int = 0 class WorkerConfig(ConfigModel): diff --git a/lps/services/search/browser_base.py b/lps/services/search/browser_base.py index 389aa41..26422e0 100644 --- a/lps/services/search/browser_base.py +++ b/lps/services/search/browser_base.py @@ -237,7 +237,7 @@ class BrowserSearchAdapter(SearchAdapter): """즉시 다음 IP(포트)로 회전 예약 + 다음 _ensure_browser 에서 브라우저 재기동. kind 는 세션 종료 사유로 기록된다(budget=선제/block=차단/proxy_error=포트사망).""" if self._proxy and self._proxy.enabled: - self._proxy.rotate() + self._proxy.rotate(kind) # budget(선제)이면 놓는 포트에 휴식이 붙는다 self._force_recycle = True self._end_reason = kind (LOG.w if warn else LOG.i)(f"[{self.source}] IP 회전 — {reason}") diff --git a/lps/services/search/port_registry.py b/lps/services/search/port_registry.py index cf0cd6c..254087a 100644 --- a/lps/services/search/port_registry.py +++ b/lps/services/search/port_registry.py @@ -40,6 +40,10 @@ class PortRegistry: self.port_end = port_end self._held: dict[tuple[str, int], tuple[str, float]] = {} # (host,port) → (owner, 취득시각) self._burned: dict[tuple[str, int], float] = {} # (host,port) → 쿨다운 만료(monotonic) + # 선제 회전(예산 도달)으로 놓은 포트의 '휴식' 만료. 차단(burn)과 다르다 — 탄 게 아니라 + # **쉬게 하는 것**이라 훨씬 짧다. 휴식이 없으면 방금 놓은 IP 를 다른 워커가 곧바로 집어 + # 요청이 다시 몰리고, 예산으로 낮춘 요청률이 도로 올라간다(선제 회전의 의미가 사라진다). + self._resting: dict[tuple[str, int], float] = {} @property def size(self) -> int: @@ -56,33 +60,45 @@ class PortRegistry: for k in range(n): port = self.port_start + ((start_at + k) % n) key = (host, port) - if key in self._burned or key in self._held: + if key in self._burned or key in self._held or key in self._resting: continue self._held[key] = (owner, time.monotonic()) return port # 전 포트 소진 — 쿨다운 만료가 가장 임박한 포트를 회수해 넘긴다. - victim = min(self._burned, key=self._burned.get, default=None) - if victim is None: # 쿨다운은 없는데 전부 점유 중 = 소유자가 포트 수보다 많다 - victim = min(self._held, key=lambda k: self._held[k][1]) + # 회수 우선순위: 휴식(탄 게 아님) → 쿨다운(차단) → 보유 중(가장 오래된 리스) + pool = {k: t for k, t in self._resting.items() if k[0] == host} or \ + {k: t for k, t in self._burned.items() if k[0] == host} + victim = min(pool, key=pool.get, default=None) + if victim is None: # 전부 점유 중 = 소유자가 포트 수보다 많다 + victim = min(((k, v[1]) for k, v in self._held.items() if k[0] == host), + key=lambda kv: kv[1])[0] + self._resting.pop(victim, None) LOG.w(f"[port] {host} 가용 포트 없음 — {victim[1]} 회수해 {owner} 에 배정 " f"(보유 {len(self._held)} · 쿨다운 {len(self._burned)} / {n})") self._burned.pop(victim, None) self._held[victim] = (owner, time.monotonic()) return victim[1] - def release(self, host: str, port: int, owner: str): - """리스 반납. 소유자가 다르면 무시한다(뒤늦은 반납이 남의 리스를 깨지 않도록).""" + def release(self, host: str, port: int, owner: str, rest_sec: float = 0): + """리스 반납. 소유자가 다르면 무시한다(뒤늦은 반납이 남의 리스를 깨지 않도록). + + rest_sec > 0 이면 그만큼 **휴식**을 준다 — 선제 회전(예산 도달)으로 놓은 IP 가 + 곧바로 다시 잡히지 않게 해 요청률을 실제로 낮춘다. 차단으로 태우는 burn 과는 다르다. + """ key = (host, port) cur = self._held.get(key) if cur and cur[0] == owner: del self._held[key] + if rest_sec > 0: + self._resting[key] = time.monotonic() + rest_sec def burn(self, host: str, port: int, cooldown_sec: float, owner: str = "", reason: str = ""): """차단·소진된 포트를 전역 격리. 보유 중이면 함께 반납한다.""" key = (host, port) self._burned[key] = time.monotonic() + cooldown_sec self._held.pop(key, None) + self._resting.pop(key, None) # 휴식 중이었어도 차단이 우선 LOG.i(f"[port] {host}:{port} 쿨다운 {int(cooldown_sec)}s ({owner}{' · ' + reason if reason else ''}) " f"— 가용 {self.available(host)}/{self.size}") @@ -98,12 +114,13 @@ class PortRegistry: self._prune(host) used = sum(1 for (h, _) in self._held if h == host) cooling = sum(1 for (h, _) in self._burned if h == host) - return self.size - used - cooling + resting = sum(1 for (h, _) in self._resting if h == host) + return self.size - used - cooling - resting def snapshot(self) -> dict: """게이트웨이별 현황 — ops 모니터/알림용. {host: {held, cooling, available, owners}}""" self._prune() - hosts = {h for (h, _) in self._held} | {h for (h, _) in self._burned} + hosts = {h for (h, _) in self._held} | {h for (h, _) in self._burned} | {h for (h, _) in self._resting} out = {} for h in sorted(hosts): owners: dict[str, int] = {} @@ -113,6 +130,7 @@ class PortRegistry: out[h] = { "held": sum(1 for (hh, _) in self._held if hh == h), "cooling": sum(1 for (hh, _) in self._burned if hh == h), + "resting": sum(1 for (hh, _) in self._resting if hh == h), "available": self.available(h), "total": self.size, "owners": owners, @@ -122,3 +140,4 @@ class PortRegistry: def _prune(self, host: str | None = None): now = time.monotonic() self._burned = {k: t for k, t in self._burned.items() if t > now} + self._resting = {k: t for k, t in self._resting.items() if t > now} diff --git a/lps/services/search/proxy.py b/lps/services/search/proxy.py index 128d771..e18b306 100644 --- a/lps/services/search/proxy.py +++ b/lps/services/search/proxy.py @@ -37,17 +37,26 @@ class DecodoProxy: # 불탄(차단 감지된) 포트 격리 시간([DecodoConfig].port_cooldown_sec). sticky 만료(session_minutes) # 이상이어야 쿨다운 복귀 시 같은 포트라도 사실상 새 IP 가 배정된다. 0=자동 max(sticky, 30분). self.cooldown_sec = getattr(cfg, "port_cooldown_sec", 0) or max(self.session_minutes * 60, 1800) + # 선제 회전으로 놓은 포트의 휴식 시간. 0=자동(sticky 수명) — 그 IP 의 세션이 만료돼 + # 다음에 잡힐 땐 사실상 새 IP 가 배정된다. 차단 쿨다운(cooldown_sec)보다 훨씬 짧다. + self.rest_sec = getattr(cfg, "port_rest_sec", 0) or self.session_minutes * 60 self._burned: dict[int, float] = {} # port → 쿨다운 만료 시각(monotonic) @property def enabled(self) -> bool: return all([self.host, self.username, self.password, self.port_start, self.port_end]) - def rotate(self): - """시간창과 무관하게 즉시 다음 포트(=새 IP)로 회전. 봇 감지·예산 도달 시 호출.""" + def rotate(self, kind: str = "rotate"): + """시간창과 무관하게 즉시 다음 포트(=새 IP)로 회전. 봇 감지·예산 도달 시 호출. + + kind="budget"(차단 전 선제 회전)이면 놓는 포트에 **휴식**을 준다 — 방금 쓴 IP 를 + 다른 워커가 곧바로 집으면 요청률이 도로 올라가 예산의 의미가 사라진다. + 차단으로 태우는 건 mark_burned 가 따로 처리한다(휴식보다 훨씬 긴 쿨다운). + """ self._rotate_offset += 1 if self._registry is not None and self._leased is not None: - self._registry.release(self.host, self._leased, self._owner) # 리스 반납 → 다음 _port() 에서 새로 임대 + rest = self.rest_sec if kind == "budget" else 0 + self._registry.release(self.host, self._leased, self._owner, rest_sec=rest) self._leased = None def seed_offset(self, k: int): diff --git a/lps/tests/test_browser_base.py b/lps/tests/test_browser_base.py index cb4ae13..102356c 100644 --- a/lps/tests/test_browser_base.py +++ b/lps/tests/test_browser_base.py @@ -49,8 +49,9 @@ class _MockCtx: class _MockProxy: enabled = True session_minutes = 10 - def __init__(self): self.rotations, self.burned = 0, [] - def rotate(self): self.rotations += 1 + def __init__(self): self.rotations, self.burned, self.kinds = 0, [], [] + # kind 는 회전 종류(budget=선제/block=차단/proxy_error/window) — 실물은 budget 일 때 휴식을 준다 + def rotate(self, kind: str = "rotate"): self.rotations += 1; self.kinds.append(kind) def mark_burned(self, port, cooldown_sec=None): self.burned.append(port) def playwright_proxy(self): return None @property diff --git a/lps/tests/test_port_registry.py b/lps/tests/test_port_registry.py index d85ae87..fdf7a74 100644 --- a/lps/tests/test_port_registry.py +++ b/lps/tests/test_port_registry.py @@ -158,3 +158,61 @@ def test_without_registry_behaviour_is_unchanged(): p.mark_burned(port) assert p.available_ports() == 2 assert p._port() != port + + +# ── 휴식(선제 회전) ────────────────────────────────────────────────────── +def test_rested_port_is_not_immediately_reused(): + """예산 도달로 놓은 IP 를 곧바로 다시 집으면 요청률이 도로 올라가 선제 회전이 무의미해진다.""" + reg = _reg(2) + p = reg.acquire(GATE, "naver-w0") + reg.release(GATE, p, "naver-w0", rest_sec=600) + assert reg.acquire(GATE, "naver-w1") != p + + +def test_rest_expiry_returns_port_to_pool(): + reg = _reg(1) + p = reg.acquire(GATE, "naver-w0") + reg.release(GATE, p, "naver-w0", rest_sec=-1) # 이미 만료된 휴식 + assert reg.available(GATE) == 1 + assert reg.acquire(GATE, "naver-w1") == p + + +def test_release_without_rest_is_immediately_reusable(): + """차단·예산이 아닌 평범한 반납(시간창 만료 등)은 휴식 없이 바로 풀에 돌아온다.""" + reg = _reg(1) + p = reg.acquire(GATE, "naver-w0") + reg.release(GATE, p, "naver-w0") + assert reg.acquire(GATE, "naver-w1") == p + + +def test_burn_overrides_rest(): + reg = _reg(2) + p = reg.acquire(GATE, "naver-w0") + reg.release(GATE, p, "naver-w0", rest_sec=60) + reg.burn(GATE, p, 1800) # 쉬는 중이던 포트가 차단당함 + assert reg.snapshot()[GATE]["cooling"] == 1 + assert reg.snapshot()[GATE]["resting"] == 0 + + +def test_exhausted_pool_reclaims_resting_before_burned(): + """회수 우선순위: 휴식(탄 게 아님) > 쿨다운(차단). 탄 IP 를 먼저 꺼내 쓰면 안 된다.""" + reg = _reg(2) + reg.burn(GATE, 10001, 60) + r = reg.acquire(GATE, "naver-w0") # 10002 + reg.release(GATE, r, "naver-w0", rest_sec=600) + assert reg.acquire(GATE, "naver-w1") == 10002 # 쉬던 포트를 회수(태운 10001 아님) + + +def test_budget_rotation_rests_but_block_rotation_burns(): + """DecodoProxy 통합: 선제 회전은 휴식, 차단은 쿨다운 — 로그·풀 회복 속도가 달라진다.""" + reg = _reg(3) + p = DecodoProxy(_cfg(), registry=reg, owner="naver-w0") + first = p._port() + p.rotate(kind="budget") # 예산 도달 — 태우지 않는다 + snap = reg.snapshot()[GATE] + assert snap["resting"] == 1 and snap["cooling"] == 0 + assert p._port() != first + + second = p._port() + p.mark_burned(second) # 차단 — 쿨다운 + assert reg.snapshot()[GATE]["cooling"] == 1 diff --git a/lps/worker_main.py b/lps/worker_main.py index ce0f29e..d2333e2 100644 --- a/lps/worker_main.py +++ b/lps/worker_main.py @@ -80,6 +80,7 @@ def _build_worker(i: int, concurrency: int, has_openai: bool, neg_cache, history # 쿠팡과 같은 프록시를 물린다: 단일 IP 로 연달아 두드리면 WTM 캡차로 넘어간다(실측 — # 스파이크에서 십수 회 만에 홈 IP 가 탔다). 회전·예산·차단기록은 베이스가 처리한다. "naver": NaverShopAdapter(headless=False, user_data_dir=_pf("naver"), proxy=kr_proxy, + ip_request_budget=decodo_config.naver_ip_request_budget, on_detect=bot_log.record, on_session_end=ip_log.record), } # 폴백은 기본 비활성(LPS_FALLBACKS 로 켬 — 상단 주석 참고). 켤 땐 데드라인이 상한이라