Python으로 깨진 링크 확인하는 방법 (3가지)

발행: (2026년 6월 9일 PM 11:06 GMT+9)
8 분 소요
원문: Dev.to

Source: Dev.to

아무도 깨진 링크를 좋아하지 않습니다. SEO에 악영향을 주고, 사용자에게 좌절감을 줍니다. 방문자(또는 구글)보다 먼저 이를 잡아낼 수 있습니다.
이 글에서는 Python을 사용해 깨진 링크를 확인하는 실용적인 세 가지 방법을 살펴보겠습니다.

사전 정의

링크가 깨졌다고 판단하는 기준은 서버가 다음과 같은 응답을 보낼 때입니다.

  • 4xx 오류 – 404 Not Found, 410 Gone 등 클라이언트 오류
  • 5xx 오류 – 500 Internal Server Error, 503 Service Unavailable 등 서버 오류
  • 연결 실패 – DNS 해석 문제, 타임아웃, 연결 거부 등

2xx 범위는 정상이며, 3xx 리다이렉트도 보통은 괜찮습니다.

1. requests 사용

가장 간단한 방법입니다. URL 목록이 있고 어느 것이 죽었는지만 알고 싶다면 requests가 충분합니다.

import requests

urls = [
    "https://example.com",
    "https://example.com/nonexistent-page",
    "https://httpstat.us/500",
    "https://httpstat.us/itwasworkingyesterday",
]

def check_link(url):
    try:
        response = requests.head(url, allow_redirects=True, timeout=10)
        if response.status_code >= 400:
            response = requests.get(url, allow_redirects=True, timeout=10)
        return url, response.status_code, response.status_code  {status}")

왜 동작하나요?
먼저 HEAD 요청을 시도합니다. 헤더만 받아오므로 가볍습니다. 서버가 HEAD를 차단하면 GET으로 대체합니다.

언제 사용하나요?
CSV 파일에 담긴 링크를 빠르게 검증하거나, 문서에 포함된 링크를 CI 단계에서 체크할 때 적합합니다.

제한점
한 번에 하나의 URL만 검사하므로 대규모 리스트에서는 느립니다.


2. concurrent.futures 로 동시성 확보

수백 개의 링크를 순차적으로 확인하는 것은 고통스럽습니다. 네트워크 요청은 대부분 대기 시간이기 때문에 동시성을 활용하면 크게 빨라집니다. 아래 예시는 ThreadPoolExecutor를 사용합니다.

import requests
from concurrent.futures import ThreadPoolExecutor, as_completed

urls = [
    "https://example.com",
    "https://example.com/broken",
    "https://httpstat.us/404",
    "https://httpstat.us/200",
]

def check_link(url):
    try:
        response = requests.head(url, allow_redirects=True, timeout=10)
        if response.status_code >= 400:
            response = requests.get(url, allow_redirects=True, timeout=10)
        return url, response.status_code, response.status_code  {status}")

with ThreadPoolExecutor(max_workers=20) as executor:
    future_to_url = {executor.submit(check_link, u): u for u in urls}
    broken = []
    for future in as_completed(future_to_url):
        url, status, _ = future.result()
        if status >= 400:
            broken.append((url, status))

print(f"\nFound {len(broken)} broken link(s).")

왜 동작하나요?
ThreadPoolExecutor가 최대 20개의 요청을 병렬로 실행합니다. 링크 검사는 I/O‑bound 작업이므로 스레드가 최적입니다. 수만 개가 아닌 정도라면 asyncio까지 도입할 필요는 없습니다.


동일 도메인에 20개의 동시 요청을 보내면 차단당할 수 있습니다. 작은 지연을 두거나 호스트당 동시성을 제한하세요.


3. requests + BeautifulSoup 으로 페이지 내 모든 링크 추출 및 검사

앞의 두 방법은 이미 URL 리스트가 있다고 가정합니다. 실제로는 페이지를 스캔해 모든 링크를 추출하고 검증하고 싶을 때가 많습니다. 이때 BeautifulSoup과 결합하면 됩니다.

import requests
from bs4 import BeautifulSoup
from urllib.parse import urljoin, urlparse
from concurrent.futures import ThreadPoolExecutor, as_completed

def extract_links(page_url):
    response = requests.get(page_url, timeout=10)
    soup = BeautifulSoup(response.text, "html.parser")
    links = set()
    for tag in soup.find_all("a", href=True):
        href = tag["href"]
        full_url = urljoin(page_url, href)
        # http/https 만 남김
        if urlparse(full_url).scheme in ("http", "https"):
            links.add(full_url)
    return links

def check_link(url):
    try:
        response = requests.head(url, allow_redirects=True, timeout=10)
        if response.status_code >= 400:
            response = requests.get(url, allow_redirects=True, timeout=10)
        return url, response.status_code, response.status_code  {status}")

page = "https://example.com"
all_links = extract_links(page)

with ThreadPoolExecutor(max_workers=20) as executor:
    futures = {executor.submit(check_link, u): u for u in all_links}
    broken = []
    for f in as_completed(futures):
        url, status, _ = f.result()
        if status >= 400:
            broken.append((url, status))

print(f"Broken links on {page}:")
for u, s in broken:
    print(f"{u}{s}")

왜 동작하나요?
BeautifulSoup이 HTML을 파싱하고, urljoin이 상대 경로를 절대 URL로 변환합니다. 이후 앞서 만든 동시 검사 로직을 재활용해 찾은 모든 링크를 검증합니다.

어디가 어려운가?
위 코드는 단일 페이지에 한정됩니다. 전체 사이트를 크롤링하려면 큐, 방문 기록 집합, 깊이 제한, robots.txt 처리, 도메인 제한 로직 등이 필요합니다. 이는 스니펫 수준을 넘어서는 실제 프로젝트가 됩니다.


실전에서 마주치는 문제들

위 스크립트들은 작은 작업이나 학습용으로는 충분하지만, 실제 웹사이트를 모니터링하려 하면 숨은 비용이 쌓입니다.

  • CAPTCHA·봇 탐지 – 많은 사이트가 자동 스크래퍼를 차단하거나 챌린지를 보냅니다.
  • 프록시·IP 회전 – 대규모 스캔 시 IP가 제한되거나 차단됩니다.
  • JavaScript 렌더링requests는 순수 HTML만 가져옵니다. JS가 동적으로 삽입한 링크는 헤드리스 브라우저가 필요합니다.
  • 유지보수 – 사이트 구조가 바뀌면 예외가 생기고, 스케줄링·알림·운영 인프라를 관리해야 합니다.

결국 인프라를 직접 운영하게 됩니다.


위 모든 과정을 건너뛰고 싶다면 Broken Link Checker API를 이용하면 됩니다. URL을 보내면 백엔드에서 페이지를 크롤링하고, 프록시·CAPTCHA·렌더링 등을 자동으로 처리한 뒤 깨진 링크 목록을 반환합니다.

# pip install geekflare-api
from geekflare_api.client import GeekflareClient
from geekflare_api.models import BrokenLinkDto

with GeekflareClient(api_key="") as client:
    result = client.broken_link(
        BrokenLinkDto(
            url="https://example.com"
        )
    )
    print(result)

결과만 받으면 되니, 유지보수 부담이 크게 줄어듭니다.


언제 어떤 방법을 선택할까?

상황추천 방법
간단히 한 번만 검사하고 싶을 때방법 1 (requests)
URL 리스트가 많아 빠른 속도가 필요할 때방법 2 (concurrent.futures)
특정 페이지의 모든 링크를 검사하고 싶을 때방법 3 (BeautifulSoup + 동시 검사)
실제 사이트를 지속적으로 모니터링하거나 CAPTCHA/프록시/JS 문제를 피하고 싶을 때Geekflare API 사용

깨진 링크 검사는 겉보기엔 사소해 보이지만, 규모가 커지면 복잡해집니다. 작은 스크립트로 시작하고, 필요에 따라 관리형 API로 전환하는 것이 현명한 접근법입니다.

0 조회
Back to Blog

관련 글

더 보기 »

Eidentic 소개

Today we're releasing Eidentic, an open-source TypeScript SDK for building AI agents with self-improving memory and the production fundamentals built in — not b...

Typescript의 타입

Introdução Tipos são uma forma de definir a “forma” ou o contrato dos dados que estamos usando no código. Pensando em Javascript puro, ele é dinâmico: você pode...