깨진 링크 찾기 파이썬 스크립트 — 내 블로그 점검해 보니 경고 47개 중 22개가 오탐이었던 이유 (실측)

글이 쌓이면 예전 글 속 링크가 하나둘 죽습니다. 지운 글, 주소가 바뀐 페이지, 사라진 이미지가 그대로 남습니다. 방문자는 404 화면을 보고 떠나고, 운영자는 아무도 알려 주지 않으니 모릅니다. 그래서 깨진 링크 찾기 파이썬 스크립트를 표준 라이브러리만으로 만들어 이 블로그(codetipbox.com)에 직접 돌려 봤습니다. 첫 실행 결과는 “깨진 링크 47개”였는데, 하나씩 열어 보니 22개는 스크립트 쪽 오탐이었습니다. 오탐을 걸러 내고 나니 진짜 문제 두 가지가 남았습니다. 없는 /contact 페이지로 가는 링크, 그리고 이미지 CDN 주소로 나가는 썸네일 24개가 404를 내는 문제입니다. 이 글에서는 스크립트 전체 코드, 일부러 깨뜨린 시험 사이트로 검증한 결과, 실제 사이트에서 오탐이 생긴 세 가지 원인과 고친 방법을 정리합니다. 환경은 윈도우 11, Python 3.12.10입니다.

스크립트가 하는 일

구조는 두 단계입니다. 먼저 시작 주소와 같은 도메인의 페이지를 차례로 열면서 링크를 모으고(수집), 모은 주소가 살아 있는지 병렬로 확인합니다(확인). 설치할 패키지는 없습니다.

단계하는 일쓴 모듈
수집같은 도메인 HTML 페이지를 GET, a·img·link·script 주소 추출urllib.request, html.parser
확인HEAD로 상태 확인, 403·405·501이면 GET으로 다시concurrent.futures (스레드 8개)
보고깨진 링크와 그 링크가 있던 페이지 출력, CSV 저장csv
종료 코드깨진 링크가 있으면 1, 없으면 0sys.exit

“그 링크가 있던 페이지”를 같이 출력하는 게 핵심입니다. 깨진 주소만 알면 어느 글을 고쳐야 하는지 다시 찾아야 하기 때문입니다.

전체 코드 (linkcheck.py)

최종 버전입니다. 아래에서 설명할 세 번의 수정이 모두 들어가 있습니다. python linkcheck.py https://내사이트 --max-pages 30처럼 실행합니다.

"""사이트 깨진 링크 점검기 (표준 라이브러리만 사용)

사용법: python linkcheck.py https://example.com --max-pages 50 --workers 8
- 시작 주소와 같은 도메인의 HTML 페이지를 따라가며 a/img/link/script 주소를 모읍니다.
- 모은 주소를 병렬로 확인하고, 깨진 링크와 그 링크가 있던 페이지를 출력합니다.
- 깨진 링크가 하나라도 있으면 종료 코드 1, 결과는 broken_links.csv 로도 저장합니다.
"""
import argparse
import csv
import sys
import time
import urllib.error
import urllib.request
from collections import defaultdict, deque
from concurrent.futures import ThreadPoolExecutor
from html.parser import HTMLParser
from urllib.parse import quote, unquote, urljoin, urldefrag, urlparse

UA = "Mozilla/5.0 (Windows NT 10.0; Win64; x64) linkcheck/1.0"
TAGS = {"a": "href", "img": "src", "link": "href", "script": "src"}
SKIP_SCHEMES = ("mailto:", "tel:", "javascript:", "data:")
SKIP_RELS = {"preconnect", "dns-prefetch", "pingback"}
NOT_PAGE = (".png", ".jpg", ".jpeg", ".gif", ".webp", ".svg", ".css", ".js", ".pdf", ".zip")


def normalize(base, raw):
    """상대 주소를 절대 주소로 바꾸고, 한글 주소는 %인코딩으로 통일합니다."""
    url = urldefrag(urljoin(base, raw))[0]
    return quote(unquote(url), safe=":/?&=+,;@!$'()*~%")


class LinkParser(HTMLParser):
    def __init__(self):
        super().__init__()
        self.links = []

    def handle_starttag(self, tag, attrs):
        want = TAGS.get(tag)
        attrs = dict(attrs)
        # preconnect·dns-prefetch 는 도메인만 적힌 힌트, pingback 은 POST 전용이라 링크가 아닙니다.
        if tag == "link" and set((attrs.get("rel") or "").lower().split()) & SKIP_RELS:
            return
        value = attrs.get(want)
        if value:
            self.links.append(value.strip())


def fetch(url, method, timeout):
    req = urllib.request.Request(url, method=method, headers={"User-Agent": UA})
    try:
        with urllib.request.urlopen(req, timeout=timeout) as r:
            body = r.read() if method == "GET" else b""
            return r.status, r.url, r.headers.get("Content-Type", ""), body
    except urllib.error.HTTPError as e:
        return e.code, url, "", b""
    except Exception as e:  # 타임아웃, DNS 실패, 연결 거부 등
        return type(e).__name__, url, "", b""


def check(url, timeout):
    """HEAD 로 먼저 확인하고, HEAD 를 거부하는 서버면 GET 으로 다시 확인합니다."""
    status, final, _, _ = fetch(url, "HEAD", timeout)
    if status in (403, 405, 501):
        status, final, _, _ = fetch(url, "GET", timeout)
    return url, status, final


def crawl(start, max_pages, timeout):
    host = urlparse(start).netloc
    found = defaultdict(set)  # 링크 주소 -> 그 링크가 있던 페이지들
    done = {}                 # 수집하며 이미 받아 본 주소 -> (상태, 최종 주소)
    queue, seen = deque([start]), {start}
    while queue and len(done) < max_pages:
        page = queue.popleft()
        status, final, ctype, body = fetch(page, "GET", timeout)
        done[page] = (status, final)
        if status != 200 or "text/html" not in ctype:
            continue
        parser = LinkParser()
        parser.feed(body.decode("utf-8", "replace"))
        for raw in parser.links:
            if raw.startswith(SKIP_SCHEMES) or raw.startswith("#"):
                continue
            url = normalize(final, raw)
            found[url].add(page)
            parts = urlparse(url)
            # 이미지·CSS 같은 파일은 페이지가 아니므로 수집하지 않고 확인 단계에서 HEAD 로만 봅니다.
            if parts.netloc == host and not parts.path.lower().endswith(NOT_PAGE) and url not in seen:
                seen.add(url)
                queue.append(url)
    return found, done


def main():
    ap = argparse.ArgumentParser()
    ap.add_argument("start")
    ap.add_argument("--max-pages", type=int, default=50)
    ap.add_argument("--workers", type=int, default=8)
    ap.add_argument("--timeout", type=float, default=10)
    ap.add_argument("--csv", default="broken_links.csv")
    args = ap.parse_args()

    t0 = time.perf_counter()
    found, done = crawl(args.start, args.max_pages, args.timeout)
    pages = len(done)
    t1 = time.perf_counter()
    # 수집 단계에서 이미 GET 해 본 주소는 다시 요청하지 않습니다(같은 타임아웃을 두 번 기다리지 않게).
    todo = [u for u in sorted(found) if u not in done]
    with ThreadPoolExecutor(args.workers) as ex:
        results = list(ex.map(lambda u: check(u, args.timeout), todo))
    results += [(u, *done[u]) for u in sorted(found) if u in done]
    t2 = time.perf_counter()

    broken = [(u, s, f) for u, s, f in results if not (isinstance(s, int) and s < 400)]
    redirected = [(u, s, f) for u, s, f in results if isinstance(s, int) and s < 400 and f != u]
    print(f"페이지 {pages}개 수집({t1 - t0:.2f}초), 링크 {len(found)}개 중 {len(todo)}개 추가 확인({t2 - t1:.2f}초)")
    # 화면에는 %인코딩을 풀어서 사람이 읽을 수 있게 보여줍니다.
    for u, s, f in redirected:
        print(f"  [이동] {unquote(u)} -> {unquote(f)}")
    for u, s, _ in broken:
        where = ", ".join(unquote(x) for x in sorted(found[u])[:2])
        more = f" 외 {len(found[u]) - 2}곳" if len(found[u]) > 2 else ""
        print(f"  [깨짐 {s}] {unquote(u)}  (있던 곳: {where}{more})")
    with open(args.csv, "w", newline="", encoding="utf-8-sig") as f:
        w = csv.writer(f)
        w.writerow(["링크", "상태", "있던 페이지"])
        for u, s, _ in broken:
            w.writerow([u, s, " ".join(sorted(found[u]))])
    print(f"깨진 링크 {len(broken)}개, 이동 {len(redirected)}개 -> {args.csv}")
    return 1 if broken else 0


if __name__ == "__main__":
    sys.stdout.reconfigure(encoding="utf-8")
    sys.exit(main())

일부러 깨뜨린 시험 사이트로 먼저 검증

실제 사이트에 바로 돌리면 결과가 맞는지 판단할 기준이 없습니다. 그래서 파이썬 http.server로 시험용 사이트를 띄웠습니다. 글 120개짜리 블로그 구조에 지워진 이미지(404), 없는 페이지(404), 지워진 글을 가리키는 링크(404), 서버 오류(500), 15초 동안 응답이 없는 페이지, 꺼진 외부 서버, 301 이동, HEAD 요청만 거부하는 페이지를 심었습니다. 실제 네트워크처럼 모든 응답에 50ms 지연도 넣었습니다.

$ python testsite.py --max-pages 200 --workers 8 --timeout 5
페이지 139개 수집(12.98초), 링크 140개 확인(5.91초)
  [이동] http://127.0.0.1:8765/old-url -> http://127.0.0.1:8765/posts/1
  [깨짐 500] http://127.0.0.1:8765/error  (있던 곳: http://127.0.0.1:8765/posts/58)
  [깨짐 404] http://127.0.0.1:8765/img/thumb-old.png  (있던 곳: http://127.0.0.1:8765/)
  [깨짐 404] http://127.0.0.1:8765/missing  (있던 곳: http://127.0.0.1:8765/)
  [깨짐 404] http://127.0.0.1:8765/posts/999  (있던 곳: http://127.0.0.1:8765/posts/37)
  [깨짐 TimeoutError] http://127.0.0.1:8765/slow  (있던 곳: http://127.0.0.1:8765/)
  [깨짐 URLError] http://127.0.0.1:8766/  (있던 곳: http://127.0.0.1:8765/)
깨진 링크 6개, 이동 1개 -> broken_links.csv
[exit 1, 전체 19.02초]

심어 둔 깨진 링크 6개를 모두 찾았고, 이동한 링크 1개는 따로 표시했습니다. mailto:와 #top은 건너뛰고, /about#team은 /about과 같은 주소로 합쳐졌습니다.

HEAD만 거부하는 /head-405가 깨짐 목록에 없는 건 GET 재시도 덕분입니다. HEAD만 썼을 때와 비교해 보면 차이가 분명합니다.

$ python head_test.py
HEAD만: 405
HEAD 후 GET 재시도: 200

HEAD는 본문을 받지 않아 빠르지만, 서버에 따라 거부합니다. HEAD만 믿으면 멀쩡한 페이지를 깨졌다고 보고하게 됩니다.

첫 버전의 낭비: 같은 페이지를 두 번 요청

결과는 맞았지만 확인 단계가 5.91초 걸린 게 이상했습니다. 확인할 링크 대부분은 수집 단계에서 이미 열어 본 같은 도메인 페이지였기 때문입니다. 코드를 보니 수집 단계에서 받은 결과를 버리고 확인 단계에서 같은 주소를 다시 요청하고 있었습니다. 응답 없는 /slow의 5초 타임아웃도 두 번 기다렸습니다. 수집할 때 받은 상태를 저장해 두고 확인 단계에서는 나머지만 요청하게 바꿨습니다.

# 수집 단계: 페이지를 GET 할 때 결과를 저장해 둡니다
done[page] = (status, final)

# 확인 단계: 이미 받아 본 주소는 다시 요청하지 않습니다
todo = [u for u in sorted(found) if u not in done]
with ThreadPoolExecutor(args.workers) as ex:
    results = list(ex.map(lambda u: check(u, args.timeout), todo))
results += [(u, *done[u]) for u in sorted(found) if u in done]
페이지 139개 수집(13.00초), 링크 140개 중 1개 추가 확인(2.04초)
... (깨진 링크 6개, 이동 1개 — 결과는 같음)
[exit 1, 전체 15.15초]

전체 시간이 19.02초에서 15.15초로 줄었습니다. 추가로 확인할 주소가 꺼진 외부 서버 하나뿐이었는데도 2초가 걸려서 따로 재 보니, 이 노트북에서 닫힌 포트로의 연결 거부 하나가 2.05초 걸렸습니다. 연결 거부는 바로 끝날 거라고 생각했는데 그렇지 않았습니다. 외부 링크가 많은 사이트라면 병렬 확인이 필요한 이유입니다.

실제 블로그에 돌렸더니 47개 — 오탐 세 종류

검증을 마치고 이 블로그에 돌렸습니다. 서버에 부담을 주지 않도록 페이지는 30개까지만 수집했습니다.

$ python linkcheck.py https://codetipbox.com/ --max-pages 30 --workers 8 --timeout 10
페이지 30개 수집(52.32초), 링크 193개 중 163개 추가 확인(16.22초)
  [깨짐 UnicodeEncodeError] https://codetipbox.com/소개/  (있던 곳: https://codetipbox.com/%ec%86%8c%ea%b0%9c/)
  [깨짐 UnicodeEncodeError] https://codetipbox.com/category/자동화-스크립트/  (...)
  [깨짐 405] https://codetipbox.com/xmlrpc.php  (...)
  [깨짐 400] https://i0.wp.com  (있던 곳: https://codetipbox.com/, ...)
  [깨짐 404] https://pagead2.googlesyndication.com  (...)
  [깨짐 404] https://www.googletagmanager.com  (...)
  ...
깨진 링크 47개, 이동 15개 -> real_v2.csv
exit: 1, 소요 68.67초

47개를 하나씩 확인해 보니 절반 가까이가 스크립트 쪽 문제였습니다.

증상개수실제 원인고친 방법
UnicodeEncodeError18워드프레스가 일부 링크를 /소개/처럼 한글 그대로 내보냄. urllib는 주소에 한글이 있으면 요청을 보내지 못함모든 주소를 %인코딩으로 통일
i0.wp.com 400, googletagmanager 404 등3<link rel="preconnect">, dns-prefetch. 도메인만 적힌 연결 힌트라 링크가 아님rel이 preconnect·dns-prefetch면 건너뜀
xmlrpc.php 4051<link rel="pingback">. POST 전용 주소라 GET·HEAD는 405가 정상rel이 pingback이면 건너뜀

한글 주소 문제는 오탐 말고 부작용도 있었습니다. 같은 글이 %ec%86%8c... 형태와 한글 형태로 두 번 들어와 같은 페이지를 중복 수집하고 있었습니다. 30페이지 한도 중 상당 부분을 중복에 쓴 셈입니다. 주소를 한 번 풀었다가 다시 인코딩하면 두 형태가 같은 문자열이 됩니다.

def normalize(base, raw):
    """상대 주소를 절대 주소로 바꾸고, 한글 주소는 %인코딩으로 통일합니다."""
    url = urldefrag(urljoin(base, raw))[0]
    return quote(unquote(url), safe=":/?&=+,;@!$'()*~%")

이 밖에 이미지·CSS·JS 파일은 수집 단계에서 GET으로 받지 않고, 확인 단계에서 HEAD로만 보게 바꿨습니다. 페이지가 아닌 파일을 통째로 내려받을 필요가 없기 때문입니다.

고친 뒤 남은 26개 — 진짜 문제 두 가지

$ python linkcheck.py https://codetipbox.com/ --max-pages 30 --workers 8 --timeout 10
페이지 30개 수집(55.68초), 링크 192개 중 162개 추가 확인(20.51초)
  [이동] https://codetipbox.com/?p=11 -> https://codetipbox.com/소개/
  [이동] https://codetipbox.com/?p=15 -> https://codetipbox.com/문의/
  ...
  [깨짐 404] https://codetipbox.com/contact  (있던 곳: https://codetipbox.com/면책조항/, https://codetipbox.com/소개/)
  [깨짐 404] https://i0.wp.com/codetipbox.com/wp-content/uploads/2026/09/thumb-294.png?fit=708,396&ssl=1  (있던 곳: https://codetipbox.com/category/개발-생산성-팁/)
  ... (i0.wp.com 썸네일 404 총 24개)
  [깨짐 429] https://www.aboutads.info/choices/  (있던 곳: https://codetipbox.com/개인정보처리방침/)
깨진 링크 26개, 이동 20개 -> real_v3.csv
exit: 1, 소요 76.32초

경고가 47개에서 26개로 줄었습니다. 오탐 22개가 사라졌고, 중복 수집이 없어지면서 같은 30페이지 한도 안에 면책조항·개인정보처리방침 페이지가 새로 들어와 경고 1개가 새로 생겼습니다. 남은 26개는 세 종류입니다.

  • /contact 404 (1개): 소개·면책조항 페이지에 있는 링크인데 사이트에 그런 페이지가 없습니다. 실제 문의 페이지 주소는 /문의/입니다. 진짜 깨진 링크입니다.
  • i0.wp.com 썸네일 404 (24개): 워드프레스 이미지 CDN 주소로 나가는 썸네일들입니다. 아래에서 따로 확인했습니다.
  • aboutads.info 429 (1개): 429는 “요청이 너무 많다”는 응답이라 깨졌다고 단정할 수 없습니다. 이 링크는 따로 확인하지 않았습니다.

썸네일 404가 정말인지 원본 주소와 나란히 다시 요청해 봤습니다.

$ python probe.py
HEAD 404 / GET 404 '' 0바이트  https://i0.wp.com/codetipbox.com/wp-content/uploads/2026/10/thumb-2026-10-25-jsondecodeerror.png?fit=1200%2C675&ssl=1
HEAD 200 / GET 200 'image/png' 188171바이트  https://codetipbox.com/wp-content/uploads/2026/10/thumb-2026-10-25-jsondecodeerror.png
HEAD 404 / GET 404 '' 0바이트  https://codetipbox.com/contact

같은 파일인데 원본 주소(codetipbox.com/wp-content/...)는 200으로 이미지 188,171바이트가 왔고, CDN 주소(i0.wp.com/...)는 404였습니다. 스크립트의 User-Agent 탓일 수도 있어서 크롬 브라우저와 같은 User-Agent로 바꿔 다시 요청했지만 결과는 같은 404였습니다. 원인까지는 아직 확인하지 못했습니다. 다만 이런 건 사람이 글을 하나씩 열어 봐서는 알아채기 어렵습니다. 스크립트가 아니었으면 계속 몰랐을 문제입니다.

정기 점검으로 돌리기

스크립트는 깨진 링크가 하나라도 있으면 종료 코드 1로 끝나고, 결과를 broken_links.csv로 저장합니다. CSV는 엑셀에서 한글이 깨지지 않도록 utf-8-sig로 씁니다. 그래서 다른 자동화 스크립트 뒤에 붙이거나 매주 한 번 실행해 두고, 종료 코드가 1일 때만 CSV를 열어 보는 식으로 쓸 수 있습니다.

시간은 수집 단계가 대부분입니다. 이 블로그 기준으로 30페이지 수집에 55.68초(페이지당 약 1.9초), 링크 162개 확인에 20.51초(스레드 8개)가 걸렸습니다. 수집은 한 페이지씩 순서대로 하기 때문에 페이지 수에 거의 비례해서 늘어납니다. 처음에는 --max-pages를 작게 잡고 결과를 본 뒤 늘리는 편이 좋습니다.

주의할 점

  • 처음 결과를 그대로 믿지 마세요. 이번에 첫 실행 경고 47개 중 22개가 오탐이었습니다. 몇 개를 직접 다시 요청해 보고 원인을 확인한 뒤 고치는 게 순서입니다.
  • HEAD만 쓰면 멀쩡한 페이지가 깨짐으로 나올 수 있습니다. 403·405·501이면 GET으로 다시 확인하세요.
  • 한글 주소는 반드시 %인코딩하세요. 워드프레스 한글 슬러그 사이트라면 이것 없이는 글 링크 대부분이 오류로 나옵니다.
  • 429는 깨진 링크가 아닙니다. 상대 서버가 요청을 잠시 막은 것이라 시간을 두고 다시 확인해야 합니다.
  • 남의 사이트에 대량으로 돌리지 마세요. 내 사이트라도 페이지 수 한도를 두고, 스레드 수를 과하게 늘리지 않는 게 좋습니다. 이번에는 30페이지, 스레드 8개로만 돌렸습니다.
  • 자바스크립트로 그려지는 링크는 못 찾습니다. 이 스크립트는 서버가 보낸 HTML만 읽습니다.

마무리

깨진 링크 점검은 스크립트를 한 번 돌리는 것보다, 결과를 다시 확인해 오탐을 걸러 내는 과정이 더 중요했습니다.

가장 작은 첫걸음은 이렇습니다.

  1. 위 linkcheck.py를 저장하고 내 사이트 주소로 --max-pages 10부터 돌려 봅니다.
  2. 깨짐 목록에서 두세 개를 골라 브라우저로 직접 열어 보고, 오탐이면 원인(한글 주소, preconnect, HEAD 거부)을 확인합니다.
  3. 결과가 믿을 만해지면 한도를 늘리고, 정기적으로 실행해 종료 코드 1일 때만 CSV를 확인합니다.

다음 글에서는 스크립트가 모은 데이터를 CSV 대신 SQLite에 가볍게 저장하는 방법을 실제로 돌려 보겠습니다.

댓글 남기기