목차
- "requests가 느리다"는 오진이다
- aiohttp 비동기 크롤링의 첫 번째 함정
- Semaphore — 동시 요청 수를 조이는 밸브
- 지수 백오프 없으면 재시도가 아니라 공격이다
- Python 비동기 크롤러 전체 코드
- 체감 속도와 남은 선택지
import requests
urls = [f"https://api.example.com/products/{i}" for i in range(500)]
results = []
for url in urls:
resp = requests.get(url, timeout=10)
results.append(resp.json())
이 코드를 보고 "느리니까 aiohttp로 바꿔라"고 말하는 글이 많다. Python 비동기 크롤링 aiohttp 조합이 정답이라고. 틀린 말은 아닌데, 이대로만 따라 하면 높은 확률로 더 큰 문제를 만난다.
화요일 오전, 사내 상품 데이터 파이프라인을 리팩토링하고 있었다. 외부 API 500개 엔드포인트에서 정보를 수집하는 크롤러가 문제였는데, requests로 순차 호출하니 실행 시간이 약 8분. 프론트엔드에서 백엔드로 넘어온 지 2년 된 시점이라 Node.js의 Promise.all이 자동으로 떠올랐다. M1 Mac 터미널에서 pip install aiohttp 치고 코드를 갈아엎는 데 30분. 실행했더니 ClientConnectorError가 콘솔을 가득 채웠다.
"requests가 느리다"는 오진이다
requests가 느린 게 아니다. 이게 이 글의 출발점이다.
HTTP 요청은 I/O 바운드 작업이다. 요청을 보내고 응답이 돌아올 때까지 CPU는 대기 상태에 놓인다. 동기 코드에서는 이 대기 시간 동안 아무것도 하지 못한다. requests 라이브러리 자체의 처리 속도가 느린 것과, for 루프 안에서 하나씩 기다리는 패턴이 느린 것은 전혀 다른 진단이다.
Node.js에서 넘어온 입장에서 이 구분이 처음에는 와닿지 않았다. JavaScript는 태생이 비동기다. fetch 100개를 Promise.all에 넣으면 이벤트 루프가 알아서 동시에 처리한다. 별도의 선언 없이도 논블로킹이 기본 동작이다. Python은 다르다. async def로 코루틴을 선언하고, await를 붙이고, 이벤트 루프를 명시적으로 실행해야 한다. 처음에는 이 보일러플레이트가 거슬렸다.
그런데 쓰다 보니 이 명시성이 장점이었다. Node.js에서 "이 콜백이 왜 먼저 실행됐지?"라고 헤맨 경험이 있을 것이다. Python의 asyncio는 동시성 범위를 개발자가 직접 결정한다. 의도하지 않은 동시 실행 자체가 발생하지 않는 구조다. 동시성을 "허용"하는 게 아니라 "선언"하는 모델이라 디버깅이 훨씬 수월하다.
그래서 진단을 바로잡을 필요가 있다. "requests가 느리다"가 아니라 "순차 I/O 패턴이 느리다"가 정확한 문장이다. 이 차이를 모르면 aiohttp로 교체해도 같은 종류의 실수를 반복하게 된다.
aiohttp 비동기 크롤링의 첫 번째 함정
Python 비동기 크롤링 aiohttp 조합을 다루는 블로그 글 대부분이 아래와 비슷한 코드를 보여준다.
import aiohttp
import asyncio
async def fetch(session, url):
# 세션을 재사용해서 커넥션 풀 활용
async with session.get(url) as resp:
return await resp.json()
async def main():
async with aiohttp.ClientSession() as session:
tasks = [fetch(session, url) for url in urls] # 500개 코루틴 생성
results = await asyncio.gather(*tasks)
asyncio.run(main())
깔끔하다. 500개 URL을 코루틴으로 만들어서 asyncio.gather에 넣으면 동시에 실행된다. ClientSession이 커넥션 풀을 관리하니까 TCP 핸드셰이크 오버헤드도 줄어든다. 여기까지 보면 requests에서 바꾸기만 하면 끝인 것 같다.
실행해보면 안다. 이 코드는 500개 요청을 말 그대로 한꺼번에 쏜다. 서버 입장에서는 한 클라이언트가 순간적으로 500개 TCP 연결을 열고 데이터를 퍼가는 것이다. 터미널에 찍힌 에러는 이런 모양이었다:
aiohttp.client_exceptions.ClientConnectorError: Cannot connect to host api.example.com:443 ssl:default [Connect call failed]
aiohttp.client_exceptions.ServerDisconnectedError: Server disconnected
asyncio.exceptions.TimeoutError
(솔직히 처음 이 에러를 봤을 때는 aiohttp 라이브러리 버그인 줄 알았다.)
원인은 세 가지로 나뉜다.
첫째, 대상 서버의 rate limit이다. 대부분의 API는 클라이언트별 초당 요청 수를 제한한다. 500개를 동시에 날리면 429 Too Many Requests가 돌아온다.
둘째, OS 레벨의 파일 디스크립터 한계다. TCP 연결 하나가 소켓 하나를 점유하는데, Linux 기본 설정에서 프로세스당 소켓 수는 보통 1024개다. 여기에 기존 연결까지 합치면 금방 한계에 도달한다.
셋째, aiohttp TCPConnector의 기본 동시 연결 제한이다. limit 파라미터 기본값이 100이라서, 100개를 초과하는 요청은 내부 큐에서 대기하다 타임아웃에 걸린다 (출처: aiohttp 공식 문서 — TCPConnector, v3.9 기준).
프론트엔드 시절 브라우저가 도메인당 동시 연결을 6개로 제한하던 것과 같은 원리다. 클라이언트, 서버, 네트워크 어디에도 무한한 동시 연결을 감당할 수 있는 곳은 없다.
2시간 동안 timeout 값을 늘리고, 에러를 try-except로 무시하는 코드를 덧붙이며 돌아간 경로가 길었다. 근본 원인은 동시 연결 수를 제어하지 않은 것이었다.
Semaphore — 동시 요청 수를 조이는 밸브
asyncio.Semaphore가 해결책이다. 동시에 실행 가능한 코루틴 수를 제한하는 동기화 도구인데, 수도 밸브라고 생각하면 직관적이다. 20으로 설정하면 한 번에 20개만 흐르고, 21번째는 앞의 하나가 끝날 때까지 자동으로 대기한다. Node.js 생태계에서 p-limit이나 p-queue 라이브러리를 써봤다면 바로 이해될 개념이다. Python은 이게 표준 라이브러리에 들어있어서 별도 설치가 필요 없다.
CONCURRENCY = 20
semaphore = asyncio.Semaphore(CONCURRENCY)
async def fetch(session, url):
async with semaphore: # 동시 진입 수 제한
async with session.get(
url, timeout=aiohttp.ClientTimeout(total=10)
) as resp:
return await resp.json()
async with semaphore 한 줄이 전부다. 이 컨텍스트 매니저 안에 있는 코루틴은 동시에 CONCURRENCY개까지만 실행된다. 구현은 간단한데 값을 얼마로 잡느냐가 실무에서의 진짜 고민이다.
CONCURRENCY 값 정하기
정답은 없다. 대상 서버의 rate limit, 네트워크 환경, 로컬 리소스에 따라 달라진다. 경험적으로 외부 공개 API는 10~30이 안전하다. 사내 API는 서버 사양에 따라 50~100까지 올려볼 수 있다. 정적 파일 다운로드라면 100 이상도 괜찮다.
내가 쓰는 방법은 20에서 시작해서 에러율 0%로 완주하면 5씩 올리는 것이다. 429 응답이 나오기 시작하는 지점에서 한 단계 내리면 그게 최적값에 가깝다. 과학적이진 않지만, 대상 서버의 rate limit 문서가 없을 때는 이게 현실적이다.
TCPConnector(limit=CONCURRENCY)도 같이 설정해야 한다. 세마포어는 코루틴 레벨의 제한이고, TCPConnector의 limit은 TCP 커넥션 풀 크기 제한이다. 세마포어를 20으로 잡아도 커넥션 풀이 기본값 100으로 남아 있으면 리소스가 낭비된다. 두 값을 맞춰놓는 게 좋다.
지수 백오프 없으면 재시도가 아니라 공격이다
네트워크 요청은 실패한다. 이건 확률의 문제다. 500번 호출하면 체감상 5~10개는 타임아웃이나 5xx 응답으로 빠진다. 재시도가 없으면 결과 데이터에 구멍이 생긴다.
재시도 자체는 단순하다. 실패하면 다시 보내면 된다. 진짜 문제는 "언제" 다시 보내느냐다. 실패 직후 바로 재시도하면 상황을 악화시킨다. 서버가 과부하 상태라서 503을 보냈는데 클라이언트가 즉시 동일한 요청을 반복하면, 서버 입장에서는 의도적인 공격과 구분하기 어렵다.
지수 백오프(exponential backoff)는 실패 횟수에 따라 대기 시간을 2배씩 늘리는 전략이다. 1초 → 2초 → 4초. 여기에 랜덤 지터(jitter)를 더하면, 동시에 실패한 여러 코루틴이 재시도하는 타이밍이 분산된다.
지터가 없으면 이런 일이 생긴다. 세마포어 20개가 동시에 429를 받고, 20개가 정확히 2초 뒤에 다시 요청을 보내고, 또 20개가 동시에 429를 받는 악순환. AWS SDK 내부에서도 이 패턴을 쓰고 있을 만큼 검증된 방식이다 (출처: AWS Architecture Blog — Exponential Backoff And Jitter).
한 가지 더 신경 쓸 게 있다. 마지막 재시도까지 실패한 요청의 처리다. 예외를 그대로 던지면 asyncio.gather가 전체를 중단시킨다. return_exceptions=True 옵션을 쓸 수도 있지만, 에러 정보를 딕셔너리로 반환하는 쪽이 후처리가 편하다. 500개 중 3개가 실패했을 때 나머지 497개 결과까지 날리면 안 되니까.
Python 비동기 크롤러 전체 코드
위에서 설명한 세마포어, 지수 백오프 재시도, 커넥션 풀 제어를 합치면 아래 코드가 된다. 진행 상황 출력과 결과 집계까지 포함한 실전 버전이다.
import aiohttp
import asyncio
import random
import time
CONCURRENCY = 20
MAX_RETRIES = 3
async def fetch(session, url, semaphore, counter):
"""단일 URL 요청 — 세마포어 + 재시도 포함"""
async with semaphore:
for attempt in range(MAX_RETRIES):
try:
async with session.get(
url,
timeout=aiohttp.ClientTimeout(total=10)
) as resp:
if resp.status == 429:
# 서버가 rate limit 초과를 알림 — 백오프 필수
wait = 2 ** attempt + random.uniform(0, 1)
await asyncio.sleep(wait)
continue
resp.raise_for_status()
data = await resp.json()
counter["done"] += 1
if counter["done"] % 100 == 0:
print(f'{counter["done"]}/{counter["total"]}건 완료')
return data
except (aiohttp.ClientError, asyncio.TimeoutError) as e:
if attempt == MAX_RETRIES - 1:
# 최종 실패 — 에러 정보를 딕셔너리로 반환
counter["done"] += 1
counter["fail"] += 1
return {"url": url, "error": str(e)}
# 지수 백오프 + 지터
await asyncio.sleep(2 ** attempt + random.uniform(0, 1))
return None
async def crawl(urls):
semaphore = asyncio.Semaphore(CONCURRENCY)
connector = aiohttp.TCPConnector(limit=CONCURRENCY)
counter = {"done": 0, "fail": 0, "total": len(urls)}
async with aiohttp.ClientSession(connector=connector) as session:
tasks = [fetch(session, url, semaphore, counter) for url in urls]
results = await asyncio.gather(*tasks)
return results, counter
def main():
urls = [f"https://api.example.com/products/{i}" for i in range(500)]
start = time.time()
results, counter = asyncio.run(crawl(urls))
elapsed = time.time() - start
print(f'완료: {counter["total"] - counter["fail"]}건 성공, '
f'{counter["fail"]}건 실패, {elapsed:.1f}초 소요')
if __name__ == "__main__":
main()
카운터를 global 대신 딕셔너리 파라미터로 넘기는 방식을 택했다. asyncio가 싱글 스레드라 race condition 걱정은 없지만, 전역 변수보다는 명시적으로 넘기는 쪽이 테스트할 때 깔끔하다. 클래스로 감싸는 방법도 있는데, 이 정도 규모에서는 딕셔너리로 충분하다.
세션 밖에서 절대 요청하지 말 것
공식 문서에서도 강조하는 내용인데, ClientSession을 요청마다 새로 만드는 코드를 가끔 본다. 이러면 커넥션 풀의 의미가 사라진다. async with aiohttp.ClientSession() as session 블록 안에서 모든 요청을 처리해야 TCP 연결 재사용 효과를 볼 수 있다. requests에서 Session() 객체를 쓰는 것과 같은 원리다.
체감 속도와 남은 선택지
사내 API 500개 엔드포인트 기준 결과다. requests 순차 호출은 약 8분이 걸렸다. 요청당 평균 0.9초 정도. aiohttp + Semaphore(20) 조합으로 바꾸니 약 48초. 딱 10배 차이가 났다. CONCURRENCY를 50으로 올리면 25초 근처까지 줄었지만 간헐적으로 429가 섞였다. 안정적으로 전부 성공하는 지점이 20 근처였다.
이 수치는 사내 환경 한정이다. 외부 API라면 latency와 rate limit 정책이 다르니 동일한 결과를 기대하긴 어렵다. 다만 "동기 대비 5~15배"라는 범위는 다른 프로젝트에서도 비슷하게 나왔다.
aiohttp 말고 httpx라는 선택지도 있다. requests와 API가 거의 동일하면서 async를 지원해서 기존 코드에서의 전환 비용이 훨씬 낮다. requests.get() → await client.get()으로 바꾸면 대부분 그대로 동작한다. HTTP/2 기본 지원도 장점이다. 다만 순수 성능 벤치마크에서는 aiohttp 쪽이 가볍다는 결과가 많다. 이 부분은 같은 워크로드로 직접 비교를 돌려보지 못해서 확언은 못 하겠다. 나중에 같은 크롤링 작업에 httpx를 적용해볼 생각이다.
한 가지 주의할 점은, aiohttp든 httpx든 비동기 I/O가 빠른 것은 I/O 대기 시간을 겹쳐 쓰기 때문이다. CPU 바운드 작업이 코루틴 안에 섞여 있으면 이점이 줄어든다. JSON 파싱이 무겁다면 orjson 같은 C 확장 파서를 쓰는 것도 방법이다 (Python 3.12 기준, 2026년 4월 확인).
개인적으로는 새 프로젝트에서는 aiohttp, 기존 requests 코드를 점진적으로 전환할 때는 httpx가 나은 것 같다.
관련 글
- Python Docker 멀티스테이지 빌드로 이미지 크기 80% 줄이기 – FastAPI 프로젝트의 Docker 이미지가 1.2GB까지 불어났다. 멀티스테이지 빌드와 .dockerignore 설정으로 187MB까지…