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] [DecodoConfig]
host = "gate.decodo.com" host = "gate.decodo.com"
# ⚠️ 네이버는 **한국 IP 가 필수**다. 이 줄이 비면 네이버가 host(국가 무지정)로 떨어지고,
# 해외 residential IP 로는 매 검색이 하드차단('비정상적인 접근', 2.6KB)된다 — IP 를 아무리
# 회전해도 회복되지 않는 구조적 차단이라 네이버 결과가 통째로 0건이 된다(fatal_block 알림).
kr_host = "kr.decodo.com" # 한국 타깃 게이트웨이(네이버용). 비우면 host 사용 = 네이버 불가
username = "<DECODO_USERNAME>" username = "<DECODO_USERNAME>"
password = "<DECODO_PASSWORD>" password = "<DECODO_PASSWORD>"
port_start = 10001 port_start = 10001
port_end = 10100 port_end = 10100
session_minutes = 10 session_minutes = 10
cost_per_gb = 3.0 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분) 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._on_session_end = on_session_end # async def(event: dict) — IP 세션 종료 기록(선택, 상한 튜닝 데이터)
self._pw = None self._pw = None
self._ctx = None self._ctx = None
self._launched_at = 0.0 # 현재 브라우저 컨텍스트 기동 시각(시간창 재기동 판정용)
# ── IP(포트) 세션 상태 — 브라우저 수명과 **분리**한다 ──────────────────── # ── IP(포트) 세션 상태 — 브라우저 수명과 **분리**한다 ────────────────────
# 시계는 **하나만** 둔다(_session_started_at). 예전엔 브라우저 기동 시각(_launched_at)이
# 따로 있어 sticky 만료·세션 지속시간을 그 시계로 쟀는데, 유휴 정리로 브라우저가 닫혔다
# 열릴 때마다 시계가 되감겨 실제 IP 사용 시간과 어긋났다(F1·F4 의 공통 원인).
# 예산은 '이 IP 로 몇 번 요청했나'지 '이 브라우저로 몇 번'이 아니다. 예전엔 아래 값들을 # 예산은 '이 IP 로 몇 번 요청했나'지 '이 브라우저로 몇 번'이 아니다. 예전엔 아래 값들을
# 브라우저 재기동 때마다 0 으로 되돌렸는데, 유휴 정리(close_if_idle)는 브라우저만 닫고 # 브라우저 재기동 때마다 0 으로 되돌렸는데, 유휴 정리(close_if_idle)는 브라우저만 닫고
# 임대는 그대로 두므로 **같은 IP 를 계속 쓰면서 카운터만 리셋**됐다. 게다가 ensure_port # 임대는 그대로 두므로 **같은 IP 를 계속 쓰면서 카운터만 리셋**됐다. 게다가 ensure_port
@ -174,30 +176,43 @@ class BrowserSearchAdapter(SearchAdapter):
ESM(Turnstile) 챌린지 solving 중엔 차단하면 되므로 override(=cf_clearance 있으면만 차단).""" ESM(Turnstile) 챌린지 solving 중엔 차단하면 되므로 override(=cf_clearance 있으면만 차단)."""
return self._block_resources return self._block_resources
def _recycle_due(self) -> bool: def _sticky_expired(self) -> bool:
if self._force_recycle: """현재 IP 세션이 제공자 sticky 수명을 넘겼는지.
return True
if not (self._proxy and self._proxy.enabled): 기준은 브라우저 기동 시각이 **아니라 IP 세션 시작 시각**이다. 유휴 정리로 브라우저가
닫혀 있어도 IP 시간은 계속 흐르기 때문이다 브라우저 시계로 재면 저트래픽에서
재기동마다 시계가 되감겨 만료를 영영 본다(실측: 예산을 끄면 IP 고정됐다).
"""
if not (self._proxy and self._proxy.enabled) or not self._session_started_at:
return False 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: async def _recycle_if_due(self) -> bool:
"""기존 컨텍스트를 계속 쓸 수 있으면 False, 닫고 새로 띄워야 하면 True. """기존 컨텍스트를 계속 쓸 수 있으면 False, 닫고 새로 띄워야 하면 True.
재기동 사유는 가지고 처리가 다르다: 재기동 사유는 가지고 처리가 다르다:
force_recycle 이미 회전이 예약된 상태(예산·차단·프록시오류) 여기서 회전하지 않는다 force_recycle 이미 회전이 예약된 상태(예산·차단·프록시오류) 여기서 회전하지 않는다
시간창 만료 제공자 sticky 세션도 끝났다 = 같은 포트라도 IP 바뀐다 sticky 만료 제공자 세션도 끝났다 = 같은 포트라도 IP 바뀐다
시간창 만료인데 임대를 브라우저만 다시 띄우면 ensure_port renew **같은 포트를 만료인데 임대를 브라우저만 다시 띄우면 ensure_port renew **같은 포트를
계속 붙잡아** 회전이 일어나지 않는다(로그만 'IP 회전'이라 찍혔다). 명시적으로 놓아준다 계속 붙잡아** 회전이 일어나지 않는다(로그만 'IP 회전'이라 찍혔다). 명시적으로 놓아준다
그래야 LRU 다음 IP 내주고 요청 카운터도 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: if self._ctx is None:
return True return True
if not self._recycle_due(): if not self._recycle_due():
return False 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}] 브라우저 재기동") LOG.d(f"[{self.source}] 브라우저 재기동")
await self._close_ctx() await self._close_ctx()
return True return True
@ -227,7 +242,6 @@ class BrowserSearchAdapter(SearchAdapter):
self._ctx = await self._pw.chromium.launch_persistent_context(**kwargs) self._ctx = await self._pw.chromium.launch_persistent_context(**kwargs)
if self._block_resources: if self._block_resources:
await self._ctx.route("**/*", self._route) await self._ctx.route("**/*", self._route)
self._launched_at = time.monotonic()
self._force_recycle = False self._force_recycle = False
async def _begin_ip_session(self, port): async def _begin_ip_session(self, port):

View File

@ -77,7 +77,6 @@ class _MockAdapter(BrowserSearchAdapter):
return return
self._ctx = _MockCtx(self._page) self._ctx = _MockCtx(self._page)
await self._begin_ip_session(self._proxy.current_port if self._proxy else None) await self._begin_ip_session(self._proxy.current_port if self._proxy else None)
self._launched_at = time.monotonic()
self._force_recycle = False self._force_recycle = False
async def _ensure_net_meter(self, page): pass # CDP 없음 → last_bytes=DOM 크기 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") await ad.search("q")
assert (ad._current_port, ad._proxy.rotations) == (10001, 0) 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") await ad.search("q")
assert ad._proxy.kinds == ["window"], "시간창 만료는 window 사유로 회전한다" assert ad._proxy.kinds == ["window"], "시간창 만료는 window 사유로 회전한다"
assert (ad._current_port, ad._ip_requests) == (10002, 1), "새 IP 로 바뀌고 카운터도 새로 센다" 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(): async def test_forced_rotation_does_not_double_rotate_on_window():
"""이미 회전이 예약된 상태(예산·차단)에서 시간창까지 걸려도 회전은 1번이어야 한다.""" """이미 회전이 예약된 상태(예산·차단)에서 시간창까지 걸려도 회전은 1번이어야 한다."""
ad = _MockAdapter(_MockPage(["<PRODUCT>ok</PRODUCT>"] * 2), proxy=_MockProxy(), ip_request_budget=1) ad = _MockAdapter(_MockPage(["<PRODUCT>ok</PRODUCT>"] * 2), proxy=_MockProxy(), ip_request_budget=1)
await ad.search("q") # 예산 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") await ad.search("q")
assert ad._proxy.kinds == ["budget"], "예약된 회전이 우선 — window 로 덧회전하지 않는다" assert ad._proxy.kinds == ["budget"], "예약된 회전이 우선 — window 로 덧회전하지 않는다"
assert ad._proxy.rotations == 1 assert ad._proxy.rotations == 1