다른 링크 체커들은 재귀를 어떻게 처리할까
원문은 Matthias Endler님이 에 게재했습니다. 이 블로그 구독하기
lychee에 재귀를 추가하려던 5년간의 시도를 공개한 뒤, 꽤 타당한 질문 하나를 받았습니다.
재귀가 그렇게 어렵다면 다른 링크 체커들은 대체 어떻게 하죠? 웹사이트를 크롤링하는 곳도 이미 많잖아요!
이 질문 덕분에 다른 링크 체커들의 코드를 파헤치는 토끼굴에 빠지게 됐습니다. 핵심 결론은 이렇습니다. 우리가 놓친 기발한 꼼수는 없었다. 다른 체커들은 첫 커밋부터 크롤러로 설계됐고, 나는 애초에 lychee를 스트림으로 만들었다는 것.
그래서 lychee README에 있는 재귀 지원 체커들의 소스를 직접 들여다봤습니다. muffet(Go), LinkChecker(Python), linkinator(TypeScript), 그리고 broken-link-checker(JavaScript)가 그 대상입니다. 이번 글에서는 각 도구가 실제로 재귀를 어떻게 처리하는지, 그 대가는 무엇인지, 그리고 그게 lychee에 어떤 의미인지 뜯어봅니다.
첫 번째 글을 아직 안 읽었다면 요약하자면, lychee는 단발성 단방향 파이프라인(inputs → extract → check → output)으로 설계됐습니다. 재귀에는 사이클(응답이 새로운 입력을 만든다)이 필요한데, 비동기 채널 기반 파이프라인에서 사이클은 곧 용이 사는 곳입니다. 🐲 다섯 해와 네 번의 시도 끝에, 제대로 해내기 위해 필요한 조각들이 이제야 겨우 맞춰졌습니다.
DAG와 사이클
내가 살펴본 모든 재귀 체커는 같은 세 요소로 이루어져 있었습니다.
- 고정된 입력 스트림이 아닌 가변 작업 큐(이른바 ‘프런티어’). 발견된 URL은 나왔던 큐로 다시 들어갑니다.
- 요청이 끝나기 전, 큐에 넣는 시점에 갱신되는 방문 집합(visited set). 그래서 두 페이지가 같은 링크를 발견해도 중복으로 제출되지 않습니다.
- “모든 작업이 끝났는가?”에 답하는 원시 수단:
WaitGroup, 조인 가능한 큐 카운터,onIdle()프로미스, 혹은 큐 드레인 이벤트.
그림으로 보면 lychee가 다른 체커들과 어떻게 다른지 명확해집니다.
graph TD
subgraph crawler["Everyone else: a cycle"]
direction TB
CQ[Frontier queue] --> CW[Worker pool]
CW --> CP[Fetch and parse page]
CP -->|new links| CQ
CP --> CR[Results]
end
subgraph lychee["lychee: a DAG"]
direction TB
LA[Inputs] --> LB[Extractor]
LB --> LC[Checker]
LC --> LD[Results]
end크롤러는 애초에 되돌아오는 간선을 갖고 있습니다. 우리 파이프라인에는 그게 없고, 내가 실패했던 모든 시도는 애초에 그런 구조를 전제하지 않은 그래프에 그 간선을 억지로 끼워 넣으려던 시도였습니다.
그 그래프 설계를 좀 더 자세히 들여다보겠습니다.
graph TD
Seed[Seed URLs] --> Enq["Enqueue step: is URL in visited set?"]
Enq -->|yes| Skip[Drop]
Enq -->|no| Mark["Mark visited, then push"]
Mark --> Q[Frontier queue]
Q --> Pool["Worker pool, bounded concurrency"]
Pool --> FP[Fetch page and extract links]
FP -->|discovered links| Enq
FP --> Rec[Results]
Q -.->|empty AND no worker busy| Stop[Terminate]방문 여부 검사는 워커가 네트워크에 손대기 전, 큐에 넣는 단계에서 마킹과 원자적으로 일어난다는 점에 주목하세요. 이 순서가 lychee의 1~4차 시도 내내 괴롭혔던 중복 제거 레이스를 해결하는 핵심입니다. 당시 캐시는 검사한 뒤에야 기록됐기 때문입니다.
각 도구는 이 패턴을 조금씩 변주해서 사용합니다.
muffet(Go): WaitGroup과 Set
muffet은 정신적으로 lychee와 가장 가깝습니다. 빠르고, 단일 바이너리이며, 동시에 동작하는 웹사이트 체커죠. 중복 제거와 스케줄링 결정은 하나의 메서드(page_checker.go)에 들어 있습니다.
func (c *pageChecker) addPage(p page) {
if !c.donePages.Add(p.URL().String()) {
c.daemonManager.Add(func() { c.checkPage(p) })
}
}donePages는 concurrentStringSet(뮤텍스로 보호되는 map[string]struct{})입니다. Add는 URL이 이미 존재했는지 여부를 반환하므로, 페이지는 처음 발견됐을 때만 스케줄링됩니다. 중복 제거는 뮤텍스로 동기화된 set에 의해 큐에 넣는 시점에 일어납니다. 위 다이어그램을 코드로 그대로 옮긴 셈이죠.
페이지를 검사할 때는 그 페이지의 모든 링크를 동시에 가져오고, 조건에 맞는 링크를 다시 addPage로 되돌립니다. 바로 그 되돌아오는 간선입니다.
go func(u string) {
defer w.Done()
status, p, err := c.fetcher.Fetch(u)
// ...
if !c.onePageOnly && p != nil && c.linkValidator.Validate(p.URL()) {
c.addPage(p) // recursion: discovered page re-enters the frontier
}
}(u)muffet은 완료를 어떻게 아는가
muffet이 종료를 판단하는 답은 sync.WaitGroup을 감싼 작은 daemonManager입니다(daemon_manager.go).
func (m daemonManager) Add(f func()) {
m.waitGroup.Add(1)
m.daemons <- func() {
f()
m.waitGroup.Done()
}
}
func (m daemonManager) Run() {
go func() {
for f := range m.daemons {
go f()
}
}()
m.waitGroup.Wait() // <- termination
}스케줄링된 페이지마다 그룹 카운터를 하나 올리고, 완료된 페이지마다 하나 내립니다. 카운트가 0이 되면 Wait()이 반환됩니다. 전체 크롤은 Run() 전에 단 한 번의 addPage로 부트스트랩되므로, 누군가 대기하기 전에 이미 카운터는 양수입니다.
이건 시도 1과 시도 4에서 내가 시도하고 실패했던 바로 그 카운터와 같은 카운터입니다. 차이는 불변 조건에 있습니다. waitGroup.Add(1)은 이미 실행 중이어서 카운트를 0보다 크게 유지하고 있는 데몬 내부에서만 호출됩니다(부트스트랩 제외). 작업이 아직 남아 있는데 카운터가 잠깐 0을 가리키는 구간이 없습니다. Go의 WaitGroup은 이 불변식을 너무나 자연스럽게 강제해서 분산 종료 감지처럼 느껴지지도 않지만, 정확히 그 일을 하고 있는 겁니다. 2026년 Kait가 lychee에 기여한 WaitGroup 원시 수단과 본질적으로 같은 존재죠.
트레이드오프는 어디에 있는가
- 동시성은 데몬 매니저가 제한하지 않습니다.
Run()은 작업마다go f()를 실행해 고루틴을 무제한으로 만듭니다. 실제 제한은 하위 단계의semaphore(버퍼 채널 기반 카운팅 세마포어)와 호스트별 스로틀러 풀에서 일어납니다. muffet은 ‘프런티어’와 ‘속도 제한기’를 분리합니다. 이는 과거 lychee가 하나의 bounded 채널로 두 역할을 모두 떠맡기려다 실패했던 지점과 정확히 대조됩니다. - 값싼 고루틴이 무거운 일을 도맡습니다. 링크마다 고루틴을 하나 띄우는 것은 Go에서는 ‘괜찮습니다’. Rust에서 그에 상응하는 일(링크마다
tokio::spawn을 하고 각각에Send + 'static상태가 필요)은 내가 쓴 글에서 다뤘던Arc<RwLock<…>>와 소유권 문제로 이어졌습니다. - 확장성 면에서 muffet은 집중된 CLI이지 라이브러리가 아닙니다. 플러그인 인터페이스가 없고 플래그가 주는 기능이 전부입니다. lychee는 의도적으로 재사용 가능한 크레이트인
lychee-lib를 함께 배포하므로, 모든 아키텍처 결정이 공개 API의 기준을 지켜야 한다는 점에서 허들이 더 높습니다. - 규모 면에서는 무제한 고루틴과 인메모리 방문 집합 덕분에 큰 사이트도 무리 없이 처리하지만, 디스크 기반 프런티어가 없으므로 정말로 거대한 크롤은 RAM에 의해 제한됩니다. lychee도 마찬가지입니다.
핵심 요약: muffet
- muffet의 종료 판단은
sync.WaitGroup그 자체입니다. lychee가 5년에 걸쳐 수렴한 설계와 같고, muffet은 Go 표준 라이브러리 덕에 첫날부터 그걸 공짜로 가졌습니다. - 프런티어와 동시성 제한기는 별개입니다. 뮤텍스로 보호된 set이 프런티어이고, 세마포어와 호스트 스로틀러가 동시성을 제한합니다. 둘을 혼동한 것이 lychee를 교착 상태에 빠뜨렸습니다.
- 고루틴은 Rust가 명시적으로 치르게 하는 비용을 숨겨줍니다. Go에서는 사소한 작업 단위 모델이 Rust에서는
Send/소유권 마찰이 드러나는 지점이 됩니다.
LinkChecker(Python): 조인 가능한 무제한 큐
LinkChecker는 2000년부터 존재해 온 동기식 스레드 풀 크롤러입니다.
프런티어는 파이썬의 queue.Queue를 복제한 직접 만든 UrlQueue(cache/urlqueue.py)로, task_done()/join()을 갖추고 있습니다. 첫 번째 설계 주석부터 보죠.
def __init__(self, max_allowed_urls=None):
# Note: don't put a maximum size on the queue since it would
# lead to deadlocks when all worker threads called put().
self.queue = collections.deque()
# ...
self.unfinished_tasks = 0내게 교착 상태를 안겼던 바로 그 문제를 명시적으로 적어두고 있습니다.
이 주석은 우리의 시도 4 배압 교착 상태를 그대로 설명하고 미리 설계로 피해 간 것입니다. lychee는 발견된 URL을 bounded 채널로 밀어 넣으려 했고, 채널이 가득 차면 응답 핸들러가 블록되고, 응답이 빠져나가지 못하니 슬롯도 비지 않았습니다. 교착 상태. 💥
LinkChecker의 답은 무뚝뚝할 정도로 단순합니다. 프런티어를 무제한으로 만드는 것이죠. 배압은 다른 곳에서(고정된 스레드 수와 호스트별 스로틀링) 강제할 뿐, 생산자이자 소비자이기도 한 주체가 블로킹되도록 두지 않습니다.
제대로 만든 카운터 기반 종료
join()은 unfinished_tasks가 0이 될 때까지 블록합니다(urlqueue.py).
def task_done(self, url_data):
with self.all_tasks_done:
self.finished_tasks += 1
self.unfinished_tasks -= 1
self.in_progress -= 1
if self.unfinished_tasks <= 0:
self.all_tasks_done.notify_all()
def join(self, timeout=None):
with self.all_tasks_done:
while self.unfinished_tasks:
self.all_tasks_done.wait()또 카운터입니다. 하지만 _put에서의 증가와 task_done에서의 감소 모두 큐의 Condition 락 안에서 일어나고, 워커는 자식들을 모두 큐에 넣는 것을 포함해 항목 처리를 완전히 끝낸 뒤에만 task_done을 호출합니다. 따라서 부모가 완료로 표시되기 전에 자식이 먼저 카운트되어, 조기에 0이 되는 일이 없습니다. 뮤텍스와 조건 변수로 구현한 WaitGroup 의미론이죠.
요청 전 중복 제거
LinkChecker는 URL을 큐에 넣는 시점에 결과 캐시에 기록합니다(urlqueue.py).
def _put(self, url_data):
key = url_data.cache_url
cache = url_data.aggregate.result_cache
if cache.has_result(key):
return # already queued/checked -> skip
# ...
self.queue.append(url_data)
self.unfinished_tasks += 1
# add a None placeholder so this URL is never queued twice
cache.add_result(key, None)이 add_result(key, None) 센티넬이 lychee의 시도들에는 없었던 ‘수정’입니다. 어떤 워커 스레드가 URL을 검사할 때쯤이면 캐시는 이미 “내 것”이라고 말하고 있으므로, 다른 페이지에서 동시에 발견해도 아무 일도 일어나지 않습니다.
호스트별 예의와 종료 가드
Aggregate(director/aggregator.py)는 호스트별로 스로틀링합니다.
@synchronized(_hosts_lock)
def wait_for_host(self, host):
t = time.time()
if host in self.times and self.times[host] > t:
time.sleep(self.times[host] - t)
# spread requests using maxrequestspersecond
wait_time = random.uniform(wait_time_min, wait_time_max)
self.times[host] = time.time() + wait_time그리고 abort()는 urlqueue.join(timeout=…)을 호출하므로, 멈춘 크롤이 영원히 hanging되는 일을 막습니다.
트레이드오프는 어디에 있는가
- 비동기 대신 블로킹 스레드. (기본값 10~100개인)
Checker스레드들은 각각requests로 블로킹 I/O를 수행합니다. 단순하고 검증됐지만, 동시성 상한은 스레드 수이고 스레드마다 전체 스택을 차지합니다. lychee의 Tokio 모델은 소수의 OS 스레드로 수천 개의 동시 요청을 처리하지만, LinkChecker는 그렇지 않으며 시도조차 하지 않습니다. - 무제한 프런티어는 교착 상태와 무제한 메모리를 맞바꾼 것입니다. “최대 크기 없음”이라는 명시적 결정은 거대한 사이트에서 RAM 사용량이 늘어난다는 뜻입니다. 이를 완화하기 위해
max_allowed_urls상한과 주기적인cleanup()이 있습니다. - 확장성은 뛰어납니다. LinkChecker는 진짜 플러그인 시스템(
linkcheck/plugins/: 앵커 검사, SSL, 바이러스 스캔 등)과 다양한 출력 로거를 갖추고 있습니다. 이번에 비교한 도구 중 확장성이 가장 뛰어나며, 그 대가로 크고 성숙하지만 다소 옛스러운 코드베이스를 안고 있습니다. - 규모 면에서는 GIL에 묶여 있고 스레드 수에 제한되므로 순수 처리량은 여기서 가장 낮지만, 정확성과 기능 커버리지는 높습니다.
핵심 요약: LinkChecker
- 무제한 프런티어는 교착 상태를 피하려는 의도적인 선택으로, 한 줄짜리 주석에 문서화되어 있습니다. 우리가 lychee 시도 4에서 겪은 문제를 정확히 설명합니다.
put()시점의 중복 제거(캐시에None플레이스홀더)가 동기화 메커니즘입니다. 캐시는 요청 이후가 아니라 이전에 URL을 선점해야 합니다.- 스레드는 단순함을 처리량과 맞바꿉니다. 블로킹 스레드 풀은 가장 쉽게 올바르게 만들 수 있는 모델이자, 가장 느린 모델입니다.
linkinator(TypeScript): 단일 스레드 queue.onIdle()
linkinator는 Node.js 체커이며, Go나 Rust가 제공하지 않는 이점을 하나 누립니다. 바로 단일 스레드 이벤트 루프죠. 방문 집합에 대한 검사-삽입이 공짜로 원자적입니다. 두 콜백이 동시에 실행되는 일이 없기 때문입니다.
프런티어는 동시성을 제한하는 Queue(p-queue 스타일 구조)입니다. 종료는 check() 안의 한 줄입니다(src/index.ts).
const queue = new Queue({ concurrency: options.concurrency || 100 });
// ... seed the queue ...
// resolve when nothing is queued or running:
await queue.onIdle();onIdle()은 라이브러리가 제공하는 종료 감지 수단입니다. 큐가 비었고 실행 중인 작업이 없을 때 resolve됩니다. muffet의 WaitGroup이나 LinkChecker의 join()과 같은 아이디어를 프로미스로 표현한 것일 뿐이며, 단일 스레드 런타임 덕에 방문 집합을 보호할 뮤텍스가 필요 없습니다.
되돌아오는 간선과 레이스 없는 중복 제거
크롤링할 때 crawl()은 페이지를 GET으로 가져와 링크를 추출하고, 새로 발견된 각 URL마다 다시 큐에 넣습니다(src/index.ts).
const inCache = options.cache.has(result.url.href);
if (!inCache) {
// Mark visited...
options.cache.add(result.url.href);
// Create the promise for this check
const checkPromise = (async () => {
await this.crawl({ url: result.url, /* ... */ });
})();
// Store the promise.
// Another page discovering the same URL can wait on this promise
// instead of enqueuing a duplicate check.
options.pendingChecks.set(result.url.href, checkPromise);
// Enqueue...
options.queue.add(() => checkPromise);
}자바스크립트는 단일 스레드이므로 이 전체가 중단 없이 실행됩니다. Rust나 Go에서는 뮤텍스로 보호해야 하는 크리티컬 섹션이지만, Node에서는 그냥 세 문장입니다. 이것이 재귀가 Rust보다 Node에서 더 쉬운 가장 큰 이유입니다. 그냥 언어 기능일 뿐이죠.
linkinator는 또한 `${url}|${parent}` 키의 relationshipCache와 pendingChecks 맵을 유지합니다. 이를 통해 진행 중인 검사를 기다리면서도, 그 검사를 참조하는 모든 부모에 대해 중복된 깨진 링크를 각각 보고할 수 있습니다. 이런 재사용 연산 자체도 같은 큐에 넣어지므로, onIdle()이 그것들까지 올바르게 기다립니다.
HEAD 대 GET
linkinator는 리프 링크에는 HEAD를 쓰지만, 크롤링이 필요할 때에는 GET을 씁니다. 재귀에는 더 많은 링크를 찾기 위해 응답 본문이 필요하기 때문입니다.
response = await makeRequest(
options.crawl ? 'GET' : 'HEAD',
options.url.href, /* ... */
);이것이 정확히 lychee에 남아 있는 열린 문제입니다. 본문을 함께 가져온 페이지로만 재귀할 수 있습니다. linkinator는 크롤링할 때 그냥 항상 GET을 합니다. lychee는 방금 수행한 검사에서 이미 캐시에 있는 본문을 재사용할 계획입니다.
트레이드오프는 어디에 있는가
- 단일 스레드는 축복이자 한계입니다. 데이터 레이스가 없고 중복 제거가 사소하게 올바르지만, HTML 파싱은 단일 이벤트 루프를 막는 CPU 작업입니다. 수천 페이지를 처리할 때는 단일 코어에 묶이게 됩니다. lychee의 멀티스레드 런타임은 파싱과 검사를 병렬로 처리합니다.
- 인메모리 결과 팽창을 겪습니다. 소스에는 “강하게 상호 연결된 사이트에서의 대규모 결과 팽창”에 대한 주석이 명시되어 있습니다.
results배열,cache,relationshipCache가 모두 크롤과 함께 커집니다. 문서 사이트에는 괜찮지만, 거대한 사이트에서는 부담이 됩니다. - 속도 제한은 선제적이 아니라 반응적입니다.
429와Retry-After에 대해 호스트별로 백오프하는delayCache는 있지만, lychee의HostPool같은 일반적인 호스트별 동시성 상한은 없습니다. linkinator는 호스트가 불평할 때까지 두드릴 수 있습니다. lychee는 불평이 나오기 전에 미리 속도를 조절합니다. - 확장성 측면에서는
EventEmitter(on('link'),on('pagestart')등)이므로 임베드하거나 스크립트로 다루기 좋다는 장점이 있습니다. lychee처럼 라이브러리 우선 설계죠.
핵심 요약: linkinator
queue.onIdle()이 종료 메커니즘입니다. 단순하고 JS 런타임이 제공합니다.- 단일 스레드 이벤트 루프 덕에 요청 중복 제거가 거의 공짜입니다. 이 구조적 이점이 재귀를 더 쉽게 만드는 가장 큰 이유입니다.
- 반응적인 429 백오프는 선제적인 호스트별 페이싱과 다릅니다. lychee의
HostPool은 더 많은 장치를 대가로 더 높은 수준을 목표로 합니다.
broken-link-checker(JavaScript): 이벤트 기반, 두 개의 큐 사용
broken-link-checker(BLC)는 이벤트 기반 모델을 가장 멀리 밀어붙인 경우입니다. limited-request-queue를 기반으로 하며, 이 큐는 maxSockets(동시성)와 rateLimit을 갖고, 그 안에 페이지 단위 HtmlUrlChecker를 중첩한 두 개의 큐를 씁니다.
프런티어와 중복 제거는 SiteChecker(lib/public/SiteChecker.js)에 있습니다. 방문한 페이지는 큐에 넣는 시점에 URLCache에 기록됩니다.
#enqueuePage(url, customData, auth) {
// Mark before crawl to avoid links to self within page.
this.#sitePagesChecked.set(url, PAGE_WAS_CHECKED);
this.#htmlUrlChecker.enqueue(url, customData, auth);
}재귀 여부는 발견된 링크가 크롤링 대상 페이지가 되는지를 결정하는 필터로 제어됩니다.
#maybeEnqueuePage(link, customData, auth) {
const tagGroup = this.#options.tags.recursive[
this.#options.filterLevel
][link.get(HTML_TAG_NAME)] ?? {};
const attrSupported = link.get(HTML_ATTR_NAME) in tagGroup;
if (!attrSupported ||
link.get(IS_BROKEN) ||
!link.get(IS_INTERNAL) ||
this.#sitePagesChecked.has(rebasedURL) || // dedup check
!this.#isAllowed(link)) { // robots.txt
// do nothing
} else if (this.#options.includePage(rebasedURL)) {
this.#enqueuePage(rebasedURL, customData, auth);
}
}이벤트 캐스케이드로 종료 판단
BLC에는 카운터도 onIdle()도 없습니다. 큐의 드레인 이벤트에 올라탑니다. 페이지 단위 큐가 비면 END_EVENT를 쏘고, 이는 SiteChecker가 SITE_EVENT를 내보내고 사이트 큐의 done 콜백을 호출하도록 만듭니다. 사이트 큐가 드레인되면 REQUEST_QUEUE_END_EVENT가 발생합니다. 그게 공개된 END_EVENT죠.
.on(END_EVENT, () => {
this.emit(SITE_EVENT, this.#currentPageError, this.#currentSiteURL, this.#currentCustomData);
this.#currentDone(); // tell the site queue this site is finished
});이것이 바로 그들의 종료 감지이며, “요청 큐가 비었다고 보고했다”는 형태로 표현됩니다.
그리고 전형적인 Node.js 방식으로, done 콜백이 실제로 사이트 큐에 다음 사이트를 위한 슬롯을 비우라고 알립니다. 그래서 한 사이트의 종료가 다른 사이트의 시작을 가능하게 하고, 전체 크롤의 종료가 프로세스 종료를 가능하게 합니다. 페이지 큐에서 사이트 큐로, 다시 프로세스로 전파되는 이벤트 캐스케이드입니다.
트레이드오프는 어디에 있는가
- 이 중에서 웹 예절이 가장 뛰어납니다. robots.txt를 준수하고(
getRobotsTxt,isAllowed),rel=nofollow를 존중하며,rateLimit과maxSockets이 일급 기능입니다. 기본적으로 예의 바른 크롤러죠. - 이벤트 캐스케이드는 강력하지만 다루기 까다롭습니다. 종료 로직이 반쯤은 여러 이벤트 핸들러와 두 개의 중첩 큐에 흩어져 있습니다. 동작은 하지만 제어 흐름을 따라가기가
await queue.onIdle()보다 훨씬 어렵습니다. 이는 내가 설명했던 “새는 추상화” 문제의 JS판 사촌으로, 재귀 인식이 여러 핸들러에 흩뿌려진 형태입니다. - linkinator와 마찬가지로 단일 스레드이며, 사이트별 인메모리
URLCache라는 한계도 같습니다. - 성숙도 대 모멘텀 측면에서는 매우 널리 쓰이지만(많은 도구의 기반이 됩니다) 개발 속도는 느려졌습니다. 아키텍처 자체는 여전히 건실하고 공부할 가치가 있습니다.
핵심 요약: broken-link-checker
- 종료는 카운터가 아니라 큐 드레인 이벤트의 캐스케이드입니다. 같은 아이디어를 다른 문법으로 표현한 것이죠.
- 예의는 기본 탑재입니다. robots.txt,
rateLimit,maxSockets덕에 기본적으로 가장 서버 친화적인 재귀 체커입니다. - 이벤트 기반 제어 흐름이 대가입니다. 재귀 로직을 여러 핸들러에 분산시키는 것은 해당 기능을 이해하기 어렵게 만드는 바로 그 종류의 복잡도입니다.
markdown-link-check와 “산업용” 크롤러에 대한 참고
우리 README는 markdown-link-check가 재귀를 지원한다고 표시하지만, 여기에는 뉘앙스가 있습니다. 이 도구는 라이브 웹사이트를 스파이더링하는 것이 아니라 Markdown 파일에 대해 재귀합니다. HTTP 프런티어도 없고, 위에서 말한 의미의 종료 문제도 없습니다. 비교의 정직함을 위해 언급할 만하지만, 뜯어볼 가치는 없습니다.
이 패턴을 완전한 산업 규모로 보고 싶다면 Scrapy(Python/Twisted)나 Colly(Go)를 보세요. 둘 다 같은 방식을 씁니다. 플러그인 가능한, 선택적으로 디스크에 백업되는 큐를 갖춘 스케줄러(프런티어), 중복 필터(대개 HashSet이 아닌 Bloom 필터), bounded 다운로더 풀, 그리고 명시적인 “엔진 유휴 → 스파이더 종료” 종료 판단이죠. lychee가 애먹었던 문제들(분산 종료 감지, 배압, 중복 제거)을 정확히 해결하며, 다만 수년간의 전용 크롤러 엔지니어링이 뒤에 있습니다. 교훈이 “lychee가 Scrapy가 돼야 한다”는 것은 아닙니다. 크롤링은 이미 잘 다져진 아키텍처이며, lychee는 지금 단지 다른 아키텍처 위에 서 있을 뿐이라는 것이죠.
나란히 비교
| 도구 | 언어 / 런타임 | 동시성 모델 | 프런티어 | “완료” 신호 | 중복 제거 시점 | 호스트별 제한 |
|---|---|---|---|---|---|---|
| muffet | Go, 고루틴 | 고루틴 풀 + 세마포어 + 호스트 스로틀러 | 뮤텍스로 보호된 set + daemon 채널 | sync.WaitGroup | 큐에 넣을 때 방문 집합 | 호스트 스로틀러 풀 |
| LinkChecker | Python, 스레드 | 고정 블로킹 스레드 풀 | 무제한 UrlQueue | 조인 가능한 큐 카운터(join()) | put() 시 결과 캐시 | wait_for_host(초당 요청 수) |
| linkinator | Node, 이벤트 루프 | 단일 스레드 + p-queue(concurrency) | p-queue | queue.onIdle() | 큐에 넣을 때 Set(레이스 없음) | 반응형 429 delayCache |
| broken-link-checker | Node, 이벤트 루프 | limited-request-queue(maxSockets) | 중첩된 요청 큐 | 큐 드레인 이벤트 | 큐에 넣을 때 URLCache | maxSockets + rateLimit |
| lychee(2026) | Rust, Tokio | 태스크 + HostPool | 채널 + WaitGroup | WaitGroup | HostPool active_requests | HostPool 호스트별 풀 |
2026년의 lychee는 마침내 열 대 열로 어깨를 나란히 하게 됐습니다. WaitGroup은 muffet의 sync.WaitGroup이자 LinkChecker의 join()입니다. HostPool은 BLC의 rateLimit/maxSockets이자 LinkChecker의 wait_for_host입니다. URI별 active_requests 뮤텍스는 모두의 큐에 넣을 때 중복 제거와 같습니다.
그럼 왜 그냥 따라 하지 못했나?
세 가지 이유가 있습니다. lychee 탓인 정도 순으로 갈수록 커지죠.
그들은 크롤러로 시작했고, lychee는 스트림으로 시작했다.
위에 나온 모든 도구는 핵심 자료구조에 되돌아오는 간선을 갖고 있습니다. lychee의 핵심은 99% 케이스(파일/URL 목록을 한 번에 빠르게 검사)를 위해 최적화된 DAG였습니다. 파이프라인에 사이클을 끼워 넣는 것은 처음부터 사이클을 갖는 것보다 훨씬 어렵습니다. 본질적으로 아키텍처 문제죠.
프런티어와 속도 제한기는 서로 다른 객체여야 한다.
muffet(set + 세마포어), LinkChecker(무제한 큐 + 스레드 수), linkinator(p-queue + delayCache), BLC(요청 큐 + maxSockets) 모두 “다음에 할 일”과 “얼마나 빨리 할지”를 분리합니다. lychee의 초기 시도들은 하나의 bounded 채널로 두 역할을 모두 떠맡기려 했고, bounded 채널을 통과하는 사이클은 교착 상태에 빠집니다. 지금 우리가 노리는 수정(lychee의 HostPool과 무제한 작업 소스 위의 WaitGroup)은 바로 그 분리와 같습니다.
단일 스레드 런타임은 중복 제거를 공짜로 얻는다.
두 Node 도구는 모두 이벤트 루프가 접근을 직렬화하기 때문에 락 없이 일반 Set으로 중복을 제거합니다. Go와 Python은 뮤텍스를 씁니다. Rust는 뮤텍스에 더해 tokio::spawn을 넘나드는 공유 상태의 소유자를 두고 빌림 검사기와 싸워야 합니다. 그게 내가 지난번에 추정한 약 30%의 “Rust 세금”입니다. 알고리즘이 아니라, Send + 'static 아래에서 공유 가변 프런티어 상태를 표현하는 마찰이죠.
이 중 어느 것도 lychee 설계를 깎아내리는 이야기가 아닙니다. 단방향 스트림은 일반적인 비재귀 케이스에는 옳은 선택입니다. lychee가 빠른 이유이며, 시도 2에서 30% 채널 회귀가 딜 브레이커였던 이유죠. 다른 도구들은 재귀 여부와 관계없이 모든 실행에서 그 되돌아오는 간선에 대한 비용을 치릅니다. lychee는 그러지 않기로 했고, 바로 그 원칙 때문에 재귀에 5년이 걸렸으며, 재귀가 마침내 들어왔을 때 모두가 실제로 쓰는 경로를 느리게 하지 않을 겁니다. 케이크를 가지고 또 먹을 수도 있다고 믿습니다. 재귀를 지원하면서도 단발성 파이프라인의 속도를 희생하지 않는 크롤러 아키텍처 말이죠. 하지만 그건 “남들이 하는 걸 베끼기만 하면 된다”보다 훨씬 어려운 문제입니다. 애초에 대부분의 링크 체커는 타협 없는 성능을 최우선 목표로 시작하지 않았기 때문입니다.
핵심 요약
- 비밀 소스는 없습니다. 모든 재귀 체커는 작업 목록(worklist)과 방문 집합, 그리고 조용해졌는지(정지 상태)를 감지하는 수단으로 이루어져 있습니다. “비법”은 첫 커밋부터 크롤러의 모양을 하고 있었다는 것이죠.
- 종료 판단은 옷만 갈아입은 같은 아이디어입니다.
sync.WaitGroup(muffet), 조인 가능한 큐 카운터(LinkChecker),queue.onIdle()(linkinator), 큐 드레인 이벤트(BLC),WaitGroup(lychee 2026). 모두 분산 종료 감지입니다. - 중복 제거는 요청 전, 큐에 넣을 때 해야 합니다. 검사 이후에 URL을 방문 처리하는 것(lychee가 네 번의 시도 동안 했던 방식)이 버그입니다. 다른 도구들은 모두 URL이 프런티어에 들어오는 순간 선점합니다.
- 프런티어와 속도 제한기를 분리하세요. 큐이자 배압 수단인 bounded 채널은 사이클을 추가하는 순간 교착 상태에 빠집니다.
- 공짜 점심은 없습니다. Node의 단일 스레드는 중복 제거를 쉽게 만드는 대신 성능을 치르고, Go의 고루틴과
WaitGroup은 런타임이라는 대가로 종료를 쉽게 만들며, Rust는 둘 다 공짜로 주지 않지만 레이스가 컴파일되지 않도록 막아주는 컴파일러를 주고, 당신이 정확히 무엇을 하는지 알고 있다면 네트워크 카드를 뜨겁게 달굴 수 있습니다.
그러니 “다른 링크 체커들은 재귀를 어떻게 하냐”는 질문에 대한 진짜 답은 이렇습니다. 그들은 처음부터 아키텍처의 일부로 만들었고, (WaitGroup, 조인 가능한 큐, 유휴 프로미스 같은 편의를 제공하는) 런타임에 기대어 “분산 종료 감지”를 풀지 않고도 종료를 해결했다는 것이죠.
muffet, LinkChecker, linkinator, broken-link-checker의 메인테이너 분들께 감사드립니다. 여러분의 소스를 읽는 것이 크롤러 아키텍처를 배우는 가장 명확한 방법이며, 우리는 모두 서로 다른 트레이드오프를 안고 같은 문제를 풀어가는 동료입니다.
글을 무작위로 읽기
댓글
로그인하고 댓글 남기기