파이썬 BeautifulSoup 크롤링 동시성 가이드 asyncio aiohttp 세마포어 재시도 설계
📌 속도와 안정성을 동시에 잡는 법, asyncio와 aiohttp로 동시 요청을 제어하고 세마포어와 재시도로 레이트 리밋을 안전하게 지키는 실전 전략
크롤링을 하다 보면 BeautifulSoup만으로는 속도가 마음처럼 나지 않는 순간이 자주 찾아옵니다.
요청이 수백 건을 넘어가면 병목이 생기고, 서버는 레이트 리밋을 걸어오거나 간헐적으로 타임아웃을 반환하죠.
이럴 때 필요한 것이 바로 asyncio 기반 동시성 처리와 aiohttp의 비동기 HTTP 요청입니다.
여기에 세마포어를 통한 동시 연결 수 제어, 지수 백오프를 포함한 재시도 정책을 더하면 대규모 수집에서도 안정적으로 데이터를 모을 수 있습니다.
오늘은 파이썬 표준 비동기 프레임워크와 aiohttp를 사용해 동시 요청을 설계하고, BeautifulSoup으로 파싱을 이어 붙이는 구조를 친절하게 정리해 드립니다.
이 글은 다음의 핵심을 분명히 다룹니다.
BeautifulSoup으로 HTML을 파싱하되, 네트워크 I/O는 asyncio와 aiohttp로 비동기 처리합니다.
세마포어로 동시 작업 수를 제한해 서비스의 레이트 리밋을 준수합니다.
네트워크 오류나 5xx 응답에 대비해 재시도 정책을 적용하고, 무분별한 반복을 막기 위해 최대 시도 횟수와 지수 백오프를 설정합니다.
또한 사용자 에이전트, 세션 재사용, 타임아웃, 작업 큐 구성 같은 실전 체크포인트도 함께 정리합니다.
이 조합만 익히면 크롤러의 속도와 신뢰도가 눈에 띄게 달라집니다.
📋 목차
🔗 BeautifulSoup 크롤링에서 동시성이 왜 중요할까
웹 크롤링을 할 때 가장 흔히 쓰이는 도구가 바로 BeautifulSoup입니다.
하지만 BeautifulSoup은 HTML 파싱에 특화된 라이브러리일 뿐, 네트워크 요청 자체는 처리하지 않습니다.
즉, 요청 속도와 안정성은 파이썬의 requests 같은 동기 HTTP 라이브러리에 의존하게 되는데, 이 방식은 단일 스레드 환경에서 병목이 쉽게 생깁니다.
요청이 수십 개라면 괜찮지만, 수백 수천 개가 되면 순차 처리 속도가 감당하기 어려울 만큼 느려지게 됩니다.
특히 최근 웹사이트들은 봇 트래픽을 막기 위해 레이트 리밋(rate limit)이나 동시 연결 제한을 걸어두는 경우가 많습니다.
단순 반복 요청으로는 타임아웃, 429 Too Many Requests, 심하면 IP 차단까지 이어질 수 있죠.
따라서 효율적인 데이터 수집을 위해서는 동시성을 고려한 요청 관리가 필수입니다.
여기서 등장하는 것이 파이썬의 비동기 처리 프레임워크 asyncio와, 네트워크에 특화된 aiohttp입니다.
💬 BeautifulSoup은 파싱에 집중하고, 네트워크 요청은 asyncio + aiohttp가 맡는 구조를 만들어야 속도와 안정성을 동시에 챙길 수 있습니다.
⚡ 동기 요청의 한계와 병목
requests로 100개의 페이지를 순차적으로 불러온다고 가정하면, 각 요청이 평균 0.5초만 걸려도 전체 작업은 50초 이상이 소요됩니다.
이때 CPU는 대부분 대기 상태에 머물고, 네트워크 I/O가 끝날 때까지 다음 요청으로 넘어가지 못합니다.
즉, 네트워크 대기 시간이 전체 성능을 크게 잡아먹는 구조인 것이죠.
🚀 비동기 요청의 장점
반면 asyncio와 aiohttp를 활용하면 여러 요청을 동시에 처리할 수 있습니다.
예를 들어 100개의 요청을 한 번에 10개씩 묶어 비동기적으로 실행하면, 평균 지연 시간은 줄어들고 전체 실행 시간은 수 초 수준으로 단축됩니다.
이 과정에서 BeautifulSoup은 여전히 파싱에만 집중하고, aiohttp가 효율적인 연결 관리와 응답 수집을 맡습니다.
- ⚠️동기 요청은 속도가 느리고, 대규모 수집에는 적합하지 않음
- 🚀비동기 요청은 대기 시간을 겹쳐 처리하므로 훨씬 빠름
- 🔒하지만 동시성 제어와 에러 대응 전략이 반드시 필요
🛠️ asyncio aiohttp로 동시 요청 설계하기
파이썬에서 동시성을 제대로 활용하려면 asyncio와 aiohttp 조합이 사실상 표준입니다.
asyncio는 비동기 이벤트 루프를 기반으로 여러 태스크를 동시에 실행할 수 있게 해주고, aiohttp는 비동기 방식의 HTTP 클라이언트를 제공하여 네트워크 I/O를 효율적으로 관리합니다.
이 둘을 함께 사용하면 크롤링 속도를 획기적으로 개선할 수 있습니다.
🔑 기본 구조 이해하기
일반적으로 aiohttp에서는 세션(aiohttp.ClientSession)을 재사용하여 성능을 높이고, 각 요청은 async with 문을 활용해 안전하게 관리합니다.
응답을 받은 뒤에는 BeautifulSoup으로 HTML을 파싱하여 원하는 데이터를 추출하는 방식으로 설계합니다.
import asyncio
import aiohttp
from bs4 import BeautifulSoup
async def fetch(session, url):
async with session.get(url) as response:
html = await response.text()
soup = BeautifulSoup(html, "html.parser")
return soup.title.text
async def main(urls):
async with aiohttp.ClientSession() as session:
tasks = [fetch(session, url) for url in urls]
results = await asyncio.gather(*tasks)
return results
urls = ["https://example.com", "https://www.python.org"]
print(asyncio.run(main(urls)))
위 예제는 단순하지만 핵심 구조를 잘 보여줍니다.
세션을 재사용하고, asyncio.gather를 통해 여러 요청을 동시에 실행하는 것이 포인트입니다.
이 접근만으로도 동기 방식 대비 수십 배 빠른 결과를 얻을 수 있습니다.
📡 세션 관리와 타임아웃 설정
비동기 요청을 대규모로 실행할 경우, 세션을 재사용하지 않으면 연결 설정 비용이 쌓여 성능이 급격히 저하됩니다.
또한 각 요청마다 타임아웃을 지정해 무한 대기를 방지해야 합니다.
aiohttp에서는 timeout=aiohttp.ClientTimeout(total=10)과 같이 설정하여 안정성을 확보할 수 있습니다.
💡 TIP: 세션 재사용과 타임아웃 설정은 동시성 크롤러의 성능과 안정성을 좌우하는 핵심 요소입니다.
⚙️ 세마포어로 레이트 리밋과 동시성 제어하기
비동기 요청이 빠른 것은 장점이지만, 동시에 너무 많은 요청을 보내면 문제가 생깁니다.
일부 웹사이트는 초당 요청 제한을 두고 있으며, 이를 무시하면 429 Too Many Requests 또는 차단에 직면할 수 있습니다.
따라서 aiohttp와 함께 asyncio.Semaphore를 사용해 동시 요청 수를 제한하는 것이 안전한 접근입니다.
🔒 세마포어 기본 개념
세마포어는 동시 실행 가능한 태스크 수를 제한하는 도구입니다.
예를 들어, asyncio.Semaphore(5)라고 설정하면 동시에 5개까지만 요청을 실행하고 나머지는 대기 상태에 들어갑니다.
이 방식으로 웹 서버의 부담을 줄이고 레이트 리밋을 회피할 수 있습니다.
import asyncio
import aiohttp
from bs4 import BeautifulSoup
semaphore = asyncio.Semaphore(5)
async def fetch(session, url):
async with semaphore: # 동시 요청 5개 제한
async with session.get(url) as response:
html = await response.text()
soup = BeautifulSoup(html, "html.parser")
return soup.title.text
⚠️ 동시성 제어 없이 발생하는 문제
세마포어를 적용하지 않으면 서버에 과도한 요청이 집중되어 타임아웃, 연결 끊김, IP 블락 같은 상황이 발생합니다.
특히 클라우드 서비스 API나 공공 데이터 포털은 하루 요청량 제한이 있기 때문에 동시 요청을 무작정 늘리는 것은 위험합니다.
⚠️ 주의: 속도만 고려해 동시 요청을 무제한으로 보내면 서버 차단뿐 아니라 본인 IP까지 블랙리스트에 올라갈 수 있습니다.
✅ 세마포어 적용 시 장점
- 🔧서버 리소스를 존중하며 안정적으로 크롤링 가능
- 📊트래픽 관리가 가능해 장기적으로 더 많은 데이터 확보
- 🔐IP 차단, 요청 거부 등 리스크를 크게 줄임
🔌 지수 백오프 기반 재시도 정책 구축하기
네트워크 크롤링에서는 언제든지 타임아웃, 연결 끊김, 서버 오류(5xx) 같은 예외가 발생할 수 있습니다.
이때 단순히 실패한 요청을 무시하면 데이터가 누락되고, 무한히 반복하면 서버에 부담을 주거나 프로그램이 멈출 위험이 있습니다.
따라서 재시도 정책을 설계하는 것이 필수입니다.
⏳ 지수 백오프(Exponential Backoff)란?
지수 백오프는 요청 실패 시 재시도 간격을 점점 늘려가는 방식입니다.
예를 들어 첫 번째 재시도는 1초 후, 두 번째는 2초 후, 세 번째는 4초 후와 같이 대기 시간을 두 배씩 증가시키는 것입니다.
이렇게 하면 서버에 과부하를 줄이지 않으면서 안정적으로 재시도를 수행할 수 있습니다.
import asyncio
import aiohttp
from bs4 import BeautifulSoup
async def fetch_with_retry(session, url, retries=3):
delay = 1
for attempt in range(retries):
try:
async with session.get(url) as response:
html = await response.text()
return BeautifulSoup(html, "html.parser")
except Exception as e:
if attempt < retries - 1:
await asyncio.sleep(delay)
delay *= 2 # 지수 백오프
else:
raise e
위 코드에서는 요청 실패 시 최대 3번까지 재시도하며, 재시도 간격은 1초, 2초, 4초로 점점 늘어납니다.
이 방식은 특히 API 요청이나 불안정한 네트워크 환경에서 효과적입니다.
💡 재시도 정책 설계 시 체크포인트
- ⏱️최대 재시도 횟수를 지정해 무한 루프 방지
- 📉지수 백오프로 서버 부하를 최소화
- 🔍재시도 실패 시 로깅 또는 예외 처리로 추적 가능
- 🛡️특정 에러 코드(예: 404)는 재시도하지 않도록 조건 분기
💡 실전 아키텍처와 성능 튜닝 체크리스트
동시성 크롤러는 작은 설정 차이만으로도 성능과 안정성이 크게 달라집니다.
아키텍처는 작업 큐로 URL을 관리하고, 세마포어로 동시성 한도를 묶으며, aiohttp.ClientSession을 재사용하는 구조가 기본입니다.
여기에 TCPConnector로 연결 풀을 제어하고, ClientTimeout, User-Agent, 지수 백오프 재시도를 결합하면 대부분의 실전 요구사항을 충족합니다.
또한 HTML 파싱은 BeautifulSoup로 수행하되, 선택자 범위를 좁히고 불필요한 DOM 탐색을 줄여 CPU 사용량을 최소화합니다.
import asyncio
import aiohttp
from bs4 import BeautifulSoup
CONCURRENCY = 10
RETRIES = 3
async def fetch_one(session, url, semaphore):
delay = 1
for attempt in range(RETRIES):
async with semaphore:
try:
async with session.get(url) as r:
if r.status in {429, 500, 502, 503, 504}:
raise aiohttp.ClientResponseError(
r.request_info, r.history, status=r.status, message="retryable", headers=r.headers
)
html = await r.text()
soup = BeautifulSoup(html, "html.parser") # 또는 "lxml"
return {"url": url, "title": soup.title.text if soup.title else ""}
except Exception as e:
if attempt == RETRIES - 1:
return {"url": url, "error": str(e)}
await asyncio.sleep(delay)
delay = min(delay * 2, 8) # 지수 백오프 상한
async def crawl(urls):
headers = {"User-Agent": "Mozilla/5.0 (compatible; AsyncCrawler/1.0)"}
timeout = aiohttp.ClientTimeout(total=15, connect=5, sock_read=10)
connector = aiohttp.TCPConnector(limit=CONCURRENCY, ssl=False, ttl_dns_cache=120)
semaphore = asyncio.Semaphore(CONCURRENCY)
async with aiohttp.ClientSession(headers=headers, timeout=timeout, connector=connector) as session:
tasks = [asyncio.create_task(fetch_one(session, u, semaphore)) for u in urls]
results = []
for coro in asyncio.as_completed(tasks):
results.append(await coro) # 빠르는 순서대로 소비
return results
# 사용 예
# asyncio.run(crawl(["https://example.com", "https://python.org"]))
위 패턴은 세션 재사용, 연결 풀 한도, 타임아웃, 세마포어, 지수 백오프 재시도를 한 번에 반영한 실전 골격입니다.
또한 asyncio.as_completed를 사용해 먼저 완료된 작업부터 결과를 소비하면 전체 처리 지연을 더 줄일 수 있습니다.
| 튜닝 포인트 | 권장 설정 및 팁 |
|---|---|
| 동시성 한도 | asyncio.Semaphore와 TCPConnector(limit)를 동일 값으로 맞추어 과출력을 방지. |
| 타임아웃 | ClientTimeout(total, connect, sock_read) 분리 설정으로 지연 구간 식별. |
| 재시도 | 429·5xx만 재시도, 404/400류는 즉시 실패 처리. 지수 백오프 상한을 둠. |
| 파싱 성능 | 불필요한 find_all 호출 최소화, CSS 셀렉터 범위를 좁히기, 필요 시 lxml 파서 사용. |
| HTTP 헤더 | 합리적인 User-Agent, Accept-Language 설정. 세션 전역 헤더로 관리. |
| 로그 & 관측 | 성공/실패/재시도 횟수, 평균 지연, 상태코드 분포를 주기적으로 기록. |
💎 핵심 포인트:
동시성 한도(CONCURRENCY)는 네트워크 품질과 대상 서버의 허용치에 맞춰 단계적으로 올립니다.
세션과 커넥터 설정은 전역으로 단일 인스턴스를 재사용해 오버헤드를 줄입니다.
결과 소비는 as_completed로 먼저 끝난 작업부터 처리하면 체감 속도가 좋아집니다.
- 🧰세션, 커넥터, 타임아웃, 세마포어를 전역으로 구성
- 📦작업 큐(리스트/DB/메시지 큐)로 URL 상태를 추적
- 🧪점진적 롤아웃: 2 → 5 → 10처럼 동시성 한도를 늘려 측정
- 🧭robots.txt, 서비스 약관을 검토하고 요청 간 간격을 유지
- 🧩파싱 로직은 선택자 기반으로 최소 작업만 수행
⚠️ 주의: 대상 서버에 과도한 부하를 주지 않도록 동시성, 재시도, 타임아웃을 균형 있게 조정하세요.
IP 차단, 법적 이슈를 피하기 위해 공개 정책을 반드시 준수합니다.
👉 성능 진단을 위한 간단한 계측 코드
import time
from statistics import mean
# 각 요청 시간 측정 후 평균/최댓값을 기록해 병목 구간 파악
latencies = []
start = time.perf_counter()
# ... 요청 수행 후 각 요청별 소요시간을 latencies에 추가 ...
print({"avg": mean(latencies), "max": max(latencies), "elapsed": time.perf_counter() - start})
❓ 자주 묻는 질문 (FAQ)
BeautifulSoup만으로도 동시 요청이 가능한가요?
세마포어와 TCPConnector의 limit 차이는 무엇인가요?
429 Too Many Requests 에러는 어떻게 처리하나요?
재시도 시에도 404 에러는 다시 시도해야 하나요?
asyncio.gather와 asyncio.as_completed는 어떻게 다른가요?
비동기 크롤링에도 User-Agent 설정이 필요한가요?
aiohttp 대신 requests-async 같은 라이브러리를 써도 될까요?
크롤링 결과를 저장하는 가장 좋은 방법은 무엇인가요?
📌 BeautifulSoup 비동기 크롤링 핵심 정리
비동기 크롤링을 구축할 때는 BeautifulSoup과 asyncio + aiohttp 조합이 사실상 표준입니다.
HTML 파싱은 BeautifulSoup이 맡고, 네트워크 요청은 aiohttp가 처리하며, asyncio 이벤트 루프가 전체를 관리하는 구조가 이상적입니다.
여기에 세마포어로 동시성 제어, 지수 백오프 재시도 정책으로 안정성을 확보하면 대규모 데이터 수집 환경에서도 신뢰할 수 있는 크롤러를 운영할 수 있습니다.
성능을 위해 세션 재사용, 타임아웃 설정, TCPConnector 제한을 반드시 적용해야 하며, 결과 처리는 as_completed로 효율을 높이는 것이 좋습니다.
무엇보다 중요한 것은 속도보다 안정성과 서버 존중이며, 이를 지키는 것이 장기적인 크롤링 성공의 핵심입니다.
🏷️ 관련 태그 : 파이썬크롤링, BeautifulSoup, asyncio, aiohttp, 동시성프로그래밍, 웹데이터수집, 레이트리밋, 세마포어, 재시도정책, 지수백오프