웹 스크래핑에서의 403 Forbidden: 실제로 효과적인 해결 사다리
403은 권한 문제가 아니라 탐지 문제입니다. 차단된 스크립트와 200 사이에는 네 개의 단이 있으며, 대부분의 사람들은 첫 번째 단에서 멈춥니다.
웹 스크래핑에서의 403 forbidden 오류는 거의 항상 상태 코드가 말하는 것과 다릅니다. HTTP 403은 서버가 요청을 이해하고 권한을 부여하지 않는 것으로 정의되지만, 스크래퍼에 도달할 때는 거의 권한이나 로그인 누락과 관련이 없습니다. 이는 사이트가 요청을 보고 기계가 보낸 것으로 판단하고 문을 닫았다는 의미입니다. 이는 무엇을 변경해야 하는지를 알려줍니다: 자격 증명이 아니라 트래픽의 모양입니다. 아래는 가장 저렴한 단부터 시작하는 해결 사다리이며, 어느 단에 멈춰 있는지를 식별하는 체크입니다.
403 vs 401 vs 429: 각각이 말하는 것
코드를 작성하기 전에 진단을 정확히 하세요. 401 Unauthorized는 자격 증명을 요구합니다 — 이를 제공하면 해결됩니다. 403 Forbidden은 자격 증명과 상관없이 거부하므로, 봇 탐지가 트리거되었다면 로그인해도 아무 변화가 없습니다. 429 Too Many Requests는 볼륨에 관한 것이며, 창이 리셋되면 자동으로 해결됩니다; 403은 정체성에 관한 것이며, 요청의 모양을 변경할 때까지 지속됩니다. 스크래퍼가 403이 아닌 429를 받는다면, 해결책은 속도를 조절하는 것이지 위장을 하는 것이 아닙니다 — 이는 429 too many requests 해결하기에서 다룹니다.
코드를 변경하기 전에 60초 안에 진단하기
세 가지 명령어가 거의 모든 것을 알려줍니다. 기본 요청을 실행하고, 브라우저 User-Agent만 교체하여 다시 실행한 후, 응답 본문을 읽으세요 — 차단 이유가 보통 그 안에 적혀 있습니다.
# 1. Bare request: what does the target give a naked client?
curl -sS -o /dev/null -w '%{http_code}\n' https://target.example/page
# 2. Same request, browser User-Agent only
curl -sS -o /dev/null -w '%{http_code}\n' \
-A 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36' \
https://target.example/page
# 3. Read the body and the response headers - the reason is in there
curl -sS -D - https://target.example/page | head -c 600
이렇게 해석하세요. 2단계에서 200을 반환하면, 문제는 전적으로 헤더에 있으며, 1단에서 해결된 것입니다. 본문에 Cloudflare, Ray ID 또는 Error 1020이 언급되면, WAF 규칙 뒤에 있는 것입니다 — Cloudflare error 1020을 참조하세요. 일반적인 Apache 또는 nginx Forbidden 페이지는 보통 mod_security와 같은 서버 모듈을 의미하며, 이는 현대적인 봇 관리가 존재하기 훨씬 전부터 알려진 봇 User-Agent를 차단해 왔습니다. 같은 기기에서 브라우저가 페이지를 로드하는 동안 클라이언트가 그렇지 않다면, curl 403 but the browser works를 참조하세요.

단 1: 헤더에서 자신을 알리는 것을 멈추세요
Python의 urllib는 python-urllib/3.3.0과 같은 것으로 자신을 식별합니다; requests는 python-requests/2.x를 보냅니다. 이러한 문자열은 고백이며, Python 스크래핑에서 403에 대한 정통 Stack Overflow 스레드에서 가장 많이 추천된 답변은 단순히: 브라우저 User-Agent를 보내라는 것입니다. 이는 여전히 많은 사이트에서 작동합니다. 그러나 그 답변이 작성된 이후 두 가지가 변경되었습니다. 첫째, 기본 Mozilla/5.0은 이제 자체적으로 플래그가 되었습니다 — 같은 스레드의 댓글에서 사이트들이 이를 차단한다고 보고합니다, 왜냐하면 실제 브라우저는 두 개의 토큰 UA를 보내지 않기 때문입니다. 둘째, 현대 서버는 전체 헤더 세트를 비교하지, 단일 필드를 비교하지 않습니다.
일관된 세트를 보내세요: 최신 브라우저 UA, 일치하는 Accept 체인, 언어, 그리고 Chromium이 모든 탐색에 추가하는 Sec-Fetch-* 메타데이터 헤더. 헤더 순서도 더 엄격한 대상에서는 중요합니다 — 순서가 있는 매핑을 사용하고 브라우저가 사용하는 순서로 배치하세요.
import requests
HEADERS = {
"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"
),
"Accept": (
"text/html,application/xhtml+xml,application/xml;q=0.9,"
"image/avif,image/webp,*/*;q=0.8"
),
"Accept-Language": "en-GB,en;q=0.9",
"Accept-Encoding": "gzip, deflate", # add 'br' only if brotli is installed
"Upgrade-Insecure-Requests": "1",
"Sec-Fetch-Dest": "document",
"Sec-Fetch-Mode": "navigate",
"Sec-Fetch-Site": "none",
"Sec-Fetch-User": "?1",
"Connection": "keep-alive",
}
with requests.Session() as s:
s.headers.update(HEADERS)
r = s.get("https://target.example/page", timeout=20)
print(r.status_code, len(r.content))
거의 모든 사람을 잡아내는 일관성 함정이 있습니다: 미국 Chrome 데스크탑을 주장하는 UA가 Accept-Language: de-DE와 브라질의 종료 IP와 짝을 이루는 것은 어떤 괜찮은 시스템이라도 알아차리는 불일치입니다. 사용자 에이전트, 언어 및 IP 지리적 위치가 같은 이야기를 하도록 유지하세요.
이 단에서의 일부 403은 훨씬 더 간단합니다: 누락된 Referer입니다. 요청이 자신의 페이지에서 온 것처럼 보일 때만 자산을 제공하는 서버는 직접 히트에 403을 반환하고 참조 URL을 추가하는 순간 200을 반환합니다. 이는 고전적이며, 테스트하는 데 하나의 헤더가 필요합니다.
단 2: TLS 지문은 헤더로 해결할 수 없습니다
완벽한 헤더가 여전히 403을 반환한다면, 차단은 헤더가 읽히기 전에 발생한 것입니다. 모든 HTTPS 클라이언트는 TLS ClientHello에서 암호 모음, 확장, 타원 곡선 및 ALPN을 발표하며, 이 조합은 JA3 또는 JA4 지문으로 해시됩니다. Python의 requests, Go의 net/http 및 기본 curl은 각각 Chrome과 구별하는 데 문제가 없는 독특한 것을 가지고 있습니다. 헤더에서 Chrome 131이라고 주장하면서 OpenSSL처럼 핸드셰이크하는 것은 스크래퍼가 할 수 있는 가장 큰 모순입니다.
해결책은 TLS 계층에서 실제 브라우저를 가장하는 클라이언트입니다. Python에서는 curl_cffi가 그것입니다, 이는 브라우저 ClientHellos를 재현하는 패치된 libcurl에 대한 바인딩입니다:
# pip install curl_cffi
from curl_cffi import requests as cffi
proxy = "http://USER:PASS@gate.quantumproxies.io:8000"
r = cffi.get(
"https://target.example/page",
impersonate="chrome", # Chrome JA3/JA4 + HTTP/2 settings
proxies={"http": proxy, "https": proxy},
timeout=20,
)
print(r.status_code)
Node는 동일한 패치된 TLS 스택에 기반한 동등한 것을 가지고 있습니다. 이 단일 변경이 보호된 사이트에서 403을 200으로 뒤집는 이유에 대한 전체 메커니즘을 원한다면, JA3/JA4 지문이 스크래퍼를 드러내는 방법을 읽으세요.
단 3: IP가 메시지입니다
헤더와 TLS는 클라이언트를 설명합니다. IP는 누가 요청하는지를 설명하며, 이는 크게 가중치가 부여됩니다. 호스팅 및 클라우드 범위의 주소는 게시되어 있으며, ASN으로 쉽게 매핑할 수 있으며, 요청의 단일 바이트가 검사되기 전에 낮은 신뢰 점수를 가집니다 — 이는 VPS에서의 스크래퍼가 동일한 코드가 홈 연결에서 문제없이 통과하는 403을 받는 이유입니다. 주거용 주소는 소비자 인터넷 제공업체에 속하며 사람으로 취급됩니다; 모바일 캐리어 IP는 각각 수천 명의 실제 가입자가 있는 CGNAT 뒤에 위치하며, 이는 전체적으로 차단하기 가장 어려운 것입니다.
따라서 단 3은 재작성이 아닌 교체입니다: 주거용 종료에서 동일한 잘 형성된 요청을 보내세요. QuantumProxies는 200개 이상의 국가에서 90M+ 주거용 IP를 운영하며, 요청당 회전 또는 고정 세션, 모든 플랜에서 HTTP 및 SOCKS5, 그리고 GB당 요금제를 제공합니다 — 구성 라인 하나로 대상이 보는 ASN을 변경합니다. 이미 주거용에 있고 여전히 차단되나요? 우리의 무료 IP 품질 점수 검사기로 풀의 평판을 확인하세요: 75 이상 점수를 받는 것은 소진된 것이며, 헤더가 아무리 좋아도 403을 수집할 것입니다.
사다리를 건너뛰고 Scraper API로 모든 페이지 가져오기

단 4: 렌더링 및 도전 과제
마지막 단은 비용이 많이 드는 것입니다. 일부 403은 JavaScript 도전의 보이는 절반입니다: 서버는 작은 스크립트를 전송하고, 몇 초 안에 응답을 기대하며, 이를 실행할 수 없는 모든 것을 거부합니다. 헤더 세트나 IP로는 이를 해결할 수 없습니다, 왜냐하면 테스트는 코드를 실행할 수 있는지 여부이기 때문입니다. 비용이 증가하는 순서로 옵션: 스텔스 패치 세트가 있는 헤드리스 브라우저, 관리되는 브라우저 풀, 또는 필요할 때 렌더링하는 스크래핑 API. 렌더링은 평범한 HTTP 요청보다 페이지당 비용이 훨씬 더 많이 들기 때문에, 진정으로 필요한 URL에 대해서만 상승하세요.
QuantumProxies Scraper API는 단 2에서 4까지를 하나의 요청으로 통합합니다: 브라우저 급 TLS, 주거용 종료, 페이지가 필요할 때 JavaScript 렌더링, 그리고 markdown, JSON 또는 raw HTML 반환. 이는 정직한 거래입니다 — 사다리를 유지하는 대신 성공한 페이지당 비용을 지불합니다.
실제로 사다리 작업하기
- 각 변경 후 다시 테스트하세요. 두 가지를 동시에 변경하면 어떤 것이 효과가 있었는지에 대해 아무것도 배우지 못합니다.
- 개발 중에 원시 403 본문을 캐시하세요. 차단 페이지는 Ray ID, 규칙 이름 및 벤더 지문을 포함하여 상대방을 지명합니다.
- 첫 번째 요청에서 403이 발생하면 정체성 문제로 취급하세요; 200개의 좋은 페이지 이후에 나타나는 403은 평판 또는 속도 문제입니다.
- 403을 최대 한 번만 재시도하고, 무언가를 변경한 후에만 시도하세요. 동일한 IP에서 동일한 요청을 반복해서 보내면 소프트 블록이 하드 블록으로 전환됩니다.
- 동일한 데이터를 위한 공개 API 또는 피드가 존재한다면, 그것을 사용하세요. 이는 위의 모든 단보다 저렴하며, 디자인 변경 시에도 절대 깨지지 않습니다.
차단 방지는 형제 학문입니다: 200을 얻은 후에는 위장이 아니라 속도 조절, 세션 위생 및 풀 건강이 문제입니다.
자주 묻는 질문
웹 스크래핑에서 403 forbidden 오류의 원인은 무엇인가요?
거의 모든 경우 탐지입니다. 일반적인 트리거는 기본 라이브러리 User-Agent, 불완전하거나 모순된 헤더 세트, 평판이 좋지 않은 데이터 센터 IP, 너무 규칙적인 요청 속도, 또는 주장하는 브라우저와 일치하지 않는 TLS 지문입니다. 진정한 권한 오류도 존재하지만, 이는 브라우저에도 403을 반환합니다 — 가정하기 전에 브라우저에서 테스트하세요.
Python에서 403 forbidden 오류를 어떻게 우회하나요?
사다리를 작업하세요. requests.Session에 전체 브라우저 헤더 세트를 추가하세요; 실패하면 curl_cffi와 같은 브라우저 TLS를 가장하는 클라이언트로 전환하세요; 실패하면 주거용 프록시를 통해 라우팅하세요; 페이지가 JavaScript 도전을 전송하면 이를 렌더링하거나 스크래핑 API를 사용하세요. 더 저렴한 단이 실제로 테스트되었을 때만 상승하세요.
왜 httpx는 브라우저가 그렇지 않은데 403을 반환하나요?
requests와 같은 이유입니다: httpx는 최소한의 헤더 세트와 Python TLS 지문을 보냅니다. DevTools에서 브라우저의 정확한 요청을 복사하고, httpx로 재생하면 403이 보통 사라집니다 — 이는 차이가 헤더였음을 알려줍니다. 동일한 헤더로도 지속된다면, 차단은 TLS 또는 IP 계층에 있습니다.
Scrapy에서 403 forbidden을 어떻게 수정하나요?
현실적인 DEFAULT_REQUEST_HEADERS와 USER_AGENT를 설정하고, ROBOTSTXT_OBEY가 가져올 수 있는 것에 대해 정직하게 유지하며, AUTOTHROTTLE_ENABLED를 활성화하고, 회전 프록시 미들웨어를 통해 요청을 라우팅하세요. Scrapy 재시도는 기본적으로 403을 재시도하지 않습니다 — IP를 시도 사이에 회전하는 경우에만 RETRY_HTTP_CODES에 추가하세요.
403은 정보이지 벽이 아닙니다. 이는 네 가지 신호 중 어떤 것이 당신을 드러냈는지를 알려주며, 사다리의 각 단은 아래 단보다 더 많은 비용이 듭니다. 가장 저렴한 것부터 시작하고, 각 변경 후 테스트하고, 상태 코드가 200으로 바뀌는 순간 멈추세요.