fix(lps): sticky 만료 회전이 저트래픽에서 안 먹던 문제 + prod 템플릿에 kr_host 누락

prod 영향 점검 중 발견. 직전 커밋(377389f)의 sticky 만료 회전은 **브라우저가 열려 있을 때만**
동작했다 — `_ctx is None` 이면 만료 검사를 건너뛰었다. 그런데 negodata 연동은 수동 트리거
전용이라 검색이 드문드문 들어오고, 그때는 유휴 정리(120s)로 브라우저가 닫힌 채 매 검색이
그 경로로 들어온다. 즉 **실사용 패턴에서만 안 먹는** 반쪽 수정이었다(377389f 커밋 메시지·README
의 '고쳤다'는 서술이 부정확했다).

실측(합성 게이트웨이·10포트, sticky 매번 경과):
  연속 검색            IP 6개 순환      ✅
  저트래픽 · 예산 3     IP 2개          예산이 대신 회전시켜 가려져 있었음
  저트래픽 · 예산 0     **IP 1개 고정**  ❌  ([DecodoConfig] 주석의 '0=시간창 회전만'이 거짓)

원인은 시계가 둘이었던 것이다. sticky 만료를 브라우저 기동 시각(_launched_at)으로 쟀는데,
브라우저는 닫혔다 열릴 때마다 시계가 되감긴다. IP 를 쥔 시간과 어긋나는 이 구조가 F1(예산
미발화)과 F4(회전 안 됨)의 공통 원인이었다.
→ 시계를 _session_started_at 하나로 통일하고 _launched_at 을 제거했다. 만료 판정은 브라우저가
   닫혀 있어도 수행한다. 세 시나리오 모두 정상 회전 확인.

**prod 템플릿 kr_host 누락**(기존 문제, 이번 변경과 무관):
config.prod.toml.example 에 kr_host 가 없어 그대로 복사하면 네이버가 국가 무지정 게이트웨이로
떨어진다. 해외 residential IP 는 '비정상적인 접근'(2.6KB) 하드차단이고 회전으로 회복 불가라
네이버 결과가 통째로 0건이 된다. kr_host + naver_ip_request_budget 을 경고 주석과 함께 추가.

테스트 2건 추가(저트래픽 sticky 만료 회전 / IP 를 쥔 시간이 수명 내면 회전 안 함), 전체 253 passed.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
민헌 2026-08-06 11:50:08 +09:00
parent 7b1bcb6d24
commit 08afc7d4ad
3 changed files with 58 additions and 16 deletions

View File

@ -70,11 +70,16 @@ model = "gpt-4o-mini"
[DecodoConfig]
host = "gate.decodo.com"
# ⚠️ 네이버는 **한국 IP 가 필수**다. 이 줄이 비면 네이버가 host(국가 무지정)로 떨어지고,
# 해외 residential IP 로는 매 검색이 하드차단('비정상적인 접근', 2.6KB)된다 — IP 를 아무리
# 회전해도 회복되지 않는 구조적 차단이라 네이버 결과가 통째로 0건이 된다(fatal_block 알림).
kr_host = "kr.decodo.com" # 한국 타깃 게이트웨이(네이버용). 비우면 host 사용 = 네이버 불가
username = "<DECODO_USERNAME>"
password = "<DECODO_PASSWORD>"
port_start = 10001
port_end = 10100
session_minutes = 10
cost_per_gb = 3.0
ip_request_budget = 3 # IP당 요청 예산. 차단이 ip_req#1 에 몰리면 이 값과 무관한 문제다
ip_request_budget = 3 # 쿠팡 IP당 요청 예산. 차단이 ip_req#1 에 몰리면 이 값과 무관한 문제다
naver_ip_request_budget = 10 # 네이버는 IP 당 12회까지 무차단 실측 → 여유 둔 10
port_cooldown_sec = 0 # 0=자동 max(sticky, 30분)

View File

@ -109,8 +109,10 @@ class BrowserSearchAdapter(SearchAdapter):
self._on_session_end = on_session_end # async def(event: dict) — IP 세션 종료 기록(선택, 상한 튜닝 데이터)
self._pw = None
self._ctx = None
self._launched_at = 0.0 # 현재 브라우저 컨텍스트 기동 시각(시간창 재기동 판정용)
# ── IP(포트) 세션 상태 — 브라우저 수명과 **분리**한다 ────────────────────
# 시계는 **하나만** 둔다(_session_started_at). 예전엔 브라우저 기동 시각(_launched_at)이
# 따로 있어 sticky 만료·세션 지속시간을 그 시계로 쟀는데, 유휴 정리로 브라우저가 닫혔다
# 열릴 때마다 시계가 되감겨 실제 IP 사용 시간과 어긋났다(F1·F4 의 공통 원인).
# 예산은 '이 IP 로 몇 번 요청했나'지 '이 브라우저로 몇 번'이 아니다. 예전엔 아래 값들을
# 브라우저 재기동 때마다 0 으로 되돌렸는데, 유휴 정리(close_if_idle)는 브라우저만 닫고
# 임대는 그대로 두므로 **같은 IP 를 계속 쓰면서 카운터만 리셋**됐다. 게다가 ensure_port
@ -174,30 +176,43 @@ class BrowserSearchAdapter(SearchAdapter):
ESM(Turnstile)은 챌린지 solving 중엔 차단하면 안 되므로 override(웜=cf_clearance 있으면만 차단)."""
return self._block_resources
def _recycle_due(self) -> bool:
if self._force_recycle:
return True
if not (self._proxy and self._proxy.enabled):
def _sticky_expired(self) -> bool:
"""현재 IP 세션이 제공자 sticky 수명을 넘겼는지.
기준은 브라우저 기동 시각이 **아니라 IP 세션 시작 시각**이다. 유휴 정리로 브라우저가
닫혀 있어도 그 IP 를 쥔 시간은 계속 흐르기 때문이다 — 브라우저 시계로 재면 저트래픽에서
재기동마다 시계가 되감겨 만료를 영영 못 본다(실측: 예산을 끄면 한 IP 에 고정됐다).
"""
if not (self._proxy and self._proxy.enabled) or not self._session_started_at:
return False
return (time.monotonic() - self._launched_at) > self._proxy.session_minutes * 60
return (time.monotonic() - self._session_started_at) > self._proxy.session_minutes * 60
def _recycle_due(self) -> bool:
"""브라우저를 새로 띄워야 하는지 = 회전이 예약됐거나 IP 가 수명을 다했는지.
브라우저 수명은 IP 수명을 따라간다 — IP 가 그대로면 컨텍스트도 재사용한다(웜 쿠키 보존)."""
return self._force_recycle or self._sticky_expired()
async def _recycle_if_due(self) -> bool:
"""기존 컨텍스트를 계속 쓸 수 있으면 False, 닫고 새로 띄워야 하면 True.
재기동 사유는 두 가지고 처리가 다르다:
force_recycle 이미 회전이 예약된 상태(예산·차단·프록시오류) — 여기서 또 회전하지 않는다
시간창 만료 제공자 쪽 sticky 세션도 끝났다 = 같은 포트라도 IP 가 바뀐다
시간창 만료인데 임대를 쥔 채 브라우저만 다시 띄우면 ensure_port 의 renew 가 **같은 포트를
sticky 만료 제공자 쪽 세션도 끝났다 = 같은 포트라도 IP 가 바뀐다
만료인데 임대를 쥔 채 브라우저만 다시 띄우면 ensure_port 의 renew 가 **같은 포트를
계속 붙잡아** 회전이 일어나지 않는다(로그만 'IP 회전'이라 찍혔다). 명시적으로 놓아준다 —
그래야 LRU 가 다음 IP 를 내주고 요청 카운터도 새 IP 기준으로 다시 센다.
⚠️ 만료 판정은 **브라우저가 닫혀 있어도** 한다. 유휴 정리가 낀 저트래픽(negodata 의
수동 트리거가 이 패턴이다)에서는 매 검색이 `_ctx is None` 으로 들어오는데, 여기서
건너뛰면 sticky 가 몇 번을 지나도 회전하지 않는다.
"""
if not self._force_recycle and self._sticky_expired():
self._rotate_ip(f"sticky {self._proxy.session_minutes}분 만료 — 새 IP",
kind="window", warn=False)
if self._ctx is None:
return True
if not self._recycle_due():
return False
if not self._force_recycle:
self._rotate_ip(f"sticky {self._proxy.session_minutes}분 만료 — 새 IP",
kind="window", warn=False)
LOG.d(f"[{self.source}] 브라우저 재기동")
await self._close_ctx()
return True
@ -227,7 +242,6 @@ class BrowserSearchAdapter(SearchAdapter):
self._ctx = await self._pw.chromium.launch_persistent_context(**kwargs)
if self._block_resources:
await self._ctx.route("**/*", self._route)
self._launched_at = time.monotonic()
self._force_recycle = False
async def _begin_ip_session(self, port):

View File

@ -77,7 +77,6 @@ class _MockAdapter(BrowserSearchAdapter):
return
self._ctx = _MockCtx(self._page)
await self._begin_ip_session(self._proxy.current_port if self._proxy else None)
self._launched_at = time.monotonic()
self._force_recycle = False
async def _ensure_net_meter(self, page): pass # CDP 없음 → last_bytes=DOM 크기
@ -208,17 +207,41 @@ async def test_sticky_window_expiry_actually_rotates_the_ip():
await ad.search("q")
assert (ad._current_port, ad._proxy.rotations) == (10001, 0)
ad._launched_at -= ad._proxy.session_minutes * 60 + 1 # sticky 수명 경과
ad._session_started_at -= ad._proxy.session_minutes * 60 + 1 # sticky 수명 경과
await ad.search("q")
assert ad._proxy.kinds == ["window"], "시간창 만료는 window 사유로 회전한다"
assert (ad._current_port, ad._ip_requests) == (10002, 1), "새 IP 로 바뀌고 카운터도 새로 센다"
async def test_sticky_expiry_rotates_even_when_the_browser_was_idle_closed():
"""저트래픽(negodata 수동 트리거)에서는 매 검색이 브라우저 닫힌 채로 들어온다.
예전엔 그 경로에서 만료 검사를 건너뛰어, 예산을 끄면 한 IP 에 영원히 고정됐다(실측)."""
ad = _MockAdapter(_MockPage(["<PRODUCT>ok</PRODUCT>"] * 3), proxy=_MockProxy(),
ip_request_budget=0) # 예산 비활성 = 시간창만이 회전 수단
seen = []
for _ in range(3):
await ad.search("q")
seen.append(ad._current_port)
await _idle_cleanup(ad) # 브라우저만 닫힘(임대는 유지)
ad._session_started_at -= ad._proxy.session_minutes * 60 + 1 # sticky 수명 경과
assert len(set(seen)) == 3, f"sticky 만료마다 새 IP 여야 한다 — 실제: {seen}"
assert ad._proxy.kinds == ["window", "window"]
async def test_sticky_clock_follows_the_ip_not_the_browser():
"""브라우저를 몇 번 닫았다 열든, IP 를 쥔 시간이 수명을 넘지 않으면 회전하지 않는다."""
ad = _MockAdapter(_MockPage(["<PRODUCT>ok</PRODUCT>"] * 3), proxy=_MockProxy(), ip_request_budget=0)
for _ in range(3):
await ad.search("q")
await _idle_cleanup(ad)
assert ad._proxy.rotations == 0 and ad._current_port == 10001
async def test_forced_rotation_does_not_double_rotate_on_window():
"""이미 회전이 예약된 상태(예산·차단)에서 시간창까지 걸려도 회전은 1번이어야 한다."""
ad = _MockAdapter(_MockPage(["<PRODUCT>ok</PRODUCT>"] * 2), proxy=_MockProxy(), ip_request_budget=1)
await ad.search("q") # 예산 1회 → 다음 검색 전에 선제 회전 예약
ad._launched_at -= ad._proxy.session_minutes * 60 + 1
ad._session_started_at -= ad._proxy.session_minutes * 60 + 1
await ad.search("q")
assert ad._proxy.kinds == ["budget"], "예약된 회전이 우선 — window 로 덧회전하지 않는다"
assert ad._proxy.rotations == 1