파이썬으로 레지덴셜 프록시 기반 구글 랭크 트래커 만들기

구글이 num=100을 제거한 2026년 환경에서 curl_cffi와 ProxyHat 레지덴셜 프록시를 활용해 파이썬 랭크 트래커를 구축하는 방법을 코드 중심으로 설명합니다. 데이터 모델, SERP 페이지네이션, 프로덕션 강화까지.

Build a Google Rank Tracker in Python with Residential Proxies

SEO 엔지니어와 파이썬 개발자에게 순위 추적은 단순한 모니터링이 아니라 비즈니스 의사결정의 기반입니다. 하지만 구글은 2025년 9월에 num=100 파라미터를 제거했고, TLS/JA3-JA4 핑거프린팅과 IP 평판 점수를 결합한 반봇 시스템을 강화했습니다. 이 가이드에서는 파이썬으로 레지덴셜 프록시 기반 구글 랭크 트래커 만들기를 주제로, 실제 동작하는 코드와 프로덕션 패턴을 단계별로 다룹니다.

파이썬으로 레지덴셜 프록시 기반 구글 랭크 트래커 만들기: 왜 필요한가

한 번의 순위 확인은 스냅샷일 뿐입니다. 진짜 인사이트는 일별 변화 추이에서 나옵니다. 예를 들어 어느 키워드가 3일 연속으로 10위→7위→4위로 상승하는지, 반대로 2위→8위로 하락하는지 알아야 콘텐츠 전략을 조정할 수 있습니다. 이것이 일별 SERP 스냅샷이 일회성 체크보다 나은 이유입니다.

문제는 구글이 이 데이터를 쉽게 내주지 않는다는 점입니다. 공식 Custom Search JSON API는 하루 100건 무료 한도를 제공하지만, 비용이 들고 유기적 순위가 아닌 커스텀 검색 엔진 결과를 반환합니다. 대부분의 SEO 팀은 직접 SERP를 수집해 파싱하는 방식을 택합니다. 이때 필수가 되는 것이 레지덴셜 프록시입니다.

데이터 모델 설계

랭크 트래커의 핵심은 일관된 데이터 모델입니다. 최소한 다음 필드를 저장해야 합니다:

필드타입설명
keywordTEXT추적할 검색어
target_domainTEXT순위를 확인할 도메인 (예: example.com)
countryTEXTISO 국가 코드 (US, DE, KR 등)
deviceTEXTdesktop 또는 mobile
positionINTEGER유기적 순위 (1~100)
captured_atTIMESTAMP수집 시각 (UTC)

이 모델을 SQLite에 저장하면 가벼운 프로덕션 시스템을 만들 수 있습니다. 아래 코드는 테이블 생성과 삽입을 담당합니다:

import sqlite3
from datetime import datetime, timezone

DB_PATH = "rank_tracker.db"

def init_db(db_path: str = DB_PATH) -> None:
    conn = sqlite3.connect(db_path)
    conn.execute("""
        CREATE TABLE IF NOT EXISTS rankings (
            id INTEGER PRIMARY KEY AUTOINCREMENT,
            keyword TEXT NOT NULL,
            target_domain TEXT NOT NULL,
            country TEXT NOT NULL,
            device TEXT NOT NULL,
            position INTEGER,
            captured_at TEXT NOT NULL,
            UNIQUE(keyword, target_domain, country, device, captured_at)
        )
    """)
    conn.execute("""
        CREATE INDEX IF NOT EXISTS idx_keyword_domain
        ON rankings(keyword, target_domain, captured_at DESC)
    """)
    conn.commit()
    conn.close()

def save_ranking(
    keyword: str,
    target_domain: str,
    country: str,
    device: str,
    position: int | None,
    captured_at: datetime | None = None,
    db_path: str = DB_PATH,
) -> None:
    if captured_at is None:
        captured_at = datetime.now(timezone.utc)
    conn = sqlite3.connect(db_path)
    conn.execute(
        """INSERT OR REPLACE INTO rankings
           (keyword, target_domain, country, device, position, captured_at)
           VALUES (?, ?, ?, ?, ?, ?)""",
        (keyword, target_domain, country, device, position,
         captured_at.isoformat()),
    )
    conn.commit()
    conn.close()

init_db()

구글 SERP 수집: num=100 제거 후 페이지네이션

2025년 9월 이전에는 https://www.google.com/search?q=키워드&num=100 한 번의 요청으로 100개 결과를 가져올 수 있었습니다. 이제 num 파라미터가 무시되며, 기본 10개씩 페이지네이션해야 합니다. 상위 100위를 추적하려면 start=0, start=10, start=20 ... start=90으로 10번 요청해야 합니다.

각 페이지 요청마다 별도 IP를 사용하는 것이 안전합니다. 같은 IP로 연속 10페이지를 요청하면 반봇 시스템이 즉시 CAPTCHA를 트리거합니다. 이것이 레지덴셜 프록시 회전이 필수인 이유입니다.

from curl_cffi import requests as cffi_requests
from urllib.parse import quote_plus
import time
import random

PROXY_URL = "http://user-country-US-city-chicago-session-kw1:pass@gate.proxyhat.com:8080"

def fetch_serp_page(keyword: str, start: int = 0, device: str = "desktop") -> str:
    """Google 검색 결과 페이지의 HTML을 반환합니다."""
    base = "https://www.google.com/search"
    params = {
        "q": keyword,
        "start": str(start),
        "hl": "en",
        "gl": "us",
    }
    headers = {
        "Accept-Language": "en-US,en;q=0.9",
        "User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) "
                     "AppleWebKit/537.36 (KHTML, like Gecko) "
                     "Chrome/131.0.0.0 Safari/537.36",
    }
    if device == "mobile":
        headers["User-Agent"] = (
            "Mozilla/5.0 (iPhone; CPU iPhone OS 17_0 like Mac OS X) "
            "AppleWebKit/605.1.15 (KHTML, like Gecko) "
            "Version/17.0 Mobile/15E148 Safari/604.1"
        )

    resp = cffi_requests.get(
        base,
        params=params,
        headers=headers,
        proxies={"http": PROXY_URL, "https": PROXY_URL},
        impersonate="chrome",
        timeout=30,
    )
    resp.raise_for_status()
    return resp.text

def fetch_full_serp(keyword: str, max_results: int = 100) -> list[str]:
    """start=0,10,20... 으로 페이지네이션하여 HTML 페이지 리스트를 반환합니다."""
    pages = []
    for start in range(0, max_results, 10):
        html = fetch_serp_page(keyword, start=start)
        pages.append(html)
        # 페이지마다 짧은 랜덤 지연
        time.sleep(random.uniform(2.0, 5.0))
    return pages

curl_cffiimpersonate="chrome" 옵션은 브라우저의 TLS 핑거프린트(JA3/JA4)를 모방합니다. 이는 단순히 User-Agent 헤더를 바꾸는 것보다 훨씬 효과적입니다. 자세한 내용은 curl_cffi 문서를 참조하세요.

유기적 결과 파싱: 광고와 SERP 기능 건너뛰기

구글 검색결과 페이지에는 광고, 지역 팩, 피플도 애스크, 뉴스 박스 등 다양한 SERP 기능이 섞여 있습니다. 우리가 원하는 것은 순수 유기적 결과만입니다. 구글의 HTML 구조는 자주 변경되므로, CSS 선택자와 정규식을 조합해 유연하게 파싱해야 합니다.

from bs4 import BeautifulSoup
import re

def parse_organic_results(html: str) -> list[dict]:
    """유기적 검색결과를 파싱하여 리스트 반환. 광고 제외."""
    soup = BeautifulSoup(html, "html.parser")
    results = []

    # 구글 유기적 결과 컨테이너 (2026년 기준 선택자)
    divs = soup.select("div.g, div[data-sokoban-container]")

    for div in divs:
        # 광고인지 확인
        if div.find(string=re.compile("Sponsored|광고", re.I)):
            continue

        # 링크와 제목 추출
        link_tag = div.select_one("a[href^='https://'], a[href^='http://']")
        title_tag = div.select_one("h3")

        if not link_tag or not title_tag:
            continue

        href = link_tag.get("href", "")
        title = title_tag.get_text(strip=True)

        # 내부 구글 링크 제외
        if "google.com" in href and "/url?" not in href:
            continue

        results.append({
            "url": href,
            "title": title,
        })

    return results

def find_position(organic_results: list[dict], target_domain: str) -> int | None:
    """타겟 도메인의 순위를 반환. 없으면 None."""
    for i, result in enumerate(organic_results, start=1):
        if target_domain in result["url"]:
            return i
    return None

레지덴셜 프록시와 도시 수준 지역 타겟팅

구글은 IP 평판 점수, TLS 핑거프린트, 요청 패턴을 결합하여 봇을 탐지합니다. 데이터센터 IP 대역은 구글에 의해 플래그되어 있어, 데이터센터 프록시로 SERP를 수집하면 성공률이 20% 이하로 떨어질 수 있습니다. 반면 레지덴셜 프록시는 실제 ISP가 할당한 IP를 사용하므로 성공률이 95% 이상 유지됩니다.

순위는 검색 위치에 따라 다릅니다. 시카고에서 "best pizza near me"를 검색한 결과와 뉴욕에서 검색한 결과가 다릅니다. 따라서 정확한 순위 추적을 위해서는 도시 수준 지역 타겟팅이 필요합니다. ProxyHat은 사용자 이름에 -country-US-city-chicago 형식으로 지역을 지정할 수 있습니다.

또한 키워드마다 고정된 세션 ID를 부여하면, 같은 키워드는 같은 IP에서 요청되어 자연스러운 검색 패턴을 만듭니다. 이를 sticky session이라고 합니다:

import hashlib

def build_proxy_url(country: str, city: str, keyword: str) -> str:
    """키워드별 sticky session을 가진 ProxyHat 프록시 URL 생성."""
    # 키워드를 해시하여 고유 세션 ID 생성
    session_id = hashlib.md5(keyword.encode()).hexdigest()[:12]
    username = f"user-country-{country}-city-{city}-session-{session_id}"
    return f"http://{username}:pass@gate.proxyhat.com:8080"

# 예: 시카고에서 "best running shoes" 검색
proxy = build_proxy_url("US", "chicago", "best running shoes")
print(proxy)
# http://user-country-US-city-chicago-session-a1b2c3d4e5f6:pass@gate.proxyhat.com:8080

ProxyHat의 프록시 위치는 지원 국가 및 도시 목록에서 확인할 수 있습니다. 자세한 연결 방법은 ProxyHat 공식 문서를 참조하세요.

전체 랭크 트래커: curl_cffi + ProxyHat SDK 통합

지금까지의 컴포넌트를 통합하여, 키워드 리스트를 받아 일별 순위를 수집하고 SQLite에 저장하는 완전한 랭크 트래커를 만듭니다:

from curl_cffi import requests as cffi_requests
from bs4 import BeautifulSoup
from datetime import datetime, timezone
import sqlite3
import hashlib
import time
import random
import logging

logging.basicConfig(level=logging.INFO, format="%(asctime)s %(levelname)s %(message)s")
logger = logging.getLogger(__name__)

DB_PATH = "rank_tracker.db"
GATEWAY = "gate.proxyhat.com"
PORT = 8080

KEYWORDS = [
    "best running shoes",
    "marathon training plan",
    "how to tie running shoes",
]
TARGET_DOMAIN = "example.com"
COUNTRY = "US"
CITY = "chicago"
DEVICE = "desktop"

def init_db():
    conn = sqlite3.connect(DB_PATH)
    conn.execute("""
        CREATE TABLE IF NOT EXISTS rankings (
            id INTEGER PRIMARY KEY AUTOINCREMENT,
            keyword TEXT NOT NULL,
            target_domain TEXT NOT NULL,
            country TEXT NOT NULL,
            device TEXT NOT NULL,
            position INTEGER,
            captured_at TEXT NOT NULL,
            UNIQUE(keyword, target_domain, country, device, captured_at)
        )
    """)
    conn.commit()
    conn.close()

def build_proxy(keyword: str) -> str:
    session_id = hashlib.md5(keyword.encode()).hexdigest()[:12]
    username = f"user-country-{COUNTRY}-city-{CITY}-session-{session_id}"
    return f"http://{username}:pass@{GATEWAY}:{PORT}"

def fetch_page(keyword: str, start: int) -> str:
    proxy = build_proxy(keyword)
    params = {"q": keyword, "start": str(start), "hl": "en", "gl": "us"}
    headers = {
        "Accept-Language": "en-US,en;q=0.9",
        "User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) "
                     "AppleWebKit/537.36 Chrome/131.0.0.0 Safari/537.36",
    }
    resp = cffi_requests.get(
        "https://www.google.com/search",
        params=params,
        headers=headers,
        proxies={"http": proxy, "https": proxy},
        impersonate="chrome",
        timeout=30,
    )
    resp.raise_for_status()
    return resp.text

def parse_organic(html: str) -> list[dict]:
    soup = BeautifulSoup(html, "html.parser")
    results = []
    for div in soup.select("div.g"):
        if div.find(string=lambda t: t and "Sponsored" in t):
            continue
        link = div.select_one("a[href^='https://']")
        title = div.select_one("h3")
        if link and title:
            results.append({"url": link.get("href", ""), "title": title.get_text(strip=True)})
    return results

def find_position(results: list[dict], domain: str) -> int | None:
    for i, r in enumerate(results, 1):
        if domain in r["url"]:
            return i
    return None

def detect_captcha(html: str) -> bool:
    captcha_markers = ["captcha", "unusual traffic", "detected unusual traffic"]
    html_lower = html.lower()
    return any(marker in html_lower for marker in captcha_markers)

def track_keyword(keyword: str, target_domain: str, max_results: int = 100) -> int | None:
    all_results = []
    for start in range(0, max_results, 10):
        try:
            html = fetch_page(keyword, start)
            if detect_captcha(html):
                logger.warning(f"CAPTCHA detected for '{keyword}' at start={start}")
                break
            all_results.extend(parse_organic(html))
            time.sleep(random.uniform(2.0, 4.0))
        except Exception as e:
            logger.error(f"Error fetching start={start} for '{keyword}': {e}")
            break
    position = find_position(all_results, target_domain)
    logger.info(f"'{keyword}' -> position: {position}")
    return position

def save_result(keyword, target_domain, country, device, position):
    now = datetime.now(timezone.utc).isoformat()
    conn = sqlite3.connect(DB_PATH)
    conn.execute(
        "INSERT OR REPLACE INTO rankings VALUES (NULL, ?, ?, ?, ?, ?, ?)",
        (keyword, target_domain, country, device, position, now),
    )
    conn.commit()
    conn.close()

def run_tracker():
    init_db()
    for kw in KEYWORDS:
        pos = track_keyword(kw, TARGET_DOMAIN)
        save_result(kw, TARGET_DOMAIN, COUNTRY, DEVICE, pos)
        time.sleep(random.uniform(5.0, 10.0))

if __name__ == "__main__":
    run_tracker()

프로덕션 강화: 재시도, CAPTCHA 감지, 동시성 제어

프로덕션 환경에서는 네트워크 오류, CAPTCHA, 속도 제한이 일상입니다. 지수 백오프 재시도, CAPTCHA 감지 후 세션 교체, 동시성 제한을 구현해야 합니다.

import asyncio
import aiohttp
import backoff
import logging
from typing import Optional

logger = logging.getLogger(__name__)

MAX_CONCURRENT = 5  # 동시 요청 수 제한
semaphore = asyncio.Semaphore(MAX_CONCURRENT)

@backoff.on_exception(
    backoff.expo,
    (aiohttp.ClientError, asyncio.TimeoutError),
    max_tries=3,
    max_time=60,
    factor=2,
)
async def fetch_with_retry(session: aiohttp.ClientSession, url: str, proxy: str) -> str:
    async with semaphore:
        async with session.get(
            url,
            proxy=proxy,
            timeout=aiohttp.ClientTimeout(total=30),
            ssl=False,
        ) as resp:
            resp.raise_for_status()
            html = await resp.text()
            if "captcha" in html.lower():
                raise RuntimeError("CAPTCHA detected - rotate session")
            return html

async def track_batch(keywords: list[str], target_domain: str) -> dict[str, Optional[int]]:
    """여러 키워드를 비동기로 추적. 동시성은 MAX_CONCURRENT로 제한."""
    results = {}
    connector = aiohttp.TCPConnector(limit=MAX_CONCURRENT, limit_per_host=1)
    async with aiohttp.ClientSession(connector=connector) as session:
        tasks = []
        for kw in keywords:
            proxy = build_proxy(kw)
            task = asyncio.create_task(fetch_with_retry(session, kw, proxy))
            tasks.append((kw, task))

        for kw, task in tasks:
            try:
                html = await task
                organic = parse_organic(html)
                results[kw] = find_position(organic, target_domain)
            except Exception as e:
                logger.error(f"Failed for '{kw}': {e}")
                results[kw] = None
    return results

# 실행
# asyncio.run(track_batch(["best running shoes", "marathon tips"], "example.com"))

동시성 제한은 매우 중요합니다. 구글은 같은 IP 대역에서 짧은 시간에 다수의 요청이 들어오면 즉시 차단합니다. limit_per_host=1로 설정하여 같은 호스트에 대한 동시 연결을 1개로 제한하는 것이 안전합니다. 100개 키워드를 추적할 때 MAX_CONCURRENT=5로 설정하면 약 20분이 소요되지만, 차단 없이 안정적으로 완료할 수 있습니다.

순위 변동성 스무딩

구글 순위는 하루 단위로도 요동칩니다. 5위에서 12위로 하락했다가 다음 날 6위로 회복되는 일이 흔합니다. 노이즈를 줄이기 위해 7일 이동평균을 사용하는 것이 좋습니다:

import sqlite3
import pandas as pd

def get_rank_history(keyword: str, target_domain: str, days: int = 30) -> pd.DataFrame:
    """최근 N일간의 순위 기록을 DataFrame으로 반환."""
    conn = sqlite3.connect(DB_PATH)
    df = pd.read_sql_query(
        """SELECT captured_at, position FROM rankings
           WHERE keyword = ? AND target_domain = ?
           ORDER BY captured_at DESC LIMIT ?""",
        conn,
        params=(keyword, target_domain, days),
    )
    conn.close()
    df["captured_at"] = pd.to_datetime(df["captured_at"])
    df = df.sort_values("captured_at")
    df["ma_7"] = df["position"].rolling(window=7, min_periods=1).mean()
    return df

# CSV로 내보내기
def export_csv(keyword: str, target_domain: str, output_path: str):
    df = get_rank_history(keyword, target_domain)
    df.to_csv(output_path, index=False)
    print(f"Exported {len(df)} rows to {output_path}")

윤리와 한계

구글 SERP 스크래핑은 구글의 서비스 약관과 충돌할 수 있습니다. 다음 원칙을 지키세요:

  • 본인 소유 도메인과 공개 순위만 추적하세요. 경쟁사 순위를 추적하더라도 공개 정보입니다.
  • 요청 속도를 제한하세요. 키워드당 최소 2초 간격, 동시 요청은 5개 이하로 유지합니다.
  • 저용량에서는 공식 API를 우선 고려하세요. Google Custom Search API는 하루 100건 무료로 제공되며, 소규모 추적에는 충분할 수 있습니다.
  • robots.txt를 확인하세요. 구글의 robots.txt는 검색결과 페이지 크롤링을 허용하지 않을 수 있습니다.
  • GDPR 및 CCPA를 준수하세요. 개인 데이터를 수집하지 마세요.

대규모 SERP 추적이 필요하다면 SERP 추적 유스케이스를 참조하고, 웹 스크래핑 가이드에서 일반적인 스크래핑 패턴을 확인하세요. 프록시 가격은 ProxyHat 가격 페이지에서 확인할 수 있습니다.

핵심 요약

Key Takeaways:

  • 일별 SERP 스냅샷은 일회성 체크보다 순위 추세를 파악하는 데 훨씬 유용합니다.
  • 구글이 num=100을 제거했으므로 start=0,10,20... 페이지네이션이 필수입니다.
  • 레지덴셜 프록시와 도시 수준 지역 타겟팅으로 95% 이상의 성공률을 달성할 수 있습니다.
  • curl_cffiimpersonate="chrome"으로 TLS 핑거프린트를 모방하세요.
  • 키워드별 sticky session을 사용하여 자연스러운 검색 패턴을 만드세요.
  • 지수 백오프 재시도, CAPTCHA 감지, 동시성 제한으로 프로덕션 안정성을 확보하세요.
  • 7일 이동평균으로 순위 변동성 노이즈를 줄이세요.

FAQ

파이썬으로 레지덴셜 프록시 기반 구글 랭크 트래커 만들기란 무엇인가?

파이썬으로 구글 검색결과 페이지를 주기적으로 수집하고, 특정 도메인의 유기적 순위를 추출하여 시계열 데이터로 저장하는 시스템입니다. 레지덴셜 프록시를 사용하여 구글의 반봇 시스템을 우회하면서 안정적으로 SERP 데이터를 수집합니다. 일별 스냅샷을 저장하면 순위 변화 추이를 분석할 수 있습니다.

왜 레지덴셜 프록시가 구글 랭크 트래커에 필요한가?

구글은 IP 평판 점수와 TLS 핑거프린팅을 결합하여 봇을 탐지합니다. 데이터센터 IP는 구글에 의해 플래그되어 있어 성공률이 20% 이하로 떨어질 수 있습니다. 레지덴셜 프록시는 실제 ISP IP를 사용하므로 성공률이 95% 이상 유지되며, 도시 수준 지역 타겟팅으로 검색 위치에 따른 순위 차이를 정확히 반영할 수 있습니다.

구글 랭크 트래커에 어떤 프록시 유형이 가장 적합한가?

구글 SERP 수집에는 레지덴셜 프록시가 가장 적합합니다. 모바일 프록시도 높은 신뢰도를 제공하지만 비용이 더 높습니다. 데이터센터 프록시는 구글의 반봇 시스템에 의해 빠르게 차단되므로 추천하지 않습니다. 도시 수준 지역 타겟팅을 지원하는 레지덴셜 프록시를 사용하고, 키워드별 sticky session을 부여하는 것이 최적의 조합입니다.

구글 랭크 트래커에서 차단을 피하려면 어떻게 해야 하나요?

세 가지 핵심 전략이 있습니다. 첫째, curl_cffiimpersonate="chrome"으로 브라우저 TLS 핑거프린트를 모방하세요. 둘째, 키워드별 고유 세션 ID를 사용하여 같은 키워드는 같은 IP에서 요청하게 하세요. 셋째, 동시 요청을 5개 이하로 제한하고 키워드당 최소 2초 간격을 유지하세요. CAPTCHA가 감지되면 즉시 세션을 교체해야 합니다.

시작할 준비가 되셨나요?

AI 필터링으로 148개국 이상에서 5천만 개 이상의 레지덴셜 IP에 액세스하세요.

가격 보기레지덴셜 프록시
← 블로그로 돌아가기