지원 대화에서 흔히 보이는 패턴이 있다. 누군가 레지덴셜 프록시로 전환하고 차단률이 개선되는데, 그럼에도 일부 완고한 타겟은 계속 거부한다. IP는 깨끗하고 지리적 위치도 맞는데 요청은 여전히 챌린지를 받는다. 거의 항상 답은 헤더에 있다. 주소는 문 앞까지 데려다줄 뿐이고, 문을 통과하는 것이 브라우저처럼 보이는지는 헤더가 결정하기 때문이다.
대부분의 헤더 문제 밑에 깔린 실수는 이것을 올바르게 설정해야 할 값들의 목록으로 생각하는 것이다. 그렇지 않다. 이것은 서로, 그리고 연결에 관한 다른 모든 것과 일치해야 하는 일련의 주장들이며, 서로 맞지 않는 부분들로 만들어진 요청은 아무 노력도 하지 않은 요청보다 더 의심스럽다.
개별 값보다 일관성이 중요하다
여기서 시작하는 이유는 이후 모든 내용의 틀을 잡아주기 때문이다. 서버는 User-Agent를 단독으로 평가하지 않는다. 서버가 보는 것은 하나의 묶음이다. UA 문자열, 함께 오는 client hints, 헤더 집합과 그 순서, Accept-Language 값, 그 아래의 TLS 핸드셰이크, 그리고 전체 요청이 도착한 IP까지. 실제 브라우저는 내부적으로 일관된 묶음을 만들어낸다. 동일한 소프트웨어가 그 모든 것을 생성했기 때문이다.
스크레이퍼는 실수로 일관성 없는 묶음을 만든다. Chrome UA인데 Python HTTP 라이브러리의 헤더 집합을 가지고 오거나, Windows UA인데 Linux 도구에 속하는 TLS 지문을 가지고 있거나, 미국 출구인데 Accept-Language: de-DE를 보내거나, 브라우저라고 주장하면서 브라우저라면 요청했을 리소스를 전혀 요청하지 않는 경우다. 이 중 어느 것도 개별적으로는 “봇”이라고 말하지 않지만, 모순은 그렇게 말한다. 그리고 모순은 어떤 단일 신호보다 훨씬 안정적으로 탐지하기 쉽다. 이는 안티디텍트 브라우저에서의 디바이스와 네트워크 페어링과 같은 논리다. 모든 레이어가 같은 이야기를 해야 한다.
따라서 목표는 가장 그럴듯한 UA 문자열이 아니다. UA, 헤더, TLS 지문, 그리고 출구 주소가 모두 동일하게 그럴듯한 방문자를 묘사하는 요청이다.
실제 브라우저가 실제로 보내는 것
User-Agent만 설정한다면 이미 일관성이 깨진 것이다. 어떤 브라우저도 UA만 보내고 아무것도 보내지 않는 경우는 없기 때문이다. 페이지에 대한 최신 Chrome 요청은 최소한 다음과 같은 집합을 담고 있다.
User-Agent: Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/141.0.0.0 Safari/537.36
Accept: text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,*/*;q=0.8
Accept-Language: en-US,en;q=0.9
Accept-Encoding: gzip, deflate, br, zstd
sec-ch-ua: "Chromium";v="141", "Not?A_Brand";v="24", "Google Chrome";v="141"
sec-ch-ua-mobile: ?0
sec-ch-ua-platform: "Windows"
Sec-Fetch-Dest: document
Sec-Fetch-Mode: navigate
Sec-Fetch-Site: none
Sec-Fetch-User: ?1
Upgrade-Insecure-Requests: 1
여기서 그대로 복사하기보다 이해할 가치가 있는 것이 세 가지 있다.
Client hints는 UA와 일치해야 한다. sec-ch-ua 브랜드 목록, sec-ch-ua-platform, sec-ch-ua-mobile은 UA 문자열이 이미 주장하는 내용을 구조화된 형태로 다시 진술한 것이다. UA가 Windows에서 Chrome이라고 말하는데 platform hint는 macOS라고 하거나, 브랜드 목록의 버전이 UA의 버전과 다르다면, 인접한 두 헤더에서 스스로 모순을 만든 것이다. 최신 Chrome이라고 주장하면서 client hints를 전혀 보내지 않는 것 역시 불일치다. 실제 Chrome은 이것들을 보내기 때문이다.
Sec-Fetch 헤더는 맥락을 설명한다. 이 헤더들은 서버에게 이것이 어떤 종류의 요청인지 알려준다. 최상위 내비게이션인지, 서브리소스 fetch인지, 동일 출처 XHR인지. 페이지 로드는 직접 열었을 때 Dest: document, Mode: navigate, Site: none이고, 내부 링크를 따라갔을 때는 same-origin이다. API에 대한 XHR은 Dest: empty, Mode: cors다. 이것들을 잘못 설정하면 바로 티가 난다. 브라우저에서는 자동으로 처리되기 때문에 스크립트에서는 잊기 쉽기 때문이다.
Accept-Encoding은 지켜야 할 주장이다. br과 zstd는 클라이언트가 실제로 압축을 해제할 수 있을 때만 광고해야 한다. 일부 라이브러리는 처리하지 못하는 인코딩을 광고하는데, 이는 오류를 일으키거나 브라우저가 협상할 방식과 다른 폴백을 만들어낸다.
Accept-Language를 출구 국가에 맞춰라
이것은 프록시 작업에 특화된 것이며 가장 흔한 자체 유발 불일치다. 독일 레지덴셜 IP를 통해 출구를 만들면서 Accept-Language: en-US를 보낸다면, 미국 영어로 설정된 브라우저를 가진 방문자가 독일 가정용 회선에 앉아있다고 묘사한 셈이다. 실제로 그런 경우도 있지만, 신호가 될 만큼 드문 일이며, 더 실질적으로는 결과에 영향을 줄 수 있다. 많은 사이트가 이 헤더를 기준으로 콘텐츠를 서빙하기 때문에, 지역 타겟팅 수집 작업이 작동하는 것처럼 보이면서도 잘못된 언어를 가져올 수 있다.
언어를 출구에 묶어라. 이상적으로는 국가를 선택하는 동일한 위치에서 처리하여 둘이 절대 어긋나지 않도록 한다.
import requests
MARKETS = {
"us": "en-US,en;q=0.9",
"de": "de-DE,de;q=0.9,en;q=0.8",
"fr": "fr-FR,fr;q=0.9,en;q=0.8",
"br": "pt-BR,pt;q=0.9,en;q=0.8",
}
UA = ("Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 "
"(KHTML, like Gecko) Chrome/141.0.0.0 Safari/537.36")
def fetch(url, country):
proxy = f"http://customer-USERNAME-country-{country}:PASSWORD@p.shifter.io:443"
headers = {
"User-Agent": UA,
"Accept": "text/html,application/xhtml+xml,application/xml;q=0.9,"
"image/avif,image/webp,*/*;q=0.8",
"Accept-Language": MARKETS[country], # 항상 출구를 따라감
"Accept-Encoding": "gzip, deflate, br",
"sec-ch-ua": '"Chromium";v="141", "Not?A_Brand";v="24", '
'"Google Chrome";v="141"',
"sec-ch-ua-mobile": "?0",
"sec-ch-ua-platform": '"Windows"', # UA와 일치함
"Sec-Fetch-Dest": "document",
"Sec-Fetch-Mode": "navigate",
"Sec-Fetch-Site": "none",
"Upgrade-Insecure-Requests": "1",
}
return requests.get(url, headers=headers,
proxies={"http": proxy, "https": proxy}, timeout=20)
브라우저를 직접 구동할 때는 타임존과 로케일에도 동일한 원칙이 적용된다. 이것들은 별도로 관찰 가능하며 출구와 일치해야 한다. 도시 단위 타겟팅 작업을 할 때는 이 부분이 더 엄격해진다.
User-Agent를 무작위로 로테이션하지 마라
이것은 널리 퍼져 있지만 득보다 실이 큰 조언이다. 요청마다 무작위 UA를 고르면 실제 사용자 집단에는 존재하지 않는 패턴이 만들어진다. 하나의 IP가 1분 안에 Windows의 Chrome이었다가, Mac의 Safari였다가, Linux의 Firefox가 되는 식이다. 더 나쁜 경우는 하나의 주소가 여러 단계로 이루어진 플로우를 처리하도록 스티키 세션을 유지할 때다. 이때 플로우 도중 UA를 바꾸면 검색을 클릭한 것과 결과를 보는 것 사이에 같은 방문자가 디바이스를 바꾼 것처럼 보인다.
일관된 모델은 세션당 하나의 아이덴티티다. 그럴듯한 UA를 하나 골라서 그 세션이 지속되는 동안 유지하고, 나머지 모든 것을 그것과 일치시켜라. 전체 시스템에 걸쳐 다양성을 원한다면 요청 사이가 아니라 세션 사이에서 변화를 주고, 지금까지 존재했던 모든 브라우저에 걸쳐 균일하게가 아니라 현실적인 비율로 변화를 주어라. 버전도 최신으로 유지해야 한다. 3년 전 브라우저 버전을 주장하는 UA 자체가 이상 신호다. 실제 설치본은 업데이트되기 때문이다.
헤더는 하나의 레이어일 뿐이다
여기서의 한계를 정직하게 말할 필요가 있다. 완벽한 헤더로도 Python 클라이언트가 Chrome처럼 보이게 만들 수는 없다. 아래 레이어가 여전히 다르기 때문이다. 클라이언트가 만들어내는 TLS 핸드셰이크와 HTTP/2 설정은 그 자체로 지문을 형성하며, Chrome UA인데 지문은 Python이라고 말하는 것은 처음에 논의했던 바로 그 모순이다. 이 불일치는 TLS와 HTTP/2 지문에서 다루는 주제이며, 헤더 작업과 무관하게 방어가 두터운 일부 타겟이 단순 HTTP 클라이언트로는 접근 불가능한 상태로 남는 이유이기도 하다. 이는 헤드리스 브라우저를 사용해야 하는 근거 중 하나다.
헤더 순서도 같은 이유로 중요하다. 브라우저는 안정적인 순서로 헤더를 내보내지만, 많은 HTTP 라이브러리는 알파벳순이나 삽입 순서로 내보낸다. 이는 모든 값이 옳더라도 묶음이 자신의 출처를 드러낼 수 있는 또 다른 방식이다. 일부 클라이언트는 순서를 제어할 수 있게 해주며, 중요한 경우 실제 브라우저의 순서에 맞추는 것은 그럴 가치가 있다. 이런 신호들에 대한 더 폭넓은 목록은 데이터 추출을 막을 수 있는 지문들에 있고, 흔한 자체 유발 사례들은 탐지를 유발하는 실수들에 있다.
간단한 체크리스트
User-Agent 하나만이 아니라 완전한 브라우저 헤더 집합을 보내라. Client hints가 브랜드, 버전, 플랫폼, 모바일 플래그에서 UA와 일치하도록 하라. Sec-Fetch 헤더를 실제 요청 유형에 맞게 설정하라. Accept-Language를 출구 국가에 묶고 동일한 코드 경로에 두라. 디코딩할 수 있는 인코딩만 광고하라. 요청마다 로테이션하지 말고 세션당 하나의 아이덴티티를 유지하며, UA 버전을 최신으로 유지하라. 그다음 TLS 지문이 당신이 주장하는 브라우저와 일치하는지 확인하라. 이것은 헤더로 고칠 수 없는 레이어이기 때문이다.
이 모든 것을 했는데도 타겟이 여전히 거부한다면, 문제는 다른 곳으로 옮겨간 것이다. 페이싱, IP 평판, 또는 행동 신호 등이며, 이는 차단 회피와 강력하게 보호된 사이트 스크레이핑에서 다루는 더 넓은 주제다.
결론
헤더는 프록시가 시작하는 아이덴티티의 후반부다. 깨끗한 레지덴셜 주소는 연결을 눈에 띄지 않게 만들고, 일관된 헤더 묶음은 요청을 눈에 띄지 않게 만든다. 그리고 일관성이 전부다. 모든 주장은 다른 모든 주장과 일치해야 한다. client hints는 UA와, 언어는 출구 국가와, fetch 메타데이터는 요청 유형과, 인코딩은 실제 능력과, 그리고 이 모든 집합은 그 아래의 TLS 지문과 일치해야 한다. 세션당 하나의 아이덴티티를 안정적으로 유지하는 것이 영리한 로테이션보다 항상 낫다.
이 연결 레이어를 제공하는 것이 레지덴셜 프록시다. 국가와 도시 타겟팅이 가능한 실제 가정용 등급 주소로, 헤더가 주장하는 지리적 위치가 실제로 출구를 내는 지리적 위치와 일치하도록 하며, GB당 과금되므로 더 적게 가져오도록 요청을 조율하면 비용도 줄어든다.