대부분의 스크래핑 코드베이스에서는 누가 설계하든 안 하든 결국 프록시 매니저가 생겨난다. 처음에는 프록시 URL을 만드는 헬퍼로 시작해서, 누군가 재시도 로직을 추가하고, 그다음 스티키 세션이 필요한 타깃을 위한 특수 케이스가 추가되고, 계속 실패하는 주소를 그만 두드리기 위한 카운터가 추가된다. 18개월이 지나면 이 로직은 네 개의 모듈에 흩어져 있고, 같은 세션에서 요청이 두 번 실패했을 때 무슨 일이 일어나는지 아무도 말할 수 없게 된다.
이것은 처음부터 신중하게 설계할 가치가 있다. 왜냐하면 여기에 관련된 결정들은 정말로 여러 곳에서 공유되기 때문이다. 이 요청은 어떤 출구를 사용하는가, 그 출구는 건강한가, 실패하면 무엇을 하는가, 그리고 어떻게 속도를 조절하는가. 여기서는 그 컴포넌트의 설계와 그것이 유지해야 할 상태를 다룬다.
매니저가 책임지는 것
경계를 엄격하게 그어야 한다. 프록시 매니저는 요청이 인프라를 어떻게 빠져나가는지, 그리고 그것이 실패했을 때 무엇을 할지를 결정한다. 페이지를 파싱하지 않고, 제품 목록이 어떻게 생겼는지 알지 못하며, 어떤 URL을 방문할지 결정하지 않는다. 비즈니스 로직에 대해 무지하게 유지하는 것이 모든 작업이 이것을 공유할 수 있게 만드는 이유다.
이렇게 하면 네 가지 책임이 남는다. 정책에 따라 각 요청에 대한 출구를 선택하는 것, 배포한 것의 건강 상태를 추적하는 것, 무언가 잘못됐을 때 실패 처리를 적용하는 것, 그리고 호출자 개개인이 타깃을 압도하지 못하도록 속도 조절을 강제하는 것. 나머지는 모두 다른 곳에 속한다.
선택: 정책은 작업별이지 전역이 아니다
매니저가 필요로 하는 첫 번째 것은 단일한 전역 모드가 아니라, 호출자가 원하는 출구의 종류에 대한 개념이다. 실제로는 세 가지가 있다.
로테이팅은 각 요청이 새로운 출구를 받는 방식이다. 대량 수집의 기본값이며, 세션 식별자를 생략함으로써 표현되는데, 이는 로테이션 모델이 사용되도록 설계된 방식이다.
스티키는 호출자가 일련의 시퀀스 동안 하나의 출구를 유지하는 방식으로, 매니저는 그 시퀀스의 생애 동안 동일한 세션 식별자를 돌려줘야 하고, 이후 그것을 해제해야 한다. 그 트레이드오프는 스티키 대 로테이팅에 나와 있다.
지오핀드는 출구가 특정 국가나 도시에 있어야 하는 방식으로, 다중 시장 작업에서는 앞의 두 가지와 직교한다. 하나의 작업이 독일에서 로테이팅하거나 시카고에서 스티키일 수 있다.
이것들을 전역 설정이 아니라 요청 범위의 리스(lease)로 모델링해서, 호출자가 필요한 것을 요청하고 매니저가 그것을 충족시키도록 하라:
from dataclasses import dataclass
from typing import Optional
import itertools, time
@dataclass
class Lease:
country: str
session: Optional[str] = None # None means rotate per request
ttl: Optional[int] = None # only meaningful with a session
def username(self, customer="USERNAME"):
parts = [f"customer-{customer}", f"country-{self.country}"]
if self.session:
parts.append(f"sid-{self.session}")
if self.ttl:
parts.append(f"ttl-{self.ttl}")
return "-".join(parts)
def proxies(self, password="PASSWORD", host="p.shifter.io:443"):
url = f"http://{self.username()}:{password}@{host}"
return {"http": url, "https": url}
매니저의 역할은 Lease를 생성하고, 그것을 호출자에게 건네고, 그것에 무슨 일이 일어나는지 관찰하는 것이다.
건강 상태: 주소가 아니라 세션을 추적하라
여기가 대부분의 자체 제작 매니저가 잘못되는 지점이다. 풀링된 게이트웨이에서는 좋음이나 나쁨으로 표시할 IP 주소 목록을 가지고 있지 않는다. 애초에 직접 선택한 것이 아니고 계속 유지할 수도 없기 때문이다. 추적할 수 있는 것은 세션의 건강 상태와, 국가와 타깃의 조합을 의미하는 경로의 집계된 건강 상태다.
따라서 두 가지를 유지하라. 활성 스티키 세션마다, 연속 실패와 챌린지 응답에 대한 작은 기록을 유지해서, 분명히 나빠진 세션을 은퇴시키고 새로운 식별자로 교체할 수 있게 하라. 경로마다, 롤링 성공률을 유지해서, 하나의 운 나쁜 세션이 아니라 국가-타깃 조합 전체가 저하되었는지 알 수 있게 하라.
이 세계에서 헬스 체크는 주기적인 핑이 아니다. 출구에 핑을 보내는 것은 그것이 테스트 엔드포인트에 도달할 수 있음을 알려줄 뿐인데, 이는 질문의 요점이 아니다. 질문은 그것이 당신의 타깃에 도달해서 실제 콘텐츠를 얻을 수 있는가이다. 그래서 수동적 헬스 체크를 사용하라. 모든 실제 요청이 헬스 체크이며, 그것의 검증된 결과가 기록을 갱신한다. 능동적 프로빙은 각 타깃에 대해 작고 저렴한 카나리아 정도로만 가치가 있는데, 이는 “이 타깃이 모두에게 다운되었다”와 “우리의 경로가 저하되었다”를 구분하는 데 유용하다.
결정적으로, 상태 코드가 아니라 검증된 응답을 기준으로 건강 상태를 판단하라. 200을 반환하는 챌린지 페이지는 헬스 목적으로는 실패이며, 이를 성공으로 세는 매니저는 타깃이 이미 판단을 내린 세션을 계속 재사용하게 된다. 이는 차단되거나 가짜인 콘텐츠 감지에서와 동일한 검증 원칙이다.
class RouteHealth:
def __init__(self, window=50):
self.window, self.results = window, []
def record(self, ok: bool):
self.results.append(ok)
if len(self.results) > self.window:
self.results.pop(0)
@property
def success_rate(self):
return sum(self.results) / len(self.results) if self.results else 1.0
@property
def degraded(self):
return len(self.results) >= 10 and self.success_rate < 0.7
실패 처리: 분류한 다음 행동하라
매니저는 실패가 무엇을 의미하는지에 대한 결정을 소유하며, 이것이 그 로직이 작업마다 재발명되는 것을 막는다. 세 가지 분류가 이를 다룬다.
속도 신호는 속도를 늦추라는 뜻이다. 429나 Retry-After가 그렇다. 올바른 대응은 대기하는 것이며, 특히 새로운 출구로 교체하지 않는 것이다. 그래야 동일한 속도를 계속 유지할 수 있는데, 그렇지 않으면 스로틀된 경로가 소진된 경로로 바뀌게 된다.
신원 신호는 이 출구가 이 타깃에 대해서는 끝났다는 뜻이다. 차단 페이지, 지속적인 403, 또는 하나의 세션에서 반복되는 챌린지가 그렇다. 올바른 대응은 세션을 은퇴시키고, 새 세션을 가져와서 계속하는 것이며, 이는 페일오버 패턴이다.
종료 오류는 멈추라는 뜻이다. 404, 형식이 잘못된 URL, 또는 재시도할 때마다 똑같이 잘못될 인증 문제인 407이 그렇다. 이는 407 및 자격 증명 오류 해결에서 다룬 것처럼 반복해서 재시도하기보다는 즉시 크게 실패해야 한다.
나머지는 모두 일시적인 것으로, 재시도와 백오프에 따라 시도 횟수 상한을 두고 지터가 있는 지수 백오프를 적용한다. 중요한 구조적 요점은 재시도가 매니저 안에 있다는 것이다. 그래야 모든 호출자가 동일한 동작을 물려받고, 매니저가 호출자 전체에 걸쳐 재시도 예산을 강제할 수 있으며, 각 작업이 독립적으로 같은 어려움을 겪는 타깃에 재시도를 반복하지 않게 된다.
거기에 경로별 서킷 브레이커도 추가하라. 어떤 경로의 건강 상태가 임계값 아래로 떨어지면, 쿨다운 동안 그곳으로 전송을 멈추고, 이후 회복을 테스트하기 위해 소량만 흘려보내라. 이는 타깃을 당신이 몰려드는 것으로부터 보호하고, 응답하지 않는 사이트에 대해 실패가 누적되는 것으로부터 당신의 계정을 보호한다.
속도 조절도 여기에 속한다
매니저가 모든 아웃바운드 요청을 보기 때문에, 타깃별 속도 제한과 동시성 상한을 강제하기에 자연스러운 위치이며, 이는 속도 제한과 요청 스로틀링에서 설명된 메커니즘이다. 여기에 두는 것에는 특정한 이점이 있다. 재시도가 자동으로 리미터를 준수하게 되는데, 다른 어떤 요청과 마찬가지로 동일한 경로를 통과하기 때문이다. 속도 조절을 우회하는 재시도는 어려움을 겪는 타깃을 차단된 타깃으로 만드는 방식이다.
종합하기
전체 인터페이스는 작다. 그것이 핵심이다:
class ProxyManager:
def __init__(self, limiter_factory, health_factory):
self.limiters = {} # host -> TargetLimiter
self.health = {} # (country, host) -> RouteHealth
self.limiter_factory, self.health_factory = limiter_factory, health_factory
def get(self, url, host, country, session=None, attempts=4):
route = self.health.setdefault((country, host), self.health_factory())
limiter = self.limiters.setdefault(host, self.limiter_factory(host))
sid = session
for attempt in range(attempts):
if route.degraded:
raise RouteUnavailable(country, host) # circuit open
limiter.acquire() # pacing, retries included
lease = Lease(country=country, session=sid, ttl=600 if sid else None)
outcome = self._send(url, lease) # returns (klass, response)
route.record(outcome.klass == "ok")
if outcome.klass == "ok":
return outcome.response
if outcome.klass == "terminal":
raise TerminalError(url, outcome.response)
if outcome.klass == "identity" and sid:
sid = new_session_id() # retire, do not reuse
if outcome.klass == "rate":
time.sleep(outcome.retry_after or backoff(attempt))
else:
time.sleep(backoff(attempt)) # transient
raise Exhausted(url)
설계상 두 가지 참고 사항이 있다. 매니저는 None을 반환하는 대신 응답을 반환하고 타입이 지정된 오류를 발생시키므로, 호출자가 실패를 빈 데이터로 조용히 취급할 수 없다. 그리고 종료 오류를 절대 삼키지 않는다. 인증이나 설정 문제는 눈에 띄지 않게 재시도되기보다는 작업을 멈춰야 하기 때문이다.
운영상의 고려 사항
관찰 가능하게 만들어라. 매니저는 모든 요청을 보기 때문에, 경로별 성공률, 재시도 비율, 요청당 바이트 수가 여기서 방출되어야 한다. 이것이 정확히 프록시 KPI의 메트릭이 만들어지는 데이터이며 파이프라인 모니터링이 소비하는 것이다.
프로세스 간에 상태를 공유하라. 프로세스별 매니저는 워커 수만큼 실제 속도를 곱하고 건강 상태 추적을 분산시킨다. 분산 배포에서는 리미터, 브레이커, 경로 건강 상태가 공유 저장소에 있어야 하며, 이는 Kubernetes로 스케일링하기와 동일한 고려 사항이다.
자격 증명을 여기서 배제하라. 매니저는 사용자 이름을 구성하지만, 비밀번호는 구성에 담아두지 말고 시크릿 스토어에서 읽어와야 하며, 로테이션은 코드 변경이 아니라 배포 단계여야 한다.
과도하게 추상화하지 마라. 가상의 프로바이더를 위한 플러그인 아키텍처를 만들려는 유혹을 피하라. 깔끔하게 표현된 하나의 게이트웨이가 사용하지 않는 벤더에 대한 추상화 레이어보다 이해하기 쉽다.
결론
프록시 매니저는 요청이 시스템을 어떻게 빠져나가는지, 그리고 실패했을 때 무엇이 일어나는지를 결정하는 컴포넌트이며, 이 결정들을 중앙화하는 것이 동일한 로직이 모든 작업에 걸쳐 일관성 없이 재구현되는 것을 막는다. 호출자에게 요청 범위의 리스를 제공해서 로테이션, 스티키성, 지리적 위치가 전역이 아니라 작업별이 되도록 하라. 개별 주소를 관리하는 척하기보다는 세션과 경로 수준에서 건강 상태를 추적하고, 검증된 응답을 기준으로 판단해서 챌린지 페이지가 실제로 그것이 실패임을 반영하도록 하라. 실패 분류를 한 곳에서 소유하라. 속도 신호에는 대기하고, 신원 신호에는 세션을 은퇴시키고, 종료 오류에는 크게 실패하고, 나머지 모든 것에는 지터를 곁들여 백오프하며, 경로별 서킷 브레이커를 둔다. 속도 조절을 같은 경로에 두어서 재시도가 그것을 우회할 수 없도록 하라. 그런 다음 여기서 메트릭을 방출하라. 이것이 모든 것을 보는 유일한 컴포넌트이기 때문이다.
그 아래에는 레지덴셜 프록시 네트워크가 있다. 국가, 도시, 세션, TTL이 모두 사용자 이름에 표현되는 하나의 게이트웨이이며, 이것이 이런 매니저를 통합 프로젝트가 아니라 소량의 코드로 만들어주는 이유이고, GB당 가격 책정이 있어서 그것이 가져다주는 효율성이 청구서에 직접 나타난다.