feat(lps): IP 를 태우지 말고 한계 직전까지 쓰고 쉬게 — 네이버 예산 상향 + 휴식 개념

기존엔 네이버가 쿠팡 기준 예산(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 를 받도록 갱신.
This commit is contained in:
민헌 2026-08-05 11:16:37 +09:00
parent f80cece9c1
commit 495e16b951
8 changed files with 111 additions and 15 deletions

View File

@ -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분)

View File

@ -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):

View File

@ -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}")

View File

@ -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}

View File

@ -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):

View File

@ -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

View File

@ -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

View File

@ -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 로 켬 — 상단 주석 참고). 켤 땐 데드라인이 상한이라