지역 타겟팅 SERP 스크래핑 모범 사례: uule부터 도시별 프록시까지

같은 키워드도 도시마다 SERP가 다릅니다. uule 파라미터 생성, gl·hl·google_domain 설정, 도시별 residential proxy 로테이션, CAPTCHA 백오프까지 코드 중심으로 정리한 가이드입니다.

Geo-Targeted SERP Scraping Best Practices for Local SEO Data
이 글의 목차

같은 키워드라도 서울, 뉴욕, 베를린에서 검색하면 Google SERP 결과가 다릅니다. 로컬 팩(Local Pack), 유기적 순위, 광고 배치가 모두 지역에 따라 변하기 때문입니다. 이 가이드는 지역 타겟팅 SERP 스크래핑 모범 사례를 코드 중심으로 정리하여, SEO 엔지니어와 랭크 트래커 개발자가 도시 단위 정밀도로 SERP 데이터셋을 구축할 수 있도록 돕습니다.

국가 수준 타겟팅만으로는 로컬 SEO 분석에 한계가 있습니다. "뉴욕의 배관공"과 "시애틀의 배관공"은 같은 gl=us로 구분할 수 없습니다. 도시/지역구 단위의 정밀 타겟팅이 필요하며, 이를 위해 uule 파라미터와 residential proxy의 조합이 필수적입니다.

지역 타겟팅 SERP 스크래핑 모범 사례: 왜 같은 검색어가 지역마다 다른 결과를 반환하는가

Google은 사용자의 IP 주소, 검색 언어, 계정 설정, 위치 정보를 종합하여 검색 결과를 개인화합니다. Google 검색 알고리즘은 지역적 신호를 강하게 반영하므로, 동일 키워드라도 검색자의 물리적 위치에 따라 로컬 팩 구성, 비즈니스 등록 정보, 지도 결과가 크게 달라집니다.

예를 들어 "plumber near me"를 뉴욕에서 검색하면 뉴욕 지역 배관업체가 로컬 팩에 노출되지만, 로스앤젤레스에서 검색하면 완전히 다른 업체 목록이 나타납니다. 국가 수준(gl=us)만 설정하면 이 두 도시의 차이를 구분할 수 없습니다. 도시/지역구 정밀도가 필요한 이유입니다.

국가 수준 vs 도시 수준 타겟팅

타겟팅 수준파라미터정밀도적용 사례
국가gl=us국가 전체국가별 랭크 추적
도시uule=...도시/지역구로컬 SEO 분석
도시 + IP 일치uule + residential proxy최고 정밀도프랜차이즈 SEO, 경쟁 분석

핵심 현지화 파라미터 이해

Google SERP의 지역 타겟팅은 여러 파라미터의 조합으로 이루어집니다. 각 파라미터의 역할을 정확히 이해하고 서로 일치시켜야 합니다.

gl, hl, google_domain, lr

  • gl — 검색 결과의 국가를 지정합니다 (예: gl=us, gl=de, gl=jp).
  • hl — Google 인터페이스 언어를 설정합니다 (예: hl=en, hl=ko, hl=ja).
  • google_domain — 검색 요청을 보낼 Google 도메인입니다 (예: google.com, google.de, google.co.jp).
  • lr — 검색 결과의 언어를 제한합니다 (예: lr=lang_en, lr=lang_ko).

이 파라미터들은 서로 일치해야 합니다. gl=us인데 google_domain=google.de로 요청을 보내면 Google이 신호 충돌을 감지하여 결과 품질이 떨어지거나 CAPTCHA가 트리거될 수 있습니다.

uule 파라미터: 도시 단위 정밀 타겟팅

uule은 Google의 지리적 위치 인코딩 파라미터입니다. canonical place name을 base64로 인코딩하여 특정 도시/지역의 SERP를 요청할 수 있습니다. 형식은 다음과 같습니다:

uule = "w+CAIQICI" + length_key + base64(canonical_name)

여기서 length_key는 base64 인코딩된 문자열의 길이를 단일 ASCII 문자로 표현한 것입니다. Python으로 구현하면 다음과 같습니다:

import base64

def build_uule(canonical_name: str) -> str:
    """Build Google uule parameter from a canonical place name.
    
    Args:
        canonical_name: e.g. "New York,New York,United States"
    Returns:
        uule string, e.g. "w+CAIQICIkTmV3IFlvcmssTmV3IFlvcmssVW5pdGVkIFN0YXRlcw=="
    """
    encoded = base64.b64encode(canonical_name.encode("utf-8")).decode("ascii")
    # length_key: offset 63 maps base64 length to a printable ASCII char
    length_key = chr(len(encoded) + 63)
    return f"w+CAIQICI{length_key}{encoded}"

# Example: pin search to Brooklyn, NY
uule = build_uule("Brooklyn,New York,United States")
print(uule)
# w+CAIQICIkQnJvb2tseW4sTmV3IFlvcmssVW5pdGVkIFN0YXRlcw==

주의: canonical name은 "City,State,Country" 형식을 따르며, Google의 canonical name 데이터베이스와 정확히 일치해야 합니다. 쉼표 뒤에는 공백이 없습니다. 잘못된 이름은 결과를 왜곡하거나 무시됩니다.

pws=0: 비개인화 기준선

pws=0 파라미터는 개인화된 검색 결과를 비활성화합니다. SERP 스크래핑에서는 일관된 비개인화 기준선이 필요하므로 항상 pws=0을 추가하는 것이 모범 사례입니다.

Python으로 지역 타겟팅 SERP 스크래핑 구현하기

이제 실전 코드를 살펴보겠습니다. curl_cffi 라이브러리를 사용해 Chrome 브라우저를 모방(impersonate)하고, ProxyHat residential proxy를 통해 요청을 보냅니다.

curl 예제: uule + gl/hl + 프록시

# Geo-targeted SERP request via ProxyHat residential proxy
curl -s "https://www.google.com/search?q=plumber&gl=us&hl=en&pws=0&uule=w+CAIQICIkQnJvb2tseW4sTmV3IFlvcmssVW5pdGVkIFN0YXRlcw==" \
  --proxy "http://user-country-US-city-newyork:PASSWORD@gate.proxyhat.com:8080" \
  -H "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" \
  -H "Accept-Language: en-US,en;q=0.9"

Python: uule 생성 + curl_cffi Chrome 모방 + residential proxy 로테이션

import base64
import itertools
from curl_cffi import requests as cffi_requests

PROXYHAT_GATEWAY = "gate.proxyhat.com:8080"
PROXYHAT_USER = "YOUR_USER"
PROXYHAT_PASS = "YOUR_PASS"

# --- Proxy rotation per locale ---
proxy_cycle = itertools.cycle([
    ("US", "newyork",    "New York,New York,United States"),
    ("US", "losangeles", "Los Angeles,California,United States"),
    ("US", "chicago",    "Chicago,Illinois,United States"),
    ("DE", "berlin",     "Berlin,Berlin,Germany"),
    ("JP", "tokyo",      "Tokyo,Tokyo,Japan"),
])

def build_uule(canonical_name: str) -> str:
    encoded = base64.b64encode(canonical_name.encode("utf-8")).decode("ascii")
    length_key = chr(len(encoded) + 63)
    return f"w+CAIQICI{length_key}{encoded}"

def scrape_serp(query: str, country: str, city_slug: str, canonical: str) -> str:
    uule = build_uule(canonical)
    proxy_url = (
        f"http://{PROXYHAT_USER}-country-{country}-city-{city_slug}:"
        f"{PROXYHAT_PASS}@{PROXYHAT_GATEWAY}"
    )
    
    params = {
        "q": query,
        "gl": country.lower(),
        "hl": "en" if country == "US" else "de" if country == "DE" else "ja",
        "pws": "0",
        "uule": uule,
        "num": "20",
    }
    
    resp = cffi_requests.get(
        "https://www.google.com/search",
        params=params,
        proxies={"http": proxy_url, "https": proxy_url},
        impersonate="chrome",
        timeout=15,
    )
    resp.raise_for_status()
    return resp.text

# Rotate across locales
for country, city_slug, canonical in proxy_cycle:
    html = scrape_serp("plumber near me", country, city_slug, canonical)
    print(f"[{country}/{city_slug}] {len(html)} bytes received")
    break  # first request only; remove for full rotation

Selectolax로 유기적 결과 및 로컬 팩 파싱

from selectolax.parser import HTMLParser
from dataclasses import dataclass
from typing import List

@dataclass
class OrganicResult:
    position: int
    title: str
    url: str
    snippet: str

@dataclass
class LocalPackResult:
    name: str
    rating: str
    address: str

def parse_organic(html: str) -> List[OrganicResult]:
    tree = HTMLParser(html)
    results: List[OrganicResult] = []
    
    for i, node in enumerate(tree.css("div.g"), start=1):
        title_node = node.css_first("h3")
        link_node = node.css_first("a[href]")
        snippet_node = node.css_first("div[data-sncf], div.VwiC3b, span.aCOpRe")
        
        if not title_node or not link_node:
            continue
        
        results.append(OrganicResult(
            position=i,
            title=title_node.text(strip=True),
            url=link_node.attributes.get("href", ""),
            snippet=snippet_node.text(strip=True) if snippet_node else "",
        ))
    return results

def parse_local_pack(html: str) -> List[LocalPackResult]:
    tree = HTMLParser(html)
    results: List[LocalPackResult] = []
    
    for node in tree.css("div[data-cid], div.eTBCqc"):
        name_node = node.css_first("span.OSrXXb, div.dbg0p")
        rating_node = node.css_first("span.MWvNee, span.BTtC6e")
        addr_node = node.css_first("span.text, div.W4r3Bb")
        
        if not name_node:
            continue
        
        results.append(LocalPackResult(
            name=name_node.text(strip=True),
            rating=rating_node.text(strip=True) if rating_node else "",
            address=addr_node.text(strip=True) if addr_node else "",
        ))
    return results

# Usage
organic = parse_organic(html)
local_pack = parse_local_pack(html)

for r in organic[:5]:
    print(f"#{r.position} {r.title} — {r.url}")
for lp in local_pack[:3]:
    print(f"  📍 {lp.name} | ⭐ {lp.rating} | {lp.address}")

주의: Google의 HTML 구조는 자주 변경됩니다. 위 셀렉터는 2025년 기준으로 작동하지만, 주기적인 업데이트가 필요합니다. CSS 셀렉터에 의존하기보다 data-* 속성을 우선 활용하는 것이 안정적입니다.

Sticky Session: 다중 페이지 SERP 수집

같은 도시의 2페이지, 3페이지를 수집할 때는 동일한 IP를 유지해야 합니다. IP가 바뀌면 Google이 새 세션으로 간주하여 페이지네이션 컨텍스트가 끊깁니다. ProxyHat의 sticky session 기능을 사용하면 됩니다:

import base64
import time
from curl_cffi import requests as cffi_requests

def build_uule(canonical_name: str) -> str:
    encoded = base64.b64encode(canonical_name.encode("utf-8")).decode("ascii")
    return f"w+CAIQICI{chr(len(encoded) + 63)}{encoded}"

def scrape_multi_page(query: str, canonical: str, country: str,
                      city_slug: str, max_pages: int = 3):
    """Scrape multiple SERP pages with a sticky session (same IP)."""
    session_id = f"serp-{country}-{city_slug}-001"
    proxy_url = (
        f"http://YOUR_USER-country-{country}-city-{city_slug}"
        f"-session-{session_id}:YOUR_PASS@gate.proxyhat.com:8080"
    )
    uule = build_uule(canonical)
    
    all_results = []
    for page in range(max_pages):
        start = page * 10  # Google uses start=0,10,20...
        params = {
            "q": query,
            "gl": country.lower(),
            "hl": "en",
            "pws": "0",
            "uule": uule,
            "start": str(start),
        }
        resp = cffi_requests.get(
            "https://www.google.com/search",
            params=params,
            proxies={"http": proxy_url, "https": proxy_url},
            impersonate="chrome",
            timeout=15,
        )
        if resp.status_code == 429:
            print(f"  [!] 429 on page {page+1}, backing off...")
            time.sleep(30 * (page + 1))
            continue
        resp.raise_for_status()
        all_results.append(resp.text)
        print(f"  Page {page+1}: {len(resp.text)} bytes")
        time.sleep(2)  # polite delay
    
    return all_results

pages = scrape_multi_page(
    "plumber near me",
    "New York,New York,United States",
    "US", "newyork"
)
print(f"Collected {len(pages)} pages")

CAPTCHA / 429 백오프 및 재시도 전략

대량 SERP 스크래핑에서 429(Too Many Requests)와 503(CAPTCHA) 응답은 불가피합니다. 지수 백오프와 회로 차단기 패턴을 적용해야 합니다:

import time
import random
from functools import wraps

def retry_with_backoff(max_retries=5, base_delay=2, max_delay=120):
    """Exponential backoff with jitter for SERP scraping."""
    def decorator(func):
        @wraps(func)
        def wrapper(*args, **kwargs):
            for attempt in range(max_retries):
                try:
                    result = func(*args, **kwargs)
                    if result.get("status_code") in (429, 503):
                        delay = min(
                            base_delay * (2 ** attempt) + random.uniform(0, 3),
                            max_delay
                        )
                        print(f"  [!] {result['status_code']} — retry in "
                              f"{delay:.1f}s (attempt {attempt+1}/{max_retries})")
                        time.sleep(delay)
                        continue
                    return result
                except Exception as e:
                    delay = min(
                        base_delay * (2 ** attempt) + random.uniform(0, 3),
                        max_delay
                    )
                    print(f"  [!] Error: {e} — retry in {delay:.1f}s")
                    time.sleep(delay)
            raise RuntimeError(f"Max retries ({max_retries}) exceeded")
        return wrapper
    return decorator

# Circuit breaker: stop after N consecutive failures per country
class CountryCircuitBreaker:
    def __init__(self, threshold=5, reset_timeout=300):
        self.failures = {}
        self.threshold = threshold
        self.reset_timeout = reset_timeout  # 300 seconds = 5 minutes
    
    def record_failure(self, country: str):
        self.failures[country] = self.failures.get(country, 0) + 1
    
    def record_success(self, country: str):
        self.failures[country] = 0
    
    def is_open(self, country: str) -> bool:
        return self.failures.get(country, 0) >= self.threshold

breaker = CountryCircuitBreaker(threshold=5, reset_timeout=300)

# Per-country proxy pool with fallback
PROXY_POOLS = {
    "US": ["newyork", "losangeles", "chicago", "houston", "phoenix"],
    "DE": ["berlin", "munich", "hamburg", "frankfurt"],
    "JP": ["tokyo", "osaka", "yokohama", "nagoya"],
}

이 패턴을 사용하면 한 국가의 프록시 풀에서 연속 5회 실패 시 해당 국가 요청을 5분간 중단하고, 다른 국가 풀로 전환할 수 있습니다. 99.9% uptime을 목표로 하는 서비스에서는 이러한 회로 차단기가 필수적입니다.

Node.js 예제: ProxyHat으로 지역 타겟팅 SERP 수집

const https = require("https");
const { HttpsProxyAgent } = require("https-proxy-agent");

function buildUule(canonicalName) {
    const encoded = Buffer.from(canonicalName, "utf-8").toString("base64");
    const lengthKey = String.fromCharCode(encoded.length + 63);
    return `w+CAIQICI${lengthKey}${encoded}`;
}

async function scrapeSerp(query, country, citySlug, canonical) {
    const uule = buildUule(canonical);
    const proxyUrl = `http://YOUR_USER-country-${country}-city-${citySlug}:YOUR_PASS@gate.proxyhat.com:8080`;
    const agent = new HttpsProxyAgent(proxyUrl);
    
    const params = new URLSearchParams({
        q: query,
        gl: country.toLowerCase(),
        hl: "en",
        pws: "0",
        uule: uule,
        num: "20",
    });
    
    return new Promise((resolve, reject) => {
        const url = `https://www.google.com/search?${params.toString()}`;
        https.get(url, {
            agent,
            headers: {
                "User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36",
                "Accept-Language": "en-US,en;q=0.9",
            },
        }, (res) => {
            let data = "";
            res.on("data", (chunk) => data += chunk);
            res.on("end", () => resolve({ status: res.statusCode, body: data }));
            res.on("error", reject);
        });
    });
}

// Execute
scrapeSerp("plumber near me", "US", "newyork", "New York,New York,United States")
    .then(r => console.log(`[${r.status}] ${r.body.length} bytes`))
    .catch(e => console.error(e));

ProxyHat으로 지역 타겟팅 프록시 설정하기

ProxyHat은 residential, mobile, datacenter 프록시를 제공하며, 사용자 이름에 국가 및 도시 타겟팅 플래그를 포함할 수 있습니다. 지원 위치 목록에서 사용 가능한 국가와 도시를 확인하세요.

프록시 유형별 비교

프록시 유형신뢰도평균 지연지역 정밀도추천 용도
Residential높음~200ms도시/지역구SERP 스크래핑 (최우선)
Mobile최고~300ms도시모바일 SERP, 민감 대상
Datacenter낮음~50ms국가대량 비 SERP 수집

SERP 스크래핑에서는 residential 프록시가 가장 안정적인 선택입니다. Google은 datacenter IP를 쉽게 감지하므로, gate.proxyhat.com:8080을 통해 residential IP를 사용하는 것이 핵심입니다. 프록시 가격은 트래픽 기반이며, SERP 수집에 최적화된 플랜을 확인할 수 있습니다.

ProxyHat SDK를 사용하면 프록시 관리를 더 간단하게 할 수 있습니다. ProxyHat 문서에서 SDK 설치 및 사용법을 참조하세요. 웹 스크래핑 사용 사례SERP 추적 사용 사례 페이지에서 더 많은 예제를 확인할 수 있습니다.

흔한 실수와 엣지 케이스

1. 파라미터 불일치

gl=us인데 IP가 독일에서 나오면 Google이 신호 충돌을 감지합니다. 항상 gl, google_domain, uule, 그리고 프록시 IP 지역이 일치해야 합니다. ProxyHat 사용자 이름에 -country-US-city-newyork 플래그를 추가하면 IP 지역과 파라미터를 자동으로 일치시킬 수 있습니다.

2. uule canonical name 오타

"New York City, NY, USA"가 아니라 "New York,New York,United States" 형식을 사용해야 합니다. Google의 canonical name 데이터베이스와 정확히 일치해야 하며, 쉼표 뒤에는 공백이 없습니다.

3. 개인화 신호 누락

pws=0을 빼면 검색 이력과 계정 상태에 따라 결과가 달라집니다. 비개인화 기준선이 필요하면 항상 포함하세요.

4. robots.txt 및 ToS 준수

Google의 robots.txt 가이드라인을 확인하고, Google 서비스 약관을 준수해야 합니다. 공개 데이터 수집이더라도 대량 자동화 요청은 ToS 위반으로 간주될 수 있으므로, 적정 요청 속도(예: 요청 간 2초 이상)를 유지하고 상업적 이용 전 법무 검토를 권장합니다.

5. HTML 구조 변경

Google은 주기적으로 SERP HTML 구조를 변경합니다. 셀렉터 기반 파서는 월 1회 이상 검증해야 하며, data-* 속성 기반 파싱이 더 안정적입니다. 100개 이상의 동시 세션을 유지하는 대규모 수집에서는 HTML 변경 모니터링을 자동화하는 것이 좋습니다.

핵심 요약

  • uule + gl/hl + pws=0 조합으로 도시 단위 비개인화 SERP를 수집하세요.
  • residential proxy를 사용하고 -country-XX-city-YY 플래그로 IP 지역을 uule과 일치시키세요.
  • sticky session으로 다중 페이지 수집 시 동일 IP를 유지하세요.
  • 지수 백오프 + 회로 차단기로 429/503 응답에 대응하세요.
  • canonical name은 Google 데이터베이스와 정확히 일치해야 합니다.
  • robots.txt와 ToS를 항상 준수하세요.

자주 묻는 질문

지역 타겟팅 SERP 스크래핑 모범 사례란 무엇인가?

지역 타겟팅 SERP 스크래핑 모범 사례는 uule 파라미터로 특정 도시/지역구의 검색 결과를 수집하고, gl·hl·google_domain 파라미터를 해당 지역과 일치시키며, pws=0으로 개인화를 비활성화한 후 residential proxy를 통해 IP 지역까지 맞추는 방법론입니다. 이를 통해 로컬 SEO 분석에 필요한 도시 단위 정밀도의 일관된 SERP 데이터셋을 구축할 수 있습니다.

지역 타겟팅 SERP 스크래핑이 프록시 사용자에게 왜 중요한가?

Google은 IP 주소, uule, gl 파라미터를 교차 검증하여 지역 일치성을 확인합니다. 파라미터는 미국으로 설정했는데 IP가 독일에서 나오면 신호 충돌이 감지되어 CAPTCHA가 트리거되거나 결과 품질이 저하됩니다. residential proxy로 IP 지역을 uule과 일치시키면 차단 없이 안정적으로 도시 단위 SERP를 수집할 수 있으므로 프록시 선택이 결과 품질에 직접적인 영향을 미칩니다.

지역 타겟팅 SERP 스크래핑에 어떤 프록시 유형이 가장 적합한가?

residential 프록시가 가장 적합합니다. datacenter IP는 Google이 쉽게 감지하여 차단률이 높고, mobile 프록시는 신뢰도가 높지만 지연이 ~300ms로 상대적으로 느립니다. residential 프록시는 ~200ms 지연으로 도시/지역구 단위 타겟팅을 지원하며 차단률이 낮아 SERP 스크래핑의 최우선 선택입니다. ProxyHat의 gate.proxyhat.com:8080 게이트웨이에서 -country-XX-city-YY 플래그로 도시를 지정할 수 있습니다.

지역 타겟팅 SERP 스크래핑에서 차단을 어떻게 피하는가?

차단을 피하려면 지수 백오프 재시도, 회로 차단기 패턴, 국가별 프록시 풀 분리, 요청 간 2초 이상 지연, Chrome 브라우저 모방(impersonate), sticky session으로 다중 페이지 일관성 유지를 조합해야 합니다. 429나 503 응답 시 즉시 30~120초 백오프하고, 한 국가에서 연속 5회 실패 시 5분간 해당 국가 요청을 중단하는 회로 차단기를 적용하는 것이 효과적입니다.

시작할 준비가 되셨나요?

148개국 이상의 주거용, ISP, 모바일 프록시. 무료 계정을 만드세요.

무료 계정 만들기
← 블로그로 돌아가기