dev.to

How I Built a Broken Link Scanner for Large Websites

대형 웹사이트용 깨진 링크 스캐너를 직접 만든 방법 — Laravel은 컨트롤 플레인, Go는 스캐닝 엔진

깨진 링크 검사기는 간단한 주말 프로젝트처럼 보이지만, 대형 사이트에서는 사실상 분산 크롤링 시스템이 된다는 글입니다. 저자는 Laravel을 인증·스케줄링·리포팅이 담당하는 컨트롤 플레인으로, Go를 동시성 네트워크 작업을 처리하는 스캐닝 엔진으로 분리해 구현했습니다. URL 정규화, 호스트별 동시성 제한, 응답 분류, 리다이렉트 추적, 체크포인트 복구까지 실무 설계 결정을 상세히 공유합니다.

본문

깨진 링크 검사기는 페이지를 가져오고 <a href>를 수집해 각 URL을 요청한 뒤 404를 보고하면 되는 간단한 도구처럼 보입니다. 그러나 실제 대형 웹사이트에 적용하면 중복 URL, 리다이렉트, 외부 도메인, 누락된 에셋, rate limit, 잘못된 HTML, CSS import, 네트워크 장애, 수천 페이지의 자원 경합이 쏟아지며 단순 스크립트가 아니라 분산 크롤링 시스템이 됩니다. 저자는 이 문제를 Laravel을 컨트롤 플레인으로, Go를 스캐닝 엔진으로 분리해 해결했습니다.

Laravel과 Go의 역할 분담

Laravel은 사용자와 인증, 프로젝트와 스캔 설정, 사용량 제한, 예약 스캔, 결과 저장, 진행 상황 업데이트, 리포트와보내기 같은 애플리케이션 영역을 담당합니다. Go는 다수의 URL을 동시에 가져오면서 메모리와 동시성을 제어하는 네트워크 집약적 작업에 더 적합하다고 판단했습니다. 전체 흐름은 사용자가 스캔을 시작하면 Laravel이 큐에 스캔을 등록하고, Go 워커가 스캔을 lease해 크롤링과 URL 검사를 동시에 수행한 뒤, 결과와 진행 상황을 배치로 돌려보내고, Laravel이 이를 저장해 리포트를 만드는 구조입니다. Go 워커는 독립 프로세스로 애플리케이션에 등록하고 heartbeat를내며 API를 통해 작업을 요청하기 때문에, 웹 애플리케이션을 수정하지 않고 스캐너 서버를 늘릴 수 있었습니다.

URL 정규화와 크롤링 범위

초기 크롤러는 시작 URL을 넣고 다운로드하고 링크를 추출해 새 내부 URL을 큐에 추가하는 단순한 구조였는데, 어려운 부분은 '새로운 URL'의 정의였습니다. 예를 들어 example.com/about, 뒤에 #team이 붙은 URL, 대문자 호스트 EXAMPLE.com, :443 포트가 붙은 URL, 상대 경로 /about은 모두 같은 자원을 가리킬 수 있습니다. 정규화 없이는 같은 페이지를 여러 번 요청하거나 루프에 빠질 수 있으므로, 발견된 모든 URL은 상대 URL 해석, fragment 제거, 호스트 정규화, 기본 포트 처리를 거칩니다. 다만 문서에서 발견된 원본 값도 함께 보관하는데, 정규화된 URL은 중복 제거에 유용하지만 실제로 링크를 고칠 때는 원본 값이 더 필요하기 때문입니다. 또한 발견과 순회를 분리해 내부 페이지만 크롤 frontier에 추가하고, 외부 링크는 검사만 하되 재귀적으로 크롤링하지 않습니다. 그렇지 않으면 한 사이트 검사가 웹 전체 검사로 번질 수 있습니다.

동시성은 성능이 아니라 행동 규칙

순차 검사는 예의 바르지만 너무 느리고, 대형 사이트에서는 네트워크 지연이 스캔 시간을 지배합니다. Go는 URL마다 goroutine을 만들기 쉽지만 무제한 동시성은 스캐너와 대상 사이트 모두를 마비시킵니다. 저자는 여러 수준의 유계 동시성을 적용했는데, 워커당 동시 스캔 수 제한, 스캔 내부의 워커 풀, 활성 HTTP 요청의 전역 한도, 호스트별 동시성 제한, 같은 호스트에 대한 요청 간 선택적 지연이 그것입니다. 특히 호스트별 제한이 중요한데, 한 페이지에 메인 도메인·CDN·분석 도구·외부 사이트의 자원이 섞여 있으므로 서로 다른 호스트는 병렬로 처리하되 개별 서버에는 과도한 요청을 보내지 않게 합니다. 429 Too Many Requests나 일부 5xx는 즉시 깨진 링크로 기록하지 않고 재시도·지연·호스트별 backoff로 대응합니다.

응답 분류와 리다이렉트 처리

초기 실수는 2xx 밖의 모든 응답을 깨진 것으로 처리한 것이었습니다. 실제로는 404와 410은 강력한 신호지만, 3xx는 전체 리다이렉트 체인을 검사해야 하고, 401·403은 자원이 존재하되 스캐너를 거부하는 것일 수 있으며, 429는 속도를 줄이라는 뜻이고, 5xx는 일시 장애일 수 있습니다. DNS·TLS·연결·타임아웃 오류도 별도로 분류해야 합니다. 외부 사이트가 브라우저에서는 정상이지만 자동 클라이언트에 403을 반환하는 경우가 있는데, 이를 확정된 오류로 표시하면 리포트 전체의 신뢰가 떨어집니다. 그래서 is_broken 하나 대신 HTTP 상태, 오류 범주, content type, 요청 시간, 내부·외부 여부, 검증 상태, 최종 URL, 리다이렉트 이력을 함께 저장합니다. 리다이렉트는 각 단계의 상태와 목적지를 기록해 '/old-page [301] → /new-page [302] → 최종 [200]' 같은 체인으로 보여주며, 루프·과도하게 긴 체인·잘못된 Location 헤더·지원하지 않는 scheme도 감지합니다. 리다이렉트 대상 URL도 동일한 안전성·범위 검사를 거쳐야 하는데, 공개 URL이 스캐너가 접근하면 안 되는 곳으로 리다이렉트할 수 있기 때문입니다.

HTML과 CSS의 에셋 검사, 그리고 결과 모델

네비게이션 링크가 멀쩡해도 이미지·스타일시트·폰트·스크립트가 누락되면 페이지는 깨져 보입니다. 스캐너는 링크, 이미지와 srcset, 스타일시트, script src, iframe, 오디오·비디오, 인라인 style URL을 검사하고, CSS에서는 @import, background-image의 url(), @font-face의 src 같은 자원을 별도 추출 단계로 찾습니다. JavaScript는 참조된 파일까지만 검사하고 실행하거나 런타임에 생성되는 URL을 정적으로 해석하지는 않는데, 그것은 브라우저 렌더링 계층이 필요해 비용과 동작이 크게 달라지기 때문이며, 이 한계를 명시하는 편이 낫다고 말합니다. 결과 모델에서는 검사 대상 URL과 그 출현 위치를 분리해, 어느 페이지에서 발견됐는지·원본 URL 값·HTML 요소와 속성·링크 텍스트·CSS 발견 방식·출현 횟수를 저장합니다. 이 덕분에 '하나의 누락된 에셋이 150개 페이지에 영향' 같은 리포트가 가능하고, 전역 스타일시트 수정처럼 우선순위가 높은 수정을 가려낼 수 있습니다.

배치 전송, 체크포인트, 리포팅

URL 하나당 API 요청 하나는 오버헤드가 크고, 크롤이 끝날 때까지 기다리면 워커 중단 시 전부 잃습니다. 그래서 완료된 결과, 현재 URL, 검사·대기·건너뛴 URL 수, 크롤 지표, 남은 frontier의 체크포인트를 배치로 보내고, Laravel은 이를 검증·저장하며 실시간 진행 상황을 브로드캐스트합니다. 장시간 스캔은 결국 재시작·배포·네트워크 장애·실행 시간 초과로 끊기므로, 대기 중인 URL, 방문한 URL, 보류 작업, 카운터, 호스트별 backoff 상태를 주기적으로 저장합니다. 작업이 영구 할당이 아니라 lease 방식이기 때문에 Laravel이 중단된 스캔을 회수해 다른 워커에게 다시 맡길 수 있고, 저자는 체크포인트가 선택적 신뢰성 기능이 아니라 대형 사이트의 정상 실행 모델이라고 강조합니다. 마지막으로 리포트는 확정된 깨진 링크, 불확실한 검사, 가장 많은 페이지에 영향을 주는 문제, 리다이렉트 URL, 각 링크의 발견 위치, 자원 유형, 이전 스캔 대비 신규·지속·해소 여부를 답해야 하며 이는 Laravel이 맡습니다. 저자는 ▲URL은 일찍 정규화하되 원본을 보존할 것 ▲스캔과 호스트 수준 모두에서 동시성을 제한할 것 ▲리다이렉트를 데이터로 다룰 것 ▲확정된 실패와 불확실한 응답을 구분할 것 ▲모든 링크의 발견 위치를 기록할 것 ▲완료를 기다리지 말고 증분 배치를 보낼 것 ▲중단·재개를 처음부터 설계할 것 ▲크롤링과 리포팅 책임을 분리할 것을 교훈으로 정리했습니다. 이 프로젝트는 결국 Brokenlinks.pro라는 제품으로 발전했습니다.

원문: dev.to / 번역·요약: Trawling