목차
- Before — requests로 긁었더니 빈 리스트
- After — 정적·동적 분리 후 안정화된 구조
- 정적 페이지 스크래핑 — BeautifulSoup의 영역
- 동적 페이지의 벽 — Selenium이 필요한 순간
- BeautifulSoup vs Selenium — 언제 뭘 쓰는가
- 실무에서 자주 터지는 에러 5가지
- 자동화 구조 잡기 — 스케줄링과 에러 처리
- robots.txt와 법적 고려사항
- Selenium 없이 동적 페이지를 긁는 방법
- 판단 기준 — 상황별 도구 선택
Before — requests로 긁었더니 빈 리스트
Python 웹 스크래핑을 처음 실무에 적용한 건 부동산 매물 정보를 주기적으로 모으는 사이드 프로젝트에서였다. requests + BeautifulSoup으로 10줄짜리 코드를 짰고, 돌려보니 빈 리스트가 돌아왔다. CSS selector가 틀렸나 싶어서 열 번은 바꿔봤다. curl로 직접 HTML을 받아서 열어보니 <div id="root"></div> 하나만 덩그러니 있었다. JavaScript로 렌더링되는 SPA 페이지라는 걸 그때 알았다.
Selenium으로 전환한 뒤에도 TimeoutException, StaleElementReferenceException이 쏟아졌다. 결국 3시간을 날린 뒤에야 동적 페이지와 정적 페이지를 분리해서 처리하는 구조를 잡았다.
After — 정적·동적 분리 후 안정화된 구조
지금은 정적 페이지에 requests + BeautifulSoup, 동적 페이지에 Selenium + explicit wait를 쓰는 구조로 운영 중이다. 수집 대상 20개 사이트 기준으로 에러율이 체감상 90% 이상 줄었다. 이 글에서는 Python 웹 스크래핑에서 두 라이브러리를 어떻게 나눠 쓰는지, 어떤 에러를 만나게 되는지를 실제 코드와 함께 다룬다.
정적 페이지 스크래핑 — BeautifulSoup의 영역
requests로 HTML 받아오기
정적 페이지란 서버가 HTML을 완성된 상태로 내려주는 페이지다. 블로그, 뉴스 사이트, 위키 등이 여기에 해당한다. 이런 페이지는 requests로 HTML을 받고 BeautifulSoup으로 파싱하면 된다.
import requests
from bs4 import BeautifulSoup
# User-Agent 없으면 403 맞는 사이트가 꽤 있다
headers = {
"User-Agent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36"
}
response = requests.get("https://example.com/products", headers=headers, timeout=10)
response.raise_for_status() # 4xx, 5xx면 여기서 터진다
soup = BeautifulSoup(response.text, "html.parser")
items = soup.select("div.product-card > h2.title")
for item in items:
print(item.get_text(strip=True))
파서는 html.parser를 기본으로 쓰되, 깨진 HTML이 많은 사이트라면 lxml이 더 관대하게 처리해준다. lxml은 속도도 빠르다. pip install lxml 후 BeautifulSoup(html, "lxml")로 바꾸면 끝이다.
CSS selector vs find 메서드
soup.select()와 soup.find_all()은 결과가 같지만 쓰는 방식이 다르다. 브라우저 개발자 도구에서 CSS selector를 바로 복사할 수 있어서 select()가 실무에서 더 편하다. 복잡한 계층 구조도 div.container > ul > li:nth-child(2) 같은 식으로 한 줄에 끝난다.
find_all()은 속성 기반 필터링이 필요할 때 쓴다. soup.find_all("a", attrs={"data-type": "external"}) 같은 경우다.
동적 페이지의 벽 — Selenium이 필요한 순간
정적 스크래핑이 안 먹히는 순간이 온다. response.text를 출력했는데 원하는 데이터가 없으면 십중팔구 JavaScript 렌더링 페이지다. React, Vue, Angular로 만든 SPA가 대표적이다.
이걸 확인하는 방법은 간단하다. 브라우저에서 Ctrl+U(페이지 소스 보기)를 눌러서 원하는 데이터가 HTML에 있는지 보면 된다. 소스에 없으면 JS가 나중에 그리는 거다.
from selenium import webdriver
from selenium.webdriver.chrome.options import Options
from selenium.webdriver.common.by import By
from selenium.webdriver.support.ui import WebDriverWait
from selenium.webdriver.support import expected_conditions as EC
chrome_options = Options()
chrome_options.add_argument("--headless=new") # 구버전 --headless는 deprecated
chrome_options.add_argument("--no-sandbox")
chrome_options.add_argument("--disable-dev-shm-usage")
# headless 모드에서도 User-Agent 설정 필수
chrome_options.add_argument(
"user-agent=Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36"
)
driver = webdriver.Chrome(options=chrome_options)
driver.get("https://example.com/spa-page")
# 명시적 대기 — 이거 안 쓰면 NoSuchElementException 폭탄 맞는다
wait = WebDriverWait(driver, 15)
items = wait.until(
EC.presence_of_all_elements_located((By.CSS_SELECTOR, "div.product-card"))
)
for item in items:
title = item.find_element(By.CSS_SELECTOR, "h2.title").text
print(title)
driver.quit()
여기서 WebDriverWait을 빼고 바로 find_element를 호출하면 페이지 로딩이 끝나기 전에 요소를 찾으려 해서 에러가 터진다. 내가 처음에 당한 게 정확히 이거였다. time.sleep(5) 같은 하드코딩 대기는 느리기도 하고, 네트워크 상태에 따라 불안정하다. explicit wait이 정답이다.
BeautifulSoup vs Selenium — 언제 뭘 쓰는가
이건 간단하다. 아래 기준으로 판별하면 된다.
| 기준 | BeautifulSoup (+ requests) | Selenium |
|---|---|---|
| 페이지 타입 | 서버 사이드 렌더링 (SSR) | 클라이언트 사이드 렌더링 (CSR/SPA) |
| 속도 | 빠르다 (HTTP 요청 1회) | 느리다 (브라우저 구동) |
| 메모리 | 적다 (수십 MB) | 많다 (Chrome 인스턴스당 200~500MB) |
| JS 실행 | 불가 | 가능 |
| 로그인/클릭 | 쿠키 수동 관리 | 자동화 가능 |
| 대규모 수집 | 적합 | 리소스 부담 큼 |
체감상 requests + BeautifulSoup이 Selenium보다 10배 이상 빠르다. 1000페이지를 긁는다고 치면, requests는 몇 분이면 끝나는데 Selenium은 수십 분이 걸린다. 가능하면 BeautifulSoup으로 처리하고, 정말 JS 렌더링이 필요한 페이지만 Selenium으로 넘기는 게 맞다.
실무에서 자주 터지는 에러 5가지
스크래핑을 운영하다 보면 반복적으로 만나는 에러들이 있다. 처음 만나면 당황하는데, 패턴을 알면 대응이 빨라진다.
403 Forbidden
User-Agent를 안 넣으면 거의 확실하게 만난다. requests는 기본 User-Agent가 python-requests/2.31.0 같은 형태라 서버가 바로 봇으로 판별한다. 앞서 코드에서 보여준 것처럼 브라우저 User-Agent를 넣으면 대부분 해결된다.
그래도 안 되면 requests.Session()으로 세션을 유지하면서 쿠키를 자동 관리하게 하면 풀리는 경우가 있다.
ConnectionError / Timeout
네트워크 이슈거나 서버가 느린 경우다. timeout 파라미터를 반드시 넣고, retry 로직을 추가하는 게 좋다.
from requests.adapters import HTTPAdapter
from urllib3.util.retry import Retry
session = requests.Session()
retries = Retry(total=3, backoff_factor=1, status_forcelist=[500, 502, 503, 504])
session.mount("https://", HTTPAdapter(max_retries=retries))
# 3회까지 자동 재시도, 1초 → 2초 → 4초 간격
response = session.get("https://example.com", headers=headers, timeout=10)
이 패턴은 외워두면 좋다. 운영 환경에서 retry 없이 스크래핑을 돌리면 한두 번의 타임아웃에 전체 작업이 멈춘다.
StaleElementReferenceException
Selenium에서 요소를 찾은 뒤 페이지가 갱신되면 발생한다. 무한 스크롤 페이지에서 자주 본다. 해결법은 요소를 찾고 즉시 데이터를 추출하는 것이다. 변수에 WebElement를 저장해두고 나중에 접근하면 이미 DOM에서 사라진 상태일 수 있다.
AttributeError: ‘NoneType’ object has no attribute ‘text’
BeautifulSoup에서 find()가 None을 반환했는데 거기에 .text를 호출하면 발생한다. 사이트 구조가 바뀌었거나 selector가 잘못된 경우다. 방어 코드를 넣는 게 기본이다.
element = soup.select_one("span.price")
price = element.get_text(strip=True) if element else "N/A"
429 Too Many Requests
요청을 너무 빠르게 보내면 서버가 차단한다. rate limiting은 예의이자 생존 전략이다.
자동화 구조 잡기 — 스케줄링과 에러 처리
스크래핑 스크립트를 한 번 돌리는 건 쉽다. 매일 돌아가게 만드는 게 진짜 일이다.
나는 처음에 cron으로 스케줄링했는데, 에러가 나도 모르고 지나가는 문제가 있었다. 로깅을 추가하고, 에러 발생 시 Slack 알림을 보내는 구조로 바꿨다.
import logging
import time
import random
logging.basicConfig(
filename="scraper.log",
level=logging.INFO,
format="%(asctime)s - %(levelname)s - %(message)s"
)
def scrape_with_delay(urls: list[str]) -> list[dict]:
results = []
for url in urls:
try:
response = session.get(url, headers=headers, timeout=10)
response.raise_for_status()
soup = BeautifulSoup(response.text, "lxml")
# 파싱 로직
data = parse_page(soup)
results.append(data)
logging.info(f"성공: {url}")
except Exception as e:
logging.error(f"실패: {url} - {e}")
continue
finally:
# 1~3초 랜덤 대기 — 서버 부하 방지
time.sleep(random.uniform(1, 3))
return results
time.sleep에 랜덤 값을 넣는 건 일정한 간격의 요청 패턴을 피하기 위해서다. 일정 간격 요청은 봇 탐지에 걸리기 쉽다.
robots.txt와 법적 고려사항
짧게 짚고 넘어간다. robots.txt는 반드시 확인해야 한다. https://example.com/robots.txt를 브라우저에 치면 바로 볼 수 있다. Disallow로 막혀 있는 경로는 긁지 않는 게 원칙이다.
Python 표준 라이브러리에 urllib.robotparser가 있어서 코드로도 확인 가능하다. 법적으로는 공개된 데이터를 개인적으로 수집하는 건 대부분 문제가 없지만, 수집한 데이터를 상업적으로 재배포하면 이야기가 달라진다. 이 부분은 케이스마다 다르니 정확한 건 법률 전문가에게 확인하는 게 맞다. 내가 확실히 답할 수 있는 영역이 아니다.
Selenium 없이 동적 페이지를 긁는 방법
Selenium이 무겁다고 느끼면 다른 선택지도 있다. Playwright는 Microsoft에서 만든 브라우저 자동화 도구로, Selenium보다 API가 깔끔하고 async를 기본 지원한다 (출처: Playwright Python 공식 문서, v1.51 기준 2026년 3월 업데이트). 설치도 pip install playwright && playwright install이면 끝이다.
또 하나 확인할 건, 동적 페이지라고 무조건 브라우저가 필요한 건 아니라는 점이다. 브라우저 개발자 도구의 Network 탭을 열고 페이지를 새로고침하면, JS가 호출하는 API 엔드포인트가 보이는 경우가 많다. 그 API를 직접 requests로 호출하면 Selenium 없이도 데이터를 가져올 수 있다. JSON으로 깔끔하게 내려오는 경우가 대부분이라 파싱도 훨씬 편하다.
내가 부동산 프로젝트에서 시행착오를 겪은 뒤에 Network 탭을 뒤져봤더니, 매물 데이터가 XHR 요청으로 JSON 형태로 오고 있었다. Selenium 코드를 전부 걷어내고 requests로 교체하니 실행 시간이 체감상 5분의 1로 줄었다. 브라우저 자동화를 쓰기 전에 API 엔드포인트를 먼저 찾아보는 습관을 들이는 게 좋다.
판단 기준 — 상황별 도구 선택
Python 웹 스크래핑에서 도구 선택은 상황에 따라 달라진다. 당장 적용할 수 있는 기준 세 가지를 정리한다.
첫째, 페이지 소스(Ctrl+U)에 원하는 데이터가 있으면 requests + BeautifulSoup을 쓴다. 빠르고 가볍고 디버깅도 쉽다.
둘째, 소스에 데이터가 없으면 Network 탭에서 API 엔드포인트를 먼저 찾는다. JSON API가 있으면 브라우저 자동화 없이 requests로 해결 가능하다.
셋째, API도 없고 로그인이 필요하거나 복잡한 인터랙션이 있는 경우에만 Selenium 또는 Playwright를 쓴다. 리소스 비용이 크니까 정말 필요한 곳에만 투입하는 게 맞다 (참고: Selenium Python 공식 문서, v4.28 기준).
이 순서대로 시도하면 대부분의 Python 웹 스크래핑 작업을 불필요한 복잡도 없이 처리할 수 있다. 현재 이 구조로 20개 사이트를 매일 수집 중이고, 별다른 문제 없이 운영되고 있다.
관련 글
- Python asyncio 실전 가이드 — aiohttp와 gather로 API 호출 5배 빠르게 – requests 순차 호출로 8초 걸리던 API 집계를 asyncio.gather와 aiohttp로 1.6초까지 줄인 과정이다. event…
- Python LLM API 비용 최적화 — 캐싱, 배치, 프롬프트 압축으로 청구서 반토막 낸 방법 – 월 $180이던 LLM API 비용을 $72까지 줄인 Python LLM API 비용 최적화 실전기. 시멘틱 캐싱, OpenAI Batch…
- Python pytest 테스트 자동화 — unittest에서 전환하며 깨달은 것들 – 커버리지 80%를 달성했는데 버그는 왜 안 줄었나. unittest에서 pytest로 전환하면서 겪은 시행착오와 fixture·mock·커…