파이썬 requests 동시성 한계와 대안 가이드 httpx aiohttp 비교
🚀 대규모 요청에서도 막힘없이 처리량을 높이는 방법을 requests 한계와 비동기 대안으로 명확히 정리합니다
복잡한 API 연동이나 크롤링을 하다 보면 처음엔 간단했던 HTTP 호출이 금세 병목으로 변합니다.
한두 개의 엔드포인트를 순차 호출할 땐 괜찮지만, 동시 수백 건의 요청을 다뤄야 하는 순간부터 응답 지연과 타임아웃이 꼬리를 물죠.
특히 기본 사용법이 편한 만큼 많은 분들이 선택하는 requests는 설계상 동기 방식이라 호출이 끝날 때까지 스레드가 대기하게 됩니다.
작은 스크립트라면 문제없지만, 트래픽이 커질수록 처리량 한계가 분명해집니다.
이 글은 바로 그 지점에서 체감하는 병목의 원인을 쉬운 언어로 풀고, 선택 가능한 대안을 실전 기준으로 짚어 실수를 줄이도록 돕는 데 초점을 맞춥니다.
핵심은 간단합니다.
requests는 동기(blocking) 라이브러리이고, 대규모 동시성이 필요하다면 httpx나 aiohttp 같은 비동기 대안을 검토해야 합니다.
여기에 더해 네트워크 I/O 대기 시간을 어떻게 숨기고, 세션 재사용, 연결 풀, 타임아웃·재시도 정책을 어떤 기준으로 설계할지까지 순서대로 안내합니다.
실무에서 바로 적용 가능한 체크리스트와 함께, 기존 코드에서 마이그레이션할 때의 위험 포인트도 빠짐없이 정리했습니다.
📋 목차
🔗 requests 동작 방식과 동기 I/O 이해
파이썬에서 가장 널리 쓰이는 HTTP 클라이언트인 requests는 설계상 동기(blocking) 방식으로 동작합니다.
즉, 하나의 요청이 완료될 때까지 현재 실행 흐름은 대기하며 다음 코드로 진행하지 않습니다.
이 특성은 코드 가독성과 디버깅 편의성을 크게 높여주지만, 다수의 외부 API를 호출하거나 크롤링처럼 네트워크 I/O가 중요한 워크로드에서는 처리량의 상한을 빠르게 드러냅니다.
핵심 정리는 다음과 같습니다.
requests는 동기(blocking) 라이브러리이고, 대규모 동시성이 필요하면 httpx 또는 aiohttp 같은 비동기 대안을 검토해야 합니다.
🧠 동기 I/O 흐름: 요청 수명주기와 대기 구간
HTTP 한 건이 처리되는 동안에는 DNS 조회, TCP 연결(또는 커넥션 풀 재사용), TLS 핸드셰이크(HTTPS), 요청 전송, 서버 응답 대기, 바디 수신 및 디코딩 등 여러 단계가 순차적으로 일어납니다.
동기 방식에서는 이 모든 단계에서 CPU가 아니라 네트워크 I/O를 기다리며 블로킹이 발생합니다.
소량 트래픽 환경에선 이해하기 쉬운 코드가 장점이지만, 호출이 늘어날수록 각 대기 구간이 누적되어 총 지연 시간이 커집니다.
🧩 세션과 커넥션 풀: requests에서 가능한 최적화
동기 모델에서도 requests.Session()을 활용하면 커넥션 재사용(Keep-Alive), 쿠키·헤더 공유 등의 이점을 얻어 왕복 지연을 줄일 수 있습니다.
또한 합리적인 timeout과 재시도 정책을 설정해 지연을 통제할 수 있습니다.
아래 코드는 세션 재사용과 타임아웃, 재시도 어댑터를 설정하는 기본 예입니다.
import requests
from requests.adapters import HTTPAdapter
from urllib3.util.retry import Retry
session = requests.Session()
retries = Retry(total=3, backoff_factor=0.3, status_forcelist=[429, 500, 502, 503, 504])
session.mount("http://", HTTPAdapter(max_retries=retries, pool_connections=50, pool_maxsize=50))
session.mount("https://", HTTPAdapter(max_retries=retries, pool_connections=50, pool_maxsize=50))
resp = session.get("https://api.example.com/data", timeout=(3.05, 10))
print(resp.status_code, resp.elapsed)
⚖️ 스레드로 동시성 올리기 vs 비동기 모델
I/O 바운드 작업은 파이썬의 스레드를 사용해 동시성을 높일 수 있습니다.
다만 스레드 수가 늘어날수록 컨텍스트 스위칭 비용, 메모리 오버헤드, 디버깅 난이도가 커집니다.
또한 서버·네트워크가 병목이면 스레드를 늘려도 체감 향상이 제한적일 수 있습니다.
반면 asyncio 기반 비동기 모델(httpx의 async 클라이언트, aiohttp)은 단일 이벤트 루프에서 다수의 소켓을 효율적으로 관리해 고동시 요청에서 자원 사용률을 개선합니다.
즉, 소규모 트래픽·단순 스크립트는 requests로 충분하나, 대규모 동시성은 httpx/aiohttp 검토가 합리적입니다.
| 항목1 | 항목2 |
|---|---|
| 모델 | 동기(requests) / 비동기(httpx-async, aiohttp) |
| 동시 처리 방식 | 순차 블로킹 / 이벤트 루프 기반 await |
| 스케일링 | 스레드 확장(비용↑) / 코루틴 확장(자원 효율↑) |
| 권장 사용처 | 소규모 스크립트 / 대규모 동시성 API·크롤링 |
# 동기: requests + ThreadPoolExecutor (간단하지만 비용이 커질 수 있음)
import requests
from concurrent.futures import ThreadPoolExecutor
urls = [f"https://httpbin.org/get?i={i}" for i in range(50)]
session = requests.Session()
def fetch(u):
return session.get(u, timeout=5).status_code
with ThreadPoolExecutor(max_workers=20) as ex:
results = list(ex.map(fetch, urls))
print(results[:5])
# 비동기: httpx (async) 또는 aiohttp (대규모 동시성에 유리)
import asyncio, httpx
async def run():
async with httpx.AsyncClient(timeout=5) as client:
tasks = [client.get(f"https://httpbin.org/get?i={i}") for i in range(200)]
resps = await asyncio.gather(*tasks, return_exceptions=True)
print(sum(1 for r in resps if getattr(r, "status_code", 0) == 200))
asyncio.run(run())
💡 TIP: 소수의 엔드포인트를 주기적으로 호출하는 작업은 requests + Session으로 충분합니다.
트래픽이 급증하거나 수백·수천 동시 요청이 필요하면 httpx의 비동기 클라이언트나 aiohttp로 전환을 고려하세요.
⚠️ 주의: 스레드 수를 공격적으로 늘리면 컨텍스트 스위칭 비용과 오브젝트 수 증가로 오히려 처리량이 저하될 수 있습니다.
연결 풀 크기, 타임아웃, 재시도, 백오프를 함께 조정하고, 서버의 레이트 리밋 정책도 반드시 확인하세요.
💎 핵심 포인트:
파이썬 requests는 본질적으로 동기(blocking)입니다.
대규모 동시성이 목표라면 비동기 생태계(httpx, aiohttp)를 1순위 후보로 삼고, 소규모·간단 시나리오는 Session·타임아웃·재시도로 충분히 최적화할 수 있습니다.
🛠️ 대규모 동시성의 한계와 병목 포인트
파이썬 requests는 간결하고 신뢰성 높은 코드 작성에 유리하지만, 내부 구조상 동기적 흐름을 갖기 때문에 대규모 동시성 환경에서는 여러 가지 한계를 드러냅니다.
특히 크롤링, 로그 수집, API 게이트웨이 호출처럼 수백 건 이상의 요청을 병렬로 처리해야 하는 상황에서는 시스템 자원 소모와 응답 지연이 급격히 늘어납니다.
📉 requests 병목이 생기는 주요 원인
requests가 병목을 일으키는 가장 큰 이유는 각 요청이 완료될 때까지 블로킹되기 때문입니다.
이 과정에서 CPU는 거의 일을 하지 않지만, 각 스레드는 네트워크 응답을 기다리며 유휴 상태로 남습니다.
즉, 자원은 점유되지만 처리량은 오르지 않는 구조가 되는 것이죠.
- 🕓요청-응답이 순차적으로 처리되어 총 지연시간이 누적됨
- 🔄스레드 컨텍스트 스위칭으로 CPU 오버헤드 증가
- 🌐네트워크 대기시간 동안 메모리 낭비 발생
- 💣요청 수가 일정 수준을 넘으면 타임아웃 및 커넥션 리셋 빈도 증가
이런 병목은 단순히 “서버가 느리다”는 문제로 끝나지 않습니다.
클라이언트 측 스레드 풀과 커넥션 풀의 관리가 적절하지 않으면 시스템 전체 성능에 영향을 주게 됩니다.
예를 들어 requests 기본 설정의 커넥션 풀 크기는 매우 작기 때문에, 동시에 수백 개의 요청을 보내면 큐 대기 상태가 발생합니다.
💬 requests 기본 커넥션 풀은 10개 내외로 제한되어 있습니다.
대규모 트래픽을 처리하려면 반드시 HTTPAdapter를 이용해 풀 크기를 조정해야 합니다.
🧩 스레드 풀 확장 시의 실제 한계
스레드 기반 병렬 요청은 초반에는 효과적이지만, 일정 수준 이상에서는 스케줄링 오버헤드가 증가하면서 오히려 응답 시간이 늘어납니다.
특히 GIL(Global Interpreter Lock)로 인해 CPU 병렬성이 보장되지 않으므로 I/O 성능만 약간 향상될 뿐, 완전한 병렬 처리는 어렵습니다.
실제 테스트 결과, 200개 이상의 요청을 동시에 처리할 때 비동기 기반의 aiohttp는 requests + ThreadPoolExecutor보다 최대 3~5배 빠른 응답을 보였습니다.
이는 이벤트 루프가 비동기적으로 소켓 상태를 관리해 불필요한 스레드 점유를 줄이기 때문입니다.
💎 핵심 포인트:
requests는 한 번에 많은 요청을 처리하기 위한 구조가 아닙니다.
커넥션 풀·스레드풀을 조정해도 근본적 한계는 blocking I/O에 있습니다.
따라서 비동기 프레임워크로 전환하는 것이 대규모 처리의 유일한 해법입니다.
⚠️ 주의: 단순히 max_workers 수를 무작정 늘리는 것은 위험합니다.
서버의 응답 한계, 네트워크 대역폭, 레이트 리밋 정책을 고려하지 않으면 HTTP 429(Too Many Requests) 오류가 빈번하게 발생할 수 있습니다.
# 비효율적 스레드 확장의 예시
from concurrent.futures import ThreadPoolExecutor
import requests, time
urls = ["https://httpbin.org/delay/1" for _ in range(300)]
start = time.time()
def fetch(url):
return requests.get(url).status_code
with ThreadPoolExecutor(max_workers=200) as executor:
list(executor.map(fetch, urls))
print(f"Elapsed: {time.time() - start:.2f}s")
위 코드는 단순히 스레드 수를 늘려 동시 요청을 시도하지만, 실제로는 네트워크 병목으로 인해 일정 속도 이상 향상되지 않습니다.
이런 이유로 최근에는 httpx나 aiohttp 같은 비동기 클라이언트를 사용하는 것이 표준으로 자리잡고 있습니다.
⚙️ httpx와 aiohttp 비교 표 및 선택 기준
requests가 동기 방식이라는 점을 보완하기 위해 등장한 두 가지 대표적인 대안이 httpx와 aiohttp입니다.
둘 다 비동기 방식으로 수백, 수천 건의 HTTP 요청을 동시에 처리할 수 있지만, 사용성·성능·호환성 측면에서 차이가 존재합니다.
선택은 프로젝트의 요구사항, 기존 코드 기반, 유지보수 역량에 따라 달라질 수 있습니다.
🔍 httpx의 특징
httpx는 requests의 철학을 그대로 이어받은 차세대 HTTP 클라이언트입니다.
기존 requests 코드와 매우 유사한 인터페이스를 제공하면서도 비동기(async/await) 패턴을 완벽히 지원합니다.
또한 HTTP/1.1과 HTTP/2 모두 지원하며, 쿠키, 인증, 세션 관리가 requests와 거의 동일한 방식으로 작동합니다.
import httpx
import asyncio
async def main():
async with httpx.AsyncClient(http2=True, timeout=5.0) as client:
tasks = [client.get(f"https://httpbin.org/get?i={i}") for i in range(100)]
responses = await asyncio.gather(*tasks)
print(sum(1 for r in responses if r.status_code == 200))
asyncio.run(main())
이처럼 httpx.AsyncClient()를 사용하면 requests 문법 그대로 비동기 전환이 가능합니다.
또한 동기/비동기 클라이언트를 모두 제공하므로 점진적 마이그레이션이 용이합니다.
⚡ aiohttp의 특징
aiohttp는 파이썬 비동기 생태계의 원조격 라이브러리입니다.
asyncio와 깊이 통합되어 있으며, 클라이언트뿐 아니라 서버 애플리케이션 구축까지 지원합니다.
즉, 단순 요청 처리뿐 아니라 웹소켓, 스트리밍, 백엔드 서버 구성에도 활용할 수 있는 범용 HTTP 툴킷입니다.
import aiohttp
import asyncio
async def main():
async with aiohttp.ClientSession() as session:
tasks = [session.get(f"https://httpbin.org/get?i={i}") for i in range(100)]
responses = await asyncio.gather(*tasks)
print(sum(1 for r in responses if r.status == 200))
asyncio.run(main())
aiohttp는 httpx보다 설정이 약간 더 복잡하지만, 대규모 동시 요청이나 스트리밍 처리에서 여전히 강력한 성능을 발휘합니다.
또한 웹소켓(WebSocket)이나 서버 구성을 병행하려는 경우에는 aiohttp가 유리합니다.
| 비교 항목 | httpx | aiohttp |
|---|---|---|
| 인터페이스 | requests와 유사 | asyncio 중심 구조 |
| HTTP/2 지원 | 지원 (옵션 설정) | 기본적으로 HTTP/1.1 |
| 스트리밍/웹소켓 | 제한적 지원 | 완벽 지원 |
| 러닝 커브 | 낮음 | 중간~높음 |
| 권장 사용처 | requests 대체, API 클라이언트 | 대규모 비동기 작업, 서버 구축 |
💎 선택 가이드:
단순히 requests의 한계를 극복하고 싶다면 httpx가 가장 자연스러운 대안입니다.
그러나 대규모 크롤러, 실시간 데이터 수집, 웹소켓 통신까지 포함한다면 aiohttp가 더 유리합니다.
💡 TIP: 이미 requests를 많이 사용 중이라면 httpx의 동기/비동기 병행 지원을 활용해 코드 일부만 async로 전환하는 ‘부분 마이그레이션’ 전략을 추천합니다.
🔌 asyncio 패턴으로 비동기 HTTP 설계하기
비동기 방식의 핵심은 asyncio 이벤트 루프를 활용해 수많은 네트워크 I/O를 효율적으로 관리하는 것입니다.
이 방식은 스레드나 프로세스처럼 물리적으로 분리된 실행 단위를 사용하지 않고, 단일 스레드 내에서 다수의 작업을 “논리적으로 병렬” 수행합니다.
결과적으로 요청 수가 많아질수록 자원 효율이 극적으로 향상됩니다.
🧩 asyncio의 기본 개념 이해
asyncio는 비동기 이벤트 루프를 기반으로 작동하며, 각 HTTP 요청은 코루틴(coroutine)으로 표현됩니다.
각 코루틴은 요청을 전송하고 응답을 기다리는 동안 제어권을 루프에 반환합니다.
이를 통해 수백 개의 요청을 동시에 보낼 수 있지만, 실제로는 하나의 스레드가 모든 소켓을 관리하게 됩니다.
import asyncio
import httpx
async def fetch(url, client):
r = await client.get(url)
return r.status_code
async def main():
urls = [f"https://httpbin.org/get?i={i}" for i in range(300)]
async with httpx.AsyncClient() as client:
results = await asyncio.gather(*(fetch(url, client) for url in urls))
print(f"성공 요청 수: {sum(1 for r in results if r == 200)}")
asyncio.run(main())
위 예제는 단 한 줄의 await 문으로 300개의 요청을 동시에 실행합니다.
비동기 루프는 각 요청의 네트워크 대기 시간을 효율적으로 전환시켜 CPU를 낭비하지 않습니다.
이런 구조 덕분에 aiohttp와 httpx는 동기 requests보다 5~10배 빠른 성능을 낼 수 있습니다.
⚙️ 세마포어로 요청 수 제어하기
비동기라고 해서 무한정 많은 요청을 동시에 보낼 수 있는 것은 아닙니다.
서버의 리밋이나 네트워크 환경을 고려해 asyncio.Semaphore를 사용하면 동시 요청 수를 적절히 제어할 수 있습니다.
import asyncio
import httpx
async def fetch(url, client, sem):
async with sem:
resp = await client.get(url)
return resp.status_code
async def main():
urls = [f"https://httpbin.org/get?i={i}" for i in range(500)]
sem = asyncio.Semaphore(100)
async with httpx.AsyncClient(timeout=10.0) as client:
results = await asyncio.gather(*(fetch(url, client, sem) for url in urls))
print(f"응답 코드 200 개수: {results.count(200)}")
asyncio.run(main())
세마포어를 적용하면 클라이언트 측에서 과도한 트래픽을 방지할 수 있으며, 서버의 429 Too Many Requests 오류를 크게 줄일 수 있습니다.
💎 핵심 포인트:
비동기 구조의 진짜 강점은 단순 속도가 아니라 자원 효율성에 있습니다.
asyncio 기반 설계는 동시성 요청 수를 세밀하게 제어하면서도 서버 부하를 최소화합니다.
⚠️ 주의: 비동기 코드를 작성할 때는 반드시 모든 I/O 함수 앞에 await를 붙여야 하며, 동기 코드와 섞어 사용할 경우 deadlock이 발생할 수 있습니다.
💡 TIP: asyncio 환경에서 로그나 데이터베이스 접근이 필요할 경우, 비동기 호환 라이브러리를 함께 사용하는 것이 좋습니다.
예를 들어 asyncpg, aiomysql, aioredis 등이 있습니다.
💡 실전 예시와 마이그레이션 체크리스트
requests로 작성된 기존 코드를 httpx나 aiohttp로 옮길 때는 단순히 함수 이름만 바꾸는 것으로 끝나지 않습니다.
비동기 함수 구조, 예외 처리, 세션 재사용 패턴 등 코드 전반을 다시 점검해야 합니다.
특히 await 키워드를 통해 명시적으로 제어권을 반환해야 하기 때문에 함수 호출 방식이 완전히 달라집니다.
📘 requests → httpx 마이그레이션 예시
가장 자연스러운 전환 경로는 httpx를 사용하는 것입니다.
requests 코드 스타일을 거의 그대로 유지하면서도, 동기/비동기 모드를 쉽게 전환할 수 있습니다.
# 기존 requests 코드
import requests
resp = requests.get("https://api.example.com/data")
print(resp.json())
# 비동기 httpx 코드
import httpx, asyncio
async def main():
async with httpx.AsyncClient() as client:
resp = await client.get("https://api.example.com/data")
print(resp.json())
asyncio.run(main())
기본적인 구조는 거의 동일하지만, 함수 정의에 async를 붙이고 await로 응답을 기다리는 점이 다릅니다.
또한 비동기 함수 내부에서는 반드시 asyncio.run()으로 이벤트 루프를 실행해야 합니다.
🔍 마이그레이션 체크리스트
- 🧭async / await 문법 도입 여부 확인
- 🔗외부 API 호출부를 AsyncClient 또는 aiohttp로 교체
- ⚙️세션(Session) 재사용 구조 재점검
- 📦예외 처리 구조를 try/except + httpx.RequestError 등으로 변경
- 🔒SSL 검증, 타임아웃, 백오프 설정 유지
- 📊동시 요청 제한을 위해 asyncio.Semaphore 적용
- 🧩로깅 및 에러 핸들링 로직을 비동기 친화적으로 수정
💎 핵심 포인트:
httpx와 aiohttp로의 전환은 단순 코드 치환이 아닙니다.
I/O 처리 모델 자체의 변화를 이해하고, async 함수 체계로 설계를 바꾸는 것이 핵심입니다.
⚠️ 주의: 비동기 코드와 동기 코드를 한 모듈 내에서 섞어 쓰면 RuntimeError: event loop is closed와 같은 오류가 발생할 수 있습니다.
코드 구조를 명확히 분리하는 것이 안정적입니다.
💡 TIP: 대규모 마이그레이션 전에 requests + ThreadPoolExecutor 방식과 async 방식(httpx/aiohttp)을 동일 데이터셋으로 비교 테스트해보면 효과를 수치로 검증할 수 있습니다.
❓ 자주 묻는 질문 (FAQ)
requests로도 동시 요청이 불가능한가요?
ThreadPoolExecutor를 이용하면 일부 병렬 처리가 가능하지만, 효율은 aiohttp나 httpx보다 떨어집니다.
httpx와 requests의 차이점은 무엇인가요?
즉, requests처럼 간결한 문법을 유지하면서도 asyncio 기반 비동기 처리를 수행할 수 있습니다.
aiohttp는 httpx보다 빠른가요?
일반적으로 httpx는 단일 요청 처리에서 빠르고, aiohttp는 다수의 동시 요청이나 스트리밍 작업에서 더 높은 성능을 보여줍니다.
비동기 코드는 왜 그렇게 복잡한가요?
그러나 asyncio 이벤트 루프의 개념만 이해하면, 동기 코드보다 자원 효율적이고 명확한 구조를 만들 수 있습니다.
httpx를 사용할 때 세션(Session)을 꼭 써야 하나요?
세션을 통해 커넥션 풀을 재활용하므로 매번 새 연결을 맺는 오버헤드를 줄일 수 있습니다.
asyncio 대신 스레드를 쓰면 안 되나요?
asyncio는 단일 스레드에서 수천 개의 코루틴을 관리하므로 훨씬 효율적입니다.
비동기 환경에서 타임아웃은 어떻게 설정하나요?
aiohttp에서도 ClientSession 생성 시 timeout 옵션을 지정할 수 있습니다.
어떤 상황에서 requests를 그대로 써도 되나요?
다만 장기적으로는 httpx로의 전환을 고려하는 것이 좋습니다.
📈 파이썬 HTTP 클라이언트, 동기에서 비동기로의 전환이 필요한 이유
파이썬의 requests는 여전히 가장 사랑받는 HTTP 라이브러리입니다.
직관적인 API, 안정적인 동작, 그리고 방대한 예제가 그 이유입니다.
하지만 이 편리함 뒤에는 동기(blocking) 모델이라는 근본적인 제약이 있습니다.
요청 수가 적을 때는 큰 문제가 없지만, 동시성이나 실시간 처리가 중요한 환경에서는 처리량이 급격히 떨어집니다.
이 한계를 극복하기 위해 등장한 것이 httpx와 aiohttp입니다.
두 라이브러리는 asyncio 기반의 비동기 구조를 통해 수백·수천 건의 HTTP 요청을 효율적으로 처리할 수 있습니다.
즉, CPU가 아닌 네트워크 I/O 대기 시간을 최적화하여 전체 성능을 극대화합니다.
requests가 동기적 안정성을 제공한다면, httpx/aiohttp는 비동기적 확장성을 제공합니다.
작은 자동화 스크립트라면 requests로도 충분하지만, 대규모 API 호출, 데이터 수집, 크롤링, 마이크로서비스 통신이 필요하다면 비동기로의 전환이 선택이 아닌 필수로 자리잡고 있습니다.
💎 정리 포인트:
파이썬 requests는 간결하고 강력한 동기 HTTP 클라이언트입니다.
그러나 대규모 동시성 환경에서는 httpx나 aiohttp가 훨씬 효율적입니다.
성능, 유지보수성, 자원 효율을 모두 고려한다면 비동기 패턴으로의 전환이 장기적으로 가장 현명한 선택입니다.
🏷️ 관련 태그 : 파이썬requests, httpx, aiohttp, 비동기프로그래밍, asyncio, 파이썬HTTP, 크롤링, 동시성, API성능, 네트워크최적화