스크래핑

분산 크롤러의 백프레셔와 흐름 제어

크롤러는 파이프라인이며, 가장 빠른 단계가 가장 느린 단계를 넘치게 만듭니다. 제한된 큐, 풀 기반 작업, 호스트별 적응형 제한이 크롤러를 안정적으로 유지하는 방법을 알아봅니다.

Chris Collins

Chris Collins

2026년 9월 22일 · 9 분 소요

모든 분산 크롤러는 결국 똑같이 나쁜 오후를 맞는다. 어떤 사이트가 거대한 페이지를 내보내기 시작하면서 파서가 느려진다. 페처들은 아무도 멈추라고 하지 않았기 때문에 계속 전속력으로 가져온다. 그 사이의 큐는 수백만 개 항목만큼 커지고, 메모리가 치솟고, 노드 하나가 다운되고, 그 작업이 다른 노드들에 재할당되면서 재시도가 그것들마저 무너뜨린다. 그동안 이 모든 사태의 원인이 된 그 사이트는 그 어느 때보다 많은 트래픽을 받고 있다.

이 중 어느 것도 특정 컴포넌트의 버그가 아니다. 이는 컴포넌트 사이에 흐름 제어가 없다는 사실의 결과다. 크롤러는 속도가 크게 다른 여러 단계로 이루어진 파이프라인이며, 느린 단계가 빠른 단계에게 기다리라고 말할 방법이 없으면, 빠른 단계가 모든 것이 무너질 때까지 승리한다.

이 글은 그 피드백을 어떻게 만들어 넣는지에 관한 것이다. 신호가 어디서 오는지, 어디로 가야 하는지, 그리고 크롤러가 무너지지 않고 부드럽게 성능을 낮추도록 만드는 몇 가지 규칙에 대해 다룬다.

크롤러는 파이프라인이고, 각 단계는 속도에 대해 합의하지 않는다

세부 사항을 걷어내면 대부분의 크롤러는 네 단계로 이루어진다.

단계무엇이 제한하는가과부하 시 어떻게 실패하는가
프론티어와 스케줄러거의 없음, URL을 고르기만 함다운스트림이 흡수할 수 있는 것보다 빠르게 작업을 내보냄
페처대상 사이트, 프록시 용량, 플랜 한도타임아웃, 스로틀링, 차단
파싱과 렌더링CPU, 메모리, 헤드리스 브라우저 슬롯지연 시간 증가, 이어서 메모리 고갈
중복 제거와 저장소데이터베이스 쓰기 용량느린 쓰기, 락 경합, 적체

프론티어는 실행 비용이 거의 들지 않으므로 항상 가장 빠른 단계다. 나머지 세 단계는 각각 서로 다른 요인에 의해 제한되며, 그 한계는 계속 움직인다. 사이트가 느려지거나, 페이지 유형이 무거워지거나, 데이터베이스가 압축을 시작한다. 흐름 제어란 지금 가장 느린 단계가 나머지 모든 단계의 속도를 결정하게 만드는 것을 의미한다.

그 대안은 어떤 단계가 가장 먼저 메모리를 다 써버리는지가 속도를 결정하게 되는 것이다.

규칙 1: 모든 큐에는 한계가 있어야 한다

두 단계 사이의 무제한 큐는 버퍼가 아니다. 그것은 어떤 양의 초과 작업이든 흡수하겠다는 약속이며, 어떤 기계도 지킬 수 없는 약속이다. 또한 그것은 문제를 숨긴다. 업스트림 단계는 큐가 조용히 커지는 동안 모든 인큐(enqueue)에서 성공을 확인하고, 누군가 살펴볼 때쯤이면 가장 오래된 항목은 이미 몇 시간을 기다린 상태다.

한계가 있는 큐는 같은 상황을 신호로 바꾼다. 큐가 가득 차면 생산자는 블록되거나 명시적으로 거부당한다. 그 느려짐은 프론티어에 도달할 때까지 한 단계씩 업스트림으로 전파되고, 프론티어는 그저 잠시 URL 배포를 멈춘다. 아무것도 잃지 않고 아무것도 폭발하지 않는다.

단일 프로세스 안에서는 이것이 큐 생성자에 넘기는 인자 하나에 불과하다.

import asyncio


async def fetcher(frontier: asyncio.Queue, parsed: asyncio.Queue, fetch):
    while True:
        url = await frontier.get()
        try:
            page = await fetch(url)
            # Blocks when the parse queue is full: a slow parser slows fetching.
            await parsed.put(page)
        finally:
            frontier.task_done()


async def parser(parsed: asyncio.Queue, store):
    while True:
        page = await parsed.get()
        try:
            await store(page)
        finally:
            parsed.task_done()


async def run(urls, fetch, store, fetchers=16, parsers=4):
    frontier = asyncio.Queue(maxsize=1000)
    parsed = asyncio.Queue(maxsize=200)
    workers = [asyncio.create_task(fetcher(frontier, parsed, fetch)) for _ in range(fetchers)]
    workers += [asyncio.create_task(parser(parsed, store)) for _ in range(parsers)]
    for url in urls:
        await frontier.put(url)  # blocks when the frontier is full
    await frontier.join()
    await parsed.join()
    for w in workers:
        w.cancel()

여러 머신에 걸쳐 있을 때도 원칙은 같으며, 메커니즘만 바뀐다. 길이 제한과 거부 정책이 있는 브로커 큐, 실제로 대응하는 컨슈머 지연이 있는 스트림, 또는 여유 용량이 있는 워커에게만 URL을 내주는 데이터베이스 기반 프론티어 등이다. 큐 크기는 가진 메모리가 아니라 감내할 수 있는 지연 시간을 기준으로 정해야 한다. 파싱 단계가 초당 200페이지를 처리하고 10초의 큐잉을 받아들일 수 있다면, 큐는 약 2,000개 항목을 담는다. 그보다 크게 잡으면 문제를 알아채는 순간을 늦출 뿐이다.

규칙 2: 밀지 말고 당기게 하라

가장 깔끔한 백프레셔는 공짜로 얻는 종류다. 코디네이터가 워커에게 작업을 할당하는 대신, 워커가 여유가 있을 때 작업을 요청한다면, 느린 워커는 단순히 덜 자주 요청하게 된다. 시스템은 워커가 요청하지 않았기 때문에 그것이 처리할 수 있는 양보다 더 많이 보낼 수 없다.

작업을 밀어붙이면 코디네이터가 추측해야 한다. 각 워커의 부하를 추적해야 하고, 부하가 바뀌는 바로 그 순간에 잘못 추측하게 된다. 워커가 큐에서 항목을 대여하든 업스트림에 명시적으로 크레딧을 요청하든, 풀 기반 설계는 그 결정을 답을 아는 유일한 컴포넌트로 옮긴다.

대여에는 만료가 필요하다. 작업을 쥔 채로 죽은 워커가 그것을 영원히 붙들고 있어서는 안 되므로, 대여된 항목은 타임아웃 이후 큐로 돌아가야 한다. 그 타임아웃은 평균이 아니라 가장 느린 정상적인 페치를 기준으로 설정해야 한다. 그렇지 않으면 정상이지만 느린 작업이 두 번 배포될 수 있다.

규칙 3: 전역 큐가 아니라 호스트별 큐를 사용하라

단일 전역 페치 큐는 크롤러가 끊임없이 마주치는 실패 모드를 갖는다. 헤드 오브 라인 블로킹(head-of-line blocking)이다. 다음 천 개의 URL이 모두 느리거나 스로틀링 중인 한 사이트에 속한다면, 모든 페처가 그 사이트에 발이 묶이는 동안 빠르고 정상적인 사이트를 위한 작업은 그 뒤에서 대기하게 된다.

페치 단계를 호스트별로, 혹은 대상이 속도를 제한하는 단위가 무엇이든 그 단위로 분할하라. 각 호스트는 자신만의 큐와 동시성 한도를 갖고, 페처는 현재 여유가 있는 호스트에서 작업을 가져온다. 그러면 어려움을 겪는 사이트 하나는 오직 자기 자신만 느리게 만든다.

여기가 바로 정중함(politeness)이 자리하는 곳이기도 하다. 호스트별 한도는 당신에게는 흐름 제어이면서 동시에 사이트에 대한 자제이기도 하다. 그 한도를 애초에 어떻게 설정할지, 그리고 사이트 자체의 신호가 그것에 대해 무엇을 말해주는지는 rate limiting and request throttling에서 다룬다.

규칙 4: 호스트별 한도를 적응형으로 만들라

고정된 호스트별 동시성은 양방향 모두에서 잘못될 수 있다. 너무 낮으면 더 받아들일 수 있는 사이트에서 용량을 낭비한다. 너무 높으면 이미 반발하기 시작한 사이트를 계속 압박하게 되고, 이것이 일시적인 스로틀링이 차단으로 변하는 방식이다.

잘 검증된 답은 TCP가 사용하는 방식과 같다. 가산 증가, 승산 감소(additive increase, multiplicative decrease)다. 성공할 때마다 한도를 조금씩 올린다. 스로틀링이나 타임아웃이 발생할 때마다 절반으로 줄인다. 한도는 그 사이트가 허용하는 수준 바로 아래에 안착하고, 사이트가 바뀌면 함께 움직인다.

import asyncio


class HostLimiter:
    """Adaptive concurrency for one host: additive increase, multiplicative decrease."""

    def __init__(self, start=4, floor=1, ceiling=32):
        self.limit = start
        self.floor = floor
        self.ceiling = ceiling
        self.in_flight = 0
        self._cond = asyncio.Condition()

    async def acquire(self):
        async with self._cond:
            await self._cond.wait_for(lambda: self.in_flight < int(self.limit))
            self.in_flight += 1

    async def release(self, outcome):
        async with self._cond:
            self.in_flight -= 1
            if outcome == "ok":
                self.limit = min(self.ceiling, self.limit + 1 / max(1, int(self.limit)))
            elif outcome in ("throttled", "timeout"):
                self.limit = max(self.floor, self.limit / 2)
            self._cond.notify_all()

증가는 의도적으로 느리며, 대략 성공이 한 바퀴 돌 때마다 슬롯 하나가 추가되는 정도이고, 감소는 의도적으로 급격하다. 이 비대칭이 핵심이다. 사이트의 허용치를 초과하는 것이 그것을 밑도는 것보다 훨씬 큰 비용을 치르기 때문이다. 호스트마다 직접 설정한 상한선을 두어, 적응형 한도가 당신이 합리적이라고 판단한 수준을 결코 넘어서지 못하게 하라.

규칙 5: 어떤 신호가 어디서 왔는지 알라

“속도를 줄여라”라는 신호가 모두 같은 뜻은 아니며, 이를 똑같이 취급하면 압력이 엉뚱한 곳으로 간다.

신호발생 지점무엇을 늦춰야 하는가
사이트에서 온 429, 상승하는 지연 시간, 챌린지 페이지대상그 호스트의 리미터만
파스 큐가 가득 참, 저장소 지연자신의 파이프라인전역적으로 페칭, 이어서 프론티어
스크레이핑 API의 동시성 상한자신의 플랜해당 API로의 총 진행 중 요청
쿼터 또는 크레딧 소진자신의 플랜주기가 재설정되거나 충전할 때까지 모든 것

예를 들어 Shifter의 Web Scraping API는 플랜의 동시성 상한을 초과하면 429 Too Many Requests를 반환하고, 크레딧이 소진되면 509를 반환한다. 첫 번째는 대상이 아니라 당신에 관한 흐름 제어 신호이므로, 어떤 호스트별 리미터가 아니라 API 호출에 대한 전역 리미터에 속한다. 두 번째는 중단 조건이다. 둘 중 하나를 사이트 자체의 429와 혼동하면 크롤러가 그 사이트와 아무 관련 없는 문제 때문에 정상적인 사이트를 스로틀링하게 된다.

재시도는 부하다

크롤러가 자기 자신의 백프레셔를 무력화시키는 가장 흔한 방식은 재시도를 통해서다. 페치가 실패하고, 워커가 즉시 재시도하고, 원인이 사라지지 않았으므로 재시도도 실패하며, 같은 일을 하는 모든 워커가 대상이나 자신의 단계가 가장 감당하기 어려운 바로 그 순간에 트래픽을 배가시킨다.

  • 재시도는 첫 시도와 동일한 어드미션 컨트롤을 거쳐야 한다. 호스트별 리미터를 건너뛰는 재시도는 자신의 흐름 제어를 우회하는 것이다.
  • 지터(jitter)를 두고 백오프하라. 그래야 함께 실패한 워커 무리가 함께 재시도하지 않는다.
  • 각 작업에 재시도 예산을 부여하고, 재시도를 전체 요청 대비 비율로 추적하라. 그 비율이 올라간다는 것은 시스템이 실패에 용량을 소모하고 있다는 뜻이다.
  • 재시도 계층을 겹겹이 쌓지 말라. Web Scraping API는 이미 실패한 페치, CAPTCHA, 일시적인 대상 오류에 대해 다른 프록시로 최대 세 번까지 무료로 재시도한다. 그 위에 공격적인 클라이언트 측 재시도를 얹으면 회복력이 아니라 시도 횟수만 늘어난다.
  • 성공할 수 없는 것을 재시도하지 말라. 인증 오류와 설정 오류는 매번 같은 방식으로 실패한다.

감당할 수 없을 때는 의도적으로 덜어내라

때로는 정직한 답이 크롤러가 처리할 수 있는 것보다 더 많은 작업을 가지고 있다는 것이다. 그럴 때 백프레셔는 모든 것을 균등하게 늦추는데, 이는 종종 최악의 결과다. 중요한 작업을 포함해 모든 작업이 늦게 끝나기 때문이다.

로드 셰딩은 그 선택을 명시적으로 만든다. 작업에 우선순위를 부여하고, 큐가 임계값을 넘어 계속 가득 차 있으면, 페치 비용이 발생하기 전인 프론티어 단계에서 우선순위가 가장 낮은 항목을 버리거나 미뤄라. 변동이 심한 가격 페이지를 새로고침하는 것이 1년째 변하지 않은 아카이브 페이지를 다시 확인하는 것보다 가치가 크다. 어떤 페이지가 예산을 받을 자격이 있는지는 별개의 문제이며, cost-aware crawl scheduling에서 다룬다.

처리량이 아니라 압력을 측정하라

처리량 그래프는 붕괴 직전까지도 멀쩡해 보인다. 압력이 쌓이는 것을 보여주는 지표는 다르다.

  • 단계별 큐 깊이와 가장 오래된 항목의 나이. 나이가 깊이보다 중요하다. 빠르게 비워지는 깊은 큐는 건강하지만, 오래된 항목으로 가득한 얕은 큐는 그렇지 않다.
  • 호스트별 진행 중 요청 수와 현재 적응형 한도. 계속 반으로 줄어드는 한도는 사이트가 반발하고 있다는 뜻이다.
  • 단계별 어드미션 거부와 블록된 put. 이것은 백프레셔가 제 역할을 하고 있다는 뜻이다. 급격한 상승은 지금 어느 단계가 병목인지 알려준다.
  • 호스트별, 전체 재시도 비율.

호스트별 한도가 하락하는 것과 차단 및 챌린지 비율 상승이 결합되면, 대개 사이트가 단순히 느려지는 것이 아니라 당신에게 등을 돌리고 있다는 뜻이다. 이러한 신호를 사이트별 단일 판단으로 바꾸는 방법은 building a target health score에서 다룬다. 더 넓은 파이프라인 지표들은 monitoring a web scraping pipeline에 있다.

규모를 확장한다고 흐름 제어의 필요성이 사라지지 않는다

워커를 추가하면 페치 단계의 상한이 올라간다. 그것이 어떤 대상의 허용치나, 당신의 파싱 용량, 또는 당신의 플랜 한도를 올려주지는 않는다. 호스트별 한도와 한계가 있는 다운스트림 큐 없이 큐 깊이에 따라 페처를 자동으로 확장하는 크롤러는 모든 제약을 동시에 정면으로 들이받게 된다. Kubernetes에서 운영한다면 CPU만이 아니라 위의 신호들을 기준으로 확장하라. 그 메커니즘은 scaling residential proxy scraping with Kubernetes에서 다룬다.

같은 원칙이 리전 간에도 적용된다. 리전 간의 내구성 있는 큐와 백프레셔는 지역적 장애가 전역적 장애로 번지는 것을 막아주며, 이는 residential proxy failover for multi-region pipelines에 설명되어 있다.

결론

흐름 제어는 크롤러가 커진 뒤에 추가하는 최적화가 아니다. 그것은 스트레스를 받은 크롤러가 서서히 느려질지, 아니면 무너져 내릴지를 결정하는 요소다. 규칙은 몇 가지뿐이다. 모든 큐에 한계를 두고, 워커가 스스로 당겨오게 하고, 호스트별로 분할하고, 호스트별 한도를 급격히 내리고 천천히 올리며, 각 감속 신호를 그것이 가리키는 단계로 보내고, 재시도를 부하로 취급하고, 가치가 가장 낮은 작업을 의도적으로 덜어내라.

이렇게 만들어진 크롤러에는 하나 더 가질 만한 가치가 있는 특성이 있다. 어떤 사이트가 어려움을 겪기 시작하면, 크롤러는 이를 알아채고 스스로 완화한다. 이는 그 사이트에게도 좋은 일이고, 우연이 아니게도, 다음 주에도 그 크롤러가 계속 환영받을 가능성에도 좋은 일이다.

시작할 준비가 되셨나요?

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

시작하기