지식

레지덴셜 프록시 로드 밸런싱: 로테이션, 동시성, 재시도 아키텍처

게이트웨이가 있으면 풀은 스스로 균형을 맞춘다. 중요한 것은 여러분의 아키텍처다: 로테이션 단위, 호스트별 동시성, 그리고 차단을 증폭시키지 않는 재시도.

Chris Collins

Chris Collins

2026년 7월 17일 · 8 분 소요

“프록시 로드 밸런싱”이라는 문구는 예전과는 매우 다른 의미를 갖게 되었다. 과거 모델에서는 IP 목록을 구매하고 로테이터를 직접 만들었다. IP를 선택하고, 상태를 추적하고, 죽으면 제거하고, 나중에 다시 넣는 식이었다. 팀들은 여전히 습관적으로 그런 미들웨어를 작성하지만, 최신 게이트웨이에서는 대부분 죽은 코드나 마찬가지다.

레지덴셜 프록시 게이트웨이에서는 풀이 스스로 균형을 맞춘다. 엔드포인트는 하나이며, 프로바이더가 요청 또는 세션 단위로 서버 사이드에서 수백만 개의 IP 중 하나의 exit를 할당한다. 여러분은 IP를 로드 밸런싱하는 것이 아니다. 여러분이 실제로 설계하는 것은 그 위의 레이어다. 작업이 어떻게 identity에 매핑되는지, 타겟이 어느 정도의 동시성을 견디는지, 요청이 실패했을 때 무슨 일이 일어나는지. 이 세 가지를 제대로 하면 시스템은 확장되고, 잘못하면 아무리 프록시 품질이 좋아도 소용없다.

이 글은 프로덕션에서 수집 파이프라인을 운영하는 팀들을 위해 이 세 가지 레이어를 깊이 다룬다.

게이트웨이가 하는 일, 그리고 여러분의 아키텍처가 시작되는 지점

이 구분을 정확히 짚고 넘어갈 가치가 있다. 무엇을 만들어야 하고 무엇을 만들지 말아야 하는지를 결정하기 때문이다.

게이트웨이가 처리하는 것: 풀에서의 exit IP 선택, geo 매칭, 개별 IP의 상태, 로테이션 메커니즘. 사용자명(country, city, sid, ttl)을 통해 의도를 표현하면 게이트웨이가 그것을 exit로 변환한다.

여러분이 처리하는 것: identity의 단위(무엇이 자신만의 IP를 갖는지, 얼마 동안), 동시성 제어(각 타겟을 얼마나 강하게 밀어붙일지), 재시도 정책(실패 시 무슨 일이 일어나는지), 그리고 관찰 가능성.

가장 흔한 아키텍처 실수는 게이트웨이와 중복되는 프록시 로테이터 레이어를 만드는 것이다. IP 상태 테이블을 유지하고 있다면 멈춰라. 그것은 프로바이더의 일이며, 여러분의 버전은 프로바이더보다 풀에 대한 시야가 더 나쁘다. 여러분의 일은 요청 정책 수준에서 시작된다.

레이어 1: 로테이션 아키텍처, identity의 단위 선택하기

이것이 나머지 모든 것이 걸려 있는 설계 결정이다. 질문은 “얼마나 자주 로테이션해야 하는가”가 아니라 **“하나의 identity란 무엇이며, 어떤 작업이 그것에 속하는가”**다.

두 가지 기본 요소:

  • 요청 단위 로테이션 (sid 생략): 모든 요청이 새로운 exit IP를 받는다. 최대의 IP 다양성, 연결 재사용 없음. 이전 요청에 의존하지 않는 대규모의 독립적인 fetch 세트에 이상적이다.
  • 고정 세션 (sid + ttl): 해당 세션 id를 가진 모든 요청이 TTL이 만료될 때까지 하나의 exit IP를 공유한다. 흐름이 한 사용자처럼 보여야 할 때 필요하며, 풀링된 연결을 재사용 가능하게 만드는 요소이기도 하다(sticky vs rotating 참고).

설계 규칙: 임의로가 아니라 논리적 작업 단위의 경계에서 로테이션하라. 작업 단위란 내부적으로 일관성을 유지해야 하는 것을 의미한다. 하나의 상품 페이지네이션 리뷰, 하나의 검색 흐름, 하나의 계정 세션 등이다. 이를 명확하게 매핑하라:

worker 하나 = 작업 단위 하나 = sid 하나 = exit IP 하나 (해당 TTL 동안)

세션 id는 무작위가 아니라 작업 단위에서 파생시켜서 동작이 재현 가능하도록 하고, 재시도가 identity를 유지할지 바꿀지 의도적으로 결정할 수 있게 하라:

def session_id(job_id: str, attempt: int = 0) -> str:
# Same job → same identity. Bump `attempt` to deliberately get a new IP.
return f"{job_id}-a{attempt}"
def proxy_for(job_id, attempt=0, country="us", ttl=600):
sid = session_id(job_id, attempt)
user = f"{USER}-country-{country}-sid-{sid}-ttl-{ttl}"
url = f"http://{user}:{PASS}@p.shifter.io:443"
return {"http": url, "https": url}

이 하나의 attempt 매개변수가 실제로 아키텍처적인 일을 하고 있다. “다른 identity로 재시도”를 우연이 아니라 의도적인 일급 연산으로 만들어준다.

레이어 2: 동시성 아키텍처, 그리고 왜 호스트 단위인가

동시성의 제약은 거의 프록시 플랜이 아니다(동시 연결 수는 일반적으로 제약이 아니다). 제약은 타겟이 무엇을 견디는가다. 즉 하나의 전역 동시성 제한은 잘못된 형태다. 허용적인 타겟 하나가 취약한 타겟 하나를 굶길 수 있고, 취약한 타겟 하나가 파이프라인 전체를 스로틀링할 수 있다.

전역이 아니라 호스트별로 제한하라:

import asyncio
from collections import defaultdict
# One semaphore per target host, tuned to what that host tolerates
_limits = {"tough-site.example": 4, "open-site.example": 32}
_sems = defaultdict(lambda: asyncio.Semaphore(8)) # sensible default
for host, n in _limits.items():
_sems[host] = asyncio.Semaphore(n)
async def fetch(client, url, host):
async with _sems[host]: # backpressure applied per host
return await client.get(url, timeout=30)

이것이 가져다주는 두 가지 속성. 격리: 느리거나 적대적인 타겟이 모든 worker를 소모할 수 없다. 조정 가능성: 신경 쓰지 않는 타겟은 동시성을 올리고, 차단하는 타겟은 독립적으로 낮출 수 있다.

숫자를 무작정 올리고 싶은 충동은 참아라. 타겟의 허용 범위를 넘어서면 추가 병렬성은 처리량을 사주는 게 아니라 차단을 사게 되며, 차단은 동시성이 얻은 이득보다 재시도에서 더 많은 비용을 치르게 한다(레이턴시 줄이기에서 이 트레이드오프를 다룬다).

레이어 3: 재시도 아키텍처, 성패를 가르는 부분

대부분의 파이프라인이 여기서 실패한다. 단순한 for attempt in range(3): retry()는 안 좋은 오후를 차단을 증폭시키고 대역폭을 태워버리는 재시도 폭풍으로 바꿔버린다. 진짜 재시도 레이어는 네 가지 일을 한다.

1. 재시도 전에 분류하라. 모든 실패가 같지 않으며, 각각에 대한 대응도 다르다:

실패예시올바른 대응
전송 계층timeout, connection reset재시도, 동일 identity로 괜찮음
Rate limit429강하게 백오프, 해당 호스트 전체를 늦춤
차단403, CAPTCHA, 200을 반환하는 소프트 차단새로운 identity로 재시도, IP 재사용 금지
영구적404, 400재시도하지 않음, 기록하고 넘어감

소프트 차단에 주목하라. CAPTCHA 페이지가 담긴 200은 실패다. 상태 코드만으로 분류하면 차단을 성공으로 계산하게 되고, 파이프라인은 조용히 쓰레기 데이터로 채워질 것이다(스크레이퍼가 차단되는 이유).

2. 지터와 함께 백오프하라. 지터 없는 지수 백오프는 worker들을 동기화시켜 타겟을 파도처럼 몰아치는 thundering herd를 만든다. 항상 무작위성을 추가하라.

3. 차단 시 identity를 바꿔라. 동일한 고정 IP로 차단을 재시도하는 것은 같은 거절을 다시 요청하는 것일 뿐이다. attempt 카운터를 올려서 세션 id가 바뀌게 하고 게이트웨이가 다른 exit를 주도록 하라.

import random, asyncio
async def fetch_with_policy(client, url, job_id, host, max_attempts=4):
for attempt in range(max_attempts):
proxies = proxy_for(job_id, attempt=attempt) # new identity per attempt
try:
r = await client.get(url, proxies=proxies, timeout=30)
kind = classify(r) # ok | ratelimit | block | permanent
if kind == "ok":
return r
if kind == "permanent":
return None # don't waste attempts
if kind == "ratelimit":
await slow_down(host) # widen the host's pacing
except (asyncio.TimeoutError, ConnectionError):
pass # transport: retry
backoff = (2 ** attempt) + random.uniform(0, 1) # jitter, always
await asyncio.sleep(backoff)
await dead_letter(job_id, url) # park it, don't block the pipeline
return None

4. 우아하게 포기하라. N번 시도한 후에는 항목을 dead-letter 큐에 넣고 넘어가라. 무제한 재시도는 작업을 구제하지 못한다. 오히려 하나의 실패를 이미 여러분을 거절하고 있는 타겟에 대한 지속적인 부하로 바꿀 뿐이다.

규모가 커지면 만들어둘 가치가 있는 두 가지 패턴이 더 있다: 호스트별 회로 차단기(성공률이 붕괴하면 무리하게 밀어붙이지 말고 잠시 전송을 멈춘다), 그리고 멱등성 있는 작업 항목(재시도가 언제나 안전하게 실행되도록).

참조 구조

전체를 조합하면 파이프라인은 다음과 같은 모습이 된다:

work queue
per-host limiter (semaphore + pacing)
worker → session id (sid) → gateway → target
classify response
├── ok → validate content → emit
├── ratelimit → slow host → backoff + requeue
├── block → new identity → backoff + requeue
└── permanent → record, drop
↓ (after N attempts)
dead-letter queue

모든 화살표는 그렇지 않으면 암묵적으로, 그리고 잘못 내려졌을 결정이다. 명시적으로 만들면 시스템은 무너지는 대신 우아하게 성능이 저하된다.

관찰 가능성: 눈감고 운영할 수는 없다

최소한 다음을 계측하라: (상태 코드가 아니라 콘텐츠 검증을 기반으로 한) 호스트별 성공률, p50/p95 레이턴시, 재시도율과 실패 유형별 분류, 성공한 레코드당 바이트 수. 이 네 가지가 어디에 조치를 취해야 하는지 알려준다. 특정 호스트의 재시도율이 올라간다는 것은 해당 호스트의 동시성을 조여야 한다는 뜻이고, 레코드당 바이트 수가 올라간다는 것은 파싱하지 않는 페이로드를 끌어오고 있다는 뜻이다(대역폭 비용 절감). 측정 방법은 프록시 속도, 성공률, 위치 정확도를 테스트하는 방법에 있다.

피해야 할 안티패턴

  • 게이트웨이 위에 IP 로테이터를 만드는 것. 중복이며, 프로바이더 자체의 밸런싱보다 정보가 부족하다.
  • 하나의 전역 동시성 제한. 관련 없는 타겟들을 결합시킨다. 호스트별로 제한하라.
  • 동일한 고정 IP로 차단을 재시도하는 것. 같은 IP, 같은 답.
  • 지터 없는 백오프. worker들을 파도처럼 동기화시킨다.
  • 무제한 재시도. 차단을 증폭시키고, 대역폭을 태우며, 필연적인 결과를 지연시킬 뿐이다.
  • 200을 성공으로 취급하는 것. 소프트 차단도 200이다. 콘텐츠를 검증하지 않으면 데이터가 조용히 썩어간다.
  • 흐름 중간에 로테이션하는 것. 다단계 세션 안에서 IP를 바꾸는 것은 탐지 신호다. 단위 경계에서 로테이션하라.

FAQ

프록시 로드 밸런싱을 직접 만들어야 하나요? IP 수준에서는 아니다. 게이트웨이가 서버 사이드에서 풀로부터 exit를 할당하므로, 여러분 쪽의 IP 로테이터나 상태 테이블은 중복이다. 그 위의 레이어, 즉 identity 단위, 호스트별 동시성, 재시도 정책을 설계하라.

동시 요청은 몇 개나 실행해야 하나요? 여러분의 플랜이 아니라 타겟에 달려 있다. 해당 호스트가 견디는 것을 기준으로 호스트별 제한을 설정하고 각각을 독립적으로 조정하라. 그 지점을 넘으면 더 많은 병렬성은 처리량이 아니라 차단을 만들어낸다.

재시도는 같은 IP를 써야 하나요, 새 IP를 써야 하나요? 실패에 따라 다르다. 전송 오류(timeout)는 동일 identity로 재시도할 수 있다. 차단과 CAPTCHA는 새로운 identity로 재시도해야 한다. 세션 id를 바꾸는 것이다. 거절당한 것이 바로 그 IP이기 때문이다.

재시도가 차단을 더 악화시키지 않게 하려면 어떻게 해야 하나요? 실패를 분류하고, 지터와 함께 지수적으로 백오프하고, 시도 횟수를 제한하고, 성공하지 못할 것은 dead-letter 처리하고, 호스트별 회로 차단기를 추가하라. 무제한적이고 분류되지 않은 재시도가 작은 차단 문제를 큰 문제로 만드는 방식이다.

프록시 파이프라인에서 무엇을 모니터링해야 하나요? (콘텐츠 검증을 포함한) 호스트별 성공률, p50/p95 레이턴시, 실패 유형별 분류가 포함된 재시도율, 성공한 레코드당 바이트 수. 이 네 가지가 고칠 가치가 있는 문제 대부분을 드러낸다.

결론

게이트웨이 위에서는 풀을 로드 밸런싱하는 것이 여러분의 문제가 아니며, 그렇게 착각하면 팀들은 잘못된 것을 만들게 된다. 수집 파이프라인이 실제로 확장 가능한지를 결정하는 아키텍처는 세 가지 결정에 담겨 있다. 무엇이 하나의 identity를 구성하고 어떤 작업이 그것에 속하는지, 각 개별 타겟이 얼마나 많은 동시성을 견디는지, 그리고 실패가 어떻게 분류되고, 백오프되고, identity를 재부여받고, 결국 포기되는지. 이것들을 제대로 하고 계측하면 파이프라인은 압박 속에서도 무너지지 않고 우아하게 성능이 저하된다.

나머지는 게이트웨이가 처리한다. 이것을 구축하는 중이라면, 레지덴셜 게이트웨이는 사용자명을 통해 로테이션과 고정 세션을 제공하므로 아키텍처는 프록시 관리 레이어가 아니라 여러분의 코드 안에 머물게 되고, 풀 품질은 그 재시도 경로가 애초에 얼마나 자주 발동되는지를 결정한다(IP 평판). 대규모 고려 사항에 대해서는 대규모 스크레이핑을 위한 최고의 레지덴셜 프록시 네트워크를 참고하고, 가격 페이지에서 GB당 플랜을 통해 여러분의 워크로드에 맞춰 이 설계를 테스트해볼 수 있다.

시작할 준비가 되셨나요?

205M개 이상의 IP, 195개 이상의 국가를 지원하는 Shifter의 레지덴셜 프록시를 $0.75/GB부터 이용해보세요.

시작하기