출처로 걸어둔 외부 링크가 죽어도 블로그는 아무 말을 하지 않았습니다
저는 글에 숫자나 인용을 적으면 출처 링크를 문장 끝에 답니다. 글 목록 페이징 글만 해도 Hugo 릴리스 노트와 이슈 링크가 붙어 있습니다. 문제는 그 링크가 남의 서버에 있다는 점입니다. 상대가 문서를 옮기거나 지우면 링크는 죽는데, 제 빌드는 그걸 모른 채 성공합니다. 독자만 근거를 못 찾고 돌아갑니다.

그래서 사이트 전체의 외부 링크를 한 번에 훑어봤습니다. 도구는 lychee(링크 점검 CLI)이고 2026-10-06 밤(한국 시각)에 잰 결과입니다. 먼저 결론을 말하면 에러로 뜬 1건은 죽은 링크가 아니었고 봐야 할 건 OK로 통과한 쪽에 있었습니다.
원본이 아니라 빌드 결과물을 훑었습니다

마크다운 원본 대신 public/, 즉 Hugo가 만들어 낸 HTML을 대상으로 잡았습니다.
헤더 메뉴처럼 템플릿이 넣는 링크는 원본 글에 안 보이기 때문입니다.
독자가 실제로 누르는 것은 빌드 결과물 쪽입니다.
lychee는 GitHub 릴리스의 macOS 바이너리(0.24.2)를 받아 체크섬을 맞춰본 뒤 썼습니다. 첫 실행은 기본값 그대로입니다.
$ lychee --no-progress --root-dir "$PWD/public" \
--exclude 'birchholt\.com' \
--format json --output run1.json public
--root-dir는 /posts/...처럼 슬래시로 시작하는 링크를 public/ 안에서 찾게 합니다. 제 도메인 절대주소는 뺐습니다.
| 항목 | 값 |
|---|---|
| 검사한 링크(중복 포함) | 530 |
| 외부 링크(고유) | 76개, 호스트 27곳 |
| 외부 링크가 있는 페이지 | 19개(글 18편 + 개인정보처리방침) |
| 성공 / 리다이렉트 / 에러 | 75 / 5 / 1 |
| 걸린 시간 | 11.2초 |
호스트로는 github.com 13개, gohugo.io 12개, developers.cloudflare.com 10개 순이었습니다.
lychee는 기본값으로 코드블록 안 주소를 보지 않습니다. --include-verbatim을 켜고 세어 보니 외부 링크가
76개에서 96개로 늘었는데, 늘어난 20개는 명령 예시, API 주소, 중간에 잘린 주소 같은 것이었습니다. 기본값을 그대로 두는 게 맞았습니다. 압축 빌드(--minify)에 돌려도 숫자는 같았습니다.
비슷한 도구로 htmltest가 있지만 마지막 릴리스가 v0.17.0(2022-11-04)이라 지금도 릴리스가 나오는 lychee를 골랐습니다. htmltest는 돌려보지 않았습니다.
유일한 에러 429는 죽은 링크가 아니었습니다

에러 1건은 개인정보처리방침에 걸어둔 쿠키 거부 안내 링크였습니다.
[429] https://www.aboutads.info/choices/ | Rejected status code: 429 Too Many Requests
429는 “요청이 너무 많다"는 응답 번호입니다. 먼저 UA(User-Agent, 요청을 보낸 프로그램이 누구인지 밝히는 머리글)를 브라우저처럼 바꿔 봤습니다. curl로 lychee UA와 크롬 UA, 쿠키 저장 켜기와 끄기를 섞어 네 번 보냈는데 네 번 모두 429였습니다. 응답 머리글을 보고 이유를 알았습니다.
HTTP/2 429
server: Vercel
x-vercel-mitigated: challenge
페이지 제목도 Vercel Security Checkpoint였습니다. 속도 제한이 아니라 봇 확인 화면입니다.
자바스크립트를 실행해야 넘어가는 종류로 보이고 실제로 UA를 바꿔도 풀리지 않았습니다.
사람이 브라우저로 열면 정상인지는 제가 아직 확인하지 못했습니다.
UA를 브라우저로 바꾸면 오히려 GitHub에서 429가 생겼습니다. GitHub 소스 파일 페이지 하나를 12번 연속으로 요청해 봤습니다.
lychee/0.24.2 200 200 200 200 200 200 200 200 200 200 200 200
크롬 UA 200 200 200 429 200 200 429 200 200 429 200 200
(1분 뒤)
크롬 UA 429 200 200 429 200 200 200 429 200 429 429 429
lychee/0.24.2 200 200 200 200 200 200 200 200 200 200 200 200
curl/8.7.1 200 200 200 200 200 200 200 200 200 200 200 200
크롬 UA만 12번 중 4번, 6번 막혔습니다. 브라우저 UA에 재시도 5초, 같은 호스트 동시 요청 2개, 요청 간격 0.5초를 묶은 설정으로 사이트 전체를 다시 돌려 보니 57초가 걸렸고 결과는 기본값과 똑같았습니다. 고쳐진 링크가 0건이었습니다. 이번 측정 전체에서 403(접근 거부)은 한 번도 나오지 않았습니다.
한 번의 실패를 죽음으로 읽으면 안 됩니다. 같은 밤에 docs.github.com은 제가 짧게 여러 번 부른 직후 429를 돌려주다가 1분쯤 뒤에는 200이었습니다. web.archive.org는 몇 분 동안 연결 자체가 안 되다가 다시 200이 됐습니다. 그래서 저는 두 주 연속으로 같은 링크가 실패할 때만 손을 대기로 했습니다.
OK로 통과한 리다이렉트 5개를 손으로 열어봤습니다

lychee는 리다이렉트(다른 주소로 넘겨주는 응답)를 기본 10번까지 따라가고 끝이 200이면 성공으로 셉니다. 문서가 옮겨 갔든 엉뚱한 곳으로 갔든 초록불입니다.
그런데 어디로 넘어갔는지 목록을 얻는 데서 한 번 막혔습니다.
요약에는 5건인데 JSON 결과의 redirect_map은 비어 있었고 -v를 붙여야 채워졌습니다.
화면 출력 형식을 바꿔도 목록은 안 나왔습니다.
$ lychee -v --root-dir "$PWD/public" --exclude 'birchholt\.com' \
--format json --output out.json public
$ jq -r '.redirect_map[][] | "\(.origin)\n → \(.redirects[-1].url) (\(.redirects|length)번)"' out.json
다섯 개를 하나씩 열어봤습니다.
| 원래 링크 | 도착한 곳 | 판정 |
|---|---|---|
| Microsoft Learn API 문서 | 같은 주소에 ?view= 붙은 것 | 같은 문서 |
| ICANN UDRP 안내 | 정책 본문 페이지 | 문장 맥락엔 맞음 |
| 쿠키 거부 안내(NAI) | thenai.org 안내 페이지 | 같은 목적 |
| aboutads.info | Vercel 확인 화면 | 위 절의 그 링크 |
| 구글 설정 링크 | blocked-cookies.html | 200인데 본문이 오류 안내 |
마지막 줄이 이번 점검에서 제일 값진 발견이었습니다. lychee에게는 200이지만 받은 본문은 이렇습니다.
Cookies are disabled
Your browser's cookies seem to be disabled. …
쿠키를 저장하지 않는 프로그램이라 구글이 “쿠키가 꺼져 있다"는 안내로 보낸 것입니다.
lychee에 --cookie-jar cj.json을 주고 다시 돌리니 7번 넘어가서 설정 페이지 주소(adssettings.google.com/...)에 도착했습니다.
200은 “맞는 페이지가 나왔다"가 아닙니다. 없는 주소가 200을 돌려주던 일과 같은 함정입니다.
76개 전부의 <title>도 뽑아 봤는데 “Not Found"나 “404"가 들어간 제목은 없었습니다. 다만 제목이 카테고리
이름뿐인 도움말 4개는 본문까지 대조하지 않았습니다(2026-10-06 측정).
앵커까지 보게 하자 24건이 새로 터졌습니다

문서는 살아 있어도 #summarylength 같은 앵커(문서 안 특정 위치를 가리키는 # 뒤 이름)만 사라질 수 있습니다.
--include-fragments를 켜면 lychee가 그것까지 봅니다. 켜자마자 에러가 1건에서 25건이 됐습니다.
[ERROR] file:///…/public#%ec%9a%b4%ec%98%81%ea%b8%b0%eb%a1%9d | Cannot find fragment
늘어난 24건은 전부 같은 링크였습니다. 모든 페이지 헤더에 있는 /#운영기록입니다.
한글 앵커가 퍼센트 인코딩된 모양이라 처음엔 인코딩 문제를 의심했습니다
(한글 앵커가 주소에서 어떻게 바뀌는지는 한국어 제목 목차 링크 글에 적었습니다).
작은 HTML로 갈라 봤습니다. 같은 파일 안의 #운영기록은 통과했고 앞에 /가 붙으면
인코딩을 어떻게 바꿔도 셋 다 실패했습니다. 인코딩이 아니라 /가 문제였습니다.
/는 디렉터리를 가리키는데 lychee는 기본값으로 그 안의 index.html을 열지 않습니다. 도움말도
「If unspecified (the default behavior), index files are disabled」라고 적고 있습니다.
$ lychee --include-fragments --index-files index.html --root-dir "$PWD/public" ... public
--index-files index.html을 붙이자 24건이 사라지고 에러는 다시 1건이 됐습니다.
앵커 검사가 진짜로 죽은 앵커를 잡는지도 확인했습니다. gohugo.io와 docs.github.com 문서에
있는 앵커와 일부러 지어낸 #nonexistent-zzz를 섞어 넣었더니 지어낸 두 개만 Cannot find fragment로 걸렸습니다.
옵션을 끄면 gohugo.io 쪽은 지어낸 앵커도 통과했습니다(docs.github.com은 그때 429였습니다). 제 사이트에서 앵커가 붙은 외부 링크 4개는 모두 살아 있었습니다.
lychee README의 최신판은 「Anchors that only appear after JavaScript execution may be missed, since lychee does not run a browser engine」이라고 한계를 밝혀 둡니다. 자바스크립트로 그리는 문서의 앵커는 이 검사로 보장되지 않습니다.
일주일에 한 번 돌리는 점검표로 묶었습니다

지금까지 확인한 것을 설정 파일 하나로 모았습니다.
# lychee.toml — public/ 을 대상으로 돌린다
no_progress = true
index_files = ["index.html"] # "/#앵커" 링크를 index.html 에서 찾게 한다
include_fragments = "anchor-only" # 외부 문서의 #앵커가 사라진 것도 잡는다
exclude = [
'^https://birchholt\.com',
'^mailto:',
'^https://www\.aboutads\.info/', # Vercel 확인 화면(429). 사람이 브라우저로 따로 본다
]
# user_agent 는 바꾸지 않는다 — 브라우저 UA 가 GitHub 에서 오히려 429 를 받았다
max_retries = 3
retry_wait_time = 5
host_concurrency = 2
host_request_interval = "500ms"
cache = true
max_cache_age = "6d"
cache_exclude_status = "429, 500.."
[hosts."github.com"]
concurrency = 1
request_interval = "2s"
include_fragments = true라고 적으면 설정 오류로 끝납니다. 값은 "anchor-only" 같은 문자열이어야 합니다.
이 설정으로 돌리면 25초, 에러 0건이었습니다. 바로 한 번 더 돌리면 캐시 덕분에 0초에 끝났습니다. 그런데 두 번째 결과에서는 리다이렉트가 0건으로 나왔습니다. 캐시에서 꺼낸 결과는 리다이렉트로 세지 않습니다. 그래서 유효기간을 6일로 잡았습니다. 7일 간격 예약 실행끼리는 캐시가 만료돼 있어 매번 새로 묻고 같은 주에 손으로 다시 돌릴 때만 서버 부담을 덜어줍니다.
GitHub Actions에 붙일 워크플로도 실험용 사본에 만들었습니다. 배포 워크플로와 분리해서 아무것도 배포하지 않고 보고서만 남깁니다. 링크를 고치는 건 제가 보고 합니다.
on:
schedule:
- cron: '17 23 * * 0' # 월요일 08:17 KST, 정각은 피한다
workflow_dispatch:
# … 빌드 뒤
- name: 외부 링크 점검
id: lychee
uses: lycheeverse/lychee-action@v2
with:
args: --config lychee.toml --verbose --root-dir ${{ github.workspace }}/public public
format: json
output: lychee/out.json
fail: false
- name: 에러와 리다이렉트 목록
if: always()
run: |
jq -r '.error_map[][]? | "ERROR \(.status.code // "-") \(.url)"' lychee/out.json
jq -r '.redirect_map[][]? | "REDIRECT \(.origin) -> \(.redirects[-1].url)"' lychee/out.json
--verbose는 앞에서 본 redirect_map을 채우려고 넣었습니다. 아래 두 줄의 jq는
제 컴퓨터에서 실제 결과 파일에 돌려 에러 1줄과 리다이렉트 5줄이 나오는 것까지 확인했습니다.
다만 이 워크플로를 GitHub에서 실제로 돌려보지는 않았습니다.
lychee-action v2.9.0의 lychee 버전 기본값은 v0.24.2로, 제가 잰
버전과 같습니다. GitHub schedule 문서는
「High load times include the start of every hour」라 지연될 수 있고 공개 저장소에서
「no repository activity has occurred in 60 days」이면 예약 실행이 꺼진다고 적고 있습니다(2026-10-06 확인).
종료 코드는 README 기준 0이 성공, 2가 링크 실패, 3이 설정 파일 오류이고 이번에 셋 다 봤습니다.
에러 목록보다 먼저 볼 것

- 에러 목록보다 리다이렉트 목록이 더 중요했습니다. 200인데 본문이 엉뚱한 링크는 OK로 통과한 쪽에 있었습니다
- 429는 “죽음"이 아니라 “판정 불가"입니다. 응답 머리글을 보면 속도 제한인지 봇 확인 화면인지 갈립니다
- UA를 브라우저로 바꾸는 건 만능 해결책이 아닙니다. 제 경우엔 GitHub에서 오히려 막혔습니다
- 앵커 검사를 켤 때는
--index-files index.html을 같이 줘야/#앵커오탐이 안 생깁니다 - 캐시는 서버 부담을 덜지만 리다이렉트 집계를 숨깁니다. 정기 점검 주기보다 짧게 잡았습니다
- 내부 링크는 빌드 단계에서 잡을 수 있습니다. 이 점검은 그 바깥, 남의 서버 쪽입니다