Home > AI > Infra > 무료 프록시로 봇 차단을 버티는 크롤러 설계하기

무료 프록시로 봇 차단을 버티는 크롤러 설계하기
AI Claude Code 크롤링 Flask

문제: 반복 크롤링이 IP 차단에 걸리기 시작했다

골프 부킹 정보를 10분 주기로 크롤링하는 대시보드를 운영하고 있었다. 문제는 이 반복 패턴 자체가 상대 서버 입장에서는 봇으로 보이기 딱 좋다는 점이었다. 코드에는 _direct_blocked라는 플래그와 “IP 차단 시” 분기가 새로 생겼는데, 이건 직접 연결 방식으로는 더 이상 안정적으로 크롤링을 지속할 수 없었다는 뜻이다.

설계: 프록시를 목록이 아니라 순환 상태로 다룬다

가장 단순한 접근은 프록시 서버 주소 몇 개를 코드에 박아 넣는 것이다. 하지만 이번엔 Webshare라는 무료 프록시 API에서 프록시 목록을 그때그때 새로 받아오는 방식을 택했다. 무료 프록시는 수명이 짧아 금방 만료되기 때문에, 하드코딩보다는 매번 최신 목록을 받는 편이 유지보수 부담이 적다.

def _fetch_webshare_proxies(api_token: str) -> List[str]:
    """Webshare API에서 프록시 목록을 가져옵니다."""
    resp = requests.get(
        "https://proxy.webshare.io/api/v2/proxy/list/?mode=direct&page=1&page_size=100",
        headers={"Authorization": f"Token {api_token}"},
        timeout=10,
    )
    ...

그런데 API 하나에만 의존하면 이 API 자체가 실패했을 때 크롤러가 완전히 멈춘다. 그래서 config.py에 수동으로 입력하는 프록시 목록(PROXY_LIST)도 함께 남겨뒀다 — API 실패 시 폴백 경로다.

핵심은 프록시를 단순한 “목록”이 아니라 순환하는 상태로 다룬 부분이다.

def _rotate_proxy(self) -> bool:
    """다음 프록시로 전환합니다. 전환 성공 시 True, 모두 소진 시 False."""
    if not self._proxy_list:
        return False

    if self._current_proxy_index == -1:
        # 직접 연결 중이었다면 첫 프록시로
        self._current_proxy_index = 0
        self._apply_proxy(self._proxy_list[0])
        return True

    next_index = self._current_proxy_index + 1

    if next_index >= len(self._proxy_list):
        if not self._direct_blocked:
            # 프록시를 다 돌았으면 직접 연결도 한 번 시도
            self._current_proxy_index = -1
            self._clear_proxy()
            return True
        else:
            # 직접 연결도 막혔으면 처음 프록시부터 다시
            self._current_proxy_index = 0
            self._apply_proxy(self._proxy_list[0])
            return True

    self._current_proxy_index = next_index
    self._apply_proxy(self._proxy_list[next_index])
    return True

순서는 이렇다: 직접 연결 → 프록시 1 → 프록시 2 → … → 프록시 소진 → (아직 안 막혔다면) 직접 연결 재시도 → 그래도 막혔다면 처음 프록시부터 다시. 프록시를 “쓰다가 버리는 자원”이 아니라, 직접 연결까지 포함한 하나의 순환 링으로 구성한 것이다.

프록시 전환만으로는 부족했다

프록시를 바꾸는 것과 별개로, HTTP 요청이 실패했을 때는 지수 백오프로 최대 2회까지 재시도하도록 했다(2초 → 4초 대기). 그리고 프록시나 IP가 바뀌면 세션 쿠키(JSESSIONID)도 새로 확보해야 하므로, 이걸 자동으로 초기화하는 로직도 붙였다.

크롤링 결과가 0건일 때를 별도로 다뤘다

여기서 실제로 겪었을 것으로 보이는 문제가 하나 더 있다. 프록시가 막혀서 크롤링이 빈 결과를 반환했는데, 그게 그대로 “오늘은 매물이 없다”는 정상 데이터처럼 화면에 반영돼버리는 상황이다. 실패와 진짜 0건을 구분하지 않으면, 크롤러가 죽어있어도 사용자 눈에는 “매물이 하나도 없는 날”로만 보인다.

그래서 크롤링 결과가 0건일 때는 기존 캐시나 DB 데이터를 덮어쓰지 않고 보호하도록 했다. 그리고 “오래된 데이터는 지운다”는 정리(stale 삭제) 로직에도 최소 기준(50건 미만이면 삭제하지 않음)을 걸었다 — 크롤링이 일부만 성공했을 때, 멀쩡히 남아있는 기존 데이터까지 “오래됐다”고 오인해서 지워버리는 걸 막기 위해서다.

최종 구성

프록시 로테이션, 재시도, 데이터 보호 로직을 한 번에 묶어서 배포했고, 크롤링 주기도 10분에서 30분으로 늘렸다. 순환 주기를 줄이는 것 자체가 차단 빈도를 낮추는 가장 근본적인 방법이기도 했다.

- Webshare 무료 프록시 10개 자동 로드 (WEBSHARE_API_TOKEN)
- IP 차단 시 프록시 자동 전환 (로테이션)
- HTTP 요청 실패 시 지수 백오프 재시도 (2회, 2s→4s)
- 세션 쿠키 자동 초기화 (JSESSIONID 확보)
- 크롤링 결과 0건 시 기존 캐시/DB 데이터 보호
- stale 삭제 최소 기준 50건 미만이면 삭제 스킵
- 크롤링 주기 10분 → 30분으로 변경

남는 교훈

크롤러의 안정성은 “차단당했을 때 우회한다” 하나로 끝나지 않는다. 우회 수단(프록시)이 있어도 그게 소진됐을 때 어떻게 순환할지, 실패와 정상적인 빈 결과를 어떻게 구분할지, 부분 실패가 기존 데이터를 오염시키지 않게 어떻게 막을지까지 함께 설계해야 한다. 이 작업에서 실제로 배포 이후 겪은 연쇄적인 문제들은 별도의 글에 정리했다.