모든 스크래퍼는 실패한 요청을 재시도하며, 대부분 재시도 횟수를 같은 방식으로 정합니다: 3회, 적당해 보인다는 이유로요. 하지만 재시도는 공짜가 아닙니다. 기가바이트 단위로 과금되는 레지덴셜 프록시에서는 재시도할 때마다 페이지를 다시 다운로드하게 되고, 어떤 시스템에서든 대상 서버에 시간과 부하가 듭니다. 그리고 각 재시도는 보통 이전 시도보다 성공 가능성이 낮습니다.
따라서 추가 시도의 비용이 그 가치보다 커지는 지점이 존재합니다. 이 글에서는 실제 사례와 직접 데이터에 적용할 수 있는 코드를 통해, 그 지점을 자신의 로그에서 찾는 방법을 보여줍니다.
Why later retries succeed less often
실패가 순전히 운이 나쁜 것이었다면, 모든 재시도는 첫 시도와 동일한 성공 확률을 가질 것입니다. 하지만 실제로는 그렇지 않습니다. 한 번 실패한 요청은 지속되는 이유로 실패하고 있을 가능성이 더 큽니다: 존재하지 않는 페이지, 해당 주소에 대한 차단, 다운된 사이트, 결코 일치하지 않는 파서 등입니다. 4번째 시도까지 도달하는 작업은 대부분 어려운 사례들입니다.
즉 시도당 성공률은 떨어지고, 그에 따라 추가 성공 하나당 비용은 올라간다는 뜻입니다.
Measure it from your logs
필요한 데이터는 단 하나, 각 작업에 대해 성공한 시도 번호, 혹은 모든 시도가 실패했다는 기록뿐입니다. 대부분의 스크래퍼는 이미 이를 기록하고 있습니다. 이로부터, 시도 k의 성공률은 시도 k까지 도달해서 그 시도에서 성공한 작업의 비율로 구할 수 있습니다.
from collections import Counter
def attempt_success_rates(outcomes, max_attempts):
"""Chance that attempt k succeeds, given the job got that far.
outcomes: per job, the attempt number that succeeded, or None if every
attempt failed (with max_attempts tries).
"""
succeeded_at = Counter(o for o in outcomes if o is not None)
reached = len(outcomes)
rates = []
for k in range(1, max_attempts + 1):
rates.append(succeeded_at[k] / reached if reached else 0.0)
reached -= succeeded_at[k]
return rates
def cost_per_success(rates, cap, attempt_cost):
"""Expected cost of one successful job if we stop after `cap` attempts."""
p_reach, spent, success = 1.0, 0.0, 0.0
for rate in rates[:cap]:
spent += p_reach * attempt_cost
success += p_reach * rate
p_reach *= 1 - rate
return spent / success if success else float("inf")
def best_cap(rates, attempt_cost, value_of_success):
"""The last attempt whose expected value still exceeds its cost."""
cap = 0
for k, rate in enumerate(rates, start=1):
if rate * value_of_success > attempt_cost:
cap = k
return cap
세 가지 함수가 있습니다:
attempt_success_rates는 로그를 시도별 성공률로 변환합니다.cost_per_success는 주어진 시도 횟수에서 멈출 경우 성공한 작업 하나당 평균 비용을 알려줍니다.best_cap은 기대 가치가 여전히 비용보다 큰 마지막 시도를 찾아냅니다.
A worked example
다음은 각각 최대 6회 시도가 허용된 10,000개 작업에 대한 예시 로그입니다:
from retry_econ import attempt_success_rates, best_cap, cost_per_success
# Illustrative outcomes for 10,000 jobs, each allowed up to six attempts:
# the attempt that succeeded, or None if all six failed.
outcomes = [1] * 8200 + [2] * 900 + [3] * 260 + [4] * 90 + [5] * 40 + [6] * 20 + [None] * 490
rates = attempt_success_rates(outcomes, max_attempts=6)
print("success rate by attempt:", [round(r, 3) for r in rates])
for cap in range(1, 7):
print(f"cap {cap}: cost per success {cost_per_success(rates, cap, attempt_cost=1.0):.3f}")
print("best cap if a success is worth 5 attempts:", best_cap(rates, attempt_cost=1.0, value_of_success=5.0))
print("best cap if a success is worth 20 attempts:", best_cap(rates, attempt_cost=1.0, value_of_success=20.0))
success rate by attempt: [0.82, 0.5, 0.289, 0.141, 0.073, 0.039]
cap 1: cost per success 1.220
cap 2: cost per success 1.297
cap 3: cost per success 1.357
cap 4: cost per success 1.412
cap 5: cost per success 1.464
cap 6: cost per success 1.514
best cap if a success is worth 5 attempts: 3
best cap if a success is worth 20 attempts: 5
세 가지가 눈에 띕니다.
성공률이 빠르게 떨어집니다. 첫 시도는 82%의 확률로 성공합니다. 두 번째 시도에 도달한 작업 중에서는 절반이 성공합니다. 다섯 번째 시도에 이르면 성공률은 10개 중 1개 미만으로 떨어집니다.
추가 성공 하나하나의 비용은 이전보다 커집니다. 시도 1회의 비용을 해당 시도의 성공률로 나누면 그 시도에서 얻는 성공 하나의 비용이 나옵니다: 첫 시도에서는 약 1.2회 분, 두 번째는 2회 분, 세 번째는 3.5회 분, 네 번째는 7회 분, 여섯 번째는 25회 분을 넘습니다. 시도 횟수를 늘릴수록 성공당 평균 비용은 완만하게 올라가는데, 이는 추가 시도마다 대상이 되는 작업 수가 줄어들기 때문이지만, 한계 비용은 가파르게 상승합니다.
적절한 상한선은 성공의 가치에 달려 있습니다. 성공한 레코드 하나의 가치가 시도 5회 분이라면, 3회 후에 멈추십시오. 반드시 확보해야 하는 페이지처럼 그 가치가 20회 분이라면, 5회까지 가십시오. 모두에게 맞는 단 하나의 숫자는 없으며, 각 가치마다 적절한 숫자가 따로 있습니다.
Putting a value on a success
성공의 가치는 여러분이 직접 정해야 하는 유일한 입력값입니다. 이를 설정하는 몇 가지 방법은 다음과 같습니다:
- 대체 가능한 레코드, 예를 들어 매일 수집하는 많은 가격 관측치 중 하나는 개별적으로 가치가 낮습니다. 하나를 놓쳐도 괜찮으며, 낮은 상한선이 적합합니다.
- 필수 레코드, 예를 들어 고객이 기다리는 데이터셋을 완성시키는 페이지는 훨씬 더 가치가 큽니다. 더 높은 상한선이 타당합니다.
- 대역폭 외의 비용도 고려해야 합니다: 시간, 대상 서버에 걸리는 부하, 그리고 실패하는 페이지를 계속 두드리면 다른 모든 것까지 차단될 위험 등입니다.
성공한 응답에 대해서만 과금하는 웹 스크래핑 API에서는 재시도의 금전적 부담이 제공업체 쪽으로 넘어가지만, 시간과 대상 서버 부하는 여전히 적용됩니다. 예를 들어 Shifter의 Web Scraping API는 실패한 요청에 대해서는 과금하지 않습니다.
Making retries cheaper
상한선 설정은 절반에 불과합니다. 나머지 절반은 각 재시도의 성공 가능성을 높이는 것입니다:
- 재시도 전에 대기하고, 매번 더 오래 대기하십시오. 많은 실패는 저절로 해소되는 일시적인 속도 제한입니다.
- 실패가 무언가를 시사한다면 그것을 바꾸십시오. 한 주소에서의 차단은 다른 주소에서는 성공할 수 있고, 타임아웃은 같은 요청을 반복하는 것이 아니라 더 긴 타임아웃이 필요할 수 있습니다.
- 성공할 수 없는 요청은 재시도하지 마십시오. 404나 인증 오류는 매번 실패합니다.
웹 스크래퍼를 위한 재시도와 백오프 가이드에서 이러한 기법을 자세히 다룹니다.
Run it on your own data
- 일주일치 작업에 대해, 성공한 시도 번호 또는 실패 표시를 내보내십시오.
- 현재의 최대 시도 횟수로
attempt_success_rates를 실행하십시오. - 성공 하나가 시도 몇 회 분의 가치가 있는지 정하십시오.
- 상한선을
best_cap이 반환하는 값으로 설정하십시오. 작업 유형별로 다르다면 유형별로 설정하십시오. - 매달 다시 확인하십시오. 사이트와 여러분의 설정이 바뀌면 비율도 달라집니다.
FAQ
스크래퍼는 실패한 요청을 몇 번 재시도해야 할까요?
이는 시도당 성공률이 얼마나 빠르게 떨어지는지, 그리고 성공한 레코드의 가치가 얼마인지에 달려 있습니다. 로그에서 각 시도의 성공률을 측정한 다음, 기대 가치가 여전히 비용보다 큰 마지막 시도에서 멈추십시오. 많은 작업에서는 이것이 2회에서 4회 사이입니다.
재시도는 왜 첫 시도보다 성공률이 낮을까요?
실패는 무작위가 아니기 때문입니다. 한 번 실패한 작업은 누락된 페이지나 차단처럼 지속되는 문제를 가지고 있을 가능성이 더 크므로, 이후의 각 시도는 더 어려운 사례들로부터 나오게 됩니다.
레지덴셜 프록시에서는 재시도가 비용을 발생시키나요?
그렇습니다. 레지덴셜 프록시 트래픽은 용량 단위로 과금되므로, 재시도할 때마다 페이지를 다시 다운로드하게 됩니다. 큰 오류 페이지를 반환하는 실패한 시도 역시 대역폭 비용이 듭니다.
404 오류는 재시도해야 하나요?
아닙니다. 존재하지 않는 페이지는 다음 시도에서도 나타나지 않습니다. 타임아웃, 속도 제한, 일시적인 서버 오류처럼 나중에 성공할 수 있는 실패만 재시도하십시오.
The bottom line
재시도는 다음 시도가 성공할 확률에 성공의 가치를 곱한 값이 시도 비용 아래로 떨어지기 전까지는 할 가치가 있습니다. 여러분의 로그에는 이미 모든 시도의 성공률이 담겨 있습니다. 이를 측정하고, 성공에 가치를 매기고, 습관이 아니라 숫자에 근거해 상한선을 설정하십시오.