본문 바로가기
튜토리얼/자동화

[Python] BeautifulSoup으로 웹 스크래퍼 만들기 — 파싱, 페이지네이션, CSV 저장

by 루까(Luka) 2026. 3. 4.
반응형

왜 만들었나

웹에 있는 데이터를 자동으로 수집하고 싶을 때 웹 스크래핑을 쓴다. 가격 비교, 뉴스 수집, 상품 모니터링 등 반복적인 데이터 수집 작업을 자동화할 수 있다.

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.txtlxml을 포함시키거나, "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 (스크래핑 실습용 공개 사이트)

반응형