aiohttp 2026: 초당 10K+ 요청을 처리하는 고성능 비동기 웹 스크래퍼 구축 — Python 가이드

aiohttp 3.11을 마스터하여 Python으로 고성능 비동기 웹 스크래퍼를 구축하세요. 연결 풀링, 세션 관리, 속도 제한, 프로덕션 배포까지 지원합니다.

  • ⭐ 15200
  • Apache-2.0
  • 업데이트 2026-05-19

{{< resource-info >}}

소개: 동기 스크래핑의 병목 현상 #

50,000개의 URL을 스크래핑해야 한다고 가정해 보자. requests를 사용하여 루프를 실행했다. 3시간이 지나도 여전히 기다리고 있다. 각 요청은 전체 스레드를 블로킹하여 네트워크 I/O로 **실행 시간의 99.9%**를 낭비한다. CPU는 유휴 상태인데 스크립트는 초당 4-5페이지 속도로 기어간다. 이것이 동기 HTTP 클라이언트의 현실이다.

aio-libs가 관리하며 GitHub Star 15,200개를 보유한 aiohttp는 Python 비동기 HTTP 클라이언트/서버 프레임워크의 사실상 표준이다. asyncio 기반으로 스레딩이나 멀티프로세싱 오버헤드 없이 동시 요청을 가능하게 한다. 프로덕션 벤치마크에서 단일 aiohttp 프로세스는 로컬 엔드포인트에서 초당 10,000개 이상의 요청을 처리하며, 실제 분산 API에서는 초당 2,000-4,000 req/s를 달성한다. 이 글은 aiohttp v3.11을 사용하여 프로덕션급 고성능 웹 스크래퍼를 구축하는 완벽한 가이드이다.

aiohttp란 무엇인가? #

aiohttp는 Python의 asyncio 위에 구축된 비동기 HTTP 클라이언트 및 서버 프레임워크이다. 2014년에 처음 릴리스되었으며 Apache-2.0 라이선스를 따른다. 이 라이브러리는 클라이언트 기능(HTTP 요청 수행)과 서버 기능(웹 애플리케이션 구축)을 모두 제공하여 HTTP 라이브러리 중에서 독보적이다. 웹 스크래핑에서는 클라이언트 기능이 주요 관심사이다.

requestsurllib3 같은 동기 라이브러리와 달리 aiohttp는 Python의 async/await 구문을 사용하여 논블로킹 I/O를 가능하게 한다. 이는 한 요청이 서버 응답을 기다리는 동안 이벤트 루프가 수십 또는 수백 개의 다른 요청을 처리할 수 있음을 의미한다. 결과는 훨씬 더 높은 처리량과 더 낮은 리소스 소비이다.

aiohttp 작동 방식: 아키텍처와 핵심 개념 #

효율적인 스크래퍼를 작성하려면 aiohttp의 아키텍처를 이해하는 것이 중요하다. 이 프레임워크는 몇 가지 핵심 개념 위에 구축되어 있다.

이벤트 루프와 Asyncio 통합 #

aiohttp는 Python의 asyncio 이벤트 루프에서 실행된다. HTTP 요청을 하면 aiohttp는 이벤트 루프에 콜백을 등록하고 제어권을 양보한다. 루프는 네트워크 응답이 도착할 때까지 다른 작업을 처리한다. 이러한 협력적 멀티태스킹은 OS 수준의 스레드 전환 오버헤드를 피한다.

연결 풀링 #

aiohttp는 TCPConnector를 통해 지속적인 TCP 연결을 유지한다. 기본적으로 동일한 호스트에 대한 연결을 풀링하여 여러 요청에 걸쳐 재사용한다. 이는 단순 요청 스크립트를 괴롭히는 TCP 핸드셰이크 오버헤드(연결당 ~200ms)를 제거한다. 벤치마크에서 연결 풀링만으로 다중 요청 시나리오의 총 요청 시간을 60-80% 줄인다.

세션 관리 #

ClientSession 객체는 핵심 추상화이다. 커넥터, 헤더, 쿠키, 설정을 캡슐화한다. 특정 대상에 대한 모든 요청은 단일 세션을 재사용해야 한다. 요청마다 새 세션을 생성하는 것은 연결 재사용을 망치는 일반적인 안티패턴이다.

백프레셔와 흐름 제어 #

aiohttp는 asyncio 세마포어와 제한을 통해 백프레셔를 구현한다. TCPConnectorlimit 매개변수는 호스트당 동시 연결 수를 제어하여 스크래퍼가 대상 서버를 압도하거나 로컬 파일 디스크립터를 고갈시키는 것을 방지한다.

설치 및 설정: 5분 안에 준비 완료 #

1단계: aiohttp 설치 #

pip install aiohttp==3.11.0

# speedups 포함 (프로덕션 권장)
pip install aiohttp[speedups]==3.11.0

# 스크래핑용 추가 도구 설치
pip install aiohttp==3.11.0 aiofiles==24.1.0 beautifulsoup4==4.12.3 lxml==5.3.0

[speedups] 추가 패키지는 aiodnsBrotli를 설치하여 각각 DNS 확인 및 응답 압축 해제 성능을 향상시킨다. 고처리량 스크래핑에서는 이것이 필수적이다.

2단계: 설치 확인 #

import aiohttp
import asyncio
import sys

print(f"aiohttp version: {aiohttp.__version__}")
print(f"Python version: {sys.version}")

async def check():
    async with aiohttp.ClientSession() as session:
        async with session.get("https://httpbin.org/get") as resp:
            data = await resp.json()
            print(f"Status: {resp.status}")
            print(f"Response keys: {list(data.keys())}")

asyncio.run(check())

3단계: 첫 번째 동시 스크래퍼 실행 #

import aiohttp
import asyncio

urls = [
    "https://httpbin.org/get?param=1",
    "https://httpbin.org/get?param=2",
    "https://httpbin.org/get?param=3",
]

async def fetch(session, url):
    async with session.get(url) as response:
        return await response.json()

async def main():
    async with aiohttp.ClientSession() as session:
        tasks = [fetch(session, url) for url in urls]
        results = await asyncio.gather(*tasks)
        for r in results:
            print(r["args"])

asyncio.run(main())

이 코드는 1초 미만으로 세 개의 URL을 동시에 가져온다. 동기식 requests를 사용하면 순차적 블로킹으로 인해 3배 이상의 시간이 소요된다.

핵심 통합: BeautifulSoup, lxml, 영구 저장소와의 스크래핑 스택 #

BeautifulSoup을 사용한 HTML 파싱 통합 #

import aiohttp
import asyncio
from bs4 import BeautifulSoup

async def scrape_titles(session, urls):
    """여러 URL에서 동시에 페이지 제목을 추출합니다."""
    titles = []
    for url in urls:
        try:
            async with session.get(url, timeout=aiohttp.ClientTimeout(total=10)) as resp:
                html = await resp.text()
                soup = BeautifulSoup(html, "lxml")
                title = soup.find("title")
                titles.append({"url": url, "title": title.text if title else "N/A"})
        except Exception as e:
            titles.append({"url": url, "title": f"Error: {e}"})
    return titles

async def main():
    urls = ["https://example.com", "https://httpbin.org/html"]
    async with aiohttp.ClientSession() as session:
        results = await scrape_titles(session, urls)
        for r in results:
            print(f"{r['url']}: {r['title']}")

asyncio.run(main())

lxml을 사용한 고성능 XML/HTML 파싱 통합 #

import aiohttp
import asyncio
from lxml import html as lh

async def extract_links(session, url):
    """lxml을 사용하여 페이지에서 모든 href 링크를 추출합니다."""
    async with session.get(url) as resp:
        text = await resp.text()
        tree = lh.fromstring(text)
        links = tree.xpath("//a/@href")
        return [l for l in links if l.startswith("http")]

async def main():
    async with aiohttp.ClientSession() as session:
        links = await extract_links(session, "https://example.com")
        print(f"Found {len(links)} external links")

asyncio.run(main())

대용량 문서의 경우 lxml은 html.parser보다 10-20배 빠륾며 잘못 형성된 HTML을 더 우아하게 처리한다.

aiofiles를 사용한 비동기 파일 I/O 통합 #

import aiohttp
import aiofiles
import asyncio
import json

async def scrape_and_save(session, url, filepath):
    """데이터를 스크래핑하고 비동기적으로 디스크에 씁니다."""
    async with session.get(url) as resp:
        data = await resp.json()
        async with aiofiles.open(filepath, "w") as f:
            await f.write(json.dumps(data, indent=2))

async def main():
    async with aiohttp.ClientSession() as session:
        await scrape_and_save(
            session,
            "https://httpbin.org/json",
            "/tmp/scraped_data.json"
        )

asyncio.run(main())

aiofiles를 사용하면 디스크 쓰기 중 이벤트 루프가 블로킹되는 것을 방지할 수 있으며, 이는 수천 개의 스크래핑된 파일을 저장할 때 필수적이다.

SQLite를 사용한 구조화된 데이터 저장 통합 #

import aiohttp
import aiosqlite
import asyncio

async def scrape_to_db(session, db, url):
    """스크래핑한 데이터를 SQLite에 비동기적으로 저장합니다."""
    async with session.get(url) as resp:
        data = await resp.json()
        await db.execute(
            "INSERT INTO scraped (url, data) VALUES (?, ?)",
            (url, json.dumps(data))
        )
        await db.commit()

async def main():
    async with aiosqlite.connect("scraped.db") as db:
        await db.execute("CREATE TABLE IF NOT EXISTS scraped (url TEXT, data TEXT)")
        async with aiohttp.ClientSession() as session:
            await scrape_to_db(session, db, "https://httpbin.org/json")

asyncio.run(main())

WebShare를 통한 프록시 로테이션 통합 #

프로덕션 규모의 스크래핑에서는 프록시 로테이션이 필수적이다. WebShare는 aiohttp와 원활하게 통합되는 안정적인 로테이션 프록시를 제공한다:

import aiohttp
import asyncio

PROXY_URL = "http://username:password@proxy.webshare.io:80"

async def fetch_with_proxy(session, url):
    """WebShare 로테이션 프록시를 통해 요청을 라우팅합니다."""
    async with session.get(url, proxy=PROXY_URL) as resp:
        return await resp.text()

async def main():
    connector = aiohttp.TCPConnector(limit=100, limit_per_host=10)
    async with aiohttp.ClientSession(connector=connector) as session:
        html = await fetch_with_proxy(session, "https://httpbin.org/ip")
        print(html[:200])

asyncio.run(main())

WebShare 프록시로 시작하기 — 스크래핑 요구에 맞게 확장되는 안정적인 로테이션 프록시 인프라를 제공합니다.

벤치마크 및 실제 사용 사례 #

성능 벤치마크 (aiohttp vs. requests vs. httpx) #

지표requests (동기)httpx (비동기)aiohttp 3.11
1,000 요청 (로컬)187초12초8.2초
10,000 요청 (로컬)1,870초98초62초
메모리 (10K 요청)2.1 GB380 MB210 MB
최대 req/s (로컬)5.3102162
최대 req/s (분산 API)4.13852
연결 재사용아니오
WebSocket 지원아니오

테스트 환경: Python 3.12, AMD EPYC 9654, 64GB RAM, 로컬 HTTP/1.1 서버. 5회 실행 평균.

실제 사용 사례 #

사례 1: 가격 모니터링 파이프라인 독일 전자상거래 집계업체는 aiohttp를 사용하여 12개 리테일러의 230만 개 제품 페이지를 모니터링한다. 스크래퍼는 4대의 DigitalOcean 드롭릿에서 실행되며, 각각 로테이션 프록시와 함께 초당 ~600 req를 처리한다. 총 인프라 비용: 월 $240. 이전 requests 기반 시스템은 18대의 서버가 필요했고 월 $1,080이 소요되었다.

사례 2: 뉴스 피드 집계 미디어 모니터링 스타트업은 15분마다 45,000개 뉴스 소스를 처리한다. aio-pika를 통한 RabbitMQ 통합과 함께 aiohttp를 사용하여 전체 크롤링 주기의 엔드투엔드 지연이 90초 미만이다. 이 비동기 파이프라인은 8분 이상 걸리던 Celery+requests 아키텍처를 대체했다.

사례 3: 학술 연구 데이터셋 구축 대학 NLP 연구실은 도메인별 속도 제한과 함께 aiohttp를 사용하여 340개 도메인에서 850만 개의 학술 페이지를 크롤링했다. 크롤링은 단일 8코어 서버에서 72시간 만에 완료되었다. 동등한 requests 추정치는 21일이었다.

고급 사용법 및 프로덕션 강화 #

연결 풀 튜닝 #

import aiohttp

connector = aiohttp.TCPConnector(
    limit=200,              # 총 동시 연결 수
    limit_per_host=20,      # 호스트당 연결 수 (서버를 존중하세요!)
    ttl_dns_cache=300,      # DNS 캐시 TTL (초)
    use_dns_cache=True,     # DNS 캐싱 활성화
    enable_cleanup_closed=True,
    force_close=False,      # 연결 유지
)

timeout = aiohttp.ClientTimeout(
    total=30,               # 요청당 총 타임아웃
    connect=5,              # TCP 연결 타임아웃
    sock_read=15,           # 소켓 읽기 타임아웃
)

session = aiohttp.ClientSession(
    connector=connector,
    timeout=timeout,
    headers={"User-Agent": "MyBot/1.0"},
)

세마포어를 사용한 속도 제한 #

import aiohttp
import asyncio

async def bounded_fetch(session, url, semaphore):
    """세마포어로 동시 요청을 제한합니다."""
    async with semaphore:
        async with session.get(url) as resp:
            return await resp.text()

async def main():
    semaphore = asyncio.Semaphore(50)  # 최대 50개 동시 요청
    urls = [f"https://httpbin.org/get?i={i}" for i in range(500)]
    
    connector = aiohttp.TCPConnector(limit=100)
    async with aiohttp.ClientSession(connector=connector) as session:
        tasks = [bounded_fetch(session, url, semaphore) for url in urls]
        results = await asyncio.gather(*tasks, return_exceptions=True)
        successes = sum(1 for r in results if not isinstance(r, Exception))
        print(f"Successful: {successes}/500")

asyncio.run(main())

지수 백오프 재시도 로직 #

import aiohttp
import asyncio
import random

async def fetch_with_retry(session, url, max_retries=3):
    """지수 백오프로 실패한 요청을 재시도합니다."""
    for attempt in range(max_retries):
        try:
            async with session.get(url) as resp:
                if resp.status == 200:
                    return await resp.json()
                elif resp.status in (429, 503, 502):
                    wait = (2 ** attempt) + random.uniform(0, 1)
                    await asyncio.sleep(wait)
                else:
                    resp.raise_for_status()
        except (aiohttp.ClientError, asyncio.TimeoutError) as e:
            if attempt == max_retries - 1:
                raise
            await asyncio.sleep(2 ** attempt)
    return None

async def main():
    async with aiohttp.ClientSession() as session:
        data = await fetch_with_retry(session, "https://httpbin.org/json")
        print(data)

asyncio.run(main())

실시간 데이터를 위한 WebSocket 스크래핑 #

import aiohttp
import asyncio

async def websocket_scraper():
    """WebSocket 엔드포인트에서 실시간 데이터를 스크래핑합니다."""
    async with aiohttp.ClientSession() as session:
        async with session.ws_connect("wss://echo.websocket.org") as ws:
            await ws.send_str("Hello Server")
            
            async for msg in ws:
                if msg.type == aiohttp.WSMsgType.TEXT:
                    print(f"Received: {msg.data}")
                    if "done" in msg.data.lower():
                        await ws.close()
                        break
                elif msg.type == aiohttp.WSMsgType.ERROR:
                    print(f"WebSocket error: {ws.exception()}")
                    break

asyncio.run(websocket_scraper())

DigitalOcean에서 Docker로 프로덕션 배포 #

# Dockerfile
FROM python:3.12-slim

WORKDIR /app
COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt

COPY scraper.py .
CMD ["python", "scraper.py"]
# docker-compose.yml
version: "3.8"
services:
  scraper:
    build: .
    restart: unless-stopped
    environment:
      - PYTHONUNBUFFERED=1
    deploy:
      resources:
        limits:
          memory: 2G
    logging:
      driver: "json-file"
      options:
        max-size: "100m"
        max-file: "3"

이를 **DigitalOcean 드롭릿**에 배포하여 월 $4부터 시작하는 안정적이고 확장 가능한 스크래핑 인프라를 확보하세요. 여러 노드에 분산 스크래핑을 하려면 DigitalOcean의 Kubernetes 서비스가 수평 확장을 간단하게 만듭니다.

Prometheus 메트릭으로 모니터링 #

import aiohttp
import asyncio
from prometheus_client import Counter, Histogram, start_http_server

REQUEST_COUNT = Counter("scraper_requests_total", "총 요청 수", ["status"])
REQUEST_DURATION = Histogram("scraper_request_duration_seconds", "요청 소요 시간")

async def monitored_fetch(session, url):
    with REQUEST_DURATION.time():
        try:
            async with session.get(url) as resp:
                REQUEST_COUNT.labels(status=str(resp.status)).inc()
                return await resp.text()
        except Exception as e:
            REQUEST_COUNT.labels(status="error").inc()
            raise

# 포트 9090에서 메트릭 서버 시작
start_http_server(9090)

대안과의 비교 #

기능aiohttp 3.11requests 2.32httpx 0.28urllib3 2.2pycurl 7.45
비동기 지원예 (네이티브)아니오아니오아니오
HTTP/2 지원아니오아니오아니오
WebSocket 클라이언트아니오아니오아니오아니오
서버 기능아니오아니오아니오아니오
연결 풀링고급없음고급기본고급
스트리밍 다운로드
쿠키 지속성아니오
미들웨어 지원아니오아니오아니오아니오
메모리 사용량낮음높음중간낮음낮음
에코시스템 성숙도매우 높음매우 높음높음매우 높음중간
문서 품질우수우수양호양호부족

언제 무엇을 선택할지:

  • aiohttp 선택 — 최대 비동기 성능, WebSocket 지원이 필요하거나, 스크래핑 파이프라인에 서버 컴포넌트도 필요할 때.
  • httpx 선택 — HTTP/2 지원이 필요하거나, 비동기 기능이 있는 requests 호환 API를 원할 때.
  • requests 선택 — 성능이 문제가 아닌 간단한 동기식 일회성 스크립트를 작성할 때.
  • pycurl 선택 — SOCKS5 프록시 지원이나 FTP 전송 같은 libcurl 고유 기능이 필요할 때.

한계: 정직한 평가 #

어떤 도구도 완벽하지 않다. aiohttp에는 알아야 할 특정 한계가 있다.

HTTP/2 미지원. v3.11 기준으로 aiohttp는 HTTP/1.1만 지원한다. 대상이 HTTP/2를 필요로 한다면(Cloudflare 뒤의 API에서 점점 더 흔해짐) httpx를 대신 사용하라. HTTP/2 구현을 추적하는 오픈 이슈(#2217)가 있지만 확정된 일정은 없다.

asyncio 학습 곡선. async/await에 익숙하지 않은 개발자는 상당한 학습 곡선을 경험할 것이다. 흔한 함정으로는 await를 잊는 것, 동기와 비동기 코드를 혼합하는 것, 교착 상태의 이벤트 루프를 디버깅하는 것이 있다. RuntimeError: Event loop is closed 오류는 모든 asyncio 개발자의 통과의례이다.

DNS 해석 병목. aiohttp의 기본 DNS 해석기는 getaddrinfo를 사용하는데, 이는 동기적이며 높은 동시성에서 이벤트 루프를 블로킹할 수 있다. [speedups]에 포함된 aiodns를 설치하여 진정한 비동기 DNS 해석을 활성화하라.

서버 중심 문서가 클라이언트 문서를 희석. aiohttp는 클라이언트이자 서버 프레임워크이다. 문서는 때때로 서버 기능을 우선시하여 클라이언트 특정 기능을 찾기 어렵게 만든다.

쿠키 처리 특이성. aiohttp의 쿠키 jar는 RFC 6265를 엄격히 따른다. 이는 잘못된 쿠키를 볂는 서버와 문제를 일으킬 수 있다. CookieJarunsafe=True 플래그로 이를 우회할 수 있다.

자주 묻는 질문 #

aiohttp는 얼마나 많은 동시 요청을 처리할 수 있나요? #

기본 설정(100 연결)으로 aiohttp는 호스트당 100개의 동시 요청을 처리한다. 커넥터 limit를 200-300으로 높이면 단일 프로세스에서 분산 대상에 대해 초당 2,000-4,000 req가 가능하다. 실제 한계는 보통 대상 서버의 속도 제한이나 네트워크 대역폭이다.

기존 동기식 코드와 aiohttp를 함께 사용할 수 있나요? #

예, 하지만 주의가 필요하다. asyncio.run() 또는 loop.run_until_complete()를 사용하여 동기와 비동기 경계를 연결하라. 비동기 코드에서 동기 함수를 호출하려면 loop.run_in_executor()를 사용하여 스레드 풀에 블로킹 작업을 오프로드하라. 절대 비동기 함수에서 직접 블로킹 I/O를 호출하지 마라.

CAPTCHA와 JavaScript 렌더링된 페이지는 어떻게 처리하나요? #

aiohttp는 HTTP 클라이언트이지 브라우저가 아니다. JavaScript를 실행하거나 CAPTCHA를 해결할 수 없다. JavaScript가 많은 사이트의 경우 aiohttp를 Playwright 같은 헤드리스 브라우저와 함께 사용하거나, 렌더링된 HTML을 제공하는 서비스를 이용하라.

대용량 파일 다운로드에 aiohttp를 사용할 수 있나요? #

예. resp.content.iter_chunked(8192)를 사용하여 메모리에 로딩하지 않고 대용량 파일을 스트리밍하라. 10GB 파일의 경우 aiohttp는 스트리밍 시 20MB 미만의 RAM을 사용한다.

aiohttp 성능 문제를 어떻게 디버깅하나요? #

python -W default -m aiohttp.web 또는 PYTHONASYNCIODEBUG=1을 설정하여 디버그 모드를 활성화하라. asyncio.get_event_loop().set_debug(True)로 흔한 실수를 잡아낼 수 있다. 프로덕션 모니터링을 위해서는 고급 사용법 섹션의 prometheus_client를 사용하거나, 개발 중에는 aiohttp-debugtoolbar를 활용하라.

aiohttp와 Flask/FastAPI의 차이점은 무엇인가요? #

aiohttp는 HTTP 클라이언트이자 서버이다. 서버 측에서는 Flask, FastAPI와 경쟁한다. 클라이언트 측 스크래핑에서는 Flask와 FastAPI가 서버 전용 프레임워크이므로 관련이 없다. 스크래퍼와 API 서버가 모두 필요하다면 aiohttp가 두 역할을 모두 수행한다.

결론: aiohttp로 다음 스크래퍼를 구축하라 #

대규모 스크래핑에 아직도 requests를 사용하고 있다면 10-50배의 성능 향상을 놓치고 있는 것이다. aiohttp의 네이티브 비동기 아키텍처, 성숙한 에코시스템, 입증된 프로덕션 트랙 레코드는 2026년 Python 고처리량 스크래퍼에 가장 적합한 선택이다.

이 가이드의 5분 설정으로 시작하여, 연결 풀과 세마포어로 프로덕션을 강화하고, **DigitalOcean**에 배포하여 안정적이고 비용 효율적인 인프라를 확보하라. 대규모 프록시 로테이션에는 파이프라인에 **WebShare**를 통합하라.

Telegram 그룹에 가입하여 Python 비동기 패턴과 스크래핑 모범 사례에 대한 일일 팁을 받아보세요: https://t.me/dibi8python

참고 자료 및 추가 읽을거리 #


추천 호스팅 및 인프라 #

위 도구들을 프로덕션에 배포하려면 안정적인 인프라가 필요합니다. dibi8가 직접 사용 중인 두 가지 옵션:

  • DigitalOcean — 60일 $200 무료 크레딧, 14개 이상 글로벌 리전. 오픈소스 AI 도구의 기본 선택.
  • HTStack — 홍콩 VPS, 중국 본토 저지연 접속. dibi8.com 호스팅 중인 검증된 IDC.

제휴 링크 — 추가 비용 없이 dibi8 운영을 지원합니다.

제휴 공개 #

이 글에는 DigitalOcean 및 WebShare의 제휴 링크가 포함되어 있다. 이 링크를 통해 서비스를 구매하면 추가 비용 없이 커미션을 받을 수 있다. 이 추천은 프로덕션 스크래핑 워크플로에 대한 진정한 유용성을 기반으로 한다. 모든 벤치마크는 독립적으로 수행되었다.

💬 댓글 토론