스크레이퍼가 느린 응답을 만나면 재시도한다. 재시도도 실패하면 다시 즉시 재시도하고, 같은 순간에 같은 벽에 부딪힌 다른 모든 워커도 마찬가지로 재시도한다. 몇 초 안에 이미 트래픽을 줄여달라고 말하던 사이트에 트래픽 폭주를 퍼붓게 되고, 일시적인 스로틀링으로 시작된 것이 관련된 모든 주소에 대한 강력한 차단으로 바뀐다. 사이트가 대응 수위를 높인 것이 아니다. 당신의 재시도 루프가 그렇게 만든 것이다.
이것은 잘 만들어진 스크레이퍼가 스스로를 해치는 가장 흔한 방식이며, 완전히 피할 수 있는 문제다. 재시도 로직은 부하 분산 메커니즘이지 끈질기게 밀어붙이는 메커니즘이 아니며, 이 두 개념의 차이가 곧 우아하게 성능이 저하되는 작업과 결국 차단당하는 작업의 차이다. 더 폭넓은 위생 수칙은 차단 피하기에서 다루며, 여기서는 재시도 경로 자체의 메커니즘을 다룬다.
단순한 재시도가 상황을 악화시키는 이유
세 가지 역학이 함께 겹쳐 작용한다.
첫째, 부하가 문제가 되는 바로 그 순간에 재시도가 부하를 더한다. 429나 느린 응답은 트래픽을 줄여달라는 요청인데, 여기에 더 많은 요청으로 답하는 것은 그 신호를 정반대로 뒤집는 일이다. 둘째는 동기화다. 같은 순간에 실패하고 같은 고정된 간격으로 대기하는 워커들은 같은 순간에 재시도도 하게 되므로, 트래픽이 분산되는 대신 조율된 폭주로 도착하고, 그 폭주의 각 라운드가 다음 라운드를 다시 동기화시킨다. 셋째, 재시도는 보통 주소별로 카운트되므로, 같은 IP에서 대상을 계속 두드리면 그 주소는 스로틀링 단계에서 플래그 처리 단계로 넘어가는데, 이는 사고가 끝난 뒤에도 남아 다음 작업까지 따라다니는 평판 문제다.
이 모든 것이 합쳐지면, 단순한 루프는 회복 가능한 상태를 정확히 안티봇 시스템이 잡아내도록 설계된 바로 그 트래픽 형태로 만들어 버린다. 같은 실패라도 절제된 방식으로 처리했다면 저절로 해소되었을 것이다.
재시도하기 전에 분류하라
첫 번째 원칙은 모든 실패가 재시도할 가치가 있는 것은 아니며, 그럴 가치가 있는 것들도 서로 다른 방식으로 재시도해야 한다는 것이다. 응답을 세 가지 범주로 나누어야 한다.
일부 실패는 일시적이며 재시도할 가치가 있다: 연결 재설정, 타임아웃, 502, 503, 504, 429가 그렇다. 이들은 일시적으로 불가능한 상태를 나타내는 것이지 거부 의사를 나타내는 것이 아니며, 특히 429는 페이스에 관한 명시적 지시이지 거절이 아니다. 일부는 최종적이며 절대 재시도해서는 안 된다: 404, 400, 401, 여러 주소에서 지속되는 403, 그리고 깔끔하게 파싱되었지만 원하는 내용이 전혀 없었던 페이지가 그렇다. 이런 것들을 재시도하면 절대 바뀌지 않을 결과를 위해 대역폭과 평판을 소모하는 것이며, 파싱 실패는 재시도가 영원히 충실하게 재현할 코드 버그다.
세 번째 범주가 위험한 것이다: 성공한 것처럼 보이지만 실제로는 그렇지 않은 응답들이다. 챌린지 페이지, 일반적이거나 비어 있는 결과, 잘린 목록, 랜딩 페이지로의 리다이렉트는 모두 200 상태 코드와 함께 도착할 수 있고, 상태 코드만 믿는 스크레이퍼는 이것들을 기꺼이 데이터로 기록해 버린다. 응답을 성공으로 카운트하기 전에 본문을 검증해야 하며, 이것이 차단되거나 가짜인 콘텐츠 감지하기의 핵심 내용이다. 소프트 블록은 재시도 가능한 실패이지만, 그것이 실패임을 알아차렸을 때만 그렇다.
지수 백오프, 그리고 지터가 선택 사항이 아닌 이유
재시도 가능한 범주의 경우, 시도 간의 지연은 늘어나야 하며, 표준 형태는 지수적이다: 1초 대기, 그다음 2초, 4초, 8초. 늘어나는 것이 중요한 이유는 어려움을 겪고 있는 대상에게 일정한 북소리 대신 점점 더 많은 여유를 주기 때문이다.
하지만 지수 백오프만으로는 충분하지 않으며, 이 부분을 사람들이 놓친다. 100개의 워커가 함께 실패하고 모두 정확히 1초를 대기한다면, 1초 뒤에 함께 재시도한다. 백오프는 늘어났지만 폭주는 그대로 남았고, 결국 같은 스파이크를 타임라인상에서 옮겨놓은 것에 불과하다. 해결책은 지터다: 계산된 값을 그대로 쓰는 대신 각 지연을 무작위화하는 것이다. 풀 지터, 즉 0부터 현재 상한값 사이에서 무작위로 선택한 대기 시간을 사용하면, 동기화된 실패를 매끄러운 재시도 분포로 흩어놓을 수 있다. 이것은 두 줄짜리 변경이며 이 글에서 가장 효과적인 단일 조치다.
여기에 두 가지 규칙이 더 붙는다. 사이트가 Retry-After를 보낼 때는 이를 준수해야 하는데, 이 헤더는 대상이 정확히 얼마나 기다려야 하는지 알려주는 것이므로 자체 일정을 앞세워 이를 무시하는 것은 무례할 뿐 아니라 역효과다. 그리고 지연 시간과 시도 횟수 모두에 상한을 두어야 하는데, 다섯 번 실패한 요청이 여섯 번째에 성공할 리는 없고, 무한정 재시도하는 것은 이미 실패한 것을 절대 포기하지 않는 느린 방식일 뿐이다.
import random, time
import requests
PROXY = "http://customer-USERNAME-country-us:PASSWORD@p.shifter.io:443"
PROXIES = {"http": PROXY, "https": PROXY}
TRANSIENT = {429, 502, 503, 504}
MAX_ATTEMPTS = 5
BASE, CAP = 1.0, 60.0
def fetch(url):
for attempt in range(MAX_ATTEMPTS):
try:
r = requests.get(url, proxies=PROXIES, timeout=20)
except requests.RequestException:
pass # transient: fall through to backoff
else:
if r.status_code == 200 and is_valid(r.text):
return r.text # validate the body, not just the code
if r.status_code not in TRANSIENT:
return None # terminal: do not retry
after = r.headers.get("Retry-After")
if after:
time.sleep(min(float(after), CAP)) # the target told you the answer
continue
ceiling = min(CAP, BASE * (2 ** attempt))
time.sleep(random.uniform(0, ceiling)) # full jitter, not a fixed delay
return None
로테이션이냐 대기냐: 재시도가 흔히 잘못 판단하는 결정
레지덴셜 프록시를 사용할 때는 두 번째 축이 존재한다. 실패에는 시간으로, 다른 주소로, 또는 두 가지 모두로 대응할 수 있는데, 잘못 선택하면 둘 중 하나를 낭비하게 된다.
신호가 속도에 관한 것이라면 대기하라. 429나 Retry-After는 대상이 당신의 페이스가 너무 높다고 말하는 것이며, 같은 페이스를 유지하기 위해 새 주소로 바꾸는 것은 정확히 회피 행위처럼 보여서 개별 경로 하나가 아니라 풀 전체가 차단되는 행동이다. 대신 속도를 늦춰라.
신호가 주소에 관한 것이라면 로테이션하라. 차단 페이지, 지속되는 403, 또는 한 경로에서 계속 나타나는 챌린지는 그 특정 주소가 더 이상 신뢰받지 못한다는 뜻이며, 기다린다고 해서 복원되지 않는다. 그 경로를 은퇴시키고 새 경로로 계속 진행하는 것이 페일오버 패턴이며, 대체 주소의 평판이 그 재시도가 실제로 도움이 될지를 결정한다는 점에 유의하라. 로테이션이 틀린 유일한 경우는 세션 중간 작업이다: 플로우가 유지된 아이덴티티에 의존한다면 주소를 바꾸면 플로우가 깨지므로, 스티키 세션 내부에서의 실패는 기존 세션 아래에서 주소만 바꾸는 것이 아니라 새 세션으로 시퀀스를 다시 시작하는 것을 의미한다.
타임아웃은 이 둘 사이에 위치하며 반사적으로 대응하기보다 그 자체로 진단할 가치가 있는데, 요청이 타임아웃되는 이유에는 대상의 느림, 상태가 나쁜 경로, 그리고 자신의 동시성이 너무 높은 경우가 포함되기 때문이다.
예산과 서킷 브레이커
요청별 재시도 규칙만으로는 충분하지 않은데, 이는 시스템 전체를 조망하는 시야가 없기 때문이다. 두 가지 메커니즘이 그 시야를 제공한다.
재시도 예산은 전체 트래픽 대비 비율로 재시도를 제한한다. 예를 들어 특정 대상에 대한 요청의 최대 10퍼센트만 재시도로 허용하는 식이다. 정상적인 상황에서는 이 예산이 전혀 소모되지 않는다. 뭔가가 광범위하게 고장 나면 예산은 즉시 소진되고 추가 재시도는 그냥 일어나지 않는데, 이것이 바로 원하는 속성이다: 재시도는 고립된 실패에는 도움이 되지만 전반적인 장애 상황에서는 적극적으로 해롭고, 예산이 그 차이를 자동으로 구분해 준다.
서킷 브레이커는 한 발 더 나아간다. 대상별 실패율을 추적하고, 그것이 임계값을 넘으면 그 대상에 대한 전송을 일정 냉각 기간 동안 완전히 멈추는 것이지, 실패할 것이 뻔한 요청을 조금씩 계속 흘려보내며 대상을 계속 찔러보지 않는다. 냉각 기간이 지나면 소수의 요청만 통과시키고, 성공하면 재개한다. 이는 당신이 몰려드는 것으로부터 대상을 보호하고, 지금 아무에게도 응답하지 않는 사이트에 대해 실패를 계속 쌓아가는 것으로부터 당신의 주소들을 보호한다. 두 메커니즘 모두 전역이 아니라 대상별로 적용되어야 하는데, 하나의 고장 난 사이트가 다른 50개 사이트에서 수집 중인 작업을 절대 멈추게 해서는 안 되기 때문이다.
재시도는 비용이 들고 데이터 속에 숨는다
명확히 짚을 만한 두 가지 결과가 있다.
모든 재시도는 비용을 지불하는 요청이다. 대역폭 기준으로 가격이 책정되는 레지덴셜 프록시에서는 재시도 폭풍이 하나의 비용 항목이 되며, 오후 내내 다운되어 있는 사이트에 대해 조용히 다섯 번씩 재시도하는 작업은 아무 소득 없이 놀라운 양의 데이터를 소비할 수 있는데, 이는 프록시 대역폭 비용 절감하기에서 다루는 비용 구조에 덜 명백하게 기여하는 요인 중 하나다.
그리고 재시도율은 대시보드에 넣어야 할 선행 지표다. 특정 대상에서 재시도율이 상승하는 것은 그 대상의 방어 체계가 바뀌었거나 당신의 페이스가 흐트러졌다는 가장 이른 경고이며, 성공률이 무너지기 훨씬 전에 나타나므로, 파이프라인 모니터링은 최종 결과뿐 아니라 시도와 결과를 함께 추적해야 한다. 재시도를 통해 조용히 정상처럼 보이는 성공률에 도달하는 스크레이퍼는 문제를 해결하는 것이 아니라 숨기고 있는 것이다.
마지막으로, 요청이 시도 횟수를 다 소진했을 때는 그것을 버리지 마라. 데드레터 큐로 보내고 현재의 폭주 안에서가 아니라 다음 실행 때나 긴 냉각 기간 이후에 훨씬 나중에 재시도하라. 사고 도중에 실패한 대부분의 항목은 한 시간 후에는 저절로 성공한다. 큐는 확정적인 실패를 유예된 실패로 바꿔준다.
핵심 요약
재시도 로직은 일시적인 실패를 흡수하기 위해 존재하는 것이지, 고집을 부리기 위한 것이 아니다. 행동에 나서기 전에 실패를 분류하고, 진정으로 일시적인 것만 재시도하며, 응답 본문을 검증하여 소프트 블록을 실제 실패로 취급하라. 지수적으로 백오프하되 항상 지터와 함께 하고, Retry-After를 준수하며, 지연과 시도 횟수 모두에 상한을 두어라. 대기를 요구하는 속도 신호와 로테이션을 요구하는 주소 신호를 구분하고, 같은 페이스를 유지하기 위해 주소를 계속 바꾸는 방식으로 스로틀링에 대응하지 마라. 재시도 예산과 대상별 서킷 브레이커를 추가하여 광범위한 장애가 자초한 홍수로 번지지 않도록 하라. 이렇게 하면 팀들이 안티봇 대응 수위 상승 탓으로 돌리는 대부분의 차단은 애초에 대응 수위 상승이 아니었으므로 그냥 일어나지 않게 된다.
나머지 절반은 실패했을 때 넘어갈 곳을 갖추는 것이며, 이것이 레지덴셜 프록시가 제공하는 것이다: 실제 가정용 IP로 이루어진 대규모 풀 덕분에 은퇴한 경로가 같은 주소로 다시 시도하는 대신 깨끗한 경로로 대체된다. GB당 가격 정책 또한 절제된 재시도가 스스로 비용을 상쇄하는 이유인데, 실제로 만든 요청에 대해서만 비용을 지불하며, 결코 보내지 않은 폭풍은 결코 구매하지 않은 대역폭이기 때문이다.