스크래핑 페이지네이션과 429 대응: 대기·재시도·동시성 실측
웹 스크래핑 강좌 3. rel=next 를 따라가는 종료 조건, 429 와 Retry-After, 시드 고정 지터 백오프, 그리고 스레드 16개가 1개보다 빠르지 않았던 이유를 실측으로.
시리즈: 웹 스크래핑 강좌 3/6 (중급). 한 페이지는 잘 가져오는 스크래퍼를 이제 50 페이지짜리 목록에 돌려야 하는 분을 위한 글입니다. 막히지 않고, 상대 서버에 부담도 주지 않으면서요. 다 읽으면 "다음" 링크를 분명한 종료 조건과 함께 따라가고, 429 Too Many Requests 를 서버가 원하는 방식으로 처리하고, 스레드를 늘려도 빨라지지 않는 이유를 숫자로 설명할 수 있습니다.
흔한 상황은 이렇습니다. 쇼핑몰 여러 곳의 가격 비교표를 만들거나, 공공 사이트에 올라온 목록을 정리해 달라는 요청을 받았습니다. 목록이 여러 페이지로 나뉘어 있고, 1 페이지는 완벽하게 가져왔는데 반복문으로 바꾸자마자 끝나지 않거나, 6 페이지에서 HTTP Error 429 로 죽거나, "정상 종료"했는데 상품 절반이 빠져 있습니다. 이 강좌에서는 세 가지를 모두 내 컴퓨터에서 돌리는 연습용 쇼핑몰로 실제로 일으키고, 짧은 코드로 고칩니다.
모든 코드는 Apple Silicon Mac(M1 Pro)의 Python 3.9.6(표준 라이브러리만), PHP 8.5.6(php -S), curl 8.7.1 로 실행했고, 접속 대상은 127.0.0.1 뿐입니다. 경로는 ~/demo/ 로 바꿨습니다.
코드보다 먼저: 규칙 확인
페이지네이션과 동시성은 스크래퍼가 "스크립트 하나"에서 "남의 서버에 걸리는 부하"로 바뀌는 지점입니다. 1강의 규칙이 여기서 더 무거워집니다.
- 공식 API 나 데이터 제공이 있으면 그것부터. 공공데이터라면 공공데이터포털의 오픈 API 나 파일 데이터를 먼저 찾아보세요. 스크래핑보다 빠르고, 형식이 안정적이며, 상대 서버에도 부담이 없습니다. 민간 쇼핑몰도 제휴 API 나 상품 피드를 제공하는 경우가 많습니다.
- robots.txt 와 이용약관을 읽고 지킨다. 약관이 자동 수집을 금지하면 수집하지 않습니다. 다른 사람이 공들여 만든 데이터베이스를 통째로 복제하는 일은 데이터베이스 관련 권리나 약관 문제로 이어질 수 있습니다. 판단이 서지 않으면 사이트 약관과 해당 지역 법을 확인하고 운영자에게 문의하세요(이 글은 법률 자문이 아닙니다).
- 봇이라고 정직하게 밝힌다. User-Agent 는
tlab-practice-bot/0.1 (+https://example.com/bot-contact). 이름과 연락처입니다. 브라우저인 척하지 않습니다. - 느려 달라고 하면 느려진다. 429 와 503 은 장애물이 아니라 부탁입니다. 이 강좌는 제한을 피해 가는 방법을 전혀 다루지 않고, 제한 안에서 움직이는 방법만 다룹니다.
- 개인정보는 모으지 않는다. 여기서 모으는 것은 상품명과 가격뿐입니다.
그래서 아래의 모든 요청은 우리가 직접 만든 쇼핑몰로 갑니다.
연습용 쇼핑몰: 초당 5 요청까지
아래를 ~/demo/shop.php 로 저장합니다. 5 페이지짜리 목록에 상품 23개, 상품별 상세 페이지, Crawl-delay: 1 이 적힌 robots.txt, 그리고 속도 제한이 있습니다. 같은 1초 안에 5번을 넘으면 429 와 Retry-After: 1 을 돌려줍니다. 실제 백엔드처럼 페이지마다 50ms 씩 쉽니다.
<?php
// Practice shop, lecture 3 part. Loopback only:
// PHP_CLI_SERVER_WORKERS=4 php -S 127.0.0.1:18166 shop.php
const PER_PAGE = 5; // products per list page
const LIMIT = 5; // requests allowed per second (whole site)
const WORK_MS = 50; // pretend each page costs 50 ms to render
$path = parse_url($_SERVER['REQUEST_URI'], PHP_URL_PATH);
if ($path === '/robots.txt') {
header('Content-Type: text/plain');
echo "User-agent: *\nDisallow: /cart\nCrawl-delay: 1\n";
return true;
}
function over_limit(string $file): bool { // fixed 1-second window, shared by workers
$fp = fopen($file, 'c+');
flock($fp, LOCK_EX);
[$sec, $n] = array_map('intval', explode(' ', trim(stream_get_contents($fp)) ?: '0 0'));
$now = time();
$n = ($sec === $now) ? $n + 1 : 1;
ftruncate($fp, 0); rewind($fp); fwrite($fp, "$now $n");
flock($fp, LOCK_UN); fclose($fp);
return $n > LIMIT;
}
if (over_limit(__DIR__ . '/rate.txt')) {
http_response_code(429);
header('Retry-After: 1');
echo "Too Many Requests: max " . LIMIT . " per second\n";
return true;
}
$names = ['Desk lamp','USB-C cable','Notebook A5','Mechanical pencil','Mug','Tote bag','Sticky notes',
'Phone stand','Desk mat','Cable clips','Water bottle','Bookends','Highlighter set','Laptop sleeve',
'Coaster set','Ruler 30cm','Eraser','Paper clips','Monitor riser','Pen case','Calendar 2027',
'Clipboard','Stapler'];
$products = [];
foreach ($names as $i => $n) {
$products[$i + 1] = ['name' => $n, 'price' => 300 + (($i * 397) % 2700), 'stock' => ($i % 4) ? 'in' : 'out'];
}
usleep(WORK_MS * 1000);
header('Content-Type: text/html; charset=utf-8');
if ($path === '/products') {
$pages = (int)ceil(count($products) / PER_PAGE);
$p = max(1, (int)($_GET['page'] ?? 1));
if ($p > $pages) { http_response_code(404); echo "<p>No such page</p>\n"; return true; }
echo "<h1>Products (page $p of $pages)</h1>\n<ul class=\"products\">\n";
foreach (array_slice($products, ($p - 1) * PER_PAGE, PER_PAGE, true) as $id => $x) {
echo "<li><a href=\"/product/$id\">{$x['name']}</a> <span class=\"price\">{$x['price']}</span></li>\n";
}
echo "</ul>\n";
if ($p < $pages) echo '<a rel="next" href="/products?page=' . ($p + 1) . "\">Next</a>\n";
return true;
}
if (preg_match('#^/product/(\d+)$#', $path, $m) && isset($products[(int)$m[1]])) {
$x = $products[(int)$m[1]];
echo "<h1>{$x['name']}</h1><p class=\"price\">{$x['price']}</p><p class=\"stock\">{$x['stock']}</p>\n";
return true;
}
http_response_code(404);
echo "<p>Not found</p>\n";
return true;
PHP_CLI_SERVER_WORKERS=4 를 주면 내장 서버가 워커 프로세스 4개로 돌아(macOS·Linux 지원, Windows 미지원) 요청을 동시에 처리합니다. flock 은 여러 프로세스 사이에서 카운터를 정확히 유지하기 위한 것입니다. curl 로 7번 연달아 요청하면 제한이 보입니다.
$ cd ~/demo && PHP_CLI_SERVER_WORKERS=4 php -S 127.0.0.1:18166 shop.php &
$ for i in 1 2 3 4 5 6 7; do curl -s -o /dev/null -w "%{http_code} " http://127.0.0.1:18166/product/$i; done
200 200 200 200 200 429 429
$ curl -si http://127.0.0.1:18166/product/1 | head -7
HTTP/1.1 429 Too Many Requests
Host: 127.0.0.1:18166
Date: Sat, 14 Nov 2026 23:20:25 GMT
Connection: close
X-Powered-By: PHP/8.5.6
Retry-After: 1
Content-type: text/plain;charset=UTF-8
이런 고정 윈도 방식은 달력상의 1초마다 요청 수를 셉니다. 실제 사이트는 슬라이딩 윈도, 토큰 버킷, IP·계정별 한도 등 여러 방식을 쓰고, 대부분은 어떤 방식인지 알려 주지 않습니다. 밖에서 보이는 것은 상태 코드와 헤더뿐입니다.
"다음" 링크 따라가기, 그리고 언제 멈출까
페이지를 넘기는 방법은 두 가지입니다. 흔히 쓰는 쪽은 ?page=1, ?page=2… 처럼 뭔가 깨질 때까지 숫자를 올리는 방식입니다(counter.py).
import time
import urllib.request
page = 1
while True:
url = f"http://127.0.0.1:18166/products?page={page}"
html = urllib.request.urlopen(url, timeout=10).read()
print(page, len(html))
page += 1
time.sleep(0.25)
$ python3 counter.py
1 573
2 573
3 587
4 576
5 373
Traceback (most recent call last):
…
urllib.error.HTTPError: HTTP Error 404: Not Found
이 쇼핑몰은 마지막 페이지 다음에서 깔끔하게 404 를 돌려주니까 "동작"합니다. 하지만 실제 사이트 중에는 빈 목록을 200 으로 돌려주는 곳, 마지막 페이지를 계속 보여 주는 곳, page=999 를 1 페이지로 리다이렉트하는 곳이 있습니다. 숫자를 세는 방식은 끝나지 않거나 엉뚱한 이유로 끝납니다. 더 튼튼한 방법은 사이트가 준 링크(<a rel="next">)를 따라가고 종료 조건을 명시적으로 적는 것입니다. 크롤러 전체가 ~/demo/polite_crawl.py 입니다.
"""Follow rel="next" pages politely, retry 429 with Retry-After / backoff. Python 3.9+ stdlib only."""
import random
import sys
import time
import urllib.error
import urllib.request
import urllib.robotparser
from html.parser import HTMLParser
from urllib.parse import urljoin
BASE = "http://127.0.0.1:18166"
UA = "tlab-practice-bot/0.1 (+https://example.com/bot-contact)"
rng = random.Random(42) # fixed seed: the same jitter on every run
stats = {"requests": 0, "429": 0, "slept": 0.0}
class ListPage(HTMLParser):
"""Collect product links and the rel="next" link."""
def __init__(self):
super().__init__()
self.items, self.next = [], None
def handle_starttag(self, tag, attrs):
a = dict(attrs)
if tag == "a" and a.get("rel") == "next":
self.next = a.get("href")
elif tag == "a" and a.get("href", "").startswith("/product/"):
self.items.append(a["href"])
def nap(sec):
stats["slept"] += sec
time.sleep(sec)
def fetch(url, retries=5, base=0.25, cap=4.0, use_retry_after=True):
"""GET url. On 429/503 wait (Retry-After or backoff with full jitter) and retry."""
for attempt in range(retries + 1):
req = urllib.request.Request(url, headers={"User-Agent": UA})
stats["requests"] += 1
try:
with urllib.request.urlopen(req, timeout=10) as r:
return r.read().decode("utf-8")
except urllib.error.HTTPError as e:
if e.code not in (429, 503) or attempt == retries:
raise
stats["429"] += e.code == 429
ra = e.headers.get("Retry-After")
if use_retry_after and ra and ra.isdigit():
wait = int(ra) # the server told us
else:
wait = rng.uniform(0, min(cap, base * 2 ** attempt)) # full jitter
nap(wait)
def crawl(start, delay=None, max_pages=50, **kw):
robots = urllib.robotparser.RobotFileParser(urljoin(start, "/robots.txt"))
robots.read()
if delay is None:
delay = robots.crawl_delay(UA) or 1.0
url, seen, products = start, set(), []
while url:
if url in seen: # stop condition 1: loop
print("loop detected at", url); break
if len(seen) >= max_pages: # stop condition 2: safety cap
print("max_pages reached"); break
if not robots.can_fetch(UA, url): # stop condition 3: disallowed
print("robots.txt disallows", url); break
seen.add(url)
page = ListPage()
page.feed(fetch(url, **kw))
if not page.items: # stop condition 4: empty page
print("empty page", url); break
products += [urljoin(url, h) for h in page.items]
url = urljoin(url, page.next) if page.next else None # stop condition 5: no next
if url:
nap(delay)
return products
if __name__ == "__main__":
d = float(sys.argv[1]) if len(sys.argv) > 1 else None
t0 = time.perf_counter()
found = crawl(BASE + "/products?page=1", delay=d)
print(f"{len(found)} product URLs, {stats['requests']} requests, {stats['429']} x 429, "
f"slept {stats['slept']:.2f} s, total {time.perf_counter() - t0:.2f} s")
print(found[:3], "...")
$ python3 polite_crawl.py
23 product URLs, 5 requests, 0 x 429, slept 4.00 s, total 4.32 s
['http://127.0.0.1:18166/product/1', 'http://127.0.0.1:18166/product/2', 'http://127.0.0.1:18166/product/3'] ...
인자 없이 실행하면 robots.txt 에서 Crawl-delay: 1 을 읽어 목록 페이지 사이마다 1초씩 쉽니다. 5 페이지라 대기는 4번입니다. urljoin 은 상대 href 를 전체 URL 로 바꿉니다(문자열을 이어 붙이면 안 되는 이유는 2강). 다섯 가지 종료 조건, 즉 루프 감지, 페이지 수 상한, robots.txt, 상품이 없는 페이지, 다음 링크 없음은 앞으로 쓰는 모든 크롤러에 그대로 가져가세요.
429 Too Many Requests: 서버가 하는 말
이제 상세 페이지입니다. 순진한 반복문 naive.py 는 23 페이지를 최대 속도로 가져옵니다.
import urllib.request
UA = "tlab-practice-bot/0.1 (+https://example.com/bot-contact)"
for i in range(1, 24):
req = urllib.request.Request(f"http://127.0.0.1:18166/product/{i}", headers={"User-Agent": UA})
html = urllib.request.urlopen(req, timeout=10).read()
print(i, len(html))
$ python3 naive.py
1 147
2 150
3 151
4 163
5 136
6 145
Traceback (most recent call last):
File "~/demo/naive.py", line 5, in <module>
html = urllib.request.urlopen(req, timeout=10).read()
…
urllib.error.HTTPError: HTTP Error 429: Too Many Requests
6 페이지까지 통과한 건 연속 요청이 우연히 초 경계를 걸쳤기 때문입니다. urlopen 은 4xx·5xx 응답마다 HTTPError 를 던지므로 스크립트가 여기서 죽습니다. 429 Too Many Requests(RFC 6585)는 "바로 당신이 너무 빠르다"는 뜻입니다. 응답에 Retry-After 헤더가 있으면 초 단위 숫자나 HTTP 날짜로 언제 다시 오면 되는지 알려 줍니다(헤더 정의는 RFC 9110). Retry-After 가 붙은 503 Service Unavailable 은 점검 중처럼 서버 전체에 대해 같은 말을 합니다.
위의 fetch() 는 둘 다 처리합니다. HTTPError 를 잡고, 429/503 이 아니면 다시 던지고, 맞으면 기다렸다가 재시도합니다. 중요한 점은 두 가지입니다.
- Retry-After 가 최우선. 서버가 숫자를 줬으면 정확히 그만큼 기다립니다. 이 코드는 초 단위만 해석하므로 날짜 형식은 백오프로 넘어갑니다. 안전하지만 정확하지는 않습니다.
- 헤더가 없으면 지터를 넣은 지수 백오프. 시도할 때마다 상한을 두 배로 늘리고(0.25, 0.5, 1, 2, 4초, 최대 4초), 0 부터 그 상한 사이의 무작위 시간만큼 쉽니다("풀 지터" 방식). 지터가 없으면 같은 초에 거절된 클라이언트들이 또 같은 초에 한꺼번에 재시도합니다. 난수는
random.Random(42)라서 대기 시간이 매번 같습니다. 처음 여섯 번은 이랬습니다.
ceilings: [0.25, 0.5, 1.0, 2.0, 4.0, 4.0]
waits: [0.16, 0.013, 0.275, 0.446, 2.946, 2.707]
시드 고정은 테스트와 실측 비교를 위한 것이고, 운영 환경에서는 보통 고정하지 않습니다. 시간·난수와 테스트의 관계는 불안정한 테스트: 시간, 난수, 동시성에서 다룹니다.
실측: 대기 없음 vs 고정 대기 vs 백오프
bench_delay.py 는 목록 5 페이지를 따라가고 상세 23 페이지를 받습니다(총 28 GET). 다섯 가지 설정을 각각 3번, 매번 1.1초 쉬고 새 윈도에서 시작합니다. 시간은 중앙값, 목록은 3번의 값입니다.
no delay, no retry median 0.37 s | 429s [22, 23, 19] | details ok [1, 0, 4] failed [22, 23, 19] | requests [28, 28, 28]
no delay, Retry-After median 6.58 s | 429s [5, 5, 4] | details ok [23, 23, 23] failed [0, 0, 0] | requests [33, 33, 32]
no delay, backoff+jitter median 5.73 s | 429s [15, 15, 15] | details ok [23, 23, 23] failed [0, 0, 0] | requests [43, 43, 43]
fixed 0.2 s, Retry-After median 7.05 s | 429s [0, 0, 0] | details ok [23, 23, 23] failed [0, 0, 0] | requests [28, 28, 28]
fixed 1.0 s (Crawl-delay) median 28.66 s | 429s [0, 0, 0] | details ok [23, 23, 23] failed [0, 0, 0] | requests [28, 28, 28]
읽는 법은 이렇습니다.
- 대기 없음·재시도 없음 은 가장 빠르고 가장 나쁩니다. 상품 23개 중 0~4개만 받았습니다. 에러를 로그에 남기고 넘어가는 구조였다면 가격 비교표에는 "완료"라고 찍히고 대부분의 행이 비어 있었을 겁니다.
- Retry-After 와 백오프 는 둘 다 전부 받았습니다. 이번에는 백오프가 0.85초 빨랐는데, 무작위 대기가 Retry-After 의 1초보다 짧은 경우가 많았기 때문입니다. 대신 요청이 33번이 아니라 43번이었고 그중 15번이 거절됐습니다. 실제 사이트라면 거절된 요청도 서버가 처리한 요청입니다.
- 고정 0.2초 대기(초당 5회의 역수)는 429 가 0회, 요청은 정확히 28번이었습니다. 재시도 폭풍보다 0.5초 느릴 뿐입니다. 이것이 예의 바른 정상 상태입니다.
- Crawl-delay: 1 은 그보다 4배 느립니다. 내 사이트가 아니라면 이 숫자를 씁니다. 여기서 더 빨리 간 것은 제한을 우리가 직접 정했기 때문일 뿐입니다.
그러다 함정 하나를 밟았습니다. urllib.robotparser 는 Crawl-delay 에 정수만 받습니다. Python 3.9.6 과 3.12.14 모두 같았습니다.
'1' -> crawl_delay() = 1
'0.5' -> crawl_delay() = None
Crawl-delay: 0.5 라고 적은 사이트에서는 None 이 나오고, None 을 "대기 없음"으로 처리하는 크롤러는 최대 속도로 달립니다. crawl() 이 0 이 아니라 or 1.0 으로 기본값을 잡는 이유입니다. Crawl-delay 는 robots.txt 표준(RFC 9309)에 들어 있지 않아 무시하는 대형 크롤러도 있지만, 그게 우리가 무시해도 되는 이유는 아닙니다.
워커를 늘려도 같은 속도: 스레드 풀과 속도 제한
다음에 꼭 나오는 생각이 "스레드를 쓰자"입니다. 네트워크 대기가 대부분인 작업이라면 파이썬에서도 스레드가 쓸모 있습니다. 소켓을 기다리는 동안 GIL 이 풀리기 때문입니다(자세한 내용은 스레드, 프로세스, asyncio 와 GIL). bench_pool.py 는 ThreadPoolExecutor 로 상세 23 페이지를 받으면서 워커 수를 바꿔 비교합니다. 429 는 모두 Retry-After 만큼 기다렸다가 재시도합니다. 마지막 줄들은 모든 스레드가 공유하는 작은 리미터를 단 결과입니다.
class Limiter:
"""Shared by all threads: at most `rate` request starts per second."""
def __init__(self, rate):
self.gap, self.next, self.lock = 1.0 / rate, 0.0, threading.Lock()
def wait(self):
with self.lock:
now = time.monotonic()
t = max(now, self.next)
self.next = t + self.gap
time.sleep(max(0.0, t - now))
1 workers median 4.28 s | 429s [3, 3, 3] | requests [26, 26, 26]
2 workers median 4.66 s | 429s [7, 6, 8] | requests [30, 29, 31]
4 workers median 4.40 s | 429s [15, 15, 15] | requests [38, 38, 38]
8 workers median 4.30 s | 429s [27, 19, 27] | requests [50, 42, 50]
16 workers median 4.32 s | 429s [40, 40, 40] | requests [63, 63, 63]
1 workers + limiter 5/s median 4.47 s | 429s [0, 0, 0] | requests [23, 23, 23]
4 workers + limiter 5/s median 4.47 s | 429s [0, 0, 0] | requests [23, 23, 23]
8 workers + limiter 5/s median 4.47 s | 429s [0, 0, 0] | requests [23, 23, 23]
총 시간은 전혀 움직이지 않았습니다. 23 페이지를 초당 5개로 받으면 약 4.6초이고, 무엇을 해도 그대로입니다. 스레드 16개는 23 페이지를 받으려고 63번 요청했을 뿐입니다. 리미터를 달면 같은 속도에 거절은 0회입니다. 사이트가 속도를 제한할 때 속도를 정하는 것은 제한이고, 동시성이 정하는 것은 낭비되는 요청 수입니다. 동시성이 도움이 되는 건 병목이 내 쪽 대기(느린 응답, 제한 없음)일 때뿐이고, 그때도 풀은 2~4개로 두고 앞에 공유 리미터를 둡니다.
흔한 실수 → 실제로 일어난 일 → 해결
| 실수 | 실제로 일어난 일 | 해결 |
|---|---|---|
| 에러가 날 때까지 페이지 번호 증가 | 여기선 404 로 멈춤, 다른 사이트에선 안 멈추거나 반복 | rel="next" 를 따라가고 seen·max_pages 유지 |
urlopen 을 try 로 감싸지 않음 |
6번째에서 HTTPError: HTTP Error 429 로 종료 |
HTTPError 를 잡고 429/503 만 재시도 |
| 다 잡고 계속 진행 | 23개 중 0~4개로 "완료" | 실패 수를 세고 빠진 게 있으면 실패 처리 |
| 기다리지 않고 바로 재시도 | 429 증가, 부하 증가 | Retry-After, 없으면 지터 백오프 |
crawl_delay() 의 None 을 0 으로 |
Crawl-delay: 0.5 가 None, 최대 속도 |
안전한 기본값(1초) |
| 빨라지려고 스레드 증가 | 16개여도 4.3초, 거절 40회 | 공유 리미터, 풀은 최대 2~4 |
판단표: 어떤 대기 전략을 쓸까
| 상황 | 쓸 것 | 이유 |
|---|---|---|
| robots.txt 에 Crawl-delay 가 있음 | 최소한 그 간격 | 사이트가 밝힌 바람 |
| API 호출 한도가 공개됨(예: 분당 60회) | 그보다 약간 낮은 공유 리미터 | 429 없음, 시간 예측 가능 |
| Retry-After 가 붙은 429/503 | Retry-After 대로 대기 | 윈도는 서버가 안다 |
| Retry-After 없는 429 | 지터 지수 백오프(상한·최대 횟수) | 재시도를 흩뜨리고 결국 포기 |
| 느리게 해도 429 가 계속됨 | 멈추고 약관·API 확인 | 그 양은 환영받지 못한다 |
| 내 사이트, 제한 없음 | 작은 풀(2~4) | 대기는 겹치되 넘치지 않게 |
솔직한 한계와 버전 메모
- 모든 숫자는 한 대의 기계(Apple M1 Pro)에서 루프백으로 잰 값입니다. PHP 8.5.6 내장 서버(워커 4개)와 인위적인 50ms 지연을 썼습니다. 인터넷 너머라면 요청마다 지연이 더해지지만 "제한이 천장"이라는 모양은 같습니다.
- 초 경계 근처의 타이밍은 흔들립니다. 위에 실은 파일 그대로 풀 실측을 한 번 더 했더니 같은 경향에 약간의 편차가 있었습니다. 워커 수와 관계없이 4.3~5.4초, 워커 1개에서 거절 3~4회, 16개에서 27~40회, 리미터를 달면 4.46초에 거절 0회였습니다.
- 고정 윈도는 여러 방식 중 하나일 뿐이라 실제 사이트에서는 윈도 경계에서의 동작과 숫자가 다릅니다.
- Python 3.9.6 에서 확인했고,
robotparser는 3.12.14 에서도 확인했습니다.RobotFileParser.crawl_delay()는 Python 3.6 부터,ThreadPoolExecutor는 3.2 부터 있습니다. 외부 패키지는 쓰지 않았습니다. requests와urllib3.util.Retry를 쓰면 백오프와 Retry-After 처리를 맡길 수 있고(respect_retry_after_header), Scrapy 에는 AutoThrottle 확장이 있습니다. 여기서는 설명만 하고 실행하지 않았습니다. 표준 라이브러리 버전은 그 안에서 무슨 일이 일어나는지 보여 주기 위한 것입니다.- 이 글은 법률 자문이 아닙니다. 사이트 약관과 해당 지역 법을 확인하세요.
연습 문제
fetch()는Retry-After: 1형식만 이해합니다. HTTP 날짜 형식(Retry-After: Sat, 14 Nov 2026 23:20:26 GMT)도 받도록 고쳐 보세요.- 쇼핑몰의
LIMIT을 3 으로 바꾸면 "워커 4개 + 초당 3 리미터"로 23 페이지에 몇 초가 걸릴지, 실행하기 전에 예상해 보세요.
정답
- 표준 라이브러리의
email.utils.parsedate_to_datetime(ra)로 날짜를 만들고datetime.now(timezone.utc)와의 차이를 구한 뒤 범위를 제한합니다.wait = max(0, min(60, (when - now).total_seconds())). 해석할 수 없는 헤더는 백오프로 보내고, 잘못된 헤더 때문에 한 시간씩 멈추지 않게 상한을 둡니다. - 리미터는 요청 시작을 1/3초 간격으로 벌리고 첫 요청은 바로 시작하므로, 간격 22번 × 0.333초 ≈ 7.33초에 마지막 페이지 렌더링 50ms 가 더해집니다. 실행해 보니 3번 모두 7.40초, 거절 0회, 요청 23번이었습니다(앞의 초당 5 리미터 4.47초도 같은 계산, 22 × 0.2 + 0.05 입니다). 429 가 나온다면 리미터가 서버보다 빠르거나, 다른 클라이언트가 같은 한도를 나눠 쓰고 있는 겁니다.
다음 글
- 이전: 웹 스크래핑 강좌 2/6: html.parser 로 HTML 파싱하기
- 다음: 웹 스크래핑 강좌 4/6: 세션, 폼, 쿠키, CSRF
- 시리즈 처음: 웹 스크래핑 강좌 1/6: robots.txt 와 urllib
- 상태 코드 자세히: HTTP 상태 코드와 리다이렉트
- API 와 서버 사이에 캐시가 있을 때: CDN 이 API 응답과 리다이렉트를 캐시하는 문제
- 모든 강좌: 프로그래밍 학습 로드맵
자주 묻는 질문
요청 사이에 얼마나 쉬어야 하나요?
사이트가 정한 규칙부터 따릅니다. robots.txt 의 Crawl-delay, 공개된 API 호출 한도, 429 응답의 Retry-After 입니다. 초당 5 요청을 허용하는 연습용 쇼핑몰에서는 고정 0.2초 대기로 28 페이지를 429 없이 7.05초에 받았고, Crawl-delay: 1 을 따르면 28.66초였습니다. 내 사이트가 아니라면 느린 쪽 숫자를 씁니다.
Retry-After 와 지수 백오프 중 무엇을 써야 하나요?
Retry-After 가 먼저입니다. 서버가 언제 다시 오라고 정확히 알려 주기 때문입니다. 헤더가 없을 때만 지터를 넣은 지수 백오프를 씁니다. 실측에서는 백오프만 쓴 쪽이 조금 빨랐지만(5.73초 대 6.58초) 거절된 요청이 5회에서 15회로 늘었습니다. 거절된 요청도 서버 자원을 씁니다.
스레드를 늘리면 스크래퍼가 빨라지나요?
사이트가 속도 제한을 걸고 있다면 아닙니다. 스레드 1, 2, 4, 8, 16개 모두 23 페이지에 4.3~4.7초가 걸렸습니다. 서버가 초당 5 요청까지만 받기 때문입니다. 늘어난 것은 429 횟수로, 3회에서 40회가 됐습니다. 모든 스레드가 공유하는 리미터를 달자 같은 속도에 429 는 0회였습니다.