2026년 10월 7일 기준으로 Shifter는 Oxylabs와 Bright Data보다 많은, 가장 큰 규모의 실시간 미국 IP 풀을 보유하고 있으며, 비용은 그 일부에 불과합니다.Shifter는 이제 가장 많은 미국 라이브 IP 풀을 보유하고 있습니다

벤치마크 보기

스크래핑

당신의 스크레이퍼가 느린 이유, 프록시 때문이 아닐 수도 있습니다

프록시를 건드리지 않고도 레지덴셜 프록시를 거치는 스크레이퍼 속도를 7.5배 높였습니다. 시간이 실제로 어디서 소모되는지 찾아내고 해결하는 방법입니다.

Matt Brown

2026년 10월 8일 · 9 분 소요

“프록시가 느려요”는 저희가 고객들로부터 가장 자주 받는 메시지 중 하나입니다. 때로는 사실입니다. 하지만 스크레이퍼를 함께 들여다보면, 대부분의 시간은 스크레이퍼 자체가 하는 일들, 즉 페이지마다 새 연결을 여는 것, 느린 라이브러리로 파싱하는 것, 한 번에 하나씩 요청을 기다리는 것에 쓰이는 경우가 더 많습니다.

이것이 얼마나 중요한지 보여주기 위해, 저희는 간단한 스크레이퍼를 만들어 저희의 레지덴셜 프록시를 통해 실행하고, 한 번에 한 가지씩 바꿔보았습니다. 동일한 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 s1,906 ms577 ms
2. 연결 재사용requests.Session 하나125 s568 ms473 ms
3. 더 빠른 파서html.parser 대신 lxml76 s426 ms205 ms
4. 동시성워커 스레드 8개40 s551 ms2,024 ms
5. 배치 쓰기행마다 하나 대신 트랜잭션 하나42 s579 ms2,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배 빨라졌습니다. 가장 큰 단계부터 고치고, 다시 측정하고, 남은 시간이 정말로 네트워크 때문일 때 멈추세요.

시작할 준비가 되셨나요?

205M개 이상의 IP, 195개 이상의 국가를 지원하는 Shifter의 레지덴셜 프록시를 $0.10/GB부터 이용해보세요.

시작하기