curl_cffi를 활용한 TLS 임퍼스네이션은 2026년 웹 스크래핑 엔지니어와 안티봇 연구자에게 거의 필수적인 기술이 되었습니다. Cloudflare, Akamai, DataDome, PerimeterX 같은 엔터프라이즈 WAF는 이제 HTTP 헤더나 IP 평판만 보지 않습니다. TLS 핸드셰이크 단계에서 클라이언트가 보내는 ClientHello 메시지의 바이트 시퀀스를 분석해 "이것이 진짜 Chrome인지, Python requests인지"를 판별합니다. 이 글에서는 JA3/JA4 핑거프린트가 어떻게 생성되고, curl_cffi가 어떻게 BoringSSL을 통해 Chrome의 정확한 TLS 스택을 복제하며, 왜 완벽한 TLS 핑거프린트만으로는 부족한지를 실전 코드와 함께 다룹니다.
curl_cffi를 활용한 TLS 임퍼스네이션: 왜 Python requests는 걸릴까?
대부분의 Python 스크래퍼는 requests 또는 httpx 라이브러리를 사용합니다. 이들은 내부적으로 OpenSSL 기반의 urllib3/httplib를 사용하며, TLS 핸드셰이크 시 OpenSSL의 기본 ClientHello를 전송합니다. 문제는 이 ClientHello가 Chrome, Firefox, Safari의 그것과 전혀 다르다는 점입니다.
TLS ClientHello에는 다음 요소들이 포함됩니다:
- Cipher suites 목록과 순서: OpenSSL은 알파벳/우선순위 순으로 정렬하지만, Chrome은 자체적인 순서를 사용합니다.
- TLS extensions 순서: extension의 등장 순서 자체가 핑거프린트의 일부입니다.
- Supported groups (elliptic curves): x25519, secp256r1, secp384r1의 순서와 조합.
- GREASE values: Google이 TLS 스택의 유연성을 테스트하기 위해 삽입하는 더미 값들.
- Signature algorithms: 인증서 서명 알고리즘 목록과 순서.
- TLS 1.3 ClientHello 전체 구조: key_share extension의 curve 조합, PSK 유무 등.
RFC 8446 (TLS 1.3)은 ClientHello의 구조를 정의하지만, cipher suite와 extension의 순서까지 규정하지는 않습니다. 이 자유도 때문에 각 TLS 라이브러리마다 고유한 ClientHello 패턴이 만들어지고, 이것이 바로 JA3 핑거프린트의 원료가 됩니다.
JA3 핑거프린트란 무엇인가
JA3는 Salesforce의 연구팀이 2017년에 발표한 TLS 핑거프린팅 기법으로, ClientHello의 다음 필드들을 MD5 해시로 변환합니다:
SSLVersion,Cipher,SSLExtension,EllipticCurve,EllipticCurvePointFormat
예를 들어 Python requests (OpenSSL 3.x)의 전형적인 JA3는 다음과 같은 형태를 가집니다:
771,4865-4866-4867-49195-49199-...,0-23-65281-10-11-35-16-5-34-51-43-13-45-28-21,...
반면 Chrome 120의 JA3는 GREASE 값이 섞여 들어가고 cipher suite 순서가 완전히 다릅니다. 안티봇 시스템은 이 해시값을 데이터베이스와 비교해 "이 연결이 Python 스크립트에서 온 것"을 1초 안에 판정합니다. Salesforce JA3 원본 논문에 따르면, 핑거프린트 매칭은 DPI(Deep Packet Inspection) 단계에서 수행되므로 애플리케이션 계층에서 헤더를 위장하는 것만으로는 우회할 수 없습니다.
JA4: 순서 안정성을 강화한 차세대 핑거프린트
Chrome 110+부터는 ClientHello의 cipher suite와 extension 순서를 연결마다 무작위로 섞는 permutation 기능이 도입되었습니다. 이것은 JA3를 무력화하려는 Google의 의도적 설계 변경이었습니다. 그러나 FoxIO의 John Althouse는 이에 대응하여 JA4를 설계했습니다. JA4는 필드 값을 정렬한 후 해시를 계산하므로, 순서가 바뀌어도 동일한 해시값을 생성합니다. 즉, Chrome의 permutation은 JA3를 회피하지만 JA4에는 여전히 잡힙니다.
JA4의 구조는 다음과 같습니다:
JA4 = q(t)(d)_(ciphers_sorted)_(extensions_sorted)_(sig_algs_sorted)
q는 QUIC 유무, t는 TLS 버전, d는 SNI 존재 여부를 나타냅니다. cipher와 extension은 정렬 후 해시되므로, 동일한 클라이언트 소프트웨어는 항상 동일한 JA4를 생성합니다. curl_cffi가 Chrome을 정확히 모방하면, JA4 관점에서 Chrome과 구분할 수 없습니다.
curl_cffi가 Chrome의 TLS 핑거프린트를 복제하는 방법
curl_cffi는 curl-impersonate 프로젝트를 Python 바인딩으로 감싼 라이브러리입니다. curl-impersonate는 curl을 BoringSSL(Google이 Chrome에 사용하는 TLS 라이브러리)과 함께 빌드하고, Chrome/Firefox의 정확한 ClientHello를 재현하도록 패치합니다. 핵심 작동 원리는 다음과 같습니다:
BoringSSL 교체와 Chrome 프리셋
curl_cffi는 OpenSSL 대신 BoringSSL을 사용합니다. 이것만으로도 cipher suite 목록과 곡선 지원이 Chrome에 가까워지지만, 충분하지 않습니다. curl-impersonate는 Chrome이 ClientHello를 구성하는 정확한 순서를 하드코딩합니다:
- GREASE cipher suite를 첫 번째 위치에 배치 (예:
0x0a0a) - TLS 1.3 cipher suites (
TLS_AES_128_GCM_SHA256,TLS_AES_256_GCM_SHA384,TLS_CHACHA20_POLY1305_SHA256)를 Chrome과 동일한 순서로 - extension 순서:
server_name→extended_master_secret→renegotiation_info→supported_groups→ ... key_share에 x25519와 secp256r1을 Chrome처럼 동시 포함application_layer_protocol_negotiation에h2,http/1.1을 Chrome 순서로
curl_cffi의 impersonate 파라미터는 이 모든 것을 프리셋으로 제공합니다:
from curl_cffi import requests
# Chrome 120 핑거프린트로 요청
r = requests.get("https://tls.peet.ws/api/all", impersonate="chrome")
print(r.json()["tls"]["ja3"])
print(r.json()["tls"]["ja4"])
impersonate="chrome"은 현재 최신 Chrome 프리셋을 사용합니다. 특정 버전을 지정할 수도 있습니다: impersonate="chrome120", impersonate="chrome116", impersonate="firefox102" 등. 각 프리셋은 해당 브라우저 버전의 정확한 ClientHello, HTTP/2 SETTINGS 프레임, 우선순위 헤더를 재현합니다.
HTTP/2 SETTINGS 프레임과 Akamai 핑거프린트
TLS 핑거프린트만 맞추면 충분하지 않습니다. Chrome은 HTTP/2 연결 시작 시 특정 SETTINGS 프레임을 전송하며, 이것도 핑거프린팅 대상입니다. Akamai의 안티봇 시스템은 HTTP/2 SETTINGS의 필드 값과 순서를 분석합니다:
HEADER_TABLE_SIZE: Chrome은 65536, Firefox는 4096ENABLE_PUSH: 0 (Chrome은 push를 비활성화)INITIAL_WINDOW_SIZE: 6291456 (Chrome 고유값)MAX_HEADER_LIST_SIZE: 262144
curl_cffi의 ja3, akamai, extra_fp 파라미터를 사용하면 이 값을 세밀하게 조정할 수 있습니다:
from curl_cffi import requests
r = requests.get(
"https://tls.peet.ws/api/all",
impersonate="chrome120",
ja3="771,4865-4866-4867-49195-49199-52393-52392-...,0-23-65281-10-11-35-16-5-34-51-43-13-45-28-21,...",
akamai="1:65536;2:0;4:6291456;6:262144|15663105|0|m,a,s,p",
extra_fp={"tls_signature_algorithms": "0401-0501-0201-0403-0503-0203-0807-0808-0809-080a-080b"}
)
이 수동 오버라이드는 특정 타겟 사이트가 최신 Chrome 프리셋과 미묘하게 다른 핑거프린트를 요구할 때 유용합니다. 하지만 대부분의 경우 impersonate="chrome" 프리셋만으로 충분합니다.
왜 완벽한 TLS 핑거프린트만으로는 부족한가: IP 평판의 역할
여기가 많은 엔지니어가 실수하는 지점입니다. curl_cffi로 Chrome과 동일한 JA3/JA4를 만들어냈다고 해서 안티봇을 통과하는 것은 아닙니다. Cloudflare Bot Management와 DataDome은 TLS 핑거프린트를 한 가지 신호로만 사용합니다. 다른 신호들은 다음과 같습니다:
| 신호 | 가중치 | 설명 |
|---|---|---|
| IP 평판 (ASN, 유형) | 매우 높음 | 데이터센터 ASN(AWS, GCP, Azure, DigitalOcean)은 기본적으로 의심받습니다. |
| TLS 핑거프린트 (JA3/JA4) | 높음 | 비브라우저 클라이언트는 즉시 차단. |
| HTTP/2 핑거프린트 | 중간 | SETTINGS 프레임, WINDOW_UPDATE 패턴. |
| 행동 패턴 | 중간 | 요청 간격, 마우스/키보드 이벤트(브라우저 환경에서만). |
| JS challenge 해결 | 조건부 | Cloudflare Turnstile 등은 JS 실행을 요구. |
데이터센터 IP에서 완벽한 Chrome TLS 핑거프린트로 접속하면, 안티봇 시스템은 "이 사람이 Chrome을 사용하고 있지만 IP가 AWS us-east-1이다"라는 모순을 감지합니다. 실제 Chrome 사용자가 AWS IP에서 접속할 일은 거의 없기 때문입니다. 이것이 주거형 프록시(residential proxy)가 필수인 이유입니다. 주거형 IP는 실제 ISP 고객에게 할당된 IP이므로, IP 평판 관점에서 일반 사용자와 구분되지 않습니다.
ProxyHat의 주거형 프록시 네트워크는 이 요구사항에 정확히 부합합니다. ProxyHat 프록시 위치 페이지에서 확인할 수 있듯, 190개국 이상의 주거형 IP 풀을 제공하며, 국가 및 도시 단위 지역 타겟팅을 지원합니다.
실전 구현: curl_cffi AsyncSession + ProxyHat 주거형 프록시
이제 curl_cffi의 비동기 세션을 ProxyHat 주거형 프록시와 결합하는 완전한 예제를 살펴보겠습니다. 이 예제는 독일 IP에서 Chrome 120 핑거프린트로 SERP 데이터를 수집하는 시나리오입니다.
기본 설정: AsyncSession + 주거형 프록시
import asyncio
from curl_cffi.requests import AsyncSession
async def scrape_with_tls_impersonation():
async with AsyncSession(impersonate="chrome120") as session:
# ProxyHat 주거형 프록시 (독일, 세션 고정)
proxy = "http://user-country-DE-session-serp01:PASSWORD@gate.proxyhat.com:8080"
response = await session.get(
"https://www.google.com/search?q=python+web+scraping",
proxy=proxy,
timeout=30,
)
print(f"Status: {response.status_code}")
print(f"JA4: {response.headers.get('x-ja4', 'N/A')}")
return response.text
asyncio.run(scrape_with_tls_impersonation())
여기서 주의할 점은 impersonate="chrome120"과 proxy 파라미터를 함께 사용하면, curl_cffi가 프록시 터널을 통해 TLS 핸드셰이크를 수행한다는 것입니다. 즉, 대상 서버는 ProxyHat의 독일 주거형 IP에서 온 Chrome 120 ClientHello를 보게 됩니다.
로테이션과 재시도를 포함한 프로덕션 코드
실제 스크래핑에서는 IP 로테이션과 실패 시 재시도가 필수입니다. 다음은 ProxyHat의 세션 기반 로테이션과 지수 백오프 재시도를 결합한 패턴입니다:
import asyncio
import random
import string
from curl_cffi.requests import AsyncSession
PROXYHAT_GATEWAY = "gate.proxyhat.com"
PROXYHAT_PORT = 8080
PROXYHAT_USER = "YOUR_USERNAME"
PROXYHAT_PASS = "YOUR_PASSWORD"
def make_proxy(country: str, session_id: str | None = None) -> str:
"""국가와 세션 ID를 기반으로 ProxyHat 프록시 URL 생성."""
user_parts = [PROXYHAT_USER, f"country-{country}"]
if session_id:
user_parts.append(f"session-{session_id}")
username = "-".join(user_parts)
return f"http://{username}:{PROXYHAT_PASS}@{PROXYHAT_GATEWAY}:{PROXYHAT_PORT}"
async def fetch_with_retry(
session: AsyncSession,
url: str,
country: str = "DE",
max_retries: int = 3,
):
for attempt in range(max_retries):
# 각 시도마다 새 세션 ID = 새 IP
session_id = "".join(random.choices(string.ascii_lowercase + string.digits, k=8))
proxy = make_proxy(country, session_id)
try:
response = await session.get(
url,
proxy=proxy,
impersonate="chrome120",
timeout=30,
)
if response.status_code == 200:
return response
elif response.status_code == 429:
# Rate limited: 지수 백오프
wait = 2 ** attempt + random.uniform(0, 1)
await asyncio.sleep(wait)
continue
else:
# 403 등: 새 IP로 재시도
await asyncio.sleep(1)
continue
except Exception as e:
print(f"Attempt {attempt + 1} failed: {e}")
await asyncio.sleep(2 ** attempt)
raise RuntimeError(f"Failed after {max_retries} retries")
async def main():
targets = [
"https://www.google.com/search?q=python+frameworks",
"https://www.google.com/search?q=fastapi+tutorial",
"https://www.google.com/search?q=asyncio+guide",
]
async with AsyncSession(impersonate="chrome120") as session:
tasks = [fetch_with_retry(session, url, country="DE") for url in targets]
results = await asyncio.gather(*tasks, return_exceptions=True)
for url, result in zip(targets, results):
if isinstance(result, Exception):
print(f"FAILED: {url} -> {result}")
else:
print(f"OK: {url} -> {len(result.text)} bytes")
asyncio.run(main())
이 코드는 각 요청마다 새로운 세션 ID를 생성해 ProxyHat이 새 주거형 IP를 할당하도록 합니다. 동시에 impersonate="chrome120"으로 TLS 핑거프린트를 Chrome과 일치시킵니다. 세션을 고정하면 같은 IP를 유지하므로 로그인 상태가 필요한 시나리오에 유용합니다.
ProxyHat SDK와 비교
ProxyHat SDK를 사용하면 프록시 관리 로직을 더 간결하게 작성할 수 있습니다:
from proxyhat import ProxyHatClient
from curl_cffi.requests import AsyncSession
client = ProxyHatClient(api_key="YOUR_API_KEY")
async def scrape_with_sdk():
async with AsyncSession(impersonate="chrome120") as session:
# SDK가 자동으로 프록시 URL 생성 + 로테이션
proxy = client.get_proxy(
proxy_type="residential",
country="DE",
session_id="serp-session-01",
)
response = await session.get(
"https://www.google.com/search?q=tls+fingerprinting",
proxy=proxy.url,
timeout=30,
)
return response
자세한 SDK 사용법과 API 레퍼런스는 ProxyHat 공식 문서를 참조하세요. SERP 추적과 웹 스크래핑 사용 사례 페이지에서도 실전 시나리오를 확인할 수 있습니다.
curl_cffi의 한계: JS 챌린지와 행동 분석
curl_cffi는 TLS 핑거프린트와 HTTP/2 핑거프린트를 완벽히 복제하지만, JavaScript를 실행하지 않습니다. 이것은 치명적인 한계입니다. 다음 상황에서는 curl_cffi만으로는 통과할 수 없습니다:
- Cloudflare Turnstile / JS Challenge: 난독화된 JavaScript를 실행해 특정 쿠키를 생성해야 함.
- Akamai Bot Manager: Sensor data라는 복잡한 JS 기반 디바이스 핑거프린트 수집.
- DataDome: 캔버스 핑거프린트, WebGL 렌더러 정보, 오디오 컨텍스트 데이터 수집.
- 행동 분석: 마우스 무브먼트, 스크롤 패턴, 타이핑 속도를 측정하는 시스템.
이런 경우에는 Playwright, Puppeteer, 또는 undetected-chromedriver 같은 실제 브라우저 자동화 도구를 사용해야 합니다. 이때도 ProxyHat 주거형 프록시는 동일하게 적용됩니다 — 브라우저 자동화 도구의 프록시 설정에 gate.proxyhat.com:8080을 지정하면 됩니다.
전략적으로는 다음과 같이 접근하는 것이 효율적입니다:
- 1차 시도: curl_cffi + ProxyHat 주거형 프록시로 빠르게 시도 (초당 50~100 요청 가능).
- 차단 시: Playwright + ProxyHat 주거형 프록시로 전환 (초당 2~5 요청, 하지만 JS 챌린지 통과 가능).
- 여전히 차단 시: 대상 사이트의 robots.txt와 ToS를 재검토하고, 데이터 수집의 정당성을 확인.
윤리와 법적 고려사항
TLS 임퍼스네이션은 강력한 기술이지만, 그 사용은 합법적이고 윤리적인 범위 내에 있어야 합니다. 다음 원칙을 준수하세요:
- robots.txt 준수: 대상 사이트의 robots.txt를 먼저 확인하고, 허용된 경로만 수집.
- 공개 데이터에만 접근: 로그인이 필요한 페이지, 유료 콘텐츠, 비공개 API는 수집하지 않음.
- 속도 제한 준수: 대상 서버에 부하를 주지 않는 요청 속도 유지 (초당 1~5 요청 권장).
- 개인정보 보호: GDPR(유럽) 및 CCPA(캘리포니아)에 따라 개인정보 수집 시 명시적 동의 필요.
- CFAA 주의: 미국 컴퓨터 사기 및 남용법(CFAA)은 ToS 위반 접근을 형사 범죄로 간주할 수 있음. FTC 사례에서 볼 수 있듯, 스크래핑 관련 소송은 실제로 발생합니다.
- 인가된 보안 연구: 침투 테스트나 보안 연구의 경우, 대상 조직의 사전 서면 허가를 받을 것.
curl_cffi와 ProxyHat 프록시는 합법적인 자동화, 공개 데이터 수집, 인가된 보안 연구를 위한 도구입니다. 이를 승인 없는 접근이나 서비스 거부 공격에 사용해서는 안 됩니다.
주요 요약: Key Takeaways
- JA3/JA4 핑거프린트는 TLS ClientHello의 cipher suites, extensions, 곡선 목록을 해시한 값으로, Python requests는 Chrome과 완전히 다른 핑거프린트를 생성합니다.
- curl_cffi는 BoringSSL과 Chrome 프리셋을 사용해 JA3, JA4, HTTP/2 SETTINGS 프레임을 실제 Chrome과 동일하게 복제합니다.
- Chrome 110+ permutation은 JA3를 무력화하지만, JA4는 정렬 후 해시하므로 여전히 핑거프린트가 유지됩니다.
- 완벽한 TLS 핑거프린트만으로는 부족합니다. 데이터센터 IP는 IP 평판에서 즉시 탐지되므로, 주거형 프록시가 필수입니다.
- ProxyHat 주거형 프록시와 curl_cffi를 결합하면, IP 평판과 TLS 핑거프린트 양쪽 모두에서 일반 사용자와 구분되지 않는 요청을 만들 수 있습니다.
- JS 챌린지가 필요한 사이트에서는 curl_cffi 대신 Playwright/Puppeteer + ProxyHat 조합을 사용해야 합니다.
- 윤리적 사용: 공개 데이터 수집, 인가된 연구, robots.txt 준수는 선택이 아닌 필수입니다.
curl_cffi를 활용한 TLS 임퍼스네이션은 2026년 웹 데이터 수집의 기본 소양입니다. 하지만 기술적 우회만큼이나 중요한 것은, 수집의 목적과 방식이 법적/윤리적 기준에 부합하는지 확인하는 것입니다. ProxyHat의 요금제에서 주거형, 모바일, 데이터센터 프록시 옵션을 비교하고, 프로젝트에 가장 적합한 조합을 선택하세요.






