웹사이트 크롤링 시 403 Forbidden 오류 해결법

브라우저 주소창에 붙여 넣으면 멀쩡히 열리는 페이지가, 파이썬으로 요청하면 403 Forbidden을 뱉습니다. 코드는 다섯 줄뿐이라 틀릴 데도 없어 보이는데 말이죠. 이 지점에서 검색만 반복하다 두세 시간을 날리는 경우가 많습니다. 크롤링 403 오류 해결이 어려운 이유는 원인이 하나가 아니라 다섯 가지쯤 되는데, 서버는 그중 무엇이 문제인지 알려주지 않기 때문입니다. 이 글에서는 원인을 좁히는 순서와, 단계별로 그대로 복사해 쓸 수 있는 코드를 정리합니다.

403은 “차단”이 아니라 “거절”이다

먼저 오류 코드를 정확히 읽어야 합니다. 401은 “당신이 누군지 모르겠으니 로그인하라”는 뜻이고, 429는 “당신이 누군지는 알지만 너무 빠르다”는 뜻입니다. 그에 비해 403은 “요청은 이해했고, 당신이 누군지도 알겠는데, 그래도 안 준다”는 의미입니다. 즉 서버는 이미 판단을 마친 상태이고, 같은 요청을 다시 보내는 것만으로는 절대 통과하지 못합니다. 재시도 로직에 403을 넣으면 안 되는 이유가 여기에 있습니다.

그래서 코드를 고치기 전에, 지금 상황이 아래 다섯 가지 중 어디에 해당하는지부터 좁힙니다.

관찰되는 상황유력한 원인해결 단계
브라우저에선 열리는데 코드로만 403User-Agent가 파이썬 기본값1단계
목록 페이지는 되는데 상세 페이지만 403Referer·헤더 세트 검사2단계
로그인해야 보이는 페이지에서 403세션 쿠키 없음3단계
처음엔 되다가 수십 번째부터 403요청 속도 제한4단계
응답 본문에 “Just a moment…”Cloudflare 봇 차단5단계

1단계: User-Agent부터 확인한다

전체 403의 절반 이상이 여기서 끝납니다. requests는 아무 설정을 하지 않으면 python-requests/2.31.0이라는 User-Agent를 그대로 보냅니다. “나는 스크립트입니다”라고 자기소개를 하고 들어가는 셈이라, 봇을 걸러내는 서버라면 굳이 고민할 것도 없이 거절합니다.

추측하지 말고, 내가 실제로 무엇을 보냈는지 request.headers로 직접 찍어서 확인하세요.

import requests

url = "https://example.com/notice"

# 1) 아무 설정 없이 보낸 요청
r1 = requests.get(url, timeout=10)
print(r1.status_code)                        # 403

# 내가 실제로 무엇을 보냈는지 확인해 본다
print(r1.request.headers["User-Agent"])      # python-requests/2.31.0

# 2) 브라우저 User-Agent를 붙인 요청
headers = {
    "User-Agent": (
        "Mozilla/5.0 (Windows NT 10.0; Win64; x64) "
        "AppleWebKit/537.36 (KHTML, like Gecko) "
        "Chrome/124.0.0.0 Safari/537.36"
    )
}
r2 = requests.get(url, headers=headers, timeout=10)
print(r2.status_code)                        # 200

User-Agent 문자열은 실제로 쓰는 브라우저에서 가져오는 편이 안전합니다. 크롬 개발자도구를 열고 Network 탭에서 아무 요청이나 클릭하면 Request Headers에 그대로 나옵니다. 몇 년 지난 버전 번호를 그대로 복사해 두면 그 자체가 오래된 봇의 표식이 되기도 합니다.

2단계: 브라우저와 같은 헤더 세트를 보낸다

User-Agent만 바꿨는데도 403이라면, 서버가 헤더 조합 전체를 본다는 뜻입니다. 진짜 크롬은 Accept-Language, Accept, Referer를 항상 함께 보내는데 User-Agent만 크롬이고 나머지가 비어 있으면 오히려 더 티가 납니다. 특히 Referer는 게시판 상세 페이지에서 자주 검사합니다.

HEADERS = {
    "User-Agent": (
        "Mozilla/5.0 (Windows NT 10.0; Win64; x64) "
        "AppleWebKit/537.36 (KHTML, like Gecko) "
        "Chrome/124.0.0.0 Safari/537.36"
    ),
    "Accept": "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8",
    "Accept-Language": "ko-KR,ko;q=0.9,en-US;q=0.8,en;q=0.7",
    "Accept-Encoding": "gzip, deflate",
    "Referer": "https://example.com/",
    "Connection": "keep-alive",
    "Upgrade-Insecure-Requests": "1",
}

res = requests.get("https://example.com/board/123", headers=HEADERS, timeout=10)
print(res.status_code)

Accept-Encoding에 br(Brotli)을 넣을 때는 주의하세요. brotli 패키지가 설치돼 있지 않으면 서버가 압축해서 보낸 응답을 풀지 못해 본문이 깨진 문자로 나옵니다. 상태 코드는 200인데 내용만 이상하다면 이걸 의심하세요. pip install brotli로 해결하거나, 위 예제처럼 gzip, deflate만 남기면 됩니다.

3단계: 세션과 쿠키를 유지한다

요청 하나하나를 requests.get()으로 따로 보내면 매번 처음 방문한 손님이 됩니다. 첫 화면에서 세션 쿠키를 심어 두고 그게 없는 요청을 거절하는 사이트에서는, 목표 URL로 곧장 들어가는 순간 403입니다. requests.Session()을 쓰면 받은 쿠키를 자동으로 다음 요청에 실어 보냅니다.

import requests

s = requests.Session()
s.headers.update(HEADERS)

# 1) 목표 페이지로 바로 가지 않고, 첫 화면을 먼저 열어 쿠키를 받는다
s.get("https://example.com/", timeout=10)
print(s.cookies.get_dict())

# 2) 같은 세션으로 목표 페이지를 요청한다 (쿠키가 자동으로 따라간다)
res = s.get(
    "https://example.com/board/123",
    headers={"Referer": "https://example.com/"},
    timeout=10,
)
print(res.status_code)

연결을 재사용하기 때문에 속도도 눈에 띄게 빨라집니다. 페이지 수십 개를 도는 작업이라면 세션을 쓰는 것만으로 전체 시간이 줄어듭니다.

4단계: 속도를 줄이고 재시도를 설계한다

처음 열 개는 잘 되다가 갑자기 403이 쏟아진다면 헤더 문제가 아니라 속도 문제입니다. 사람은 1초에 세 페이지를 열지 않습니다. 요청 사이에 간격을 두되, time.sleep(1)처럼 정확히 일정한 간격은 오히려 기계적인 패턴으로 잡히므로 약간의 무작위성을 섞습니다.

import time
import random
import requests
from requests.adapters import HTTPAdapter
from urllib3.util.retry import Retry

retry = Retry(
    total=5,
    backoff_factor=1.5,                       # 재시도할수록 대기가 길어진다
    status_forcelist=(429, 500, 502, 503, 504),
    allowed_methods=("GET", "HEAD"),
    respect_retry_after_header=True,          # 서버가 알려준 대기 시간을 따른다
)

session = requests.Session()
session.headers.update(HEADERS)
session.mount("https://", HTTPAdapter(max_retries=retry))

for i in range(1, 51):
    res = session.get(f"https://example.com/board/{i}", timeout=10)

    if res.status_code == 403:
        print("403 발생:", i, "- 재시도해도 소용없다. 헤더/세션을 다시 본다")
        break

    print(i, res.status_code)
    time.sleep(random.uniform(1.0, 2.5))      # 간격을 일정하게 두지 않는다

위 코드에서 status_forcelist에 403이 없는 점을 눈여겨보세요. 403은 서버가 이미 결론을 내린 응답이라 재시도해도 같은 답이 돌아옵니다. 그저 차단 기록만 쌓일 뿐입니다. 429나 503처럼 “지금은 곤란하다”는 응답에만 재시도를 겁니다.

5단계: 그래도 막히면 무엇이 막는지 본다

응답 본문을 출력했을 때 “Just a moment…” 또는 “Checking your browser”가 보인다면 사이트가 아니라 그 앞단의 Cloudflare가 막고 있는 것입니다. 이 경우는 헤더를 아무리 다듬어도 통과하지 못합니다. TLS 핸드셰이크 지문까지 비교하기 때문에, 파이썬 표준 requests는 헤더가 완벽해도 접속 단계에서 정체가 드러납니다.

# pip install curl_cffi
from curl_cffi import requests as cffi

# 크롬의 TLS 지문까지 흉내 내서 요청한다
res = cffi.get("https://example.com/", impersonate="chrome", timeout=15)

print(res.status_code)
print(res.text[:200])

상황별로 선택지가 다르므로 아래 표에서 가장 가벼운 것부터 시도하세요.

방법통과 가능한 차단속도준비 부담
requests + 헤더단순 User-Agent 검사매우 빠름없음
requests.Session쿠키·Referer 검사매우 빠름없음
curl_cffiTLS 지문 검사빠름pip 설치 한 줄
Playwright / Selenium자바스크립트 챌린지느림브라우저 설치 필요
공식 API해당 없음빠름API 키 발급

마지막 줄을 그냥 넘기지 마세요. 네이버, 카카오, 유튜브처럼 규모 있는 서비스는 대부분 공식 API를 제공합니다. 차단을 우회하려고 며칠 붙잡고 있느니 API 키를 하나 발급받는 쪽이 빠르고, 무엇보다 사이트 개편에 코드가 깨지지 않습니다.

시작하기 전에 확인할 것

기술적으로 가능한 것과 해도 되는 것은 다릅니다. 403은 “오지 말라”는 사이트의 명시적인 의사 표시이기도 하므로, 우회하기 전에 아래는 반드시 확인하세요.

  1. robots.txt에서 해당 경로가 허용돼 있는지 확인합니다.
  2. 이용약관에 자동 수집 금지 조항이 있는지 봅니다. 회원 가입 시 동의한 약관이라면 구속력이 있습니다.
  3. 로그인이 필요한 영역이나 개인정보가 담긴 페이지는 대상에서 제외합니다.
  4. 수집한 데이터를 그대로 재배포하지 않습니다. 저작권은 별개의 문제입니다.
from urllib.robotparser import RobotFileParser

rp = RobotFileParser()
rp.set_url("https://example.com/robots.txt")
rp.read()

# 이 경로를 긁어도 되는지 먼저 물어본다
print(rp.can_fetch("*", "https://example.com/board/123"))

# 사이트가 권장하는 요청 간격이 적혀 있는 경우도 있다
print(rp.crawl_delay("*"))
  • 서버에 부담을 주지 않을 만큼 간격을 둡니다. 상업용 사이트라도 새벽 시간대 과도한 요청은 장애로 이어질 수 있습니다.
  • 수집 목적이 개인 학습이나 내부 분석인지, 서비스로 재가공하는 것인지에 따라 판단 기준이 달라집니다. 후자라면 사전에 문의하는 편이 안전합니다.
  • IP를 계속 바꿔 가며 차단을 뚫는 방식은 권장하지 않습니다. 차단 강도만 올라가고, 분쟁이 생겼을 때 고의성의 근거가 됩니다.

마무리

403을 만나면 코드를 고치기 전에 print(res.request.headers)와 print(res.text[:300]) 두 줄부터 찍어 보세요. 내가 무엇을 보냈고 서버가 무엇을 돌려줬는지만 봐도 다섯 가지 원인 중 서너 개는 그 자리에서 지워집니다. 대부분은 1단계와 2단계에서 끝나고, 5단계까지 가야 하는 경우는 생각보다 드뭅니다.

다음 글에서는 이렇게 만든 스크립트들을 엮어 블로그 글이 매일 자동으로 올라가게 만든 시스템의 구축 과정과, 그 과정에서 실제로 겪은 실패들을 정리해 보겠습니다.

댓글 남기기