스크래핑 페이지네이션과 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, 상품이 없는 페이지, 다음 링크 없음은 앞으로 쓰는 모든 크롤러에 그대로 가져가세요.

흐름도. 목록 페이지를 가져와 상품 링크와 다음 페이지 링크를 파싱한다. 다음 링크가 없으면 완료로 멈춘다. 있으면 이미 본 URL 인지, robots.txt 가 막는지, max_pages 를 넘는지 확인하고, 해당하면 로그를 남기고 멈춘다. 아니면 크롤링 간격만큼 쉬고 다음 페이지를 가져온다.
polite_crawl.py 의 크롤링 루프. rel="next" 가 없어질 때까지 따라가고, 루프·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 이 아니면 다시 던지고, 맞으면 기다렸다가 재시도합니다. 중요한 점은 두 가지입니다.

  1. Retry-After 가 최우선. 서버가 숫자를 줬으면 정확히 그만큼 기다립니다. 이 코드는 초 단위만 해석하므로 날짜 형식은 백오프로 넘어갑니다. 안전하지만 정확하지는 않습니다.
  2. 헤더가 없으면 지터를 넣은 지수 백오프. 시도할 때마다 상한을 두 배로 늘리고(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]
막대그래프. 대기도 재시도도 없음 0.37초(대부분 놓침). 대기 없음 + Retry-After 6.58초. 대기 없음 + 백오프와 지터 5.73초. 고정 0.2초 대기 7.05초, 429 0회. Crawl-delay 대로 1초 대기 28.66초.
연습용 쇼핑몰(초당 5 요청 허용)에서 목록 5 페이지와 상세 23 페이지(총 28 GET)를 모으는 총 시간의 중앙값(각 3회, Python 3.9.6, Apple M1 Pro, 127.0.0.1 만). 가장 빠른 설정은 429 때문에 상세 23 페이지 중 19~23 페이지를 놓쳐 쓸모가 없다.

읽는 법은 이렇습니다.

  • 대기 없음·재시도 없음 은 가장 빠르고 가장 나쁩니다. 상품 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]
429 횟수 막대그래프. 워커 1개 3회, 2개 7회, 4개 15회, 8개 27회, 16개 40회, 공유 초당 5 리미터를 단 워커 4개는 0회.
스레드 풀로 상세 23 페이지를 가져올 때의 429 횟수(3회 중앙값, 429 는 Retry-After 뒤 재시도). 총 시간은 워커 수와 관계없이 4.3~4.7초였다. 서버의 초당 5 요청이 천장이라 워커를 늘리면 거절되는 요청만 늘어난다. 모든 스레드가 공유하는 초당 5 리미터를 쓰니 429 가 0 회가 됐다.

총 시간은 전혀 움직이지 않았습니다. 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 확장이 있습니다. 여기서는 설명만 하고 실행하지 않았습니다. 표준 라이브러리 버전은 그 안에서 무슨 일이 일어나는지 보여 주기 위한 것입니다.
  • 이 글은 법률 자문이 아닙니다. 사이트 약관과 해당 지역 법을 확인하세요.

연습 문제

  1. fetch() 는 Retry-After: 1 형식만 이해합니다. HTTP 날짜 형식(Retry-After: Sat, 14 Nov 2026 23:20:26 GMT)도 받도록 고쳐 보세요.
  2. 쇼핑몰의 LIMIT 을 3 으로 바꾸면 "워커 4개 + 초당 3 리미터"로 23 페이지에 몇 초가 걸릴지, 실행하기 전에 예상해 보세요.

정답

  1. 표준 라이브러리의 email.utils.parsedate_to_datetime(ra) 로 날짜를 만들고 datetime.now(timezone.utc) 와의 차이를 구한 뒤 범위를 제한합니다. wait = max(0, min(60, (when - now).total_seconds())). 해석할 수 없는 헤더는 백오프로 보내고, 잘못된 헤더 때문에 한 시간씩 멈추지 않게 상한을 둡니다.
  2. 리미터는 요청 시작을 1/3초 간격으로 벌리고 첫 요청은 바로 시작하므로, 간격 22번 × 0.333초 ≈ 7.33초에 마지막 페이지 렌더링 50ms 가 더해집니다. 실행해 보니 3번 모두 7.40초, 거절 0회, 요청 23번이었습니다(앞의 초당 5 리미터 4.47초도 같은 계산, 22 × 0.2 + 0.05 입니다). 429 가 나온다면 리미터가 서버보다 빠르거나, 다른 클라이언트가 같은 한도를 나눠 쓰고 있는 겁니다.

다음 글

자주 묻는 질문

요청 사이에 얼마나 쉬어야 하나요?

사이트가 정한 규칙부터 따릅니다. 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회였습니다.

  • #스크래핑
  • #중급
  • #파이썬
  • #페이지네이션
  • #속도 제한
  • #429
  • #재시도

t.laboratory

직접 만들고 운영하는 앱·서버·서비스에서 겪은 문제와, 그 해결에 쓴 코드를 기록하는 기술 블로그입니다.

운영자 소개 · 앱

↑