서론: 웹 스크래핑이란?

웹 스크래핑(Web Scraping)은 웹사이트에서 필요한 데이터를 자동으로 추출하는 기술입니다. 웹 크롤링(Web Crawling)이라고도 불리며, 방대한 양의 웹 데이터를 효율적으로 수집할 수 있어 데이터 분석, 가격 모니터링, 뉴스 수집 등 다양한 분야에서 활용됩니다.

이번 편에서는 Python을 사용한 웹 스크래핑의 기초를 배웁니다. requests 라이브러리로 웹 페이지를 가져오고, BeautifulSoup으로 HTML을 파싱하여 원하는 데이터를 추출하는 방법을 단계별로 알아보겠습니다.

1. 법적/윤리적 고려사항

웹 스크래핑을 시작하기 전에 반드시 알아야 할 법적, 윤리적 고려사항이 있습니다.

1.1 robots.txt 확인

robots.txt는 웹사이트 루트 디렉토리에 위치한 파일로, 웹 크롤러가 접근할 수 있는 페이지와 접근하면 안 되는 페이지를 명시합니다.

# robots.txt 예시 (https://example.com/robots.txt)
User-agent: *
Disallow: /private/
Disallow: /admin/
Allow: /public/

Crawl-delay: 10
  • User-agent: 규칙이 적용될 크롤러를 지정합니다. *는 모든 크롤러를 의미합니다.
  • Disallow: 크롤링이 금지된 경로를 지정합니다.
  • Allow: 크롤링이 허용된 경로를 지정합니다.
  • Crawl-delay: 요청 사이의 대기 시간(초)을 지정합니다.

1.2 웹 스크래핑 윤리 수칙

  • robots.txt 존중: 웹사이트의 robots.txt 규칙을 반드시 확인하고 준수합니다.
  • 서버 부하 최소화: 요청 사이에 적절한 딜레이를 두어 서버에 과부하를 주지 않습니다.
  • 이용약관 확인: 웹사이트의 이용약관에서 스크래핑 관련 조항을 확인합니다.
  • 개인정보 보호: 개인정보를 무단으로 수집하지 않습니다.
  • 저작권 존중: 수집한 데이터의 저작권을 존중하고 적절히 사용합니다.

1.3 Python으로 robots.txt 확인하기

from urllib.robotparser import RobotFileParser

def check_robots_txt(url, user_agent='*'):
    """robots.txt를 확인하여 크롤링 가능 여부를 반환합니다."""
    # robots.txt URL 생성
    from urllib.parse import urlparse
    parsed = urlparse(url)
    robots_url = f"{parsed.scheme}://{parsed.netloc}/robots.txt"

    # RobotFileParser 설정
    rp = RobotFileParser()
    rp.set_url(robots_url)
    rp.read()

    # 해당 URL의 크롤링 가능 여부 확인
    can_fetch = rp.can_fetch(user_agent, url)
    crawl_delay = rp.crawl_delay(user_agent)

    return {
        'can_fetch': can_fetch,
        'crawl_delay': crawl_delay
    }

# 사용 예시
result = check_robots_txt('https://www.google.com/search')
print(f"크롤링 가능: {result['can_fetch']}")
print(f"크롤링 딜레이: {result['crawl_delay']}초")

2. requests 라이브러리

requests는 Python에서 HTTP 요청을 보내는 가장 인기 있는 라이브러리입니다. 간단하고 직관적인 API를 제공하여 웹 페이지를 쉽게 가져올 수 있습니다.

2.1 설치

# pip를 사용하여 설치
pip install requests

2.2 HTTP 메서드

HTTP 프로토콜에서 사용되는 주요 메서드와 requests에서의 사용법을 알아봅니다.

GET 요청

가장 일반적인 요청 방식으로, 서버에서 데이터를 가져올 때 사용합니다.

import requests

# 기본 GET 요청
response = requests.get('https://httpbin.org/get')
print(response.text)

# 쿼리 파라미터와 함께 GET 요청
params = {
    'search': 'python',
    'page': 1,
    'limit': 10
}
response = requests.get('https://httpbin.org/get', params=params)
print(response.url)  # https://httpbin.org/get?search=python&page=1&limit=10

POST 요청

서버에 데이터를 전송할 때 사용합니다. 폼 데이터나 JSON 데이터를 보낼 수 있습니다.

import requests

# 폼 데이터 전송
form_data = {
    'username': 'user123',
    'password': 'pass456'
}
response = requests.post('https://httpbin.org/post', data=form_data)
print(response.json())

# JSON 데이터 전송
json_data = {
    'name': '홍길동',
    'email': 'hong@example.com'
}
response = requests.post('https://httpbin.org/post', json=json_data)
print(response.json())

헤더 설정

User-Agent 등 HTTP 헤더를 설정하여 요청을 커스터마이즈할 수 있습니다.

import requests

headers = {
    'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36',
    'Accept': 'text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8',
    'Accept-Language': 'ko-KR,ko;q=0.9,en-US;q=0.8,en;q=0.7',
    'Referer': 'https://www.google.com/'
}

response = requests.get('https://httpbin.org/headers', headers=headers)
print(response.json())

2.3 응답 처리

requests는 다양한 형태로 응답을 처리할 수 있는 기능을 제공합니다.

import requests

response = requests.get('https://httpbin.org/get')

# 상태 코드 확인
print(f"상태 코드: {response.status_code}")  # 200

# 성공 여부 확인
if response.ok:  # status_code가 200-299 범위인 경우 True
    print("요청 성공!")

# 응답 본문 (텍스트)
print(response.text)

# 응답 본문 (JSON)
data = response.json()
print(data)

# 응답 본문 (바이너리) - 이미지 등
content = response.content

# 응답 헤더
print(response.headers)
print(response.headers['Content-Type'])

# 인코딩
print(response.encoding)  # UTF-8
response.encoding = 'utf-8'  # 인코딩 지정

HTTP 상태 코드

상태 코드 의미 설명
200 OK 요청 성공
301 Moved Permanently 영구적으로 이동됨
302 Found 임시 리다이렉트
400 Bad Request 잘못된 요청
403 Forbidden 접근 권한 없음
404 Not Found 페이지를 찾을 수 없음
500 Internal Server Error 서버 내부 오류

2.4 에러 처리와 타임아웃

import requests
from requests.exceptions import RequestException, Timeout, HTTPError

def safe_request(url, timeout=10):
    """안전한 HTTP 요청을 수행합니다."""
    try:
        response = requests.get(url, timeout=timeout)
        response.raise_for_status()  # 4xx, 5xx 에러 시 예외 발생
        return response
    except Timeout:
        print(f"타임아웃 발생: {url}")
    except HTTPError as e:
        print(f"HTTP 에러: {e.response.status_code}")
    except RequestException as e:
        print(f"요청 실패: {e}")
    return None

# 사용 예시
response = safe_request('https://httpbin.org/get')
if response:
    print(response.text)

3. BeautifulSoup으로 HTML 파싱

BeautifulSoup은 HTML과 XML 문서를 파싱하기 위한 Python 라이브러리입니다. 복잡한 HTML 구조에서 원하는 데이터를 쉽게 추출할 수 있습니다.

3.1 설치

# BeautifulSoup과 lxml 파서 설치
pip install beautifulsoup4 lxml

3.2 기본 사용법

from bs4 import BeautifulSoup
import requests

# 웹 페이지 가져오기
url = 'https://example.com'
response = requests.get(url)

# BeautifulSoup 객체 생성
soup = BeautifulSoup(response.text, 'lxml')  # 또는 'html.parser'

# HTML 구조 확인 (예쁘게 출력)
print(soup.prettify())

3.3 태그로 요소 찾기

from bs4 import BeautifulSoup

html = """
<html>
<head><title>테스트 페이지</title></head>
<body>
    <h1>메인 제목</h1>
    <p>첫 번째 단락</p>
    <p>두 번째 단락</p>
    <a href="https://example.com">링크1</a>
    <a href="https://google.com">링크2</a>
</body>
</html>
"""

soup = BeautifulSoup(html, 'lxml')

# 첫 번째 태그 찾기
title = soup.find('title')
print(title.text)  # 테스트 페이지

h1 = soup.find('h1')
print(h1.text)  # 메인 제목

# 모든 태그 찾기
paragraphs = soup.find_all('p')
for p in paragraphs:
    print(p.text)

links = soup.find_all('a')
for link in links:
    print(link.text, link['href'])

3.4 클래스와 ID로 요소 찾기

from bs4 import BeautifulSoup

html = """
<html>
<body>
    <div id="header">헤더 영역</div>
    <div class="content">
        <p class="intro">소개 글</p>
        <p class="main-text">본문 내용</p>
    </div>
    <div class="sidebar">사이드바</div>
    <ul class="menu">
        <li class="menu-item active">홈</li>
        <li class="menu-item">소개</li>
        <li class="menu-item">연락처</li>
    </ul>
</body>
</html>
"""

soup = BeautifulSoup(html, 'lxml')

# ID로 찾기
header = soup.find(id='header')
print(header.text)  # 헤더 영역

# 클래스로 찾기
content = soup.find(class_='content')
print(content.text)

# 클래스명으로 여러 요소 찾기
menu_items = soup.find_all(class_='menu-item')
for item in menu_items:
    print(item.text)

# 복합 조건으로 찾기
active_item = soup.find('li', class_='active')
print(active_item.text)  # 홈

# CSS 선택자 사용 (select)
main_text = soup.select_one('.content .main-text')
print(main_text.text)  # 본문 내용

all_menu_items = soup.select('ul.menu li')
for item in all_menu_items:
    print(item.text)

3.5 속성 접근과 텍스트 추출

from bs4 import BeautifulSoup

html = """
<html>
<body>
    <a href="https://example.com" title="예제 링크" data-id="123">
        <span>링크</span> 텍스트
    </a>
    <img src="image.jpg" alt="이미지 설명">
    <div class="article">
        <h2>제목</h2>
        <p>첫 번째 단락</p>
        <p>두 번째 단락</p>
    </div>
</body>
</html>
"""

soup = BeautifulSoup(html, 'lxml')

# 속성 접근
link = soup.find('a')
print(link['href'])        # https://example.com
print(link['title'])       # 예제 링크
print(link.get('data-id')) # 123
print(link.get('class'))   # None (없으면 None 반환)

# 모든 속성 가져오기
print(link.attrs)  # {'href': 'https://example.com', 'title': '예제 링크', 'data-id': '123'}

# 이미지 태그 속성
img = soup.find('img')
print(img['src'])  # image.jpg
print(img['alt'])  # 이미지 설명

# 텍스트 추출
print(link.text)          # 링크 텍스트 (모든 자식 텍스트 포함)
print(link.string)        # None (여러 자식이 있으면 None)
print(link.get_text())    # 링크 텍스트

# 공백 제거
print(link.get_text(strip=True))  # 링크 텍스트

# 구분자로 연결
article = soup.find(class_='article')
print(article.get_text(separator=' | ', strip=True))
# 제목 | 첫 번째 단락 | 두 번째 단락

3.6 CSS 선택자 고급 사용법

from bs4 import BeautifulSoup

html = """
<html>
<body>
    <table id="data-table">
        <tr><th>이름</th><th>나이</th><th>직업</th></tr>
        <tr><td>홍길동</td><td>30</td><td>개발자</td></tr>
        <tr><td>김영희</td><td>25</td><td>디자이너</td></tr>
        <tr><td>박철수</td><td>35</td><td>매니저</td></tr>
    </table>
    <div class="products">
        <div class="product" data-price="10000">
            <span class="name">상품 A</span>
        </div>
        <div class="product" data-price="20000">
            <span class="name">상품 B</span>
        </div>
    </div>
</body>
</html>
"""

soup = BeautifulSoup(html, 'lxml')

# 자손 선택자 (공백)
cells = soup.select('#data-table td')
for cell in cells:
    print(cell.text)

# 직계 자식 선택자 (>)
rows = soup.select('#data-table > tr')

# 속성 선택자
products = soup.select('div[data-price]')
for product in products:
    name = product.select_one('.name').text
    price = product['data-price']
    print(f"{name}: {price}원")

# n번째 요소 선택
first_row = soup.select_one('#data-table tr:nth-child(2)')  # 첫 번째 데이터 행
print([td.text for td in first_row.find_all('td')])

# 복합 선택자
products_with_high_price = soup.select('.product[data-price="20000"]')
for product in products_with_high_price:
    print(product.select_one('.name').text)

4. 실전 예제: 간단한 스크래퍼 만들기

이제 배운 내용을 종합하여 실전 스크래퍼를 만들어 보겠습니다.

4.1 뉴스 헤드라인 수집기

import requests
from bs4 import BeautifulSoup
import time

class NewsHeadlineScraper:
    """뉴스 헤드라인을 수집하는 스크래퍼"""

    def __init__(self):
        self.headers = {
            'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36'
        }
        self.session = requests.Session()
        self.session.headers.update(self.headers)

    def fetch_page(self, url):
        """웹 페이지를 가져옵니다."""
        try:
            response = self.session.get(url, timeout=10)
            response.raise_for_status()
            return response.text
        except requests.RequestException as e:
            print(f"페이지 요청 실패: {e}")
            return None

    def parse_headlines(self, html, selector):
        """HTML에서 헤드라인을 추출합니다."""
        soup = BeautifulSoup(html, 'lxml')
        headlines = []

        elements = soup.select(selector)
        for element in elements:
            title = element.get_text(strip=True)
            link = element.get('href', '')
            if title:
                headlines.append({
                    'title': title,
                    'link': link
                })

        return headlines

    def scrape(self, url, selector, delay=1):
        """주어진 URL에서 헤드라인을 스크래핑합니다."""
        print(f"스크래핑 시작: {url}")

        html = self.fetch_page(url)
        if not html:
            return []

        headlines = self.parse_headlines(html, selector)
        print(f"{len(headlines)}개의 헤드라인을 찾았습니다.")

        time.sleep(delay)  # 서버 부하 방지
        return headlines

# 사용 예시 (실제 사용 시 해당 사이트의 이용약관을 확인하세요)
if __name__ == '__main__':
    scraper = NewsHeadlineScraper()

    # 예시 (실제 URL과 선택자는 대상 사이트에 맞게 수정)
    headlines = scraper.scrape(
        url='https://example.com/news',
        selector='a.headline'
    )

    for idx, headline in enumerate(headlines, 1):
        print(f"{idx}. {headline['title']}")
        print(f"   링크: {headline['link']}")

4.2 테이블 데이터 추출기

import requests
from bs4 import BeautifulSoup
import csv

def extract_table_data(url, table_selector='table'):
    """웹 페이지의 테이블 데이터를 추출합니다."""
    headers = {
        'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36'
    }

    response = requests.get(url, headers=headers, timeout=10)
    response.raise_for_status()

    soup = BeautifulSoup(response.text, 'lxml')
    table = soup.select_one(table_selector)

    if not table:
        print("테이블을 찾을 수 없습니다.")
        return []

    data = []
    rows = table.find_all('tr')

    for row in rows:
        # 헤더 셀 또는 데이터 셀 추출
        cells = row.find_all(['th', 'td'])
        row_data = [cell.get_text(strip=True) for cell in cells]
        if row_data:
            data.append(row_data)

    return data

def save_to_csv(data, filename):
    """데이터를 CSV 파일로 저장합니다."""
    with open(filename, 'w', newline='', encoding='utf-8') as f:
        writer = csv.writer(f)
        writer.writerows(data)
    print(f"데이터가 {filename}에 저장되었습니다.")

# 사용 예시
if __name__ == '__main__':
    # 예시 URL (실제 사용 시 적절한 URL로 변경)
    data = extract_table_data(
        url='https://example.com/data-table',
        table_selector='#main-table'
    )

    if data:
        for row in data[:5]:  # 처음 5행만 출력
            print(row)

        save_to_csv(data, 'extracted_data.csv')

4.3 이미지 다운로더

import requests
from bs4 import BeautifulSoup
from urllib.parse import urljoin, urlparse
import os
import time

class ImageDownloader:
    """웹 페이지에서 이미지를 다운로드하는 클래스"""

    def __init__(self, download_dir='images'):
        self.download_dir = download_dir
        self.headers = {
            'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36'
        }

        # 다운로드 디렉토리 생성
        if not os.path.exists(download_dir):
            os.makedirs(download_dir)

    def get_image_urls(self, page_url, img_selector='img'):
        """페이지에서 이미지 URL을 추출합니다."""
        response = requests.get(page_url, headers=self.headers, timeout=10)
        response.raise_for_status()

        soup = BeautifulSoup(response.text, 'lxml')
        images = soup.select(img_selector)

        image_urls = []
        for img in images:
            src = img.get('src') or img.get('data-src')
            if src:
                # 상대 URL을 절대 URL로 변환
                full_url = urljoin(page_url, src)
                image_urls.append(full_url)

        return image_urls

    def download_image(self, url, filename=None):
        """이미지를 다운로드합니다."""
        try:
            response = requests.get(url, headers=self.headers, timeout=30)
            response.raise_for_status()

            # 파일명이 없으면 URL에서 추출
            if not filename:
                parsed = urlparse(url)
                filename = os.path.basename(parsed.path) or 'image.jpg'

            filepath = os.path.join(self.download_dir, filename)

            with open(filepath, 'wb') as f:
                f.write(response.content)

            print(f"다운로드 완료: {filename}")
            return filepath

        except requests.RequestException as e:
            print(f"다운로드 실패 ({url}): {e}")
            return None

    def download_all(self, page_url, img_selector='img', delay=1):
        """페이지의 모든 이미지를 다운로드합니다."""
        image_urls = self.get_image_urls(page_url, img_selector)
        print(f"{len(image_urls)}개의 이미지를 찾았습니다.")

        downloaded = []
        for idx, url in enumerate(image_urls, 1):
            print(f"[{idx}/{len(image_urls)}] 다운로드 중...")
            filepath = self.download_image(url)
            if filepath:
                downloaded.append(filepath)
            time.sleep(delay)  # 서버 부하 방지

        return downloaded

# 사용 예시
if __name__ == '__main__':
    downloader = ImageDownloader(download_dir='downloaded_images')

    # 예시 URL (실제 사용 시 적절한 URL로 변경)
    downloaded_files = downloader.download_all(
        page_url='https://example.com/gallery',
        img_selector='div.gallery img',
        delay=2
    )

    print(f"\n총 {len(downloaded_files)}개의 이미지를 다운로드했습니다.")

5. 팁과 주의사항

5.1 효율적인 스크래핑을 위한 팁

  • Session 사용: 같은 사이트에 여러 번 요청할 때는 requests.Session()을 사용하여 연결을 재사용합니다.
  • 적절한 딜레이: 요청 사이에 time.sleep()을 사용하여 서버에 과부하를 주지 않습니다.
  • 에러 처리: 네트워크 오류, 타임아웃 등에 대한 적절한 예외 처리를 추가합니다.
  • 캐싱 활용: 같은 페이지를 반복해서 요청하지 않도록 결과를 캐싱합니다.
  • 로깅: 스크래핑 과정을 로깅하여 문제 발생 시 디버깅에 활용합니다.

5.2 흔한 문제와 해결책

  • 인코딩 문제: response.encoding을 명시적으로 설정하거나 chardet 라이브러리를 사용합니다.
  • 403 Forbidden: User-Agent 헤더를 설정하거나 다른 헤더를 추가합니다.
  • 동적 콘텐츠: JavaScript로 로드되는 콘텐츠는 requests로 가져올 수 없습니다. 다음 편에서 Selenium을 사용한 해결 방법을 배웁니다.
  • IP 차단: 요청 빈도를 줄이거나 프록시를 사용합니다.

마무리

이번 편에서는 Python 웹 스크래핑의 기초를 배웠습니다. requests 라이브러리로 웹 페이지를 가져오고, BeautifulSoup으로 HTML을 파싱하여 원하는 데이터를 추출하는 방법을 익혔습니다.

다음 4편에서는 더 복잡한 웹 스크래핑 시나리오를 다룹니다. JavaScript로 동적 생성되는 콘텐츠를 처리하기 위한 Selenium, 로그인이 필요한 사이트 스크래핑, 그리고 수집한 데이터를 저장하는 다양한 방법을 배울 예정입니다.

시리즈 안내: Python 자동화 마스터 시리즈는 계속됩니다. 다음 편에서 더 심화된 웹 스크래핑 기술을 배워보세요!