파이썬 asyncio·aiohttp 비동기 웹 수집 가이드: 동시 요청과 오류 처리
파이썬 asyncio와 aiohttp로 대량 웹 요청을 비동기 처리하는 방법부터 ClientSession 재사용, 동시성 제한, 타임아웃·재시도와 수집 파이프라인 설계까지 설명합니다.

웹 페이지나 API를 대량 수집할 때 asyncio와 aiohttp를 사용하면 한 요청의 응답을 기다리는 동안 다른 요청을 진행할 수 있습니다. 다만 URL 수만큼 작업을 한꺼번에 만들면 서버 차단, 메모리 증가와 연결 고갈이 생길 수 있으므로 하나의 ClientSession을 재사용하고 세마포어로 동시 요청 수를 제한하며 타임아웃·재시도·응답 검증을 함께 적용해야 합니다.
핵심 요약
async def로 코루틴을 만들고await에서 이벤트 루프에 실행 기회를 돌려줍니다.- 요청마다 세션을 만들지 말고
ClientSession하나를 작업 범위에서 재사용합니다. Semaphore로 동시 요청 수를 제한하고 서버의 robots.txt와 이용약관을 준수합니다.- 상태 코드, 콘텐츠 형식, 타임아웃과 개별 실패를 기록해 일부 오류가 전체 수집을 망치지 않게 합니다.
asyncio 코루틴과 Task 이해하기
async def로 정의한 함수를 호출하면 즉시 본문이 실행되는 것이 아니라 코루틴 객체가 만들어집니다. 실제 실행하려면 다른 코루틴에서 await하거나 asyncio.run()으로 최상위 진입점을 실행해야 합니다. Python 공식 문서도 호출만 하고 기다리지 않은 코루틴은 실행되지 않는다고 설명합니다.
import asyncio
async def work(name, delay):
await asyncio.sleep(delay)
return f"{name} 완료"
async def main():
results = await asyncio.gather(
work("A", 1),
work("B", 1),
)
print(results)
asyncio.run(main())
await는 현재 코루틴을 잠시 멈추고 다른 준비된 작업이 실행되게 합니다. 그래서 네트워크·파일 대기처럼 I/O 비중이 큰 작업에 효과적입니다. 순수 계산이 오래 걸리는 CPU 작업은 이벤트 루프를 막으므로 프로세스 풀이나 다른 실행 방식을 검토해야 합니다.
Python 3.11 이상에서는 관련 작업을 TaskGroup으로 묶을 수 있습니다. 그룹 안의 한 작업이 실패하면 나머지를 취소하고 종료를 기다린 뒤 예외를 전달하므로, 여러 하위 작업의 수명을 구조적으로 관리하기 좋습니다.
aiohttp ClientSession으로 요청하기
aiohttp는 별도 설치가 필요합니다. 세션은 연결 풀과 쿠키 등을 관리하므로 요청마다 새로 만들지 말고 수집 단위에서 재사용해야 합니다. 응답도 비동기 컨텍스트 관리자로 열면 블록을 벗어날 때 연결이 정리됩니다.
import asyncio
import aiohttp
async def fetch(session, url):
async with session.get(url) as response:
response.raise_for_status()
return await response.text()
async def main():
timeout = aiohttp.ClientTimeout(total=15)
headers = {"User-Agent": "ResearchBot/1.0"}
async with aiohttp.ClientSession(
timeout=timeout,
headers=headers,
) as session:
html = await fetch(session, "https://example.com")
print(len(html))
asyncio.run(main())
JSON API는 await response.json(), HTML은 await response.text(), 바이너리는 await response.read()로 받을 수 있습니다. 파싱하기 전에 상태 코드와 Content-Type을 확인하면 HTML 오류 페이지를 JSON으로 해석하는 실수를 줄일 수 있습니다.
Semaphore로 동시 요청 수 제한하기
비동기는 무제한 요청을 의미하지 않습니다. URL이 수천 개라면 모든 코루틴을 동시에 시작하는 대신 세마포어로 실제 네트워크 진입 수를 제한하세요. 적정 값은 대상 서버 정책, 응답 시간과 네트워크 환경에 따라 달라지며 낮은 값에서 시작해 오류율을 확인해야 합니다.
import asyncio
import aiohttp
async def fetch_limited(session, url, semaphore):
async with semaphore:
async with session.get(url) as response:
response.raise_for_status()
return url, await response.text()
async def collect(urls):
semaphore = asyncio.Semaphore(5)
timeout = aiohttp.ClientTimeout(total=20)
async with aiohttp.ClientSession(timeout=timeout) as session:
tasks = [
fetch_limited(session, url, semaphore)
for url in urls
]
return await asyncio.gather(*tasks)
같은 도메인에 짧은 시간 동안 집중 요청하면 서비스 장애나 차단을 유발할 수 있습니다. 공개 페이지라도 robots.txt, 이용약관, 접근 빈도와 개인정보·저작권을 확인하고, 429 응답의 Retry-After가 있으면 이를 존중하세요.
타임아웃·재시도·취소 처리
| 문제 | 확인할 예외·상태 | 대응 |
|---|---|---|
| 응답 지연 | TimeoutError | 시간 제한 후 제한적 재시도 |
| 연결 실패 | ClientConnectorError | DNS·서버 상태 기록 |
| HTTP 오류 | 4xx·5xx | 상태별 중단 또는 재시도 |
| 형식 오류 | Content-Type·파싱 예외 | 원문 일부와 URL 기록 |
| 작업 취소 | CancelledError | 정리 후 취소를 다시 전달 |
일시적인 500·502·503과 연결 실패는 지수형 대기 후 소수 횟수만 재시도할 수 있습니다. 반면 400·401·403처럼 요청이나 권한 문제인 경우 무조건 반복하면 해결되지 않습니다. 재시도에 무작위 지연을 더하면 여러 작업이 동시에 다시 몰리는 현상을 줄일 수 있습니다.
async def fetch_safe(session, url, semaphore):
async with semaphore:
try:
async with session.get(url) as response:
response.raise_for_status()
return {"url": url, "text": await response.text()}
except (aiohttp.ClientError, TimeoutError) as exc:
return {"url": url, "error": str(exc)}
취소 신호를 잡았다면 열린 파일이나 세션을 정리한 뒤 일반적으로 다시 발생시켜야 합니다. Python 공식 문서는 CancelledError를 무시하면 TaskGroup과 timeout 같은 구조적 동시성 기능이 잘못 동작할 수 있다고 설명합니다.
대량 수집 파이프라인 설계
- 입력 정규화: 중복 URL을 제거하고 허용 도메인과 스킴을 검증합니다.
- 세션 생성: 공통 헤더, 쿠키, 전체·연결 타임아웃을 설정합니다.
- 동시성 제한: 세마포어와 연결 풀 한도를 과도하지 않게 정합니다.
- 응답 검증: 상태 코드, 최종 URL, 콘텐츠 형식과 크기를 확인합니다.
- 파싱 분리: 네트워크 수집과 HTML 파싱·DB 저장을 별도 함수로 나눕니다.
- 점진 저장: 모든 결과를 메모리에 쌓지 말고 일정 단위로 저장합니다.
- 재실행 대비: 완료 URL과 실패 원인을 기록해 중단 지점부터 재개합니다.
HTML에서 원하는 데이터를 추출할 때는 BeautifulSoup 같은 파서를 사용할 수 있습니다. 네트워크 요청은 비동기여도 HTML 파싱이 무거우면 이벤트 루프가 지연될 수 있으므로 처리량을 측정하고 필요할 때 큐와 작업자 구조로 분리하세요.
실수 방지 체크리스트
- 코루틴을 호출한 뒤 반드시 await하거나 Task로 등록합니다.
- ClientSession을 요청마다 생성하지 않습니다.
- 전체·연결·읽기 타임아웃을 목적에 맞게 설정합니다.
- 동시 요청 수와 연결 풀 크기를 제한합니다.
- 상태 코드와 Content-Type을 확인한 뒤 파싱합니다.
- robots.txt, 이용약관과 개인정보 규정을 준수합니다.
- 실패 URL과 원인을 저장하고 무한 재시도를 금지합니다.
자주 묻는 질문
1. 비동기는 멀티스레딩과 같은가요?
아닙니다. asyncio는 일반적으로 한 스레드의 이벤트 루프에서 대기 시간을 활용합니다. 실행 모델이 다릅니다.
2. aiohttp는 파이썬 기본 모듈인가요?
아닙니다. pip install aiohttp로 설치하는 외부 패키지입니다.
3. requests와 aiohttp를 함께 써도 되나요?
가능하지만 동기 requests 호출은 이벤트 루프를 막습니다. 비동기 함수 안에서는 aiohttp를 우선 사용하세요.
4. gather와 TaskGroup의 차이는 무엇인가요?
TaskGroup은 관련 작업의 수명과 실패 시 나머지 취소를 구조적으로 관리하며 Python 3.11 이상에서 사용할 수 있습니다.
5. 동시 요청은 많을수록 빠른가요?
아닙니다. 연결 고갈과 서버 제한 때문에 오히려 느려지거나 차단될 수 있습니다.
6. 세션을 전역으로 하나만 만들면 되나요?
세션 수명과 이벤트 루프를 함께 관리해야 합니다. 애플리케이션 시작·종료 범위에 맞게 생성하고 닫으세요.
7. 응답 순서는 요청 순서와 같나요?
gather의 결과 목록은 입력 순서를 유지하지만 실제 완료 순서는 다를 수 있습니다.
8. CPU 계산도 asyncio로 빨라지나요?
일반적으로 아닙니다. CPU 집약 작업은 이벤트 루프를 막으므로 프로세스 풀 등을 검토하세요.
9. 429 오류가 나오면 어떻게 하나요?
요청 빈도를 낮추고 Retry-After가 있으면 준수하세요. 우회 시도를 반복해서는 안 됩니다.
10. Jupyter에서 asyncio.run 오류가 나는 이유는 무엇인가요?
이미 이벤트 루프가 실행 중일 수 있습니다. 노트북 환경에서는 셀에서 코루틴을 직접 await하는 방식을 확인하세요.
공식 출처
공식 문서 확인일: 2026년 8월 30일