--- title: 웹 크롤링 전반 가이드, 핵심 원리부터 실전 코드까지 date: 2026-09-24 time: 1:40 model: admin category: knowhow summary: requests부터 Scrapy와 Playwright까지 크롤링 원리와 방법, 속도와 차단 회피, 저장과 법률까지 실전 코드로 정리 tags: 크롤링, 스크래핑, Python, Scrapy, Playwright, BeautifulSoup --- 결론부터 말하면, 크롤링은 HTML을 가져와서 필요한 부분만 뽑아내는 두 단계 작업이다. 정적 페이지는 requests로, 자바스크립트 페이지는 Playwright로, 대량은 Scrapy로 가져가면 된다. 원리와 코드, 필수 프로그램까지 단계별로 정리한다. ## 0. 필수 프로그램: 시작 전에 깔아둘 것들 크롤링에 필요한 프로그램은 다섯 가지다. 한 번 깔아두면 계속 쓴다. | 프로그램 | 용도 | 설치 | |---|---|---| | Python 3.11 이상 | 크롤링 언어 본체 | python.org 또는 apt install python3 | | VS Code | 코드 작성과 디버깅 | code.visualstudio.com | | Google Chrome | 선택자 확인용 개발자도구(F12) | 셀렉터 복사의 기준 | | SQLite Browser | 수집 데이터 눈으로 확인 | sqlitebrowser.org | | Docker | Playwright와 Scrapy 격리 실행 | docker.com | 파이썬 패키지는 가상환경에 묶어서 설치한다. 시스템 파이썬을 직접 쓰면 버전 충돌이 난다. ```bash python3 -m venv crawl-env source crawl-env/bin/activate pip install requests beautifulsoup4 lxml httpx scrapy playwright pandas playwright install chromium ``` 패키지별 역할은 다음과 같다. requests는 가져오기, BeautifulSoup과 lxml은 뽑아내기, httpx는 비동기 가져오기, scrapy는 대량 프레임워크, playwright는 JS 브라우저, pandas는 표(`read_html`)와 CSV 가공이다. playwright install chromium이 실제 브라우저를 내려받으므로 이 한 줄을 빼먹으면 4번 섹션 코드가 안 돈다. 선택자 찾기는 크롬 개발자도구(F12)의 Elements 탭에서 목표를 우클릭하고 Copy selector를 누르면 된다. 복사한 선택자가 너무 길면 li 단위로 잘라서 쓴다. ## 1. 핵심 원리: 가져오기(Fetch)와 뽑아내기(Parse) 브라우저가 하는 일을 코드가 대신한다. 서버에 HTTP 요청을 보내 HTML을 받고, 태그 구조에서 원하는 텍스트를 추출한다. ```python import requests from bs4 import BeautifulSoup headers = {"User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64)"} res = requests.get("https://example.com/list", headers=headers, timeout=10) res.raise_for_status() soup = BeautifulSoup(res.text, "lxml") for a in soup.select("ul.news-list > li > a"): print(a.get_text(strip=True), a.get("href")) ``` 핵심은 세 가지다. User-Agent를 넣지 않으면 봇으로 차단된다. select의 CSS 선택자가 곧 추출 설계도다. get_text(strip=True)로 공백을 정리한다. ### 보충 원리: HTTP와 HTML 구조, 인코딩 크롤링이 되는 이유를 이해하면 막혔을 때 대처가 된다. 원리는 네 가지다. 첫째, HTTP 요청과 응답이다. 브라우저 주소창에 치는 것은 GET 요청 한 방이다. 서버는 상태 코드와 HTML을 돌려준다. 200은 성공, 301과 302는 이사(리다이렉트), 403은 출입 금지, 404는 없음, 429는 너무 자주 옴이다. 크롤러는 이 번호를 읽고 다음 행동을 정한다. ```python res = requests.get(url, headers=headers, timeout=10) print(res.status_code, res.url) ``` 둘째, HTML은 나무(DOM)다. html 안에 head와 body, body 안에 ul과 li가 들어가는 트리 구조다. 그래서 CSS 선택자로 가지를 지정하면 열매(텍스트)만 딸 수 있다. 선택자 문법은 태그(`li`), 클래스(`.news`), 아이디(`#main`), 자식(`ul > li`), 속성(`a[href]`) 다섯 개면 실전의 9할이 된다. 셋째, 인코딩이다. 한글이 깨지면 `res.encoding` 문제다. UTF-8 선언이 없는 구형 사이트는 euc-kr로 읽어야 한다. ```python res.encoding = res.apparent_encoding text = res.text ``` 넷째, 쿠키와 세션이다. 로그인은 서버가 브라우저에 도장(쿠키)을 주는 것이다. requests의 Session 객체가 이 도장을 보관하므로 로그인 한 번이면 이후 요청이 이어진다. 6번 섹션 코드가 바로 이 원리를 쓴다. 다섯째, JS 렌더링이다. 최신 사이트는 빈 껍데기 HTML과 JS를 주고 브라우저가 내용을 채운다. requests는 JS를 실행 못 하므로 빈 껍데기만 받는다. Playwright가 실제 브라우저라서 되는 이유다. ## 2. 예절과 법률: robots.txt를 먼저 본다 크롤링 전에 대상 사이트의 크롤링 허용 범위를 확인한다. ```python from urllib.robotparser import RobotFileParser rp = RobotFileParser() rp.set_url("https://example.com/robots.txt") rp.read() print(rp.can_fetch("*", "https://example.com/list")) ``` 원칙은 간단하다. Disallow 경로는 건드리지 않는다. 요청 간격은 1초 이상 둔다. 로그인 뒤 정보와 개인정보는 수집하지 않는다. 공개 데이터라도 재배포는 저작권 문제라 저장은 개인 연구용으로 한정한다. ## 3. 페이지 넘기기: 페이지네이션과 무한 스크롤 목록 크롤링의 반은 다음 페이지 처리다. URL 패턴 방식과 버튼 클릭 방식이 있다. ```python import time base = "https://example.com/list?page={}" results = [] for page in range(1, 11): res = requests.get(base.format(page), headers=headers, timeout=10) soup = BeautifulSoup(res.text, "lxml") items = soup.select("ul.news-list > li") if not items: break for li in items: results.append(li.get_text(strip=True)) time.sleep(1.2) print(len(results), "건 수집") ``` 빈 목록이 나오면 종료하는 것이 핵심이다. sleep 없이 돌리면 IP가 막힌다. ## 4. 자바스크립트 페이지: Playwright React와 Vue로 만든 페이지는 HTML이 비어 있고 JS가 내용을 채운다. requests로는 빈 껍데기만 온다. 실제 브라우저를 띄우는 Playwright로 해결한다. ```python from playwright.sync_api import sync_playwright with sync_playwright() as p: browser = p.chromium.launch(headless=True) page = browser.new_page(user_agent="Mozilla/5.0 (Windows NT 10.0; Win64; x64)") page.goto("https://example.com/dynamic", wait_until="networkidle") page.wait_for_selector("ul.news-list > li") for li in page.query_selector_all("ul.news-list > li"): print(li.inner_text().strip()) browser.close() ``` wait_until과 wait_for_selector가 핵심이다. 내용이 뜨기 전에 읽으면 빈 결과가 나온다. 무한 스크롤은 키보드로 End를 눌러 내린다. ```python for _ in range(5): page.keyboard.press("End") page.wait_for_timeout(1500) ``` ## 5. 대량 수집: Scrapy 수천 페이지 이상은 Scrapy 프레임워크가 정답이다. 동시 요청과 재시도, 파이프라인을 기본 제공한다. ```python import scrapy class NewsSpider(scrapy.Spider): name = "news" start_urls = ["https://example.com/list?page=1"] custom_settings = {"DOWNLOAD_DELAY": 1.0, "CONCURRENT_REQUESTS": 4} def parse(self, response): for li in response.css("ul.news-list > li"): yield {"title": li.css("a::text").get(default="").strip()} nxt = response.css("a.next::attr(href)").get() if nxt: yield response.follow(nxt, callback=self.parse) ``` 실행은 scrapy crawl news -o news.json이다. DOWNLOAD_DELAY가 예절이고 CONCURRENT_REQUESTS가 속도다. ## 6. 속도와 차단 회피: 비동기와 세션 수백 페이지는 비동기로 병렬 처리한다. httpx와 asyncio 조합이 표준이다. ```python import asyncio, httpx from bs4 import BeautifulSoup async def fetch(client, url): r = await client.get(url, timeout=10) soup = BeautifulSoup(r.text, "lxml") return soup.title.get_text(strip=True) async def main(urls): limits = httpx.Limits(max_connections=5) async with httpx.AsyncClient(headers=headers, limits=limits) as client: tasks = [fetch(client, u) for u in urls] return await asyncio.gather(*tasks, return_exceptions=True) titles = asyncio.run(main(["https://example.com/1", "https://example.com/2"])) ``` 동시 5개가 안전선이다. 로그인이 필요하면 Session으로 쿠키를 유지한다. ```python s = requests.Session() s.post("https://example.com/login", data={"id": "me", "pw": "secret"}) res = s.get("https://example.com/mypage") ``` 차단될 때는 429와 403을 구분한다. 429는 쉬었다 가라는 신호라 대기 후 재시도하고, 403은 구조적 차단이라 헤더와 접근 방식을 바꿔야 한다. ## 7. 저장: JSON과 CSV, SQLite ```python import json, csv, sqlite3 with open("news.json", "w", encoding="utf-8") as f: json.dump(results, f, ensure_ascii=False, indent=2) with open("news.csv", "w", encoding="utf-8-sig", newline="") as f: w = csv.writer(f) w.writerow(["title"]) w.writerows([[r] for r in results]) con = sqlite3.connect("news.db") con.execute("CREATE TABLE IF NOT EXISTS news (title TEXT UNIQUE)") con.executemany("INSERT OR IGNORE INTO news VALUES (?)", [(r,) for r in results]) con.commit() ``` CSV는 엑셀 호환용으로 utf-8-sig를 쓴다. 중복 수집 방지는 UNIQUE와 INSERT OR IGNORE가 가장 간단하다. ## 8. 방법 선택표 | 상황 | 방법 | 이유 | |---|---|---| | 정적 페이지 수십 건 | requests + BeautifulSoup | 10줄이면 끝 | | JS 렌더링 페이지 | Playwright | 실제 브라우저라 막힐 일이 적음 | | 수천 페이지 정기 수집 | Scrapy | 재시도와 파이프라인 내장 | | 수백 페이지 속도전 | httpx 비동기 | 동시 5개가 안전선 | | 로그인 뒤 데이터 | Session 쿠키 유지 | 브라우저 세션 재현 | | 표와 문서 PDF | Playwright PDF 저장, 표는 pandas read_html | 직접 파싱보다 빠름 | 크롤링 실력의 8할은 선택자 설계와 예절이다. 한 사이트에 1초 간격, 막히면 쉬었다 가기, 이 두 가지만 지켜도 장기 수집이 된다.