← 블로그로 돌아가기

여러 서버에서 분산 파서: 충돌 및 차단 없이 프록시 풀 나누기

분산 파서의 아키텍처를 여러 서버에서 분석합니다. 프록시 풀을 적절하게 나누고, 하나의 IP에서 요청 중복을 피하며, 차단을 최소화하는 방법을 다룹니다.

📅2026년 10월 21일

파서가 하나의 서버에서 여러 작업자 클러스터로 이동할 때 새로운 문제가 발생합니다. 여러 대의 머신이 동시에 동일한 프록시 풀을 사용하기 시작합니다. 조정 없이 이것은 요청에서 IP 중복, 하나의 주소에 대한 한도 초과 및 대량 차단으로 이어집니다. 이 기사에서는 서버 간 프록시를 올바르게 샤딩하는 방법, 가장 잘 작동하는 분배 방식과 이를 Python 및 Redis 코드로 구현하는 방법을 살펴보겠습니다.

분산 파서에서 프록시가 충돌하는 이유

전형적인 상황을 상상해 보십시오: 500개의 프록시 풀과 각각 50개의 병렬 작업자를 실행하는 세 대의 서버가 있습니다. 각 서버가 조정 없이 공통 목록에서 프록시를 가져오면, 서로 다른 머신의 두 작업자가 동시에 동일한 IP로 동일한 도메인에 요청을 보낼 수 있습니다. 안티봇 시스템에서는 이것이 하나의 주소에서 비정상적으로 높은 요청 빈도로 보이며, IP는 몇 분 만에 차단됩니다.

두 번째 문제는 부하의 불균형한 분배입니다. 프록시가 "어떻게든" 나누어지면, 한 서버는 가장 빠르고 새로운 IP를 가져가고, 다른 서버는 이미 손상된 평판을 가진 나머지를 가져갈 수 있습니다. 결과적으로 서버의 파싱 속도는 몇 배 차이가 나고, 오류 비율도 다를 것입니다.

세 번째 문제는 상태 손실입니다. 한 서버에서 프록시가 차단되면, 다른 서버는 이를 알지 못하고 계속 사용하다가 차단됩니다. 프록시의 상태를 중앙 집중식으로 기록하지 않으면 각 서버가 "미국을 다시 열게" 되어 요청을 낭비하게 됩니다.

이 세 가지 문제의 해결책은 단일 조정 메커니즘을 도입하는 것입니다. 중앙 집중식 상태 저장소(예: Redis, PostgreSQL, Consul)를 통해서든, 서버 간 범위가 겹치지 않도록 미리 프록시 풀을 결정하는 방식이든 말입니다.

분산 파서 아키텍처

프록시를 나누기 전에 클러스터의 전체 아키텍처를 결정해야 합니다. 두 가지 기본 접근 방식이 있습니다.

정적 스킴 (shared-nothing). 프록시 풀은 사전에 서버 간에 한 번 나누어집니다. 각 서버는 서로 겹치지 않는 세그먼트를 받아 자율적으로 작업하며, 다른 서버에 접근하지 않습니다. 장점은 최대한의 단순성과 장애 내성입니다: 하나의 서버가 다운되더라도 다른 서버에는 영향을 미치지 않습니다. 단점은 한 서버에서 프록시의 절반이 차단되면, 이 서버는 이웃에게 새로운 IP를 "빌릴" 수 없다는 것입니다.

동적 스킴 (shared-state). 모든 서버는 공통 저장소(대부분 Redis)에 접근하여 각 프록시의 상태를 저장하고 요청 시 잠금과 함께 제공합니다. 장점은 풀의 균일한 활용과 차단에 대한 즉각적인 반응입니다. 단점은 저장소가 단일 실패 지점이 되며, 접근 불가능할 경우를 대비한 별도의 로직이 필요하다는 것입니다.

실제로 마켓플레이스와 소셜 미디어를 파싱할 때는 하이브리드 방식을 선택하는 경우가 많습니다. Redis 브로커는 짧은 시간(TTL) 동안 잠금과 함께 프록시를 제공하며, Redis가 접근 불가능할 경우 서버는 임시로 로컬 폴백 풀로 전환됩니다. 이러한 접근 방식은 정적 스킴의 신뢰성과 동적 스킴의 유연성을 결합합니다.

서버 간 프록시 풀 나누는 방법

프록시 풀을 나누기 위해 적용할 수 있는 구체적인 알고리즘을 살펴보겠습니다.

모듈로 나누기 (round-robin sharding). 프록시를 0부터 N-1까지 번호를 매기고, 번호가 i인 서버는 index % 서버_수 == i인 모든 프록시를 가져갑니다. 간단하지만 서버 추가 또는 제거 시 잘 견디지 못합니다. 머신 수가 변경되면 거의 모든 풀을 다시 분배해야 합니다.

일관된 해싱. 각 서버와 각 프록시에 해시 링에서 위치를 할당합니다(일반적으로 IP 주소 또는 서버 ID의 해시를 사용). 프록시는 링에서 가장 가까운 서버에 고정됩니다. 서버를 추가하거나 제거할 때는 전체 풀 대신 소량의 프록시만 재분배됩니다. 이는 동적으로 확장되는 클러스터(예: Kubernetes의 자동 스케일링)에 중요한 장점입니다.

잠금이 있는 중앙 집중식 브로커. 프록시를 서버에 강제로 고정하는 대신, 모든 서버는 공통 서비스(예: Redis, RabbitMQ)에서 "자유로운" 프록시를 요청하며, 이 서비스는 임시 잠금과 함께 제공합니다. 사용 후 프록시는 풀로 반환됩니다. 이는 특히 서버의 부하가 불균형할 때 가장 유연한 옵션입니다.

도메인 또는 작업에 따라 나누기. 파서가 여러 소스(예: Wildberries, Ozon 및 Avito)를 동시에 처리하는 경우, 서버 간에 프록시를 나누는 것뿐만 아니라 소스 간에도 나누는 것이 의미가 있습니다. 이렇게 하면 동일한 IP가 여러 플랫폼에서 동시에 노출되지 않도록 할 수 있습니다. 이는 IP가 안티 프로드 시스템의 공통 블랙리스트에 포함될 위험을 줄입니다.

표: 프록시 샤딩 방법 비교

방법 복잡성 확장성 저항성 언제 적용할지
모듈로 나누기 낮음 나쁨 고정된 서버 수
일관된 해싱 중간 좋음 클러스터 자동 스케일링
중앙 집중식 브로커 중간-높음 우수함 불균형한 부하, 높은 트래픽
도메인에 따라 나누기 낮음 중간 여러 플랫폼을 동시에 파싱

Redis에서 프록시 브로커 구현하기

대부분의 분산 파서에 가장 실용적인 옵션은 Redis에서 중앙 집중식 브로커입니다. Redis는 빠르고 원자적 작업과 TTL을 "기본적으로" 지원하기 때문에 적합합니다. 아이디어는 간단합니다: 모든 사용 가능한 프록시는 Redis Set에 저장되며, 특정 작업자에게 프록시를 제공할 때는 TTL이 있는 키를 통해 잠금이 설정됩니다.

아래는 redis-py 라이브러리를 사용한 Python 브로커의 작업 예입니다. 이 코드는 잠금이 있는 자유 프록시 제공, 사용 후 해제 및 프록시를 차단된 것으로 표시하는 기능을 구현합니다.

import redis
import time
import random

r = redis.Redis(host="localhost", port=6379, db=0, decode_responses=True)

PROXY_POOL_KEY = "proxy:pool"       # 모든 작업 프록시의 집합
LOCK_PREFIX = "proxy:lock:"         # 특정 프록시의 잠금 접두사
BANNED_PREFIX = "proxy:banned:"     # 차단 표시 접두사
LOCK_TTL = 30                       # 프록시 잠금 시간(초)
BAN_TTL = 3600                      # 프록시가 차단된 것으로 간주되는 시간(초)

def load_pool(proxy_list):
    """클러스터 시작 시 프록시 목록을 Redis에 한 번 로드합니다."""
    if proxy_list:
        r.sadd(PROXY_POOL_KEY, *proxy_list)

def get_free_proxy(server_id, max_attempts=20):
    """각 서버는 이 함수를 호출하여 자유 프록시를 얻습니다."""
    candidates = list(r.smembers(PROXY_POOL_KEY))
    random.shuffle(candidates)

    for proxy in candidates[:max_attempts]:
        if r.exists(BANNED_PREFIX + proxy):
            continue
        lock_key = LOCK_PREFIX + proxy
        # 프록시가 자유로운 경우 원자적으로 잠금 설정
        acquired = r.set(lock_key, server_id, nx=True, ex=LOCK_TTL)
        if acquired:
            return proxy
    return None  # 자유 프록시가 없으면 폴백 필요

def release_proxy(proxy):
    """성공적인 요청 후 프록시를 해제합니다."""
    r.delete(LOCK_PREFIX + proxy)

def mark_banned(proxy):
    """프록시를 BAN_TTL 초 동안 차단된 것으로 표시하고 잠금을 해제합니다."""
    r.set(BANNED_PREFIX + proxy, 1, ex=BAN_TTL)
    r.delete(LOCK_PREFIX + proxy)
    r.srem(PROXY_POOL_KEY, proxy)  # 일시적으로 풀에서 제거

def return_to_pool_if_recovered():
    """주기적인 작업: 차단이 해제된 프록시를 반환합니다."""
    for proxy in r.scan_iter(match=BANNED_PREFIX + "*"):
        clean_proxy = proxy.replace(BANNED_PREFIX, "")
        if not r.exists(BANNED_PREFIX + clean_proxy):
            r.sadd(PROXY_POOL_KEY, clean_proxy)

여기서 핵심은 Redis에서 SET ... NX EX를 사용하는 것입니다. 이 명령은 원자적입니다: 두 서버가 동시에 동일한 프록시를 잠그려고 하면, 성공하는 것은 한 서버뿐이며, 다른 서버는 실패하고 다음 후보로 넘어갑니다. 이것이 공통 풀에 대한 병렬 접근 시 서버 간의 충돌을 방지합니다.

작업자에서 브로커를 사용하는 예시입니다:

import requests

def fetch_with_proxy(url, server_id):
    proxy = get_free_proxy(server_id)
    if not proxy:
        raise RuntimeError("풀에 자유 프록시가 없습니다.")

    proxies = {"http": f"http://{proxy}", "https": f"http://{proxy}"}
    try:
        response = requests.get(url, proxies=proxies, timeout=10)
        if response.status_code in (403, 429):
            mark_banned(proxy)
        else:
            release_proxy(proxy)
        return response
    except requests.exceptions.RequestException:
        mark_banned(proxy)
        raise

이러한 구조는 쉽게 확장 가능합니다. 10대든 50대든 서버를 추가할 수 있으며, 모두 동일한 Redis 인스턴스에 접근하므로 IP 충돌이 발생하지 않습니다. 잠금은 저장소 수준에서 원자적이기 때문입니다.

스티키 세션 및 작업에 대한 프록시 바인딩

일부 작업의 경우 "자유 프록시 중 하나"를 제공하는 것만으로는 충분하지 않습니다. 파서가 사용자 행동을 에뮬레이트하는 경우 — 예를 들어, 상품 카드들을 순차적으로 보고, 장바구니에 추가하고, 카탈로그 페이지를 전환하는 경우 — 모든 세션의 요청이 동일한 IP에서 이루어지는 것이 중요합니다. 그렇지 않으면 사이트는 세션 중간에 지리적 위치나 장치의 급격한 변화를 감지하고 이를 의심스러운 것으로 차단할 수 있습니다.

이를 위해 스티키 세션 개념이 도입됩니다: 프록시는 하나의 요청이 아니라 특정 작업이나 사용자 세션에 바인딩되어 그 생애 동안 유지됩니다. Redis에서는 session:{task_id} -> proxy 형태의 별도 키를 통해 TTL을 설정하여 구현됩니다.

SESSION_PREFIX = "proxy:session:"
SESSION_TTL = 600  # 세션당 10분

def get_sticky_proxy(task_id, server_id):
    existing = r.get(SESSION_PREFIX + task_id)
    if existing:
        return existing
    proxy = get_free_proxy(server_id)
    if proxy:
        r.set(SESSION_PREFIX + task_id, proxy, ex=SESSION_TTL)
    return proxy

긴 세션이 필요한 작업의 경우 — 예를 들어, 실제 구매자의 행동을 에뮬레이트하여 Wildberries 카탈로그를 탐색하는 경우 — 일반 사용자와 최대한 유사하게 행동하는 프록시를 사용하는 것이 중요합니다. 여기서는 주거용 프록시가 적합합니다. 이들은 실제 가정 IP에 바인딩되어 있으며, 데이터 센터 주소에 비해 마켓플레이스의 안티봇 시스템에서 의심을 덜 받습니다.

차단 처리 및 클러스터 내 회전

분산 시스템에서는 프록시 차단 정보가 즉시 모든 서버에 제공되어야 하며, 차단된 서버만 알지 못해야 합니다. 위의 예에서 mark_banned 함수가 이를 담당합니다. 하나의 서버가 대상 사이트에서 403 또는 429를 기록하면, 프록시는 Redis의 공통 풀에서 제거되고, 나머지 서버는 자동으로 이를 받지 않게 됩니다.

차단을 판단하기 위해서는 HTTP 응답 코드뿐만 아니라 페이지의 내용도 분석해야 합니다. 일부 사이트는 200 OK를 반환하지만 실제 콘텐츠 대신 CAPTCHA나 대체 페이지를 반환합니다. 차단 감지 로직은 일반적으로 다음과 같습니다:

  • HTTP 코드 403, 429, 503 — 차단 또는 비율 제한의 명백한 징후입니다.
  • CAPTCHA 또는 인증 페이지로의 리디렉션 — 요청 후 URL 확인.
  • 응답 크기의 급격한 감소 또는 예상 CSS 선택자의 부재 — 대체 페이지의 징후입니다.
  • 연결 시간 초과 — 프록시가 더 이상 응답하지 않음을 의미합니다.

차단이 기록된 후에는 프록시를 영구적으로 풀에서 제거하는 것이 아니라, TTL이 있는 "격리 상태"로 두는 것이 좋습니다(예: BAN_TTL). 이 후 프록시는 자동으로 풀로 반환됩니다. 많은 마켓플레이스와 소셜 미디어의 차단은 일시적이며 30-60분 후에 해제됩니다.

부하가 균등하게 분배되더라도 차단이 너무 자주 발생하면, 이는 일반적으로 프록시 풀의 품질이 낮거나 요청량에 비해 크기가 부족하다는 신호입니다. 이러한 경우, 데이터 센터 프록시를 사용하여 덜 민감한 작업(예: 페이지 가용성 확인)에 대한 풀을 확장하고, 중요한 세션에는 더 비싼 주거용 IP를 사용하는 것이 전체 차단 비율을 증가시키지 않고도 차단 비율을 줄이는 데 도움이 됩니다.

프록시 풀 상태 모니터링

여러 서버로 구성된 클러스터에서는 전체 상황을 쉽게 잃어버릴 수 있습니다: 현재 활성 프록시는 몇 개인지, 몇 개가 차단되었는지, 각 프록시의 평균 응답 속도는 얼마인지. 모니터링 없이는 파싱 속도가 느려진 이유를 이해하기 어렵습니다. 서버가 다운되었는지, 살아있는 프록시가 부족한지 알 수 없습니다.

Redis 또는 별도의 데이터베이스에서 수집해야 할 최소한의 메트릭 세트는 다음과 같습니다:

  • 현재 풀에서 활성 프록시의 수.
  • 차단된 프록시의 수와 복구까지의 평균 시간.
  • 최근 N 요청에 대한 각 프록시의 평균 응답 시간.
  • 서버 및 전체 풀에서의 성공적인 요청 비율(success rate).
  • 부하 분배 — 각 서버가 일정 기간 동안 처리한 요청 수.

이러한 메트릭을 시각화하기 위해서는 Redis에서 데이터를 읽어 Grafana에서 이해할 수 있는 형식으로 제공하는 간단한 익스포터를 통해 Prometheus에 저장하는 것이 유용합니다. 이를 통해 특정 지리적 위치나 프록시 유형에 대한 성공률 감소를 빠르게 확인하고, 파서가 완전히 중단되기 전에 새로운 IP를 풀에 추가할 수 있습니다.

클러스터에서 새로운 서버를 시작할 때 차단 피크를 별도로 모니터링하는 것이 중요합니다. 서버 수를 3대에서 6대로 확장할 때 차단 비율이 급격히 증가한다면, 이는 샤딩 문제일 가능성이 높습니다. 새로운 서버가 기존 서버와 프록시 범위가 겹치기 시작했기 때문입니다. 이는 모듈로 나누기에서 일관된 해싱으로 전환해야 한다는 첫 번째 신호입니다.

분산 파서 시작 체크리스트

여러 서버에서 파서를 배포하기 전에 다음 항목을 확인하십시오:

  • 풀 조정 메커니즘이 선택되었습니다: 정적 나누기, 일관된 해싱 또는 Redis에서 중앙 집중식 브로커.
  • 서버 두 대가 동시에 동일한 IP를 사용하는 것을 방지하는 원자적 프록시 잠금이 구현되었습니다.
  • 작업자가 다운될 경우 프록시가 영구적으로 차단 상태에 머물지 않도록 잠금 및 차단에 대한 TTL이 설정되었습니다.
  • 사용자 세션을 에뮬레이트하는 작업에 대해 전체 작업 기간 동안 스티키 프록시 바인딩이 설정되었습니다.
  • HTTP 코드뿐만 아니라 응답 내용에 따라 차단 감지가 설정되었습니다.
  • 전체 클러스터에서 성공률, 활성 및 차단된 프록시 모니터링이 활성화되었습니다.
  • 프록시 풀은 작업 유형에 따라 나누어졌습니다: 데이터 센터 IP는 덜 민감한 요청에, 주거용 또는 모바일 프록시는 사용자와의 유사성이 중요한 세션에 사용됩니다.
  • 상태 저장소(Redis)에 대한 부하가 확인되었습니다. 많은 서버와 높은 요청 빈도가 있는 경우, 단일 인스턴스 대신 Redis 클러스터가 필요할 수 있습니다.

결론

파서를 여러 서버로 전환할 때 충돌의 주요 원인은 프록시 풀 상태에 대한 단일 진실 출처의 부재입니다. 원자적 잠금이 있는 중앙 집중식 브로커(예: Redis 또는 유사한 저장소)가 생기면 IP 중복 및 차단 비동기화 문제가 저절로 사라집니다. 이후에는 클러스터의 규모에 맞는 샤딩 전략을 선택하면 됩니다: 고정된 서버 수에는 간단한 나누기가 충분하고, 동적으로 성장하는 인프라에는 일관된 해싱을 미리 고려하는 것이 좋습니다.

마켓플레이스나 소셜 미디어와 같은 곳에서 분산 파서가 작동하는 경우, 개별 세션 수준에서 트래픽의 신뢰성이 중요합니다. 작업 유형에 따라 풀을 나누는 것이 좋습니다. 대량으로 카탈로그를 스캔하는 경우에는 저렴한 데이터 센터 프록시가 적합하고, 사용자 행동을 에뮬레이트하는 세션에는 주거용 프록시가 실제 가정 IP에 바인딩되어 차단 비율을 현저히 낮추는 데 도움이 됩니다.