웹 스크래핑 입문: robots.txt 와 urllib 로 예의 바르게 가져오기

스크래핑 강좌 1. 내 PC 에 연습용 쇼핑몰을 띄우고 urllib 로 첫 요청. 정직한 User-Agent, robotparser 의 두 가지 함정, 404·403·타임아웃 실제 오류, robots.txt 확인 비용 실측까지.

시리즈: 웹 스크래핑 강좌 1/6 (초급). "저 쇼핑몰 가격 좀 매일 긁어 와 줘"라는 요청을 받았지만 스크레이퍼를 한 번도 짜 본 적 없는 분을 위한 글입니다. 다 읽고 나면 파이썬 표준 라이브러리만으로 페이지를 가져오고, robots.txt 를 제대로 확인하고, 첫 주에 반드시 만나는 오류 네 가지를 읽을 수 있습니다.

최저가 비교, 경쟁사 가격 모니터링, 공공기관 사이트에 올라오는 공지나 통계 모으기. 신입에게 떨어지는 "크롤링 좀 해 줘" 업무는 대개 이 중 하나입니다. 코드는 쉬운 부분이고, 어려운 건 "애초에 긁어 와도 되는가"를 판단하는 일, 봇이라고 정직하게 밝히는 일, 그리고 오류 메시지를 읽는 일입니다. 이 강좌는 그 세 가지를 다룹니다. 모든 명령은 Apple Silicon Mac(M1 Pro, macOS 26.6)의 Python 3.9.6(Xcode 명령줄 도구에 들어 있는 것), 연습 사이트용 PHP 8.5.6, curl 8.7.1 로 실행했고, 경로는 ~/demo/ 로 바꿨습니다.

상대는 모두 내 PC 에서 띄우는 작은 쇼핑몰이라, 마음껏 실수해도 누구에게도 폐가 되지 않습니다.

스크래핑이란, 그리고 API 를 먼저 찾아야 하는 이유

스크래핑(scraping) 은 사람이 보라고 만든 웹 페이지를 내려받아 HTML 에서 데이터를 뽑아내는 일입니다. 크롤링(crawling) 은 다음에 어느 URL 을 방문할지 정하는 부분이고, 스크레이퍼는 보통 둘 다 합니다. 한국에서는 둘을 합쳐 흔히 "크롤링"이라고 부릅니다.

스크래핑은 첫 번째 수단이 아니라 마지막 수단입니다. 코드를 쓰기 전에 다음을 먼저 찾아보세요.

필요한 것 먼저 확인할 곳 이유
공공기관 통계·목록 공공데이터포털(data.go.kr)의 오픈 API·파일 데이터 인증키만 받으면 구조화된 데이터와 이용 조건이 함께 온다
상품 가격·재고 쇼핑몰의 공식 API, 제휴(어필리에이트) 피드, CSV 내보내기 구조화돼 있고 안정적이며 써도 된다는 근거가 있다
거래처 데이터 거래처에 내보내기나 API 키를 요청 메일 한 통이 깨지기 쉬운 스크레이퍼보다 낫다
우리 회사 사이트 데이터 DB 나 사내 API 자기 사이트를 긁는다면 데이터 경로가 빠져 있다는 신호
위에 다 없고, 약관도 허용한다 예의 바른 스크레이퍼(이 강좌) 느리게, 정직하게, 캐시하면서

특히 공공데이터는 공공데이터포털에 API 가 이미 있는 경우가 많습니다. 같은 정보를 기관 홈페이지에서 HTML 로 긁는 것보다 API 키를 신청하는 쪽이 빠르고 안전합니다.

연습용 쇼핑몰에도 HTML 상품 목록과 JSON 이 둘 다 있습니다. 먼저 JSON 쪽입니다.

import json, urllib.request

UA = "PracticeShopBot/0.1 (+https://example.com/bot-contact)"
req = urllib.request.Request("http://127.0.0.1:18160/ko/api/products",
                             headers={"User-Agent": UA})
with urllib.request.urlopen(req, timeout=10) as resp:
    data = json.load(resp)
print(len(data["items"]), "items")
for item in data["items"][:3]:
    print(item)
8 items
{'id': 101, 'name': 'USB-C 케이블 1m', 'price': 8900, 'stock': 0}
{'id': 102, 'name': '무선 마우스', 'price': 24500, 'stock': 5}
{'id': 103, 'name': '노트북 거치대', 'price': 39000, 'stock': 9}

요청 한 번으로 상품 8개가 처음부터 항목별로 나뉘어 왔습니다. 가격도 문자열 "₩8,900" 이 아니라 숫자 8900 입니다. HTML 판은 두 페이지(978 + 936 바이트)를 받은 뒤 2강에서 만들 파서까지 필요합니다. 실제 사이트에서도 API 는 디자인 개편에 잘 깨지지 않습니다.

흐름도. 필요한 데이터에서 출발한다. 첫 갈림길은 공식 API·피드·데이터 내려받기가 있는지다. 있으면 그것을 쓰고 끝낸다. 없으면 이용약관과 robots.txt 를 읽고, 금지돼 있으면 수집하지 않는다. 허용되면 정직한 User-Agent 와 타임아웃을 붙여 요청하고 결과에 따라 나뉜다. 2xx 면 파싱하고, 4xx·5xx 나 네트워크 오류는 로그에 남기고 짧은 간격으로 재시도하지 않는다. 한 페이지가 끝날 때마다 크롤 간격만큼 기다린 뒤 다음으로 간다.
스크레이퍼가 요청을 한 번 보내기 전에 확인하는 순서. "이거 긁어 와도 되나?"는 대부분 앞의 두 상자에서 결론이 난다.

연습용 쇼핑몰 5분 만에 띄우기

아래를 ~/demo/shop.php 로 저장합니다. 페이지가 나뉜 상품 목록, 상품 상세, robots.txt, 접근 금지 구역 /admin/, 응답에 5초 걸리는 페이지, 위의 JSON 을 갖춘 작은 쇼핑몰입니다. str_starts_with 를 쓰므로 PHP 8.0 이상이 필요합니다.

<?php // shop.php: practice shop for the scraping lectures. Run: php -S 127.0.0.1:18160 shop.php
$LANG = 'ko';
$ITEMS = [['USB-C 케이블 1m', 8900], ['무선 마우스', 24500], ['노트북 거치대', 39000], ['기계식 키보드', 89000],
          ['27인치 모니터', 259000], ['웹캠 HD', 45000], ['스탠드 조명', 19900], ['노이즈 캔슬링 헤드폰', 189000]];
function money($v) { return '₩' . number_format($v); }

$uri = parse_url($_SERVER['REQUEST_URI'], PHP_URL_PATH);
error_log($_SERVER['REQUEST_METHOD'] . ' ' . $_SERVER['REQUEST_URI'] . ' ua="' . ($_SERVER['HTTP_USER_AGENT'] ?? '-') . '"');
function h($s) { return htmlspecialchars($s, ENT_QUOTES, 'UTF-8'); }
function products($n) {
  global $ITEMS; $out = [];
  for ($i = 0; $i < $n; $i++) {
    [$name, $price] = $ITEMS[$i % 8]; $r = intdiv($i, 8);
    $out[] = ['id' => 101 + $i, 'name' => $r ? "$name (#" . ($r + 1) . ")" : $name,
              'price' => $price, 'stock' => ($i * 7) % 5 == 0 ? 0 : ($i * 13) % 9 + 1];
  }
  return $out;
}
if ($uri === '/robots.txt') {
  header('Content-Type: text/plain; charset=utf-8');
  echo "# Practice shop robots.txt\nUser-agent: *\nDisallow: /admin/\nDisallow: /cart\nDisallow: /members/\nAllow: /members/help\nDisallow: /*?sort=\nCrawl-delay: 1\n\nUser-agent: BadBot\nDisallow: /\n";
} elseif (str_starts_with($uri, '/admin/')) { http_response_code(403); echo "<h1>403 Forbidden</h1>";
} elseif ($uri === '/slow') { sleep(5); echo "finally";
} elseif ($uri === '/echo-ua') { header('Content-Type: text/plain'); echo "You sent User-Agent: " . ($_SERVER['HTTP_USER_AGENT'] ?? '-') . "\n";
} elseif ($uri === '/') { header('Location: /' . $LANG . '/products', true, 302);
} elseif ($uri === "/$LANG/api/products") {
  header('Content-Type: application/json; charset=utf-8');
  echo json_encode(['items' => products(8)], JSON_UNESCAPED_UNICODE) . "\n";
} elseif ($uri === "/$LANG/products") {
  $per = max(1, min(20000, (int)($_GET['per_page'] ?? 4)));
  $all = products(isset($_GET['per_page']) ? $per : 8);
  $page = max(1, (int)($_GET['page'] ?? 1)); $pages = (int)ceil(count($all) / $per);
  if ($page > $pages) { http_response_code(404); echo "<h1>Not Found</h1>"; return; }
  header('Content-Type: text/html; charset=utf-8');
  echo "<!doctype html>\n<html lang=\"$LANG\"><head><meta charset=\"utf-8\"><title>Practice Shop &amp; Co.</title></head>\n<body>\n<h1>Practice Shop &amp; Co.</h1>\n<ul class=\"products\">\n";
  foreach (array_slice($all, ($page - 1) * $per, $per) as $p) {
    $stock = $p['stock'] ? '<span class="stock in">' . $p['stock'] . '</span>' : '<span class="stock out">0</span>';
    echo "  <li class=\"product\" data-id=\"{$p['id']}\">\n    <a href=\"product/{$p['id']}\">\n      " . h($p['name'])
       . "\n    </a>\n    <span class=\"price\">" . h(money($p['price'])) . "</span>\n    $stock\n  </li>\n";
  }
  echo "</ul>\n<nav>" . ($page < $pages ? "<a rel=\"next\" href=\"?page=" . ($page + 1) . "\">Next &raquo;</a>" : "") . "</nav>\n</body></html>\n";
} elseif (preg_match("#^/$LANG/product/(\d+)$#", $uri, $m) && (int)$m[1] >= 101 && (int)$m[1] <= 108) {
  $p = products(8)[(int)$m[1] - 101];
  header('Content-Type: text/html; charset=utf-8');
  echo "<!doctype html><html><head><meta charset=\"utf-8\"><title>" . h($p['name']) . "</title></head><body><h1>" . h($p['name'])
     . "</h1><p class=\"price\">" . h(money($p['price'])) . "</p><img src=\"/static/{$p['id']}.png\" alt=\"\"></body></html>\n";
} else { http_response_code(404); header('Content-Type: text/html; charset=utf-8'); echo "<h1>Not Found</h1>\n"; }

터미널 하나에서 실행해 두고 그대로 둡니다. 요청마다 로그가 한 줄씩 찍힙니다.

cd ~/demo && php -S 127.0.0.1:18160 shop.php

127.0.0.1 은 "이 컴퓨터 자신"이라 밖에서는 접속할 수 없습니다. 파이썬 스크립트는 두 번째 터미널에서 돌립니다.

urllib 로 첫 요청

urllib.request 는 표준 라이브러리라 설치할 것이 없습니다. first_fetch.py 로 저장하세요.

import urllib.request

URL = "http://127.0.0.1:18160/ko/products"
UA = "PracticeShopBot/0.1 (+https://example.com/bot-contact)"

req = urllib.request.Request(URL, headers={"User-Agent": UA})
with urllib.request.urlopen(req, timeout=10) as resp:
    print(resp.status, resp.reason)
    print("Content-Type:", resp.headers["Content-Type"])
    body = resp.read()
    print(len(body), "bytes")
    html = body.decode(resp.headers.get_content_charset() or "utf-8")
    print(html[html.index("<li"):html.index("</li>") + 5])
200 OK
Content-Type: text/html; charset=utf-8
978 bytes
<li class="product" data-id="101">
    <a href="product/101">
      USB-C 케이블 1m
    </a>
    <span class="price">₩8,900</span>
    <span class="stock out">0</span>
  </li>

이 12줄에 중요한 개념이 세 가지 숨어 있습니다.

  • resp.read() 는 문자열이 아니라 바이트를 돌려줍니다. HTML 은 바이트로 오고, .decode() 로 문자열로 바꿉니다. 문자 인코딩은 Content-Type 헤더의 charset=utf-8 에서 가져옵니다. 오래된 국내 사이트에는 아직 EUC-KR(CP949)이 남아 있어서 헤더와 실제 내용이 다르면 글자가 깨집니다. 고치는 법은 깨진 글자 복구 가이드에 있습니다.
  • 상태 코드(200 OK)는 결과의 종류입니다. 2xx 성공, 3xx 리다이렉트, 4xx "요청이 틀렸거나 허용되지 않음", 5xx "서버 쪽 고장". 자세한 건 상태 코드 강좌를 보세요.
  • timeout=10 은 생략하면 안 됩니다. 이유는 오류 절에서 봅니다.

urllib 는 리다이렉트를 조용히 따라갑니다. 쇼핑몰의 / 는 상품 목록으로 리다이렉트합니다.

import urllib.request

UA = "PracticeShopBot/0.1 (+https://example.com/bot-contact)"
req = urllib.request.Request("http://127.0.0.1:18160/", headers={"User-Agent": UA})
with urllib.request.urlopen(req, timeout=10) as resp:
    print("asked for :", req.full_url)
    print("ended at  :", resp.url, resp.status)
    for k, v in resp.headers.items():
        print(f"{k}: {v}")
asked for : http://127.0.0.1:18160/
ended at  : http://127.0.0.1:18160/ko/products 200
Host: 127.0.0.1:18160
Date: Sat, 14 Nov 2026 23:23:51 GMT
Connection: close
X-Powered-By: PHP/8.5.6
Content-Type: text/html; charset=utf-8

302 는 어디에도 보이지 않습니다. resp.url 이 실제로 도착한 주소이고, 2강에서 링크를 만들 때 반드시 필요합니다. 원본 메시지를 보고 싶으면 curl 로 HTTP 읽기 강좌의 curl -v 를 쓰면 됩니다.

정직한 User-Agent

모든 HTTP 요청에는 보낸 소프트웨어를 밝히는 User-Agent 헤더가 붙습니다. 쇼핑몰 로그(시각은 지움)에 남은 두 줄입니다. 첫 줄은 직접 이름을 붙인 요청, 둘째 줄은 아무것도 지정하지 않은 요청입니다.

GET /ko/products ua="PracticeShopBot/0.1 (+https://example.com/bot-contact)"
GET /ko/products ua="Python-urllib/3.9"

Python-urllib/3.9 로는 사이트 운영자가 "어떤 스크립트"라는 것밖에 알 수 없고, 이것만으로 차단하는 사이트도 많습니다. 좋은 봇 User-Agent 는 이름, 버전, URL 세 부분으로 이뤄집니다. URL 에는 봇이 무엇을 하는지, 멈춰 달라고 할 때 어디로 연락하면 되는지 적은 페이지를 둡니다. https://example.com/bot-contact 는 직접 관리하는 페이지로 바꾸세요.

반대로 사람처럼 보이려고 브라우저 User-Agent 를 흉내 내서는 안 됩니다. 정직하게 이름을 밝힌 봇이 막혔다면 그게 답입니다. 차단을 피하려고 IP 를 돌리거나 프록시를 쓰는 것도 마찬가지로, 이 강좌에서는 다루지 않고 써서도 안 됩니다.

urllib.robotparser 로 robots.txt 읽기

robots.txt 는 사이트 맨 위에 두는 텍스트 파일로, 자동 접근이 어느 경로에 와도 되는지 알려 줍니다. 2022년에 RFC 9309(Robots Exclusion Protocol)로 표준이 됐습니다. 자물쇠가 아니라 부탁이라서 무시하려면 무시할 수 있고, 그래서 지키는 것이 의미가 있습니다. 연습용 쇼핑몰의 파일입니다.

# Practice shop robots.txt
User-agent: *
Disallow: /admin/
Disallow: /cart
Disallow: /members/
Allow: /members/help
Disallow: /*?sort=
Crawl-delay: 1

User-agent: BadBot
Disallow: /

파이썬에는 판정기가 들어 있습니다.

import urllib.robotparser

BASE = "http://127.0.0.1:18160"
UA = "PracticeShopBot/0.1 (+https://example.com/bot-contact)"

rp = urllib.robotparser.RobotFileParser(BASE + "/robots.txt")
rp.read()
for path in ["/ko/products", "/admin/users", "/cart", "/members/help",
             "/members/orders", "/ko/products?sort=price"]:
    print(f"{path:25} {rp.can_fetch(UA, BASE + path)}")
print("crawl_delay:", rp.crawl_delay(UA))
print("BadBot /ko/products:", rp.can_fetch("BadBot", BASE + "/ko/products"))
/ko/products              True
/admin/users              False
/cart                     False
/members/help             False
/members/orders           False
/ko/products?sort=price   True
crawl_delay: 1
BadBot /ko/products: False

이 중 두 개는 파일을 쓴 사람의 의도와 다릅니다. Python 3.12.14 에서도 똑같았습니다.

  • /members/help → False. RFC 9309 는 가장 길게(구체적으로) 일치한 규칙이 이긴다고 정하므로, Allow: /members/help 가 Disallow: /members/ 를 이겨야 합니다. 파이썬 판정기는 파일 위에서부터 처음 일치한 줄을 씁니다. 직접 robots.txt 를 쓸 때는 Allow 를 넓은 Disallow 보다 위에 두세요. 순서를 바꾸자 파이썬에서도 /members/help 는 True, /members/orders 는 그대로 False 였습니다.
  • /ko/products?sort=price → True. * 와일드카드는 흔히 쓰이지만(구글이 지원하고 RFC 9309 에도 있음), 파이썬 판정기는 경로를 단순 접두사로 비교하므로 /*?sort= 는 아무것에도 일치하지 않습니다. 와일드카드를 쓰는 사이트라면 그 줄을 직접 읽고 해당 URL 에 가지 마세요.

그 밖에 알아 둘 동작도 직접 확인했습니다.

상황 robotparser 의 동작
rp.read() 호출을 잊음 can_fetch 가 전부 False
robots.txt 가 404 전부 허용(allow_all = True)
robots.txt 가 403 전부 금지(disallow_all = True)
서버에 연결 안 됨 rp.read() 가 URLError: [Errno 61] Connection refused 를 던짐

예의 바른 반복문은 robots.txt 를 한 번만 읽고, URL 마다 확인하고, 사이사이 기다립니다.

import time, urllib.request, urllib.robotparser

BASE = "http://127.0.0.1:18160"
UA = "PracticeShopBot/0.1 (+https://example.com/bot-contact)"

rp = urllib.robotparser.RobotFileParser(BASE + "/robots.txt")
rp.read()
delay = rp.crawl_delay(UA) or 1.0          # no Crawl-delay? pick your own pace

start = time.perf_counter()
for path in ["/ko/products?page=1", "/ko/products?page=2", "/cart",
             "/ko/product/101", "/ko/product/102"]:
    url = BASE + path
    if not rp.can_fetch(UA, url):
        print(f"{time.perf_counter() - start:5.2f}s skip  {path} (robots.txt)")
        continue
    req = urllib.request.Request(url, headers={"User-Agent": UA})
    with urllib.request.urlopen(req, timeout=10) as resp:
        print(f"{time.perf_counter() - start:5.2f}s {resp.status}   {path} {len(resp.read())} bytes")
    time.sleep(delay)
 0.00s 200   /ko/products?page=1 978 bytes
 1.00s 200   /ko/products?page=2 936 bytes
 2.01s skip  /cart (robots.txt)
 2.01s 200   /ko/product/101 200 bytes
 3.03s 200   /ko/product/102 197 bytes

Crawl-delay 는 RFC 9309 에 들어 있지 않고 구글도 무시하지만, 아직 쓰는 사이트가 많고 사이트가 속도를 정해 두었다면 따르는 게 맞습니다. 지정이 없으면 작은 사이트에는 초당 1회 정도로 시작하는 것이 무난합니다.

진짜 robots.txt 읽어 보기

우리 사이트의 https://tlaboratory.net/robots.txt 에 한 번만 요청해 저장하고 rp.parse(lines) 에 넘겼습니다. 그 뒤의 판정에는 요청이 나가지 않습니다. 파일은 65줄로, 모두를 위한 기본 그룹, AI 크롤러 20종을 묶어 일부 앱·API 경로에서 빼는 그룹, SEO 분석 봇 11종을 사이트 전체에서 빼는 그룹, 검색 엔진 하나를 위한 그룹, 그리고 Sitemap: 줄이 있었습니다.

PracticeShopBot/0.1 [True, True]
GPTBot [True, True]
AhrefsBot [False, False]
['https://tlaboratory.net/sitemap.xml'] None

/en/ 과 강좌 페이지 하나에 대한 can_fetch, 이어서 rp.site_maps() 와 crawl_delay 결과입니다. 진짜 파일에서 배울 점은 두 가지입니다. 같은 사이트라도 봇마다 대우가 완전히 다를 수 있다는 것, 그리고 site_maps()(Python 3.8 추가)로 사이트맵 주소를 얻을 수 있다는 것입니다. 링크를 따라가는 것보다 사이트맵이 훨씬 깔끔한 페이지 목록인 경우가 많습니다. 파일에는 파이썬이 무시하는 종류인 /*/json/ 같은 와일드카드 줄도 있었습니다.

첫 주에 만나는 오류 (실제 트레이스백)

새 스크레이퍼는 거의 반드시 이 네 가지를 만납니다. Python 3.9.6 이 찍은 마지막 줄입니다(긴 라이브러리 경로는 생략).

urllib.error.HTTPError: HTTP Error 404: Not Found
urllib.error.HTTPError: HTTP Error 403: Forbidden
urllib.error.URLError: <urlopen error [Errno 61] Connection refused>
socket.timeout: timed out
  • 404 Not Found: URL 이 틀렸거나 페이지가 사라졌습니다. 만든 URL 을 확인하세요(상대 링크의 전형적인 실수는 2강).
  • 403 Forbidden: 서버가 알아듣고 거절했습니다. "봇 사절"이나 "로그인 필요"인 경우가 많습니다. 브라우저 User-Agent 로 바꿔 다시 시도하면 안 됩니다.
  • Connection refused(macOS Errno 61, 리눅스 111): 그 포트에서 아무도 기다리지 않습니다. 서버가 안 떠 있거나 포트 번호를 잘못 친 경우입니다.
  • timed out: timeout 안에 답이 오지 않았습니다. Python 3.10 이상에서는 마지막 줄이 TimeoutError: timed out 입니다(3.12.14 에서 확인).

네 가지를 모두 처리하고, 죽는 대신 상태를 돌려주는 함수입니다.

import socket
import urllib.error
import urllib.request

UA = "PracticeShopBot/0.1 (+https://example.com/bot-contact)"

def get(url, timeout=10):
    req = urllib.request.Request(url, headers={"User-Agent": UA})
    try:
        with urllib.request.urlopen(req, timeout=timeout) as resp:
            return resp.status, resp.read()
    except urllib.error.HTTPError as e:      # server answered 4xx/5xx
        return e.code, e.read()
    except urllib.error.URLError as e:       # never got an answer
        return None, f"URLError: {e.reason}".encode()
    except socket.timeout:                   # answer too slow (TimeoutError on 3.10+)
        return None, b"timeout"

for path in ["/ko/products", "/ko/products/999", "/admin/users", "/slow"]:
    status, body = get("http://127.0.0.1:18160" + path, timeout=2)
    print(f"{path:18} {status!s:5} {body[:40]!r}")
status, body = get("http://127.0.0.1:18165/ko/products")
print(f"{'port 18165':18} {status!s:5} {body[:60]!r}")
/ko/products       200   b'<!doctype html>\n<html lang="ko"><head><m'
/ko/products/999   404   b'<h1>Not Found</h1>\n'
/admin/users       403   b'<h1>403 Forbidden</h1>'
/slow              None  b'timeout'
port 18165         None  b'URLError: [Errno 61] Connection refused'

흔한 실수와 실제로 벌어지는 일

실수 1: HTTPError 보다 URLError 를 먼저 잡기. HTTPError 는 URLError 의 하위 클래스입니다(issubclass 결과 True). 순서를 거꾸로 쓰면 404 가 URLError 쪽으로 떨어집니다.

caught as URLError: HTTPError 404 Not Found

로그에는 "네트워크 문제"라고 남고, 진짜 원인인 잘못된 URL 은 가려집니다. 해결: 위 코드처럼 HTTPError 를 먼저 씁니다.

실수 2: 타임아웃 없음. socket.getdefaulttimeout() 은 None, 즉 "영원히 기다림"입니다. /slow 에서는 5.01초 뒤에 돌아왔지만, 연결만 받고 답하지 않는 서버라면 cron 작업이 다음 날까지 멈춰 있습니다. 해결: timeout= 을 항상 넘깁니다.

실수 3: URL 마다 robots.txt 다시 읽기. 정중해 보이지만 반대로 요청을 두 배로 만듭니다. 해결: 실행 한 번에 한 번(긴 크롤링이면 하루 한 번) 읽습니다.

실수 4: 오류 뒤에 바로 재시도. 403 이나 500 을 while True 로 즉시 다시 요청하면 서버를 계속 두드리게 됩니다. 제대로 기다리는 법은 3강에서 다룹니다. 지금은 로그에 남기고 다음으로 넘어가면 충분합니다.

실측: robots.txt 확인 비용

연습용 쇼핑몰 50페이지를 세 가지 방식으로 가져오고 각각 5회 실행의 중앙값을 냈습니다(Python 3.9.6, 127.0.0.1, Apple M1 Pro). 요청 수는 서버 로그에서 셌습니다.

50페이지 전체 시간을 나타낸 막대그래프. robots.txt 확인 없음 12.1 ms(요청 50번), 한 번만 읽기 12.7 ms(요청 51번), URL 마다 다시 읽기 25.6 ms(요청 100번).
연습용 쇼핑몰의 같은 50페이지를 세 가지 방식으로 가져온 시간(5회 중앙값, Python 3.9.6, 127.0.0.1, Apple M1 Pro). URL 마다 robots.txt 를 다시 읽으면 요청이 51번이 아니라 100번 나갔다.

한 번만 읽는 방식은 요청이 하나 늘었을 뿐이고, 시간 차이(12.1 ms 대 12.7 ms)는 실행마다의 편차 안이었습니다(가장 빠른 실행은 각각 11.2 ms, 12.2 ms). URL 마다 다시 읽으면 51번이 아니라 100번 요청이 나갔고 시간도 두 배가 됐습니다. 네트워크 너머라면 요청 하나에 수십 ms 가 걸리므로 이 낭비는 실행마다 몇 초가 되고, 그 부담은 고스란히 상대 서버에 갑니다.

더 큰 숫자는 직접 정하는 대기 시간입니다. Crawl-delay: 1 이면 50페이지에 최소 49초가 걸립니다. 그래도 괜찮습니다. 밤에 cron 으로 도는 스크레이퍼는 빠를 필요가 없고, 환영받는 게 더 중요합니다.

실제 사이트를 상대하기 전에

  • 이용약관. robots.txt 에 아무 말이 없어도 약관에서 자동 수집을 금지하는 사이트가 많습니다. 꼭 읽으세요.
  • 데이터베이스 관련 권리. 상품 목록이나 게시물을 체계적으로 모은 데이터는 그 자체로 보호받을 수 있습니다. 다른 사이트의 데이터를 통째로 복제해 비슷한 서비스를 만드는 것은 법적 분쟁이 생기기 쉬운 영역입니다.
  • 개인정보. 이름, 이메일, 프로필, 사람과 연결된 리뷰는 개인정보입니다. 필요 없는 것은 모으지 말고, 다시 게시하지도 마세요.
  • 부하. 한 번에 요청 하나, 사이사이 대기, 캐시(ETag 와 Cache-Control), 한가한 시간대에 실행.
  • 이 글은 법률 자문이 아닙니다. 대상 사이트의 약관과 사는 곳의 법을 확인하고, 애매하면 사이트 운영자에게 물어보세요.

솔직한 한계와 버전 메모

  • Python 3.9.6(macOS 시스템 파이썬)으로 실행하고 Python 3.12.14(Homebrew)로 비교했습니다. robotparser 판정은 둘이 같았습니다. site_maps() 는 3.8, crawl_delay() 와 request_rate() 는 3.6 에서 추가됐습니다.
  • 타임아웃 예외 이름이 바뀌었습니다. Python 3.10 에서 socket.timeout 은 TimeoutError 의 별칭이 됐습니다.
  • RFC 9309(2022년 9월)는 최장 일치와 *·$ 를 포함한 robots.txt 비교 규칙을 정합니다. 파이썬 판정기는 그보다 오래됐고, 본 것처럼 둘 다 지원하지 않습니다. 구글의 오픈소스 판정기나 Scrapy 의 판정기는 동작이 다르지만 여기서는 실행하지 않았습니다.
  • 흔히 쓰는 requests 와 Scrapy 는 쓰지 않았습니다. 이번 강의 내용은 urllib 로 충분합니다.
  • 시간은 127.0.0.1 에서 쟀으므로 네트워크 지연이 아니라 오버헤드를 보여 줍니다.

연습 문제

1. 연습용 robots.txt 에서 파이썬은 /members/help 를 금지로 판정합니다. 줄 순서만 바꿔서, /members/orders 판정은 그대로 두고 파이썬의 답을 RFC 의 최장 일치 결과와 같게 만드세요.

2. 스크레이퍼 로그에 ua="Python-urllib/3.9" 가 찍힙니다. 고치는 변경 한 가지를 쓰고, 연락처 URL 이 왜 필요한지 설명하세요.

3. robots.txt 에 Crawl-delay: 2 가 있고 300페이지가 필요합니다. 최소 실행 시간은? 빨리 끝내려고 스레드 4개로 돌려도 될까요?

정답

1. Allow: /members/help 를 Disallow: /members/ 위로 옮깁니다. 처음 일치한 줄이 이기므로 파이썬은 /members/help 를 True, /members/orders 를 False 로 돌려줬습니다.

2. headers={"User-Agent": "PracticeShopBot/0.1 (+https://example.com/bot-contact)"} 를 붙인 Request 를 만듭니다. URL 은 사이트 운영자가 봇 설명을 읽고 속도를 줄이거나 멈춰 달라고 연락할 창구입니다.

3. 2초 간격이 299번이니 약 10분입니다. 스레드 4개면 요청 빈도가 지정의 4배가 됩니다. 간격은 스레드마다가 아니라 봇 전체에 대한 것입니다. 밤에 돌리면 해결됩니다.

다음 글

자주 묻는 질문

파이썬 urllib.robotparser 는 Googlebot 과 같은 규칙으로 판정하나요?

똑같지는 않습니다. Python 3.9.6 과 3.12.14 모두 처음 일치한 줄을 썼기 때문에, Disallow: /members/ 가 Allow: /members/help 보다 위에 있으면 /members/help 가 금지로 나왔습니다. RFC 9309 는 가장 길게 일치한 규칙이 이긴다고 정합니다. 또 Disallow: /*?sort= 의 * 를 와일드카드로 보지 않아 /ko/products?sort=price 가 허용으로 나왔습니다.

서버 로그에 Python-urllib/3.9 라고 찍히는 이유는?

urllib 의 기본 User-Agent 이기 때문입니다. urllib.request.Request 에 headers={'User-Agent': 'YourBot/0.1 (+https://내-사이트/bot-contact)'} 를 넘기면 사이트 운영자가 누가 왜 왔는지, 어디로 연락하면 되는지 알 수 있습니다. 봇이라는 사실을 숨기려고 브라우저 User-Agent 를 흉내 내면 안 됩니다.

urlopen 에 timeout 을 안 주면 어떻게 되나요?

서버가 응답할 때까지 무한정 기다립니다. 기본 소켓 타임아웃이 None 이기 때문입니다. 5초 쉬는 페이지에서는 5.01초 뒤에 돌아왔지만, 응답하지 않는 서버라면 영원히 멈춥니다. timeout= 을 꼭 넘기고 socket.timeout(Python 3.10 이상은 TimeoutError)을 잡으세요.

  • #웹 스크래핑
  • #초급
  • #파이썬
  • #urllib
  • #robots.txt
  • #HTTP

t.laboratory

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

운영자 소개 · 앱

↑