왜 만들었나
웹에 있는 데이터를 자동으로 수집하고 싶을 때 웹 스크래핑을 쓴다. 가격 비교, 뉴스 수집, 상품 모니터링 등 반복적인 데이터 수집 작업을 자동화할 수 있다.
Python에서 웹 스크래핑의 표준 조합은 requests + BeautifulSoup이다. requests로 HTML을 가져오고, BeautifulSoup으로 원하는 데이터를 추출한다.
이번 튜토리얼은 스크래핑 연습용으로 만들어진 books.toscrape.com을 대상으로 한다. 책 제목, 가격, 평점, 재고 정보를 5페이지 분량(100권) 수집해서 CSV로 저장한다.
앱 구조
22-python-scraper/
├── main.py # 스크래퍼 스크립트
├── requirements.txt # requests, beautifulsoup4, lxml
└── output/
└── books.csv # 수집된 데이터
구현할 기능:
- requests로 HTML 가져오기 (헤더 설정, 타임아웃)
- BeautifulSoup + lxml 파서로 파싱
- CSS 선택자 (
select,select_one)로 데이터 추출 - 페이지네이션 자동 처리 (다음 페이지 링크 탐색)
- 데이터 정제 (평점 텍스트 → 숫자, 가격 파싱)
- CSV 저장 + 수집 결과 요약 출력
핵심 구현
HTML 가져오기
def fetch_page(url: str) -> BeautifulSoup:
headers = {"User-Agent": "Mozilla/5.0 (BookScraper Tutorial)"}
response = requests.get(url, headers=headers, timeout=10)
response.raise_for_status()
return BeautifulSoup(response.text, "lxml")
User-Agent 헤더를 설정하지 않으면 일부 사이트에서 요청을 차단한다. raise_for_status()는 4xx/5xx 응답 시 예외를 발생시킨다. lxml 파서는 기본 html.parser보다 빠르고 HTML 오류에 더 관대하다.
CSS 선택자로 데이터 추출
def parse_books(soup: BeautifulSoup) -> list[dict]:
articles = soup.select("article.product_pod")
for article in articles:
# 제목: <h3><a title="실제 제목">...</a></h3>
title_tag = article.select_one("h3 > a")
title = title_tag["title"] if title_tag else "N/A"
# 가격: £13.76 → 13.76
price_tag = article.select_one("p.price_color")
price = float(price_tag.get_text(strip=True).replace("£", "").replace("Â", "").strip())
# 평점: <p class="star-rating Three"> → 3
rating_tag = article.select_one("p.star-rating")
rating_class = rating_tag["class"][1] # ["star-rating", "Three"][1]
rating = RATING_MAP.get(rating_class, 0) # "Three" → 3
select("article.product_pod")는 <article class="product_pod"> 요소를 모두 가져온다. select_one()은 첫 번째 일치 요소만 반환한다.
평점은 HTML 클래스에 텍스트로 저장되어 있다 ("Three"). 딕셔너리로 숫자로 변환한다.
페이지네이션
def get_next_page_url(soup: BeautifulSoup, current_url: str) -> str | None:
next_btn = soup.select_one("li.next > a")
if not next_btn:
return None
base_dir = current_url.rsplit("/", 1)[0]
return base_dir + "/" + next_btn["href"]
마지막 페이지에는 "next" 버튼이 없다. select_one()이 None을 반환하면 순환을 멈춘다. 다음 페이지 링크는 상대경로(page-2.html)로 저장되어 있어서 현재 URL의 디렉터리를 기반으로 절대 URL을 조합한다.
스크래핑 루프
def scrape_books(max_pages: int = 5) -> list[dict]:
all_books = []
url = BASE_URL + "/catalogue/page-1.html"
page = 1
while url and page <= max_pages:
soup = fetch_page(url)
books = parse_books(soup)
all_books.extend(books)
url = get_next_page_url(soup, url)
page += 1
time.sleep(0.5) # 서버 부하 방지
return all_books
time.sleep(0.5)은 서버에 과부하를 주지 않기 위한 최소한의 예절이다. 실제 프로젝트에서는 robots.txt 확인과 요청 간격 조절이 필수다.
CSV 저장
def save_csv(books: list[dict], output_path: str):
fieldnames = ["title", "price_gbp", "rating", "in_stock", "url"]
with open(output_path, "w", newline="", encoding="utf-8") as f:
writer = csv.DictWriter(f, fieldnames=fieldnames)
writer.writeheader()
writer.writerows(books)
csv.DictWriter는 딕셔너리 리스트를 CSV로 쉽게 저장한다. newline=""은 Windows에서 줄바꿈이 이중으로 들어가는 문제를 방지한다.
실행 결과
pip3 install -r requirements.txt
python3 main.py
books.toscrape.com 스크래핑 시작 (최대 5페이지)
페이지 1 스크래핑 중...
→ 20권 수집 (누적: 20권)
...
페이지 5 스크래핑 중...
→ 20권 수집 (누적: 100권)
✓ CSV 저장 완료: output/books.csv (100권)
==================================================
수집 결과 요약
==================================================
총 권수 : 100권
재고 있음 : 100권 (100.0%)
평균 가격 : £34.56
평균 평점 : 2.93 / 5.0
평점 분포:
5점: ★★★★★★★★★★★★★★★★★★★ (19권)
4점: ★★★★★★★★★★★★★★★★★★ (18권)
...
평점 상위 5권:
1. [★★★★★] £13.34 Princess Between Worlds
2. [★★★★★] £13.61 Princess Jellyfish 2-in-1 Omnibus
...
비교 테이블
| 라이브러리 | 특징 | 적합한 상황 |
|---|---|---|
| BeautifulSoup | 간단한 API, 학습 쉬움 | 정적 HTML, 소규모 수집 |
| lxml | 빠른 파서, XPath 지원 | 대용량, XML 처리 |
| Scrapy | 비동기, 미들웨어, 파이프라인 | 대규모 크롤러, 프로덕션 |
| Playwright / Selenium | 브라우저 자동화 | JavaScript 렌더링 필요 |
| httpx | 비동기 HTTP | requests 대체, async 환경 |
정적 HTML에서 간단한 데이터 추출이 목적이라면 requests + BeautifulSoup이 가장 빠르게 시작할 수 있다.
삽질 기록
£ 가격 파싱: "Â" 문자 문제
price_tag.get_text()가 "£13.76"처럼 Â가 붙어서 나왔다. HTML 인코딩 문제로, £ 기호가 ISO-8859-1로 인코딩된 £로 읽히는 경우가 있다. response.text 대신 response.content를 쓰거나, .replace("Â", "")로 간단히 처리할 수 있다.
다음 페이지 URL 조합
next_btn["href"]가 page-2.html 같은 상대경로여서 처음엔 BASE_URL + href로 연결했는데 URL이 틀렸다. 현재 페이지 URL에서 마지막 슬래시 이전 경로를 잘라내서 붙여야 한다. current_url.rsplit("/", 1)[0]으로 해결했다.
lxml 미설치 시 파서 경고
BeautifulSoup(html, "lxml")에서 lxml이 없으면 FeatureNotFound 예외가 발생한다. requirements.txt에 lxml을 포함시키거나, "html.parser"로 대체하면 된다.
마무리
BeautifulSoup의 핵심은 CSS 선택자다. 브라우저 개발자 도구에서 요소를 우클릭 → "Copy selector"로 선택자를 바로 얻을 수 있다. select()로 여러 개, select_one()으로 하나를 뽑고, get_text() 또는 ["속성명"]으로 값을 꺼낸다.
JavaScript로 렌더링되는 페이지(SPA, 무한 스크롤 등)는 requests로 HTML을 가져와도 데이터가 없다. 그런 경우엔 Playwright나 Selenium으로 브라우저를 직접 제어해야 한다.
기술 스택: Python 3.x · requests 2.32.3 · beautifulsoup4 4.12.3 · lxml 5.3.0
소스 코드: GitHub
대상 사이트: books.toscrape.com (스크래핑 실습용 공개 사이트)
'튜토리얼 > 자동화' 카테고리의 다른 글
| [n8n + Make] 블로그 포스팅 자동화 — Markdown push → 티스토리 자동 발행 (0) | 2026.03.05 |
|---|---|
| [Python + Ollama] API 키 없이 PDF 요약 자동화 스크립트 (0) | 2026.03.05 |
| [Python] Telegram 봇 만들기 — 명령어, 날씨 API, 인라인 버튼, 메모 (1) | 2026.03.04 |
| [Python] openpyxl로 엑셀 보고서 자동 생성하기 — 스타일링, 수식, 차트까지 (0) | 2026.03.03 |
| [Python] 유튜브 자막 추출기 만들기 (0) | 2026.02.28 |