Python 웹 스크래핑: BeautifulSoup와 Selenium

이 글의 핵심

정적 HTML만 받는 requests로는 JavaScript가 렌더링하는 페이지의 데이터를 볼 수 없어서 헤드리스 브라우저가 필요해집니다. HTML 파서와 Selenium이 내부에서 어떻게 동작하는지 짚은 뒤, 분명히 있는 요소를 못 찾는 에러의 원인인 로딩 대기 문제와 과도한 요청으로 차단당하지 않는 방법을 다룹니다.

들어가며

웹 스크래핑은 웹사이트에서 자동으로 데이터를 수집하는 기술입니다. 브라우저가 주소를 치고 HTML을 받아 오는 과정을, Python 스크립트가 대신 반복해 주는 것으로 이해하시면 됩니다.

이 글에서 다루는 내용

  • 웹 스크래핑의 역사와 동작 원리: HTTP 통신부터 HTML 파싱까지의 전체 흐름
  • BeautifulSoup vs Selenium: 정적/동적 페이지의 차이와 선택 기준
  • 실전 패턴: 세션 재사용, 재시도와 요청 간격, 인코딩·빈 결과 처리
  • 법적·윤리적 고려사항: robots.txt, ToS, 개인정보 보호

웹 스크래핑의 역사와 설계 철학

웹 스크래핑의 탄생 배경

웹 스크래핑의 역사는 웹 자체의 역사와 함께합니다. 1990년대 초 Tim Berners-Lee가 World Wide Web을 발명한 직후부터, 사람들은 웹에 흩어진 정보를 자동으로 모으고 싶어했습니다.

초기 웹은 정적 HTML 문서의 집합에 불과했습니다. 검색 엔진이 등장하기 전, 웹 크롤러(web crawler)는 링크를 따라가며 페이지를 수집하고 인덱싱하는 봇이었습니다. Google의 PageRank 알고리즘(1998)도 본질적으로는 대규모 웹 크롤링과 링크 분석의 산물입니다.

HTML 파싱의 진화

초기 스크래핑은 정규 표현식으로 HTML을 파싱했습니다. 하지만 HTML은 잘 정의된 XML이 아니라 “느슨한” 마크업이라는 문제가 있었습니다:

# ❌ 안티패턴: 정규식으로 HTML 파싱
import re
html = '<div class="title">Hello</div>'
match = re.search(r'<div class="title">(.*?)</div>', html)
# 중첩 태그, 속성 순서 변경, 공백 차이 등에 취약

이런 문제를 해결하기 위해 HTML 파서 라이브러리가 등장했습니다:

  • BeautifulSoup(2004): Python의 대표적인 HTML 파서. lxml, html.parser 등 다양한 백엔드를 지원하며, 잘못된 HTML도 관대하게 처리하는 것이 특징입니다.
  • lxml(2005): C 라이브러리 libxml2의 Python 바인딩. 속도가 빠르고 XPath를 지원합니다.
  • html5lib(2006): 브라우저와 동일한 방식으로 HTML을 파싱하여 최대한 관대한 처리를 목표로 합니다.

JavaScript 시대와 Headless 브라우저

2010년대 들어 Single Page Application(SPA) 과 JavaScript 프레임워크(React, Vue, Angular)가 보편화되면서, 서버가 보내는 초기 HTML이 거의 빈 껍데기인 경우가 많아졌습니다. 실제 콘텐츠는 JavaScript가 실행된 후에야 DOM에 삽입됩니다.

<!-- 서버가 보내는 초기 HTML -->
<div id="root"></div>
<script src="app.js"></script>
<!-- JavaScript 실행 후 -->
<div id="root">
  <h1>실제 콘텐츠</h1>
  <p>데이터가 여기 렌더링됨</p>
</div>

이를 스크래핑하려면 브라우저처럼 JavaScript를 실행할 수 있는 환경이 필요합니다. 따라서 등장한 것이 Headless 브라우저입니다:

  • PhantomJS(2011-2018, 지원 종료): WebKit 기반의 headless 브라우저
  • Selenium(2004~): 원래는 웹 애플리케이션 테스트 도구였으나, 실제 브라우저를 제어할 수 있어 스크래핑에도 널리 사용
  • Puppeteer(2017~): Google이 만든 Node.js용 Chrome/Chromium 제어 라이브러리
  • Playwright(2020~): Microsoft가 만든 크로스 브라우저 자동화 도구

Python에서는 Selenium + Chrome/Firefox가 가장 일반적이며, 최근에는 Playwright의 Python 바인딩도 인기입니다.

HTTP 통신의 내부 구조

웹 스크래핑의 핵심은 HTTP 요청/응답입니다. requests.get(url)이 내부적으로 하는 일은 다음과 같습니다:

sequenceDiagram
    participant Client as Python Script
    participant DNS
    participant Server as 웹 서버
    
    Client->>DNS: example.com의 IP는?
    DNS-->>Client: 93.184.216.34
    Client->>Server: TCP 3-Way Handshake
    Server-->>Client: SYN-ACK
    Client->>Server: GET /page HTTP/1.1\nHost: example.com\nUser-Agent: ...
    Server-->>Client: HTTP/1.1 200 OK\nContent-Type: text/html\n\n<html>...
    Client->>Client: HTML 파싱, 데이터 추출

중요한 개념:

  1. DNS 조회: 도메인 이름을 IP 주소로 변환. 캐싱으로 반복 조회를 줄일 수 있음.
  2. TCP 연결: 3-Way Handshake로 연결 수립. Keep-Alive로 연결 재사용 가능.
  3. HTTP 요청: Method(GET/POST), Headers(User-Agent, Cookie, …), Body
  4. HTTP 응답: Status Code(200, 404, 503, …), Headers(Content-Type, Set-Cookie, …), Body(HTML, JSON, …)

BeautifulSoup의 내부 동작

BeautifulSoup은 HTML을 트리 구조로 파싱합니다. 내부적으로는 다음 과정을 거칩니다:

from bs4 import BeautifulSoup

html = """
<html>
  <body>
    <div class="container">
      <h1 id="title">제목</h1>
      <p>내용</p>
    </div>
  </body>
</html>
"""

soup = BeautifulSoup(html, 'html.parser')
# 1. 토큰화(Tokenization): <html>, <body>, <div>, ... 로 분해
# 2. 파싱(Parsing): 트리 구조로 구성
# 3. 탐색(Navigation): find, select 등으로 노드 접근

파서 백엔드 비교:

파서속도관대함외부 의존성
html.parser중간중간없음 (표준 라이브러리)
lxml빠름중간C 라이브러리 필요
html5lib느림매우 관대순수 Python

실무 권장: 속도가 중요하면 lxml, 호환성이 중요하면 html.parser, 브라우저와 완전히 동일한 파싱이 필요하면 html5lib.

Selenium의 작동 원리

Selenium은 WebDriver 프로토콜을 통해 브라우저를 원격 제어합니다:

from selenium import webdriver
from selenium.webdriver.common.by import By

# 1. WebDriver 시작 (Chrome, Firefox, ...)
driver = webdriver.Chrome()  # chromedriver 실행 (Selenium 4.6+는 Selenium Manager가 자동 설치)

# 2. Python ↔ chromedriver: W3C WebDriver 프로토콜 (HTTP + JSON)
driver.get('https://example.com')  
# → POST /session/:sessionId/url {"url": "..."}

# 3. JavaScript 실행 대기
driver.implicitly_wait(10)

# 4. DOM 요소 찾기
element = driver.find_element(By.CSS_SELECTOR, '.title')
# → POST /session/:sessionId/element {"using": "css selector", "value": ".title"}

# 5. 요소 조작
element.click()
# → POST /session/:sessionId/element/:elementId/click

왜 느릴까?

  • 실제 브라우저를 띄우기 때문: 메모리 수백 MB, CPU 사용
  • 렌더링 과정 포함: CSS 계산, 레이아웃, 페인팅
  • 네트워크 통신 오버헤드: Python ↔ WebDriver ↔ Browser

최적화 방법:

  • Headless 모드: GUI 없이 실행 (options.add_argument('--headless=new'), 최신 Chrome의 헤드리스 모드)
  • 이미지/CSS 비활성화: 필요 없는 리소스 로딩 차단
  • 브라우저 인스턴스 재사용: 매번 새로 띄우지 않기

브라우저를 띄우기 전에 먼저 확인해 볼 것이 있습니다. JavaScript로 그려지는 페이지도 대부분 데이터를 별도의 API 요청으로 받아 옵니다. 개발자 도구의 Network 탭에서 Fetch/XHR 필터를 켜고 페이지를 새로고침하면, 목록 데이터를 담은 JSON 응답을 찾을 수 있는 경우가 많습니다. 그 요청을 requests로 직접 보내면 브라우저 없이 훨씬 빠르고 안정적으로 데이터를 얻을 수 있고, HTML 구조가 바뀌어도 덜 깨집니다. Selenium은 로그인 흐름이나 복잡한 상호작용처럼 API를 재현하기 어려운 경우에 쓰는 편이 효율적입니다.

위 코드처럼 implicitly_wait를 설정한 상태에서 아래 기본 사용 예제의 WebDriverWait(명시적 대기)를 함께 쓰면 두 대기 시간이 겹쳐 예상보다 훨씬 오래 멈추는 일이 생깁니다. Selenium 공식 문서도 둘을 섞지 말라고 안내하므로, 한 프로젝트에서는 명시적 대기로 통일하는 것이 좋습니다.


requests로 HTML 가져오기

HTML 가져오기

requests.get은 서버에 “이 페이지 주세요”라고 부탁하며, 응답 본문·상태 코드·헤더를 돌려받는 우편 요청과 같습니다. 일부 사이트는 기본 User-Agent만 보면 봇으로 막기 때문에, 실제 브라우저에 가까운 헤더를 넣으면 차단이 줄어드는 경우가 많습니다.

import requests
# GET 요청
response = requests.get('https://example.com')
print(response.status_code)  # 200
print(response.text)  # HTML 내용
print(response.headers)  # 헤더 정보
# User-Agent 설정
headers = {
    'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36'
}
response = requests.get('https://example.com', headers=headers)

response.text를 쓸 때 한글 사이트에서 자주 겪는 문제가 글자 깨짐입니다. requests는 응답 헤더의 Content-Type에 charset이 없으면 텍스트를 ISO-8859-1로 가정해 디코딩하므로, EUC-KR로 된 오래된 사이트나 charset을 헤더에 안 쓰는 서버에서 ë¡ê·¸ì¸ 같은 문자열이 나옵니다. print(response.encoding)으로 현재 추정값을 확인하고, 틀렸다면 response.encoding = response.apparent_encoding으로 본문 기반 추정을 쓰거나 response.content(바이트)를 BeautifulSoup에 그대로 넘기면 파서가 HTML 안의 <meta charset>을 읽어 올바르게 디코딩합니다.

또 하나 기억할 것은 requests.get에 기본 타임아웃이 없다는 점입니다. 서버가 응답하지 않으면 스크립트가 영원히 멈출 수 있으므로, 실제 코드에서는 항상 timeout=10처럼 지정해야 합니다. 같은 사이트에 여러 번 요청한다면 requests.Session()을 만들어 재사용하면 TCP·TLS 연결을 다시 맺지 않아 빨라지고, 로그인 후 받은 쿠키도 자동으로 유지됩니다.

import requests
from requests.adapters import HTTPAdapter
from urllib3.util.retry import Retry

session = requests.Session()
session.headers.update({'User-Agent': 'MyScraper/1.0 ([email protected])'})
retry = Retry(total=3, backoff_factor=1,
              status_forcelist=[429, 500, 502, 503, 504],
              respect_retry_after_header=True)
session.mount('https://', HTTPAdapter(max_retries=retry))

response = session.get('https://example.com', timeout=10)
response.raise_for_status()

Retry는 429(요청 과다)나 5xx 응답에 대해 1초, 2초, 4초처럼 간격을 늘려 가며 다시 시도하고, 서버가 Retry-After 헤더를 보내면 그 시간만큼 기다립니다. 실패할 때마다 즉시 다시 요청하는 직접 만든 재시도 루프는 이미 부하가 걸린 서버를 더 괴롭히고 IP 차단을 앞당기므로, 이런 지수 백오프 방식이 서버와 스크래퍼 모두에게 낫습니다.


BeautifulSoup으로 파싱하기

HTML 파싱

서버가 준 HTML 문자열은 한 덩어리 텍스트라서, 그대로는 제목·링크만 골라내기 어렵습니다. BeautifulSoup은 이를 태그 단위로 나눈 뒤 find/select로 원하는 조각만 집는 도구입니다. 마치 책에서 목차와 본문만 골라 읽는 것과 비슷합니다.

from bs4 import BeautifulSoup
import requests
# HTML 가져오기
url = 'https://example.com'
response = requests.get(url)
soup = BeautifulSoup(response.text, 'html.parser')
# 태그 찾기
title = soup.find('title')
print(title.text)
# 여러 태그 찾기
links = soup.find_all('a')
for link in links:
    print(link.get('href'))
# CSS 선택자
articles = soup.select('.article-title')
for article in articles:
    print(article.text)

뉴스 목록 크롤링

import requests
from bs4 import BeautifulSoup
import pandas as pd
def scrape_news(url):
    """뉴스 제목과 링크 수집"""
    headers = {
        'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64)'
    }
    
    response = requests.get(url, headers=headers)
    soup = BeautifulSoup(response.text, 'html.parser')
    
    articles = []
    
    for item in soup.select('.news-item'):
        title = item.select_one('.title').text.strip()
        link = item.select_one('a')['href']
        date = item.select_one('.date').text.strip()
        
        articles.append({
            'title': title,
            'link': link,
            'date': date
        })
    
    return pd.DataFrame(articles)
# 사용
df = scrape_news('https://news.example.com')
df.to_csv('news.csv', index=False, encoding='utf-8-sig')

이 코드는 모든 .news-item 안에 .title, a, .date가 반드시 있다고 가정합니다. 광고 항목이나 날짜가 없는 기사 하나만 섞여도 select_one이 None을 돌려주고, .text에서 AttributeError: 'NoneType' object has no attribute 'text'가 나며 수집 전체가 중단됩니다. 스크래퍼는 남의 HTML에 의존하므로 요소가 없을 수 있다는 전제로 짜야 합니다. el = item.select_one('.date'); date = el.get_text(strip=True) if el else None처럼 하나씩 확인하거나, 필수 필드가 없는 항목은 건너뛰고 몇 개를 건너뛰었는지 로그로 남기면 사이트 구조가 바뀌었을 때 빨리 알아챌 수 있습니다.

링크도 주의해야 합니다. href에는 /news/123처럼 상대 경로가 들어 있는 경우가 많아 그대로 저장하면 나중에 열 수 없습니다. from urllib.parse import urljoin으로 urljoin(url, href)를 거쳐 절대 URL로 바꿔 두는 것이 좋습니다. 인코딩을 utf-8-sig로 저장하는 이유는 엑셀이 BOM이 없는 UTF-8 CSV를 CP949로 열어 한글이 깨지기 때문입니다.


Selenium으로 동적 페이지 다루기

설치

pip install selenium

기본 사용

from selenium import webdriver
from selenium.webdriver.common.by import By
from selenium.webdriver.support.ui import WebDriverWait
from selenium.webdriver.support import expected_conditions as EC
# 드라이버 설정
driver = webdriver.Chrome()
try:
    # 페이지 열기
    driver.get('https://example.com')
    
    # 요소 대기
    element = WebDriverWait(driver, 10).until(
        EC.presence_of_element_located((By.CLASS_NAME, 'content'))
    )
    
    # 요소 찾기
    title = driver.find_element(By.TAG_NAME, 'h1')
    print(title.text)
    
    # 클릭
    button = driver.find_element(By.ID, 'load-more')
    button.click()
    
    # 스크롤
    driver.execute_script('window.scrollTo(0, document.body.scrollHeight);')
    
finally:
    driver.quit()

presence_of_element_located는 요소가 DOM에 존재하는지만 확인합니다. 요소가 있어도 아직 화면에 보이지 않거나 다른 요소에 가려져 있으면 click()에서 ElementNotInteractableException이나 ElementClickInterceptedException이 납니다. 클릭할 요소는 EC.element_to_be_clickable로 기다리는 것이 맞습니다. 또 “더 보기” 버튼을 누르거나 스크롤해서 목록이 다시 그려지면, 그 전에 찾아 둔 요소 객체가 무효가 되어 StaleElementReferenceException이 납니다. 프레임워크가 DOM을 새로 만들었기 때문이므로, 목록이 바뀐 뒤에는 find_element를 다시 호출해야 합니다. 이 세 가지 예외가 Selenium 스크래퍼에서 가장 흔히 만나는 에러이고, 대부분 “요소를 찾는 시점”과 “화면이 준비된 시점”이 어긋나서 생깁니다.


상품 가격 모니터링 예제

import requests
from bs4 import BeautifulSoup
import time
from datetime import datetime
def check_price(url, target_price):
    """상품 가격 확인"""
    headers = {
        'User-Agent': 'Mozilla/5.0'
    }
    
    response = requests.get(url, headers=headers)
    soup = BeautifulSoup(response.text, 'html.parser')
    
    # 가격 추출 (사이트마다 다름)
    price_text = soup.select_one('.price').text
    price = int(price_text.replace(',', '').replace('원', '').strip())
    
    print(f"[{datetime.now()}] 현재 가격: {price:,}원")
    
    if price <= target_price:
        print(f"🎉 목표 가격 달성! ({target_price:,}원 이하)")
        return True
    
    return False
# 사용 (1시간마다 확인)
url = 'https://shopping.example.com/product/123'
target = 50000
while True:
    if check_price(url, target):
        break
    time.sleep(3600)  # 1시간 대기

가격 텍스트를 숫자로 바꾸는 부분이 이 코드에서 가장 깨지기 쉽습니다. "12,900원"은 잘 처리되지만 "12,900원~", "₩12,900", 할인 전후 가격이 함께 들어 있는 "15,000원 12,900원" 같은 텍스트가 오면 int()에서 ValueError: invalid literal for int()가 납니다. re.sub(r'[^\d]', '', price_text)로 숫자만 남기는 방법도 있지만, 두 가격이 붙어 있으면 1500012900이라는 엉뚱한 숫자가 되므로 가격 요소를 더 좁은 선택자로 고르는 것이 먼저입니다. 또 while True 루프는 한 번의 네트워크 오류나 파싱 실패로 전체가 종료되므로, 실제로 오래 돌릴 모니터링이라면 check_price 호출을 try/except로 감싸고, 파이썬 프로세스를 계속 띄워 두는 대신 cron이나 작업 스케줄링 도구로 한 번씩 실행하는 편이 안정적입니다.


CSV로 저장하기

CSV 저장

import pandas as pd
def scrape_and_save(url, output_file):
    """스크래핑 후 CSV 저장"""
    # 스크래핑
    data = scrape_data(url)
    
    # DataFrame 생성
    df = pd.DataFrame(data)
    
    # CSV 저장
    df.to_csv(output_file, index=False, encoding='utf-8-sig')
    print(f"저장 완료: {output_file}")

요청 간격·robots.txt·예외 처리 (에티켓)

같은 서버에 짧은 시간에 수백 번 요청을 보내면 부하와 차단으로 이어질 수 있습니다. robots.txt로 허용 범위를 확인하며, time.sleep으로 간격을 두며, 네트워크 오류는 try/except로 한 번 실패했다고 전체가 죽지 않게 받아 주는 편이 좋습니다.

# ✅ robots.txt 확인
# https://example.com/robots.txt
# ✅ 요청 간격 두기
import time
time.sleep(1)  # 1초 대기
# ✅ User-Agent 설정
headers = {'User-Agent': '...'}
# ✅ 에러 처리
try:
    response = requests.get(url, timeout=10)
    response.raise_for_status()
except requests.exceptions.RequestException as e:
    print(f"요청 실패: {e}")

robots.txt는 주석으로 적어 둔 것처럼 “눈으로 확인”할 수도 있지만, 표준 라이브러리의 urllib.robotparser.RobotFileParser로 읽어서 can_fetch("MyScraper", url)로 코드에서 검사할 수 있습니다. Crawl-delay 지시어가 있다면 crawl_delay()로 읽어 요청 간격에 반영하는 것이 예의입니다. robots.txt는 법적 강제력이 있는 문서는 아니지만, 무시하고 수집했다가 문제가 생기면 “사이트 운영자의 의사를 알면서도 어겼다”는 근거가 되기 쉽습니다. 로그인해야 볼 수 있는 페이지, 개인정보가 담긴 페이지, 이용약관에서 자동 수집을 명시적으로 금지한 사이트는 기술적으로 가능하더라도 수집하지 않는 것이 원칙입니다.


웹 스크래핑 요약

  1. requests: HTTP 요청
  2. BeautifulSoup: HTML 파싱
  3. Selenium: 동적 페이지
  4. 에티켓: robots.txt, 요청 간격
  5. 저장: CSV, JSON, 데이터베이스

다음 단계


같이 보면 좋은 글


자주 묻는 질문 (FAQ)

Q. Selenium에서 분명히 있는 요소를 찾지 못한다는 에러가 나면 어떻게 하나요?

A. 동적 페이지는 JavaScript가 실행된 뒤에 요소가 생기기 때문에, 페이지를 열자마자 find_element를 호출하면 아직 요소가 없어 실패할 수 있습니다. WebDriverWait(driver, 10).until(EC.presence_of_element_located(...))처럼 요소가 나타날 때까지 기다린 뒤 접근하는 것이 기본입니다. 작업이 끝나거나 예외가 나도 브라우저가 남지 않도록 try...finally에서 driver.quit()을 호출하는 것도 잊지 않아야 합니다.