웹 스크래핑 입문: 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 는 디자인 개편에 잘 깨지지 않습니다.
연습용 쇼핑몰 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 & Co.</title></head>\n<body>\n<h1>Practice Shop & 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 »</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). 요청 수는 서버 로그에서 셌습니다.
한 번만 읽는 방식은 요청이 하나 늘었을 뿐이고, 시간 차이(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배가 됩니다. 간격은 스레드마다가 아니라 봇 전체에 대한 것입니다. 밤에 돌리면 해결됩니다.
다음 글
- 다음: 웹 스크래핑 강좌 2/6: html.parser 로 HTML 파싱하기
- 이후: 페이지 넘기기·속도 제한·재시도, 세션·폼·쿠키·CSRF, SQLite 로 변경 감지, 레이아웃 변경에 강한 스크레이퍼
- 배경 지식: HTTP 헤더·쿠키·세션
- 모든 강좌: 프로그래밍 학습 로드맵
자주 묻는 질문
파이썬 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)을 잡으세요.