스크래핑

레지덴셜 프록시 재시도 로직: 실패 및 속도 제한 요청 처리하기

재시도는 반복이 아니라 결정이다. 나쁜 1분을 나쁜 하루로 만들지 않도록 재시도를 막는 분류 표, 코드, 그리고 안전장치를 소개한다.

Chris Collins

Chris Collins

2026년 8월 28일 · 6 분 소요

모든 스크레이퍼에는 재시도 로직이 필요하며, 대부분은 우연히 자라난다. 여기 try 블록 하나, 저기 time.sleep 하나, 뭔가 고장 났던 주에 추가한 시도 카운터. 그 결과물은 타깃이 나쁜 시간을 보내는 날까지는 작동하지만, 그 시점부터 재시도 경로는 원래의 실패보다 더 큰 피해를 입힌다. 왜 그런 일이 벌어지는지에 대한 논거는 재시도와 백오프에 있으며, 이 글은 그 구현이다.

핵심 아이디어는 재시도가 요청을 감싼 루프가 아니라 분류된 실패로부터 내려지는 결정이라는 것이다. 분류를 제대로 하면 나머지는 저절로 따라온다.

먼저 분류하라

모든 실패는 네 가지 부류 중 하나에 속하며, 각각에는 정확히 하나의 올바른 대응이 있다.

부류예시대응
일시적(Transient)타임아웃, 연결 재설정, 502, 503, 504백오프를 적용해 재시도
속도 제한(Rate)429, Retry-After 존재지시받은 대로 대기, 동일 경로 유지
아이덴티티(Identity)챌린지 페이지, 지속적인 403, 차단 페이지새 세션 후 재시도
종결(Terminal)404, 400, 401, 407, 파싱 실패재시도하지 말고 그대로 노출

이 중 두 가지가 사람들이 자주 틀리는 부분이다. 속도 제한(Rate) 신호는 페이싱에 관한 지시이므로 대응은 주소를 바꾸는 것이 아니라 대기하는 것이다. 같은 속도를 유지하기 위해 로테이션하는 행위야말로 스로틀을 차단으로 확대시키는 바로 그 행동이기 때문이다. 종결(Terminal) 실패는 절대 재시도해서는 안 된다. 407은 자격 증명이나 잘못된 사용자명 플래그를 의미하며 다섯 번째 시도에서도 똑같이 틀릴 것이고, 파싱 실패는 코드의 버그이며 재시도는 그것을 영원히 충실하게 재현할 뿐이다.

다섯 번째 경우는 상태 코드에 전혀 나타나지 않는 경우다. 겉보기엔 멀쩡하지만 실제로는 아닌 응답이다. 챌린지 페이지, 빈 결과 집합, 200으로 제공된 잘린 목록은 아이덴티티 부류에 속하지만, 오직 당신이 그것을 알아챘을 때만 그렇다. 즉 무언가 결정하기 전에 본문을 검증해야 한다는 뜻이다. 이 검사는 전체 시스템의 토대이며, 차단되거나 가짜인 콘텐츠 탐지하기에 자세히 다뤄져 있다.

코드로 보는 분류

import random, time, requests

TRANSIENT = {502, 503, 504}

def classify(resp, exc, is_valid):
    if exc is not None:
        return "transient"                      # timeout, reset, DNS
    if resp.status_code == 429:
        return "rate"
    if resp.status_code in TRANSIENT:
        return "transient"
    if resp.status_code in (403, 401) or looks_like_challenge(resp):
        return "identity"
    if resp.status_code == 200 and not is_valid(resp.text):
        return "identity"                       # soft block: 200 but not our data
    if resp.ok:
        return "ok"
    return "terminal"                           # 404, 400, 407, everything else

looks_like_challenge는 타깃마다 다르며 보통 짧은 마커 목록이다: 캡차 스크립트 참조, 알려진 인터스티셜 타이틀, 실제 페이지보다 훨씬 짧은 본문. 타깃당 이 목록을 한 곳에 유지해서 재시도 경로와 모니터링이 동일한 정의를 사용하도록 하라.

지터를 곁들인 백오프, 그리고 Retry-After 존중하기

일시적(transient) 부류의 경우 지연은 지수적으로 커지되 반드시 무작위화되어야 한다. 고정된 지연은 워커들을 동기화시켜서, 함께 실패한 백 개의 요청이 함께 재시도하게 되고, 그 폭주는 백오프를 통과해 살아남는다.

def backoff(attempt, base=1.0, cap=60.0):
    ceiling = min(cap, base * (2 ** attempt))
    return random.uniform(0, ceiling)           # full jitter

속도 제한(rate) 부류의 경우 타깃이 정확히 얼마나 기다려야 하는지 알려줄 수 있으며, 그 지시는 당신 자신의 스케줄보다 우선한다:

def wait_for(resp, attempt):
    ra = resp.headers.get("Retry-After")
    if ra:
        try:
            return min(float(ra), 300)          # honour it, but cap it
        except ValueError:
            pass                                 # HTTP-date form, fall through
    return backoff(attempt, base=2.0)            # rate signals start slower

전체를 조합하기

MAX_ATTEMPTS = 4

def fetch(url, country, is_valid, session_id=None):
    sid = session_id
    for attempt in range(MAX_ATTEMPTS):
        proxies = build_proxies(country, sid)     # sid=None means rotate
        resp = exc = None
        try:
            resp = requests.get(url, proxies=proxies, timeout=20)
        except requests.RequestException as e:
            exc = e

        kind = classify(resp, exc, is_valid)

        if kind == "ok":
            return resp
        if kind == "terminal":
            raise TerminalError(url, getattr(resp, "status_code", None))
        if kind == "identity":
            sid = new_session_id() if sid else None   # retire the session
            time.sleep(backoff(attempt))
        elif kind == "rate":
            time.sleep(wait_for(resp, attempt))       # wait, do not rotate
        else:
            time.sleep(backoff(attempt))

    raise Exhausted(url)

여기서 구조보다 중요한 세부 사항이 세 가지 있다. 이 함수는 종결(terminal) 오류에서 None을 반환하는 대신 예외를 발생시키므로, 호출자가 실패를 빈 데이터로 착각할 수 없다. 아이덴티티 실패는 세션을 재사용하는 대신 교체하는데, 이전 세션은 이미 타깃에 알려져 있기 때문이다. 그리고 속도 제한 실패는 의도적으로 세션을 건드리지 않고 동일한 경로를 유지하면서 속도만 늦춘다.

재시도가 문제 자체가 되지 않도록 막는 안전장치

요청 단위 로직만으로는 충분하지 않은데, 시스템 전체를 조망하는 시야가 없기 때문이다. 세 가지 추가 요소가 보호 작업의 대부분을 담당한다.

**재시도 예산(retry budget)**은 타깃에 대한 전체 트래픽 대비 재시도 비율을 예를 들어 10퍼센트로 상한을 둔다. 정상 운영 중에는 절대 이 한도에 근접하지 않는다. 타깃이 전반적으로 고장 나면 예산은 즉시 소진되고 추가 재시도는 그냥 일어나지 않는데, 이것이 바로 원하는 동작이다. 재시도는 고립된 실패에는 도움이 되지만 전반적인 장애 상황에서는 적극적으로 해를 끼치기 때문이다.

**타깃별 서킷 브레이커(circuit breaker)**는 실패율이 임계값을 넘으면 전송을 완전히 멈추고, 쿨다운 기간을 기다린 뒤, 회복 여부를 시험하기 위해 소량만 통과시킨다. 이는 당신이 몰려드는 것으로부터 타깃을 보호하고, 아무도 응답하지 않는 사이트에 대해 실패가 쌓이는 것으로부터 당신의 주소들을 보호한다.

재시도도 통과해야 하는 공유 속도 제한기(rate limiter). 재시도가 페이싱을 우회한다면, 타깃이 가장 감당하기 어려운 바로 그 순간에 당신의 오류 경로가 스로틀되지 않은 홍수가 되어버린다. 모든 시도를 동일한 제한기를 통해 라우팅하라. 속도 제한과 스로틀링 참조.

이 세 가지 모두 이미 모든 요청을 보고 있는 컴포넌트에 속해야 하며, 이것이 작업별 재시도 코드보다 프록시 매니저를 두어야 하는 논거다.

멱등성과 데드레터 큐

잊기 쉬운 두 가지 실무적 관심사가 있다.

재시도는 작업이 반복해도 안전하다고 가정한다. 수집 작업에서는 거의 항상 그렇다. 페이지를 두 번 가져오는 것은 대역폭 외에는 아무 비용도 들지 않기 때문이다. 파이프라인의 어떤 부분이든 가져오기의 부수 효과로 쓰기를 수행한다면, 안정적인 무언가를 키로 삼아 그 쓰기를 멱등하게 만들어서 재시도가 중복 레코드를 만들지 않도록 하라.

그리고 요청이 시도 횟수를 다 소진했을 때 그것을 버리지 마라. 데드레터 큐로 보내서 현재의 폭주 안에서가 아니라 다음 실행 때나 긴 쿨다운 이후처럼 훨씬 나중에 다시 처리하라. 사고 중에 실패한 대부분의 항목은 한 시간 뒤에 스스로 성공하며, 큐는 아무 비용 없이 확실한 실패를 유예된 실패로 바꿔준다.

재시도율을 지켜보라

재시도는 당신이 얻는 가장 이른 경고다. 재시도 비율, 즉 타깃당 요청 대비 재시도 비중은 성공률이 떨어지기 전에 상승하는데, 정상처럼 보이는 결과로 재시도를 거듭해서 도달하는 파이프라인은 문제를 해결하는 것이 아니라 숨기고 있는 것이기 때문이다. 이는 또한 대역폭 과금 상품에서 순수한 비용이기도 하므로 건강 지표이자 지출 지표를 동시에 나타낸다. 검증된 성공률 옆에 대시보드에 올려두라. 프록시 KPI파이프라인 모니터링 참조.

결론

재시도 로직은 카운터가 달린 루프가 아니라 분류기와 그에 뒤따르는 네 가지 대응이다. 소프트 차단이 실패로 분류되도록 본문을 검증하고, 속도 제한 신호에는 로테이션 없이 대기하고, 아이덴티티 실패에는 세션을 폐기하고, 일시적 실패에는 지터를 적용해 백오프하며, 종결 오류는 절대 재시도하지 마라. 시도 횟수와 지연에 상한을 두라. 그런 다음 요청 단위 시야로는 제공할 수 없는 안전장치를 추가하라. 광범위한 장애가 홍수로 번지지 않도록 하는 재시도 예산, 타깃별 서킷 브레이커, 재시도도 존중해야 하는 공유 제한기. 소진된 요청은 나중 실행을 위해 데드레터 큐로 보내고, 무언가 변하고 있다는 가장 이른 신호로서 재시도 비율을 지켜보라.

이것의 페일오버 절반, 즉 깨끗하게 재시도할 곳이 있다는 것은 레지덴셜 프록시가 제공하는 것이다. 폐기된 세션이 재사용되지 않고 교체되도록 하는 실제 가정용 등급 주소들의 대규모 풀과, 절제된 재시도를 절제되지 않은 재시도보다 직접적으로 더 저렴하게 만드는 GB당 가격 정책이 그것이다.

시작할 준비가 되셨나요?

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

시작하기