“프록시가 느려요”는 저희가 고객들로부터 가장 자주 받는 메시지 중 하나입니다. 때로는 사실입니다. 하지만 스크레이퍼를 함께 들여다보면, 대부분의 시간은 스크레이퍼 자체가 하는 일들, 즉 페이지마다 새 연결을 여는 것, 느린 라이브러리로 파싱하는 것, 한 번에 하나씩 요청을 기다리는 것에 쓰이는 경우가 더 많습니다.
이것이 얼마나 중요한지 보여주기 위해, 저희는 간단한 스크레이퍼를 만들어 저희의 레지덴셜 프록시를 통해 실행하고, 한 번에 한 가지씩 바꿔보았습니다. 동일한 120개 페이지를 처리하는 시간이 298초에서 40초로, 프록시에 대해서는 아무것도 바꾸지 않고도 7.5배 빨라졌습니다. 이 가이드에서는 이 테스트 과정과, 느린 부분을 찾는 데 사용한 도구, 그리고 여러분 자신의 스크레이퍼를 진단하기 위한 체크리스트를 다룹니다.
핵심 요약
- 무언가를 바꾸기 전에 먼저 측정하세요. 가져오기, 파싱, 쓰기에 걸리는 시간을 기록하는 단계별 타이머가 시간이 어디로 가는지 몇 분 안에 알려줍니다.
- 연결을 재사용하세요. 프록시를 통해 모든 요청마다 새 연결을 여는 것은 테스트에서 가져오기당 시간을 3배 이상 늘렸습니다.
Session하나로 해결되었습니다. - 파싱도 가져오기만큼 비용이 들 수 있습니다. HTML 파서를 바꾸자 파싱 시간이 절반 이상 줄었습니다.
- 동시성이 가장 큰 효과를 주지만, 그다음 병목을 드러냅니다. 워커 8개로 실행 시간이 절반으로 줄었고, CPU 바운드 파싱이 새로운 한계임이 드러났습니다.
- 느리다고 해서 모두 고칠 가치가 있는 건 아닙니다. 데이터베이스 쓰기는 298초 중 0.3초에 불과했으므로, 이를 배치 처리해도 바뀐 것은 없었습니다.
테스트
저희는 유럽의 한 머신에서 Shifter의 미국 출구 레지덴셜 프록시를 통해 영어 위키백과 문서 120개를 수집했습니다. 위키백과 문서는 큰 페이지로, 종종 수백 킬로바이트의 HTML을 포함합니다. 각 페이지마다 스크레이퍼는 HTML을 가져오고, Beautiful Soup으로 파싱하여 제목을 읽고 링크 수를 세며, SQLite에 한 행을 씁니다.
같은 작업을 다섯 번 실행하면서 매번 한 가지씩 바꾸었습니다. 스크레이퍼는 위키백과가 요구하는 대로(기본 Python User-Agent는 서버가 거부합니다) 설명이 포함된 User-Agent를 사용했고, 실패한 요청은 최대 두 번까지 재시도했습니다.
| 버전 | 변경 사항 | 총 시간 | 페이지당 가져오기 | 페이지당 파싱 |
|---|---|---|---|---|
| 1. 기준선 | 요청마다 새 연결, html.parser, 한 번에 하나씩 | 298 s | 1,906 ms | 577 ms |
| 2. 연결 재사용 | requests.Session 하나 | 125 s | 568 ms | 473 ms |
| 3. 더 빠른 파서 | html.parser 대신 lxml | 76 s | 426 ms | 205 ms |
| 4. 동시성 | 워커 스레드 8개 | 40 s | 551 ms | 2,024 ms |
| 5. 배치 쓰기 | 행마다 하나 대신 트랜잭션 하나 | 42 s | 579 ms | 2,141 ms |
레지덴셜 출구를 통한 가져오기 시간은 실행마다 수백 밀리초 정도 차이가 날 수 있습니다. 위의 차이는 그보다 훨씬 큽니다.
1단계: 시간이 어디로 가는지 측정하기
이 도구는 스레드 간에도 안전하게 단계별로 실제 경과 시간을 합산하는 작은 타이머입니다.
import time
from collections import defaultdict
from contextlib import contextmanager
from threading import Lock
class StageTimer:
"""Adds up wall-clock time per pipeline stage, safely across threads."""
def __init__(self):
self.totals = defaultdict(float)
self.counts = defaultdict(int)
self._lock = Lock()
@contextmanager
def stage(self, name):
start = time.perf_counter()
try:
yield
finally:
elapsed = time.perf_counter() - start
with self._lock:
self.totals[name] += elapsed
self.counts[name] += 1
def report(self, wall_seconds):
busy = sum(self.totals.values())
print(f"wall {wall_seconds:.1f}s, stage time {busy:.1f}s")
for name, total in sorted(self.totals.items(), key=lambda kv: -kv[1]):
print(f"{name:>8}: {total:7.1f}s {100 * total / busy:5.1f}% "
f"{1000 * total / self.counts[name]:7.1f} ms per call")
스크레이퍼의 각 단계를 with timer.stage("fetch"):, with timer.stage("parse"): 등으로 감싸고, 마지막에 리포트를 출력하세요. 거의 비용이 들지 않으면서도 “스크레이퍼가 느리다”는 말을 구체적인 답으로 바꿔줍니다.
다음은 타이머가 적용된 전체 테스트 스크레이퍼입니다. run() 함수는 저희가 바꾼 네 가지 설정을 받습니다.
import os
import sqlite3
import time
from concurrent.futures import ThreadPoolExecutor
import requests
from bs4 import BeautifulSoup
from stagetimer import StageTimer
from urls import URLS
user, password = os.environ["PROXY_USER"], os.environ["PROXY_PASS"]
PROXY = f"http://{user}-country-us:{password}@p.shifter.io:443"
PROXIES = {"http": PROXY, "https": PROXY}
HEADERS = {"User-Agent": "ExampleScraper/1.0 (+https://example.com/bot)"} # name your scraper
def scrape(url, session, parser, timer):
with timer.stage("fetch"):
for attempt in range(3):
try:
if session is None: # a new connection for every request
response = requests.get(url, headers=HEADERS, proxies=PROXIES, timeout=20)
else: # reuse connections through one Session
response = session.get(url, timeout=20)
response.raise_for_status()
break
except requests.RequestException:
if attempt == 2:
raise
html = response.text
with timer.stage("parse"):
soup = BeautifulSoup(html, parser)
title = soup.find("h1").get_text(strip=True)
links = len(soup.select("a[href]"))
return url, title, links
def run(reuse=False, parser="html.parser", workers=1, batch_writes=False):
timer = StageTimer()
session = None
if reuse:
session = requests.Session()
session.proxies = PROXIES
session.headers.update(HEADERS)
adapter = requests.adapters.HTTPAdapter(pool_connections=workers, pool_maxsize=workers)
session.mount("https://", adapter)
db = sqlite3.connect("pages.db", check_same_thread=False)
db.execute("CREATE TABLE IF NOT EXISTS pages (url TEXT, title TEXT, links INTEGER)")
db.execute("DELETE FROM pages")
db.commit()
start = time.perf_counter()
with ThreadPoolExecutor(workers) as pool:
rows = pool.map(lambda u: scrape(u, session, parser, timer), URLS)
if batch_writes:
rows = list(rows)
with timer.stage("write"):
db.executemany("INSERT INTO pages VALUES (?, ?, ?)", rows)
db.commit()
else:
for row in rows:
with timer.stage("write"):
db.execute("INSERT INTO pages VALUES (?, ?, ?)", row)
db.commit() # one transaction per row
wall = time.perf_counter() - start
timer.report(wall)
return wall
if __name__ == "__main__":
steps = [
("baseline", {}),
("reuse connections", {"reuse": True}),
("faster parser", {"reuse": True, "parser": "lxml"}),
("8 workers", {"reuse": True, "parser": "lxml", "workers": 8}),
("batch writes", {"reuse": True, "parser": "lxml", "workers": 8, "batch_writes": True}),
]
for name, config in steps:
print(f"\n== {name}")
run(**config)
urls.py에는 120개 문서 URL 목록이 들어 있습니다. 이를 실행하면 다음과 같이 출력되었습니다.
== baseline
wall 298.2s, stage time 298.1s
fetch: 228.7s 76.7% 1905.7 ms per call
parse: 69.2s 23.2% 576.6 ms per call
write: 0.3s 0.1% 2.2 ms per call
== reuse connections
wall 125.0s, stage time 125.2s
fetch: 68.2s 54.5% 568.3 ms per call
parse: 56.7s 45.3% 472.7 ms per call
write: 0.3s 0.2% 2.1 ms per call
== faster parser
wall 75.7s, stage time 75.9s
fetch: 51.1s 67.4% 425.8 ms per call
parse: 24.6s 32.4% 204.8 ms per call
write: 0.2s 0.3% 1.6 ms per call
== 8 workers
wall 39.7s, stage time 309.8s
parse: 242.8s 78.4% 2023.6 ms per call
fetch: 66.1s 21.3% 551.0 ms per call
write: 0.8s 0.3% 6.9 ms per call
== batch writes
wall 42.1s, stage time 326.4s
parse: 256.9s 78.7% 2141.0 ms per call
fetch: 69.5s 21.3% 579.0 ms per call
write: 0.0s 0.0% 1.8 ms per call
각 변경이 한 일
연결 재사용은 가져오기 시간을 70% 줄였습니다. 기준선에서는 requests.get()을 호출할 때마다 완전히 새로운 연결을 엽니다. 프록시를 거칠 경우, 이는 프록시로의 TCP 연결, 터널 요청, 그리고 출구를 통한 대상과의 전체 TLS 핸드셰이크를 의미하며, 이 모든 과정이 페이지의 바이트 하나라도 도착하기 전에 일어납니다. requests.Session은 연결을 열어둔 채로 재사용하므로, 대부분의 요청은 이 모든 설정 과정을 건너뜁니다. 페이지당 가져오기 시간은 1.9초에서 0.57초로 떨어졌습니다. 거의 모든 HTTP 라이브러리에는 이에 상응하는 기능, 즉 한 번 생성해서 공유하는 클라이언트 객체가 있습니다.
더 빠른 파서는 파싱 시간을 절반으로 줄였습니다. Beautiful Soup은 여러 파서를 사용할 수 있습니다. 내장된 html.parser는 순수 Python으로 작성되어 있고, lxml은 C로 작성되어 있습니다. 바꾸는 것만으로 이 큰 페이지들에서 페이지당 파싱 시간이 473밀리초에서 205밀리초로 줄었으며, 인자 하나만 바꾸면 됩니다. 몇 개 필드만 필요하다면 lxml로 직접 선택하거나 내장된 JSON을 추출하는 것이 더욱 빠릅니다.
동시성은 총 시간을 절반으로 줄이고 병목을 옮겼습니다. 가져오기 시간의 대부분은 네트워크 대기이며, 스레드는 병렬로 대기할 수 있습니다. 워커 8개로 실행 시간이 76초에서 40초로 떨어졌습니다. 하지만 파싱 열을 보세요. 페이지당 205밀리초였던 것이 2초를 넘겼습니다. CPython에서는 스레드들이 하나의 인터프리터 락을 공유하므로, 파싱처럼 CPU가 많이 드는 작업은 병렬로 실행되지 않습니다. 스레드 8개는 같은 코어를 두고 줄을 서게 됩니다. 단계별 타이머가 이를 명확히 보여줍니다. 파싱이 단계 시간의 3분의 1에서 거의 80%로 늘었습니다. 이 지점에서 스크레이퍼는 CPU에 의해 제한되며, 다음 단계는 별도 프로세스에서 파싱하거나 파싱을 줄이는 것입니다.
쓰기를 배치 처리해도 아무것도 바뀌지 않았습니다. 애초에 쓰기는 문제가 아니었기 때문입니다. 기준선에서 298초 중 0.3초에 불과했습니다. 버전 4와 5 사이의 작은 차이는 실행 간 변동에 불과합니다. 이것이 먼저 측정해야 하는 이유입니다. 데이터베이스 쓰기를 배치 처리하는 것은 일반적으로 좋은 조언이지만, 여기서는 들인 노력이 낭비였을 것입니다.
느린 스크레이퍼를 위한 체크리스트
| 관찰되는 현상 | 가능성 있는 원인 | 확인할 사항 |
|---|---|---|
| 모든 요청이 느리고, CPU는 유휴 상태 | 한 번에 하나씩 요청 | 동시성을 추가하세요. 대부분의 스크레이퍼는 시간을 대기하는 데 씁니다 |
| 각 호스트의 첫 요청이 나머지보다 훨씬 느림 | 연결 설정 | 세션이나 클라이언트를 재사용하세요. keep-alive가 비활성화되어 있지 않은지 확인하세요 |
| 한 코어에서 CPU가 100%에 도달 | 파싱 또는 기타 CPU 바운드 작업 | 더 빠른 파서를 사용하거나, 추출을 줄이거나, 파싱을 별도 프로세스로 옮기세요 |
| 동시성을 높일수록 처리량이 떨어짐 | 대상이 요청을 제한하고 있거나, 자신의 머신이 포화 상태임 | 대상별 상태 코드와 응답 시간을 관찰하세요. 요청량과 동시성 계획 가이드를 참고하세요 |
| 간혹 요청이 20초에서 30초 걸림 | 타임아웃과 재시도 | 명시적 타임아웃을 설정하고, 재시도 횟수를 제한하며, 로그를 남기세요. 웹 스크레이퍼를 위한 재시도와 백오프를 참고하세요 |
| 스크레이퍼가 시간이 지날수록 느려짐 | 큐가 커지고, 메모리 압박이 생기며, 버퍼가 무한정 쌓임 | 백프레셔를 추가하세요. 백프레셔와 흐름 제어를 참고하세요 |
| 페이지가 느리고 무거움 | HTML로 충분한데 브라우저를 렌더링함 | HTML을 직접 가져오거나, 이미지, 미디어, 폰트를 차단하세요 |
정말로 프록시가 문제인 경우
때로는 실제로 프록시가 병목인 경우도 있으며, 이를 구분하는 방법을 아는 것이 중요합니다.
- 직접 연결 기준선과 비교하세요. 같은 머신에서 프록시 없이 동일한 요청의 시간을 측정하세요. 둘 다 느리다면, 문제는 대상이나 여러분의 코드에 있습니다.
- 연결 재사용 후의 가져오기 시간을 보세요. 세션을 재사용했는데도 가져오기가 여전히 느리고, 대상이 직접 연결 시 빠르다면, 프록시를 거치는 경로가 시간을 더하고 있는 것입니다.
- 타겟팅을 확인하세요. 특정 도시 같은 좁은 필터는 선택 가능한 출구를 줄입니다. 대상에서 먼 출구는 모든 요청에 왕복 시간을 더합니다.
- 평균이 아니라 백분위수를 보세요. 매우 느린 출구 몇 개가 대부분의 요청은 괜찮은데도 평균을 끌어올릴 수 있습니다.
레지덴셜 프록시로 지연 시간 줄이기 가이드에서 프록시 쪽 설정을 자세히 다룹니다. 측정을 마쳤고 수치가 프록시를 가리킨다면, 저희 지원팀에 보내주세요. 위와 같은 단계별 타이밍은 느린 프록시 신고를 훨씬 빨리 조사할 수 있게 해줍니다.
FAQ
프록시를 사용할 때 스크레이퍼가 왜 느린가요?
가장 흔한 원인은 스크레이퍼 자체에 있습니다. 요청마다 새 연결을 여는 것, 한 번에 한 페이지씩 가져오는 것, 느린 파싱입니다. 저희 테스트에서는 이를 고치는 것만으로 프록시를 바꾸지 않고도 같은 작업이 7.5배 빨라졌습니다.
프록시를 통해서도 연결 재사용이 작동하나요?
네. 프록시가 설정된 requests.Session은 연결을 열어둔 채 유지하여, 요청마다 발생하는 연결 및 TLS 설정 비용을 줄여줍니다. 저희 테스트에서는 페이지당 가져오기 시간이 1.9초에서 0.57초로 줄었습니다.
스크레이퍼는 스레드를 몇 개나 사용해야 하나요?
대상과 요금제가 허용하는 범위 안에서 네트워크를 바쁘게 유지할 만큼이면 충분합니다. 늘려가면서 측정하세요. 페이지당 파싱 시간이나 오류율이 오르기 시작하면 한계를 찾은 것입니다. 파싱 같은 CPU 바운드 작업은 CPython에서 스레드를 늘려도 빨라지지 않습니다.
BeautifulSoup은 느린가요?
Beautiful Soup 자체는 편의성 계층이며, 속도는 대부분 그 아래의 파서에 달려 있습니다. 내장된 html.parser 대신 lxml을 사용하자 저희 테스트에서 파싱이 두 배 이상 빨라졌습니다.
결론
프록시를 탓하기 전에 스크레이퍼를 측정하세요. 단계별 타이머는 시간이 가져오기, 파싱, 쓰기 중 어디로 가는지 몇 분 안에 보여줍니다. 저희 테스트에서는 연결 재사용, 파서 교체, 동시성 추가만으로 같은 프록시를 쓰고도 같은 작업이 7.5배 빨라졌습니다. 가장 큰 단계부터 고치고, 다시 측정하고, 남은 시간이 정말로 네트워크 때문일 때 멈추세요.