웹 크롤링 전반 가이드, 핵심 원리부터 실전 코드까지

requests부터 Scrapy와 Playwright까지 크롤링 원리와 방법, 속도와 차단 회피, 저장과 법률까지 실전 코드로 정리
마크다운 원문·이 글에 보충할 내용이 있나요?

결론부터 말하면, 크롤링은 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
DockerPlaywright와 Scrapy 격리 실행docker.com

파이썬 패키지는 가상환경에 묶어서 설치한다. 시스템 파이썬을 직접 쓰면 버전 충돌이 난다.


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을 받고, 태그 구조에서 원하는 텍스트를 추출한다.


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는 너무 자주 옴이다. 크롤러는 이 번호를 읽고 다음 행동을 정한다.


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로 읽어야 한다.


res.encoding = res.apparent_encoding
text = res.text

넷째, 쿠키와 세션이다. 로그인은 서버가 브라우저에 도장(쿠키)을 주는 것이다. requests의 Session 객체가 이 도장을 보관하므로 로그인 한 번이면 이후 요청이 이어진다. 6번 섹션 코드가 바로 이 원리를 쓴다.

다섯째, JS 렌더링이다. 최신 사이트는 빈 껍데기 HTML과 JS를 주고 브라우저가 내용을 채운다. requests는 JS를 실행 못 하므로 빈 껍데기만 받는다. Playwright가 실제 브라우저라서 되는 이유다.

2. 예절과 법률: robots.txt를 먼저 본다

크롤링 전에 대상 사이트의 크롤링 허용 범위를 확인한다.


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 패턴 방식과 버튼 클릭 방식이 있다.


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로 해결한다.


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를 눌러 내린다.


for _ in range(5):
    page.keyboard.press("End")
    page.wait_for_timeout(1500)

5. 대량 수집: Scrapy

수천 페이지 이상은 Scrapy 프레임워크가 정답이다. 동시 요청과 재시도, 파이프라인을 기본 제공한다.


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 조합이 표준이다.


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으로 쿠키를 유지한다.


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


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 + BeautifulSoup10줄이면 끝
JS 렌더링 페이지Playwright실제 브라우저라 막힐 일이 적음
수천 페이지 정기 수집Scrapy재시도와 파이프라인 내장
수백 페이지 속도전httpx 비동기동시 5개가 안전선
로그인 뒤 데이터Session 쿠키 유지브라우저 세션 재현
표와 문서 PDFPlaywright PDF 저장, 표는 pandas read_html직접 파싱보다 빠름

크롤링 실력의 8할은 선택자 설계와 예절이다. 한 사이트에 1초 간격, 막히면 쉬었다 가기, 이 두 가지만 지켜도 장기 수집이 된다.

👁 조회 3 · 💬 댓글 0개 · 작성자 유형: human | 빌드: 2026-09-23T23:51:00+09:00