본문 바로가기
🤖 1인 에이전트 구축기

정책을 지키는 웹 수집기: robots.txt·429·재시도·증분 수집

by BRIEFER 2026. 6. 15.

웹 수집기는 차단을 피하는 도구가 아니라, 허용된 데이터만 예측 가능한 속도로 가져오고 거부 신호가 오면 멈추는 클라이언트로 설계해야 한다. 먼저 공식 API·RSS/Atom 피드를 찾고, 웹 문서 수집이 필요한 경우에만 robots.txt와 이용약관을 확인한 뒤 도메인별 속도 제한, 식별 가능한 User-Agent, 제한된 재시도를 적용한다.

먼저 결정할 것: HTML을 수집해야 하는가

구현 순서는 다음과 같다.

  1. 공식 API가 있으면 인증·할당량·저장 조건을 확인하고 API를 사용한다.
  2. API가 없고 RSS/Atom·사이트맵·내보내기 기능이 있으면 목적에 맞는 피드를 우선한다. 사이트맵은 검색엔진용 URL 목록일 수 있으므로 본문 재사용 허가를 뜻하지는 않는다.
  3. 그래도 HTML이 필요하면 이용약관, robots.txt, 저작권, 개인정보 처리 근거를 각각 확인한다.
  4. 허용 범위가 불명확하거나 로그인·유료 장벽·접근 제어를 넘어야 한다면 자동 수집을 시작하지 말고 운영자에게 API나 서면 허가를 요청한다.

robots.txt 허용은 이용약관이나 법적 허가를 대신하지 않는다. 반대로 robots.txt는 접근 제어 장치도 아니다. 민감한 URL을 숨기는 용도로 사용해서는 안 된다는 점을 Google의 robots.txt 소개도 설명한다.

RFC 9309 기준으로 robots.txt를 처리한다

Robots Exclusion Protocol의 표준은 RFC 9309다. 수집기는 대상 URL과 같은 scheme·host·port의 /robots.txt를 가져와 자신의 product token에 맞는 규칙을 적용해야 한다. 규칙은 경로 접두사 중 가장 구체적인 일치를 기준으로 판단하며, allow와 disallow가 동등하게 구체적이면 allow가 우선한다.

운영 구현에서 특히 구분할 상태는 다음과 같다.

robots.txt 결과 RFC 9309의 의미 이 글의 보수적 운영 정책
2xx 파일을 파싱해 적용 일치하는 Disallow 경로는 요청하지 않음
리디렉션 제한된 횟수 안에서 따라감 최종 응답을 기록하고 리디렉션 루프·범위 초과 시 중단
4xx robots.txt가 unavailable인 경우 수집기가 리소스에 접근할 수 있음 데모 코드는 404만 “파일 없음”으로 처리하고, 401·403·429 등은 사람 확인 전 중단
5xx·네트워크 오류 robots.txt가 unreachable인 경우 complete disallow로 간주 대상 origin의 작업을 중단하고 이전의 유효한 캐시가 있는지 검토

RFC가 허용하는 범위보다 더 보수적으로 멈추는 것은 애플리케이션 정책이다. robots.txt 응답은 캐시하되 무기한 고정하지 말고, 표준의 캐시 규칙과 응답 헤더에 맞춰 갱신한다. HTML을 가져온 뒤 금지 여부를 판단하는 것이 아니라 요청 전에 검사해야 한다.

robots.txt의 Crawl-delay는 RFC 9309에 정의된 필드가 아니다. 특정 서비스가 별도로 문서화하지 않았다면 자체 속도 제한 정책을 명시적으로 설정한다.

식별 가능한 User-Agent를 보낸다

브라우저 문자열을 흉내 내지 말고 수집기 이름, 버전, 설명 페이지 또는 연락처를 제공한다.

PolicyCollector/1.0 (+https://example.com/crawler-info; contact: crawler@example.com)

example.comexample.com 이메일은 예시용 자리표시자다. 운영 전에는 실제로 관리하는 설명 페이지와 연락처로 바꾸고, 페이지에는 수집 목적·대상·요청 빈도·중단 요청 방법을 적는다. 인증 토큰, 세션 쿠키, 개인정보가 포함된 URL은 로그에 남기지 않는다.

도메인별 속도와 동시성을 제한한다

속도 제한은 차단 임계값을 추측해 최대한 밀어붙이는 기능이 아니다. 서버 부하와 계약된 할당량을 넘지 않기 위한 상한이다.

  • origin(scheme·host·port)별 큐를 둔다.
  • 처음에는 동시성 1로 시작하고 요청 사이 최소 간격을 둔다.
  • API 문서나 운영자가 더 낮은 한도를 제시하면 그 값을 우선한다.
  • 응답 시간, 429, 5xx, robots.txt 변경을 기록하고 부하 신호가 증가하면 감속한다.
  • 여러 워커를 쓰면 프로세스별 제한이 아니라 Redis 같은 공유 저장소에서 origin별 토큰 버킷이나 다음 허용 시각을 원자적으로 관리한다.

이 글의 데모에 들어간 간격·시도 횟수는 로컬 테스트를 빨리 끝내기 위한 예시 정책이지 모든 사이트에 적합한 권장 수치가 아니다.

429는 Retry-After를 우선한다

HTTP 429를 정의한 RFC 6585 §4에 따르면 429는 일정 시간 동안 너무 많은 요청을 보냈음을 뜻한다. 응답에는 Retry-After가 포함될 수 있다. Retry-After의 문법은 RFC 9110 §10.2.3에 정의되어 있으며, 초 단위 정수 또는 HTTP 날짜가 올 수 있다.

처리 순서는 다음과 같다.

  1. Retry-After가 유효하면 그 시각보다 일찍 재요청하지 않는다.
  2. 헤더가 없거나 파싱할 수 없으면 지수 백오프와 무작위 지터를 적용한다. 여기서 지터는 인간 행동 모사가 아니라 여러 정상 클라이언트가 동시에 재시도하는 현상을 줄이기 위한 분산 기법이다.
  3. 대기 시간이 전체 작업 deadline을 넘으면 재시도하지 않고 큐를 연기하거나 실패로 종료한다.
  4. 최대 시도 횟수를 넘기면 중단한다.

429이면 항상 300초 대기 같은 고정 규칙은 사용하지 않는다. 서버 지시, API 계약, 작업 deadline을 함께 판단해야 한다.

상태별 재시도와 중단 조건

신호 기본 동작 이유·예외
200~299 검증 후 저장 예상 콘텐츠 유형·스키마가 아니면 성공으로 집계하지 않음
robots.txt Disallow 즉시 중단 다른 IP나 User-Agent로 다시 시도하지 않음
401 즉시 중단 자격 증명·권한 설정을 사람이 확인
403 즉시 중단 프록시·헤더 변경으로 우회하지 않음. 허가 여부를 확인
404·410 해당 URL 중단 URL 목록을 갱신하고 무한 재시도하지 않음
408 조건부 재시도 멱등한 GET이고 deadline·시도 예산이 남은 경우에만
429 Retry-After 이후 제한 재시도 반복되거나 deadline 초과 시 origin 작업을 중단·연기
500·502·503·504 제한 재시도 Retry-After가 있으면 우선. 계속되면 회로를 열고 중단
DNS·TLS 오류·연결 시간 초과 제한 재시도 robots.txt를 확인할 수 없다면 RFC 9309에 따라 허용으로 추정하지 않음
CAPTCHA·로그인·유료 장벽 자동 처리 중단 인간 모사나 접근 제어 우회를 시도하지 않음
약관·허가 변경, 삭제 요청 관련 작업 중단 저장 데이터의 삭제·보존 정책도 함께 실행

GET도 서버에서 부수 효과가 있는 잘못된 구현을 만날 수 있다. 수집기는 링크 방문 뒤 상태 변경을 유발하는 동작, 폼 제출, 구매·메시지 전송을 재시도 루프에 넣지 않는다.

소유한 테스트 서버에서 200·429·403을 재현한다

다음 코드는 외부 사이트를 호출하지 않는다. Python 표준 라이브러리만 사용해 loopback 주소에 소유 테스트 서버를 띄우고 다음을 검증한다.

  • /public: robots.txt 허용 후 200
  • /flaky: 첫 요청은 429 Retry-After: 1, 다음 요청은 200
  • /forbidden: 403에서 즉시 중단하고 재시도하지 않음
  • /private: robots.txt의 Disallow로 요청 전 중단
# policy_collector_demo.py
from __future__ import annotations

import email.utils
import random
import threading
import time
import urllib.error
import urllib.parse
import urllib.request
import urllib.robotparser
from dataclasses import dataclass
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer

USER_AGENT = (
    "PolicyCollector/1.0 "
    "(+https://example.com/crawler-info; contact: crawler@example.com)"
)


class PolicyStop(RuntimeError):
    pass


class DemoHandler(BaseHTTPRequestHandler):
    flaky_requests = 0
    forbidden_requests = 0

    def log_message(self, format, *args):
        pass

    def send_text(self, status, body, **headers):
        data = body.encode("utf-8")
        self.send_response(status)
        self.send_header("Content-Type", "text/plain; charset=utf-8")
        self.send_header("Content-Length", str(len(data)))
        for name, value in headers.items():
            self.send_header(name.replace("_", "-"), value)
        self.end_headers()
        self.wfile.write(data)

    def do_GET(self):
        path = urllib.parse.urlsplit(self.path).path
        if path == "/robots.txt":
            self.send_text(
                200,
                "User-agent: PolicyCollector\nDisallow: /private\nAllow: /\n",
            )
        elif path == "/public":
            self.send_text(200, "public data")
        elif path == "/flaky":
            type(self).flaky_requests += 1
            if type(self).flaky_requests == 1:
                self.send_text(429, "slow down", Retry_After="1")
            else:
                self.send_text(200, "recovered")
        elif path == "/forbidden":
            type(self).forbidden_requests += 1
            self.send_text(403, "forbidden")
        elif path == "/private":
            self.send_text(200, "must not be fetched")
        else:
            self.send_text(404, "not found")


@dataclass
class Response:
    status: int
    headers: object
    body: bytes


class PolicyCollector:
    def __init__(
        self,
        min_interval=0.05,
        max_attempts=3,
        base_backoff=0.1,
        max_backoff=1.0,
        deadline=5.0,
    ):
        self.min_interval = min_interval
        self.max_attempts = max_attempts
        self.base_backoff = base_backoff
        self.max_backoff = max_backoff
        self.deadline = deadline
        self._next_allowed = {}
        self._robots = {}

    @staticmethod
    def origin(url):
        parts = urllib.parse.urlsplit(url)
        return f"{parts.scheme}://{parts.netloc}"

    def _wait_for_origin(self, origin):
        wait = self._next_allowed.get(origin, 0.0) - time.monotonic()
        if wait > 0:
            time.sleep(wait)
        self._next_allowed[origin] = time.monotonic() + self.min_interval

    def _request(self, url):
        self._wait_for_origin(self.origin(url))
        request = urllib.request.Request(url, headers={"User-Agent": USER_AGENT})
        try:
            with urllib.request.urlopen(request, timeout=2) as response:
                return Response(response.status, response.headers, response.read())
        except urllib.error.HTTPError as error:
            return Response(error.code, error.headers, error.read())

    def _robots_for(self, url):
        origin = self.origin(url)
        if origin in self._robots:
            return self._robots[origin]

        response = self._request(origin + "/robots.txt")
        parser = urllib.robotparser.RobotFileParser()
        parser.set_url(origin + "/robots.txt")
        if response.status == 200:
            parser.parse(response.body.decode("utf-8").splitlines())
        elif response.status == 404:
            parser.parse([])  # 이 데모의 보수적 정책: 404만 파일 없음으로 처리
        else:
            raise PolicyStop(f"robots.txt 확인 실패: HTTP {response.status}")
        self._robots[origin] = parser
        return parser

    @staticmethod
    def retry_after_seconds(value, now=None):
        if not value:
            return None
        value = value.strip()
        if value.isdigit():
            return float(value)
        try:
            parsed = email.utils.parsedate_to_datetime(value)
            current = now or email.utils.parsedate_to_datetime(
                email.utils.formatdate(usegmt=True)
            )
            return max(0.0, (parsed - current).total_seconds())
        except (TypeError, ValueError, OverflowError):
            return None

    def fetch(self, url):
        parser = self._robots_for(url)
        if not parser.can_fetch("PolicyCollector", url):
            raise PolicyStop("robots.txt가 이 경로를 허용하지 않음")

        started = time.monotonic()
        retryable = {429, 500, 502, 503, 504}
        for attempt in range(self.max_attempts):
            response = self._request(url)
            if 200 <= response.status < 300:
                return response
            if response.status in {401, 403, 404, 410}:
                raise PolicyStop(f"재시도하지 않는 HTTP {response.status}")
            if response.status not in retryable:
                raise PolicyStop(f"정책에 없는 HTTP {response.status}")
            if attempt + 1 == self.max_attempts:
                break

            server_wait = self.retry_after_seconds(
                response.headers.get("Retry-After")
            )
            cap = min(self.max_backoff, self.base_backoff * (2**attempt))
            delay = server_wait if server_wait is not None else random.uniform(0, cap)
            if time.monotonic() - started + delay > self.deadline:
                raise PolicyStop("전체 deadline을 넘는 재시도 대기")
            print(f"HTTP {response.status}: {delay:.2f}초 뒤 재시도")
            time.sleep(delay)

        raise PolicyStop("최대 시도 횟수 소진")


def expect_stop(label, callback):
    try:
        callback()
    except PolicyStop as error:
        print(f"{label}: STOP ({error})")
        return
    raise AssertionError(f"{label}은 중단되어야 함")


def main():
    DemoHandler.flaky_requests = 0
    DemoHandler.forbidden_requests = 0
    server = ThreadingHTTPServer(("127.0.0.1", 0), DemoHandler)
    thread = threading.Thread(target=server.serve_forever, daemon=True)
    thread.start()
    base = f"http://127.0.0.1:{server.server_port}"

    try:
        collector = PolicyCollector()
        public = collector.fetch(base + "/public")
        assert (public.status, public.body) == (200, b"public data")
        print("public: PASS (HTTP 200)")

        flaky = collector.fetch(base + "/flaky")
        assert (flaky.status, flaky.body) == (200, b"recovered")
        assert DemoHandler.flaky_requests == 2
        print("flaky: PASS (429 후 HTTP 200, 총 2회 요청)")

        expect_stop("forbidden", lambda: collector.fetch(base + "/forbidden"))
        assert DemoHandler.forbidden_requests == 1

        expect_stop("private", lambda: collector.fetch(base + "/private"))
        print("all checks: PASS")
    finally:
        server.shutdown()
        server.server_close()


if __name__ == "__main__":
    main()

파일로 저장한 뒤 실행한다.

python3 policy_collector_demo.py

이 글을 작성하면서 위 Python 코드 블록을 그대로 추출해 Python 3.14.4에서 실행한 결과는 다음과 같다. 이는 loopback 테스트의 기능 검증 결과일 뿐, 외부 서비스의 성공률이나 성능을 의미하지 않는다.

public: PASS (HTTP 200)
HTTP 429: 1.00초 뒤 재시도
flaky: PASS (429 후 HTTP 200, 총 2회 요청)
forbidden: STOP (재시도하지 않는 HTTP 403)
private: STOP (robots.txt가 이 경로를 허용하지 않음)
all checks: PASS

운영 코드로 확장할 때는 robots.txt 캐시 만료, 리디렉션 제한, 공유 속도 제한, 작업 큐, 구조화 로그, 메트릭, 콘텐츠 검증, 삭제 요청 처리를 추가한다. 표준 라이브러리의 파서 동작만 믿지 말고 RFC 9309 테스트 케이스와 대상 서비스가 공개한 정책을 함께 회귀 테스트한다.

측정은 성공률 하나로 끝내지 않는다

기간·환경·분모가 없는 성공률 숫자는 재현할 수 없다. 운영 보고서에는 최소한 다음을 같이 남긴다.

  • 측정 기간과 코드 버전
  • 대상 origin과 허가 근거(API 계약, 피드, robots.txt 확인 시각)
  • 계획한 URL 수와 실제 시도한 URL 수
  • 2xx, robots 차단, 401/403, 404/410, 429, 5xx, 네트워크 오류 건수
  • 재시도 전 성공과 재시도 후 성공을 분리한 건수
  • 서버가 지시한 대기 시간과 실제 대기 시간
  • 중복·스키마 오류·빈 문서 등 콘텐츠 검증 실패 건수

예를 들어 유효 문서 수 ÷ 허용되어 실제 시도한 URL 수를 수집 완료율로 정의할 수 있다. 다만 robots.txt로 제외된 URL을 실패로 볼지, 삭제된 URL을 분모에서 뺄지는 목적에 따라 달라지므로 대시보드에 산식을 함께 공개한다. 로컬 데모 결과를 운영 성과로 확대 해석하지 않는다.

실패 조건·비용·권한·할당량·개인정보/약관 한계

실패 조건

403, robots.txt 거부, CAPTCHA, 반복되는 429, deadline 초과, robots.txt 확인 실패를 자동 중단 조건으로 둔다. 응답이 200이어도 로그인 페이지, 빈 본문, 예상하지 않은 MIME type이면 저장 성공으로 처리하지 않는다. 사이트 구조 변경 알림과 수동 재개 승인을 둔다.

비용과 할당량

API 요금, egress, 저장 공간, 큐·Redis 운영비를 작업별로 계측한다. 여러 워커가 공급자의 계정 단위 할당량을 공유할 수 있으므로 워커별 제한만 두면 부족하다. 할당량이 불명확하면 추측해서 채우지 말고 공급자 문서나 운영자 답변을 확인한다.

권한과 접근 제어

공개되지 않은 API, 로그인 세션, 구매자 전용 페이지, CAPTCHA를 자동화로 넘어가지 않는다. 401·403을 다른 프록시, 회전 IP, 조작한 헤더로 회피하지 않는다. 필요한 데이터라면 최소 권한의 공식 자격 증명과 명시적 허가를 받는다.

개인정보·약관·저작권

공개 페이지라는 사실만으로 개인정보를 목적 제한 없이 수집·결합·재배포할 수 있는 것은 아니다. 수집 목적, 법적 근거, 최소 수집 필드, 보존 기간, 접근 권한, 삭제 요청 경로를 문서화한다. 원문 전체 저장이 필요한지 검토하고 ID·수정 시각·원문 링크만으로 목적을 달성할 수 있으면 저장 범위를 줄인다. robots.txt 허용 여부와 별개로 이용약관·라이선스·저작권·관할 법률을 검토한다.

운영 체크리스트

  • 공식 API 또는 RSS/Atom 피드를 먼저 확인했다.
  • 이용약관, 라이선스, 개인정보 처리 근거와 보존 기간을 기록했다.
  • RFC 9309에 맞춰 대상 origin의 robots.txt를 요청 전에 확인한다.
  • 수집기 이름·버전·연락처가 있는 User-Agent를 사용한다.
  • origin별 동시성과 최소 요청 간격을 공유 제한기로 제어한다.
  • 429·503의 Retry-After 초/HTTP-date 형식을 모두 처리한다.
  • Retry-After가 없을 때만 정책상 백오프+지터를 사용한다.
  • 최대 시도 횟수와 전체 deadline을 모두 둔다.
  • 401·403·robots 거부·CAPTCHA에서 우회하지 않고 중단한다.
  • 상태 코드뿐 아니라 MIME type·스키마·중복도 검증한다.
  • URL·쿠키·본문 로그에서 인증정보와 개인정보를 제거한다.
  • 삭제 요청·약관 변경·robots 변경 시 큐와 저장 데이터를 처리할 절차가 있다.
  • 소유한 테스트 서버에서 200·403·429·robots 거부를 회귀 테스트했다.

자주 묻는 질문

robots.txt에서 허용하면 자유롭게 수집해도 되나?

아니다. robots.txt는 크롤러 동작에 대한 규칙이며 이용약관, 개인정보 보호 의무, 저작권 허가를 대체하지 않는다. API 계약이나 별도 라이선스가 있으면 그 조건도 함께 적용된다.

403이 나오면 User-Agent나 IP를 바꿔 재시도해도 되나?

이 설계에서는 하지 않는다. 403은 영구 재시도 목록에 넣고 자동 작업을 중단한다. 권한이 필요하면 운영자에게 공식 접근 방법을 요청한다.

지터도 인간 행동 모사 아닌가?

목적이 다르다. 이 글의 지터는 재시도 시점을 분산해 동시 재시도 폭주를 줄이기 위한 것이다. 브라우저 지문, 클릭 간격, Referer를 사람처럼 꾸며 탐지를 피하는 방법은 사용하지 않는다.

Retry-After가 너무 길면 어떻게 하나?

서버가 지시한 시각보다 일찍 요청하지 않는다. 대기 시간이 작업 deadline을 넘으면 해당 작업을 연기하거나 중단한다. 지시값을 임의로 짧게 잘라 재요청하지 않는다.

성공률은 어떻게 계산해야 하나?

기간·대상·분모·제외 조건을 먼저 정의한다. 2xx 비율 외에도 robots 제외, 권한 거부, 제한, 콘텐츠 검증 실패를 별도 집계하고 산식과 원본 로그 위치를 함께 남긴다.

함께 읽기

공식 출처

아래 문서는 2026-07-20에 응답 상태와 내용을 확인했다.

  1. IETF, RFC 9309: Robots Exclusion Protocol
  2. IETF, RFC 6585 §4: 429 Too Many Requests
  3. IETF, RFC 9110 §10.2.3: Retry-After
  4. Google Search Central, robots.txt 소개