Python으로 웹 스크래핑을 하다 보면 코드는 완벽한데 403 Forbidden이 반복됩니다. 헤더도 브라우저처럼 세팅했고, User-Agent도 최신 Chrome으로 맞췄는데 말이죠. 원인은 대부분 TLS 레이어에 있습니다. curl_cffi를 이용한 TLS 임퍼스네이션은 이 문제를 해결하는 가장 실용적인 방법입니다. 본 글에서는 JA3/JA4 핑거프린트가 어떻게 “브라우저가 아님”을 폭로하는지, curl_cffi가 어떻게 Chrome의 TLS 스택을 정밀 복제하는지, 그리고 ProxyHat 주거 프록시와 결합해 실제로 차단을 우회하는 방법까지 코드와 함께 다룹니다.
curl_cffi를 이용한 TLS 임퍼스네이션이란 무엇인가
TLS 임퍼스네이션(TLS Impersonation)은 TLS 핸드셰이크 단계에서 클라이언트가 브라우저와 동일한 ClientHello 메시지를 전송하여, 서버 측 핑거프린팅 시스템이 해당 연결을 “진짜 브라우저”로 인식하게 만드는 기술입니다. curl_cffi는 curl_cffi 프로젝트로, Python에서 curl-impersonate의 기능을 바인딩한 라이브러리입니다. 내부적으로 BoringSSL(Chrome이 사용하는 TLS 라이브러리)을 교체하여, cipher suite 순서, 확장(extensions) 정렬, 지원 곡선(supported curves), GREASE 값, 그리고 TLS 1.3 ClientHello 구조까지 Chrome과 동일하게 재현합니다.
단순히 헤더를 바꾸는 것과는 차원이 다릅니다. Cloudflare, Akamai, DataDome 같은 엔터프라이즈 WAF는 TCP 핸드셰이크 직후 TLS 레이어에서 JA3/JA4 해시를 계산하고, 이를 알려진 봇 시그니처와 비교합니다. Python requests가 보내는 ClientHello는 Chrome의 것과 전혀 다르기 때문에, 헤더를 아무리 정교하게 세팅해도 TLS 레이어에서 즉시 식별됩니다.
왜 Python requests의 ClientHello는 “브라우저가 아님”을 외치는가
J/A3 핑거프린트의 구성 요소
J/A3은 ClientHello 메시지에서 다음 요소들을 해시한 값입니다:
- TLS 버전 — ClientHello의
legacy_version필드 - Cipher suites — 클라이언트가 지원하는 암호 스위트 목록과 순서
- Extensions — SNI, ALPN, supported_groups, signature_algorithms 등의 확장과 그 순서
- Elliptic curves — 지원하는 타원곡선 목록
- EC point formats — 압축 포인트 형식
Python requests는 OpenSSL 기반의 urllib3을 사용합니다. OpenSSL의 ClientHello는 Chrome(BoringSSL)과 구조적으로 다릅니다. 구체적으로:
- Cipher suite 순서가 다릅니다. OpenSSL은
TLS_AES_256_GCM_SHA384를 먼저 제시하지만, Chrome은TLS_AES_128_GCM_SHA256을 우선합니다. - 확장 순서가 다릅니다. Chrome은
GREASE확장을 특정 위치에 삽입하지만 OpenSSL은 GREASE를 전송하지 않습니다. supported_versions확장의 배치,key_share의 곡선 선택,session_ticket의 존재 여부 등이 모두 다릅니다.
결과적으로 requests의 JA3 해시는 771,4865-4866-4867-49195-49199-...,0-23-65281-10-11-35-16-5-34-51-43-13-45-28-65037,29-23-24-25-256-257,0 같은 형태가 되며, 이는 수많은 봇 시그니처 데이터베이스에 “Python/OpenSSL 클라이언트”로 등록되어 있습니다.
핵심: 헤더를 바꾸는 것은 가짜 신분증에 이름만 바꾸는 것과 같습니다. TLS 핑거프린트는 신분증의 종류 자체를 드러냅니다.
J/A4가 설계된 이유 — 순서 안정성
Chrome 110+부터는 ClientHello의 cipher suite와 확장 순서가 연결마다 무작위로 순열(permutation)됩니다. 이는 JA3를 무력화하기 위한 Chrome의 의도적 설계입니다. 하지만 J/A4 기술 사양에 따르면, JA4는 순서에 의존하지 않고 카테고리별로 정렬된 값을 해시합니다. 예를 들어 cipher suites를 값 기준으로 오름차순 정렬한 뒤 해시하므로, Chrome의 순열에도 불구하고 동일한 JA4 해시가 산출됩니다.
이는 anti-bot 시스템이 TLS 핑거프린팅을 계속 사용할 수 있게 만드는 설계입니다. curl_cffi는 Chrome의 순열 동작까지 모방하므로, JA3와 JA4 모두에서 Chrome과 일치합니다.
curl_cffi의 작동 원리: BoringSSL 교체와 Chrome 복제
curl-impersonate 프로젝트
curl_cffi의 기반이 되는 curl-impersonate는 curl을 BoringSSL과 함께 빌드하고, Chrome/Firefox/Safari의 TLS 스택을 패치 단위에서 복제합니다. 단순히 cipher 목록을 바꾸는 것이 아니라, BoringSSL 내부의 SSL_CTX_set_strict_cipher_list 동작, 확장 직렬화 순서, ALPS(Application-Layer Protocol Settings) 등을 Chrome과 동일하게 만듭니다.
impersonate 프리셋
curl_cffi는 impersonate 파라미터를 통해 브라우저 핑거프린트를 선택합니다:
| 프리셋 | 대상 브라우저 | TLS 라이브러리 | HTTP/2 |
|---|---|---|---|
chrome | Chrome (최신) | BoringSSL | h2 with Chrome SETTINGS |
safari | Safari (최신) | Secure Transport | h2 with Safari SETTINGS |
firefox | Firefox (최신) | NSS | h2 with Firefox SETTINGS |
chrome110 | Chrome 110 (순열 포함) | BoringSSL | h2 with Chrome SETTINGS |
impersonate="chrome"을 지정하면 curl_cffi는 자동으로 Chrome의 cipher suite, 확장 순서, GREASE 값, supported curves, HTTP/2 SETTINGS 프레임(HEADER_TABLE_SIZE, ENABLE_PUSH, INITIAL_WINDOW_SIZE, MAX_CONCURRENT_STREAMS)까지 Chrome과 동일하게 설정합니다.
고급 오버라이드: ja3, akamai, extra_fp
curl_cffi는 핑거프린트를 세밀하게 제어할 수 있는 파라미터를 제공합니다:
ja3— TLS ClientHello 핑거프린트를 문자열로 직접 지정akamai— HTTP/2 SETTINGS 프레임을 직접 지정extra_fp— 추가 핑거프린트 요소(User-Agent, Accept-Language 등)를 오버라이드
이를 통해 특정 Chrome 버전이나 커스텀 핑거프린트를 정밀 재현할 수 있습니다. 하지만 대부분의 경우 impersonate="chrome"만으로 충분합니다.
왜 주거 프록시가 필수인가: 완벽한 TLS도 데이터센터 IP면 실패한다
TLS 핑거프린트를 완벽히 복제해도, 출발 IP가 데이터센터 대역이면 평판 스코어링(reputation scoring)에서 불합격 처리됩니다. Cloudflare와 DataDome은 IP 평판 데이터베이스를 통해 ASN(Autonomous System Number)을 조회하고, 데이터센터 ASN(AWS, DigitalOcean, Hetzner 등)에서 오는 요청에 기본적으로 의심 점수를 부여합니다.
반면 주거 프록시(residential proxy)는 실제 ISP에서 할당된 IP를 사용하므로, IP 평판이 일반 가정용 사용자와 동일합니다. TLS 핑거프린트 + 주거 IP의 조합이 차단 우회의 정답입니다. ProxyHat 주거 프록시는 전 세계 190개국 이상의 주거 IP를 제공하며, 프록시 가격에서 확인할 수 있듯이 GB 기반 유량 요금제로 운영됩니다.
| 조합 | TLS 핑거프린트 | IP 평판 | 차단 확률 |
|---|---|---|---|
| requests + 데이터센터 IP | ✗ (OpenSSL) | ✗ (데이터센터 ASN) | 매우 높음 (~95%) |
| curl_cffi + 데이터센터 IP | ✓ (Chrome) | ✗ (데이터센터 ASN) | 중간 (~50%) |
| requests + 주거 IP | ✗ (OpenSSL) | ✓ (주거 ASN) | 중간 (~40%) |
| curl_cffi + 주거 IP | ✓ (Chrome) | ✓ (주거 ASN) | 낮음 (~5%) |
핵심: TLS 임퍼스네이션은 “신분증이 진짜”임을 증명하고, 주거 프록시는 “주소가 진짜”임을 증명합니다. 둘 다 필요합니다.
실전 구현: curl_cffi AsyncSession + ProxyHat 주거 프록시
이제 curl_cffi의 AsyncSession을 사용해 Chrome TLS 핑거프린트로 요청을 보내고, ProxyHat 주거 프록시를 통해 라우팅하는 완전한 예제를 살펴보겠습니다. 독일(DE) 지역의 주거 IP를 사용합니다.
기본 curl 예제
curl --proxy "http://user-country-DE:pass@gate.proxyhat.com:8080" \
--ciphers "TLS_AES_128_GCM_SHA256:TLS_AES_256_GCM_SHA384:TLS_CHACHA20_POLY1305_SHA256" \
--http2 \
-H "User-Agent: Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36" \
https://httpbin.org/headers
하지만 curl만으로는 Chrome의 ClientHello를 완전히 재현할 수 없습니다. 여기서 curl_cffi가 필요합니다.
Python curl_cffi AsyncSession 예제
import asyncio
from curl_cffi.requests import AsyncSession
async def scrape_with_tls_impersonation():
proxy = "http://user-country-DE:pass@gate.proxyhat.com:8080"
async with AsyncSession(impersonate="chrome") as session:
response = await session.get(
"https://httpbin.org/headers",
proxy=proxy,
timeout=30,
)
print(f"Status: {response.status_code}")
print(f"JA3 matches Chrome: yes")
print(response.json())
asyncio.run(scrape_with_tls_impersonation())
impersonate="chrome" 한 줄로 curl_cffi는 Chrome 120의 TLS 핑거프린트를 자동 적용합니다. cipher suite 순서, 확장 정렬, GREASE, supported curves, HTTP/2 SETTINGS 프레임이 모두 Chrome과 일치합니다.
회전 및 재시도 포함 예제
대량 스크래핑에서는 IP 회전과 재시도 로직이 필수입니다. ProxyHat은 세션 ID를 통해 sticky session을 제어할 수 있습니다:
import asyncio
import random
from curl_cffi.requests import AsyncSession
PROXY_TEMPLATE = (
"http://user-country-DE-session-{session_id}:pass@gate.proxyhat.com:8080"
)
targets = [
"https://httpbin.org/headers",
"https://httpbin.org/ip",
"https://httpbin.org/user-agent",
]
async def fetch_one(session, url, session_id):
proxy = PROXY_TEMPLATE.format(session_id=session_id)
for attempt in range(3):
try:
r = await session.get(url, proxy=proxy, timeout=30)
if r.status_code == 200:
return r.json()
except Exception as e:
print(f"Attempt {attempt+1} failed for {url}: {e}")
await asyncio.sleep(2 ** attempt)
return None
async def main():
async with AsyncSession(impersonate="chrome") as session:
tasks = []
for i, url in enumerate(targets):
sid = f"sess-{i}-{random.randint(1000, 9999)}"
tasks.append(fetch_one(session, url, sid))
results = await asyncio.gather(*tasks)
for r in results:
print(r)
asyncio.run(main())
각 요청에 고유한 session_id를 부여하면 ProxyHat이 다른 주거 IP를 할당합니다. 동일한 session_id를 재사용하면 동일한 IP가 유지됩니다(sticky session). 재시도는 지수 백오프로 구성해 일시적 차단을 자연스럽게 회피합니다.
SOCKS5 사용 예제
HTTP 프록시가 차단된 환경에서는 SOCKS5를 사용할 수 있습니다:
from curl_cffi.requests import AsyncSession
async def scrape_socks5():
proxy = "socks5://user-country-DE:pass@gate.proxyhat.com:1080"
async with AsyncSession(impersonate="chrome") as session:
r = await session.get("https://httpbin.org/ip", proxy=proxy, timeout=30)
print(r.json())
asyncio.run(scrape_socks5())
흔한 실수와 엣지 케이스
1. impersonate 없이 curl_cffi 사용
impersonate 파라미터를 생략하면 curl_cffi는 일반 curl처럼 동작합니다. TLS 핑거프린트가 Chrome이 아니라 OpenSSL 기반이 되므로 의미가 없습니다. 반드시 impersonate="chrome"을 지정하세요.
2. HTTP/2 SETTINGS 불일치
TLS 레이어만 맞추고 HTTP/2 SETTINGS 프레임을 무시하면, Akamai의 HTTP/2 핑거프린팅에 걸립니다. curl_cffi의 impersonate는 HTTP/2 SETTINGS까지 함께 세팅하므로, impersonate를 사용하면 자동으로 해결됩니다.
3. 캔버스 핑거프린팅과 JS 챌린지
curl_cffi는 TLS와 HTTP/2 레이어만 모방합니다. JavaScript 챌린지(Cloudflare Turnstile, DataDome CAPTCHA 등)가 필요한 사이트에서는 curl_cffi만으로 해결할 수 없습니다. 이 경우 Playwright나 Selenium과 같은 실제 브라우저 자동화 도구를 사용해야 합니다. 웹 스크래핑 사용 사례에서 더 자세한 전략을 확인할 수 있습니다.
4. 지역 타겟팅 누락
많은 사이트가 IP 지역과 요청 헤더의 Accept-Language가 일치하는지 확인합니다. 독일 IP로 접속하면서 Accept-Language: en-US를 보내면 의심 점수가 올라갑니다. curl_cffi의 extra_fp로 Accept-Language: de-DE,de;q=0.9,en;q=0.8를 설정하세요.
한계와 윤리: curl_cffi로 풀 수 없는 것
curl_cffi는 강력하지만 한계가 명확합니다:
- JS 챌린지 불가 — Cloudflare의 JS 챌린지, DataDome의 캔버스 핑거프린팅, reCAPTCHA 등은 JavaScript 실행 환경이 필요합니다. curl_cffi는 JS를 실행하지 않으므로 이런 챌린지를 통과할 수 없습니다.
- 행동 분석 — 마우스 움직임, 스크롤 패턴, 타이핑 속도 등 행동 핑거프린팅은 실제 브라우저에서만 재현 가능합니다.
- WebSocket 핑거프린팅 — 일부 사이트는 WebSocket 연결에서도 핑거프린팅을 수행합니다.
법적 고지
TLS 임퍼스네이션과 프록시 사용은 공개 데이터에 대한 정당한 접근에 한해 사용해야 합니다. 미국의 CFAA(Computer Fraud and Abuse Act)는 인가되지 않은 접근을 금지하며, EU의 GDPR은 개인정보 처리에 제한을 둡니다. robots.txt 준수, 서비스 약관(ToS) 확인, 그리고 대상 사이트의 rate limit을 존중하는 것이 필수입니다. 보안 연구, 인가된 침투 테스트, 공개 데이터 수집의 경우에만 사용하세요.
핵심 요약
Key Takeaways:
- Python
requests의 TLS ClientHello는 OpenSSL 기반으로, Chrome(BoringSSL)과 cipher 순서, 확장 정렬, GREASE, HTTP/2 SETTINGS가 모두 다릅니다. JA3/JA4 핑거프린트로 즉시 식별됩니다.- curl_cffi의
impersonate="chrome"은 BoringSSL을 사용해 Chrome의 TLS 스택을 정밀 복제합니다. cipher suite, 확장 순서, GREASE, supported curves, HTTP/2 SETTINGS 프레임까지 일치시킵니다.- Chrome 110+의 ClientHello 순열에 대응하기 위해 JA4는 순서 독립적 설계를 채택했습니다. curl_cffi는 Chrome의 순열 동작까지 모방합니다.
- 완벽한 TLS 핑거프린트도 데이터센터 IP면 IP 평판 스코어링에서 실패합니다. 주거 프록시와 결합해야 차단 확률이 ~5% 수준으로 떨어집니다.
- curl_cffi는 JS 챌린지를 해결할 수 없습니다. Cloudflare Turnstile, DataDome CAPTCHA 등이 필요한 사이트에서는 실제 브라우저 자동화를 사용하세요.
- 공개 데이터 접근, 보안 연구, 인가된 테스트에만 사용하고, CFAA/GDPR 및 robots.txt를 준수하세요.
TLS 임퍼스네이션과 주거 프록시를 결합하면 대부분의 anti-bot 시스템을 우회할 수 있습니다. ProxyHat 문서에서 프록시 설정 가이드를 확인하고, SERP 추적과 같은 구체적인 사용 사례에서 실전 적용 방법을 참고하세요.






