다른 링크 체커는 재귀를 어떻게 구현할까
lychee에 재귀 기능 추가하기, 5년간의 시도를 공개한 뒤, 꽤 타당한 질문 하나를 받았습니다.
재귀가 그렇게 어렵다면, 다른 링크 체커들은 어떻게 할까요? 이미 웹사이트를 크롤링하는 툴도 많잖아요!
이 질문 때문에 다른 링크 체커들의 코드를 파고드는 토끼굴에 빠졌습니다. 핵심 결론은 이렇습니다. 우리가 놓친 기발한 꼼수가 있었던 게 아닙니다. 그 툴들은 첫 커밋부터 크롤러로 설계됐고, 저는 처음에 lychee를 스트림으로 만들었습니다.
lychee의 README에 소개된 재귀 지원 체커들의 소스를 직접 읽어봤습니다. muffet(Go), LinkChecker(Python), linkinator(TypeScript), 그리고 broken-link-checker(JavaScript)입니다. 이번 글에서는 각 도구가 재귀를 실제로 어떻게 처리하는지, 그 대가는 무엇인지, 그리고 lychee에 어떤 의미가 있는지 뜯어보겠습니다.
이전 글을 아직 읽지 않으셨다면 요약을 드리겠습니다. lychee는 단발성 단방향 파이프라인(inputs → extract → check → output)으로 설계됐습니다. 재귀에는 사이클이 필요합니다(응답이 새로운 입력을 만들어내야 합니다). 그리고 비동기 채널 기반 파이프라인에서 사이클이 있는 곳이 바로 용이 사는 곳입니다. 🐲 5년과 네 번의 시도 끝에, 제대로 구현하는 데 필요한 조각들이 이제야 겨우 맞춰졌습니다.
DAG와 사이클
제가 살펴본 모든 재귀 체커는 같은 세 가지 요소로 이루어져 있었습니다.
- 고정된 입력 스트림이 아니라 가변적인 작업 큐(이하 “프론티어”). 발견된 URL은 나왔던 큐로 다시 들어갑니다.
- enqueue 시점(요청이 완료되기 전)에 업데이트되는 방문 집합(visited set). 덕분에 두 페이지가 같은 링크를 발견해도 중복으로 제출되지 않습니다.
- “모든 작업이 끝났는가?”에 답하는 원시 수단:
WaitGroup이나 join 가능한 큐 카운터,onIdle()프로미스, 또는 큐 드레인(drain) 이벤트입니다.
그림으로 보면 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크롤러에는 백엣지(back-edge)가 처음부터 내장되어 있습니다. 저희 파이프라인에는 없습니다. 실패했던 모든 시도는 애초에 백엣지를 염두에 두지 않고 설계된 그래프에 그 백엣지를 억지로 끼워 넣으려던 시도였습니다.
이 그래프 설계를 조금 더 자세히 살펴보겠습니다.
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]방문 여부 검사가 enqueue 단계에서 표시(mark)와 원자적으로, 워커가 네트워크에 손을 대기도 전에 일어난다는 점에 주목해 주십시오. 이 순서가 바로 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이 이미 존재했는지를 반환하므로, 페이지는 처음 발견됐을 때만 스케줄링됩니다. 중복 제거는 enqueue 시점에 집합의 뮤텍스로 동기화되어 일어납니다. 이는 위 다이어그램을 사실상 한 줄 한 줄 코드로 옮긴 것입니다.
페이지를 검사할 때는 모든 링크를 동시에 가져오고, 조건에 맞는 링크를 다시 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
}스케줄링된 페이지는 그룹 카운터를 1 증가시키고, 완료된 페이지는 1 감소시킵니다. Wait()는 카운트가 0이 되면 반환됩니다. 전체 크롤은 Run() 이전에 단 한 번의 addPage로 시작되므로, 누군가 기다리기 전에 이미 카운터가 양수인 상태가 됩니다.
이는 제가 시도 1과 시도 4에서 시도했다가 실패했던 그 카운터와 같은 것입니다. 차이는 불변식(invariant)에 있습니다. waitGroup.Add(1)은 카운트를 0보다 크게 유지하고 있는 이미 실행 중인 데몬 내부에서만(또는 부트스트랩에서) 호출됩니다. 아직 처리할 작업이 남아 있는데 카운터가 순간적으로 0을 가리키는 구간이 없습니다. Go의 WaitGroup은 이 불변식을 너무나 자연스럽게 강제해서 분산 종료 감지처럼 느껴지지도 않지만, 정확히 그 역할을 하는 것입니다. 이는 2026년 Kait가 lychee에 기여한 WaitGroup 원시 수단과 도덕적으로 같은 것입니다.
트레이드오프는 어디에 있을까
- 동시성이 daemon 매니저에 의해 제한되지 않습니다.
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 표준 라이브러리 덕분에 첫날부터 이를 공짜로 얻었습니다. - 프론티어와 동시성 제한기는 별개의 존재입니다. 뮤텍스로 보호되는 집합이 프론티어이고, 세마포어와 호스트 스로틀러가 동시성을 제한합니다. 둘을 혼동한 것이 lychee를 교착 상태에 빠뜨렸습니다.
- 고루틴이 Rust가 명시적으로 치르게 하는 비용을 숨겨줍니다. Go에서는 사소한 작업 단위 모델이 Rust에서는
Send/소유권 마찰이 드러나는 지점입니다.
LinkChecker (Python): join 가능한 무제한 큐
LinkChecker는 2000년부터 존재해 온 동기식 스레드 풀 크롤러입니다.
프론티어는 직접 만든 UrlQueue(cache/urlqueue.py)로, task_done()/join()을 갖춘 Python의 queue.Queue를 복제한 것입니다. 가장 첫 설계 주석을 보십시오.
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는 enqueue 시점에 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을 검사할 때쯤이면 캐시는 이미 “내 것”이라고 표시된 상태이므로, 다른 페이지에서 동시에 같은 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);
}JavaScript는 싱글 스레드이므로 이 전체가 중단 없이 실행됩니다. Rust나 Go에서는 뮤텍스로 보호해야 하는 크리티컬 섹션이지만, Node에서는 그냥 세 문장일 뿐입니다. 이것이 재귀가 Node에서 Rust보다 훨씬 쉬운 가장 큰 이유입니다. 그냥 언어 자체의 특징입니다.
linkinator는 `${url}|${parent}` 키 형태의 relationshipCache와 pendingChecks 맵도 유지합니다. 이를 통해 진행 중인 검사를 기다리면서도, 그 링크를 참조하는 모든 부모에 대해 중복된 깨진 링크를 보고할 수 있습니다. 이런 재사용 연산 자체도 같은 큐에 푸시되므로 onIdle()이 이를 올바르게 기다립니다.
HEAD vs GET
linkinator는 리프(leaf) 링크에는 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에 추적되며, enqueue 시점에 기록됩니다.
#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를 emit하고 사이트 큐의 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가 일등 시민으로 다뤄집니다. 기본적으로 예의 바른 크롤러입니다. - 이벤트 연쇄는 강력하지만 다루기 까다롭습니다. 종료 로직이 6개가 넘는 이벤트 핸들러와 두 개의 중첩된 큐에 흩어져 있습니다. 동작은 하지만 제어 흐름을 따라가기가
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 필터), 제한된 다운로더 풀, 그리고 명시적인 “엔진 유휴 → 스파이더 종료” 종료 판단입니다. lychee가 애먹었던 문제들(분산 종료 감지, 배압, 중복 제거)을 정확히 해결하며, 그 뒤에는 수년간의 전용 크롤러 엔지니어링이 있습니다. 교훈이 “lychee가 Scrapy가 되어야 한다”는 것은 아닙니다. 크롤링은 이미 잘 닦인 아키텍처이고, lychee는 지금 단지 다른 아키텍처 위에 서 있을 뿐이라는 것뿐입니다.
나란히 비교
| 도구 | 언어 / 런타임 | 동시성 모델 | 프론티어 | 완료 신호 | 중복 제거 시점 | 호스트별 제한 |
|---|---|---|---|---|---|---|
| muffet | Go, 고루틴 | 고루틴 풀 + 세마포어 + 호스트 스로틀러 | 뮤텍스로 보호된 집합 + 데몬 채널 | sync.WaitGroup | enqueue 시 방문 집합 | 호스트 스로틀러 풀 |
| LinkChecker | Python, 스레드 | 고정 블로킹 스레드 풀 | 무제한 UrlQueue | join 가능한 큐 카운터(join()) | put() 시 결과 캐시 | wait_for_host(초당 요청 수) |
| linkinator | Node, 이벤트 루프 | 싱글 스레드 + p-queue(concurrency) | p-queue | queue.onIdle() | enqueue 시 Set(레이스 없음) | 반응형 429 delayCache |
| broken-link-checker | Node, 이벤트 루프 | limited-request-queue(maxSockets) | 중첩 요청 큐 | 큐 드레인 이벤트 | enqueue 시 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 뮤텍스는 모두가 enqueue 시점에 수행하는 중복 제거와 같습니다.
그런데 왜 그냥 베껴오지 못했을까?
세 가지 이유가 있습니다. lychee 탓인 정도가 낮은 순서대로 말씀드리겠습니다.
그들은 처음부터 크롤러로 시작했고, lychee는 스트림으로 시작했다.
위 모든 도구는 핵심 자료구조에 백엣지를 갖추고 있습니다. lychee의 핵심은 99%의 경우(파일/URL 목록을 한 번에 빠르게 검사하는 경우)에 최적화된 DAG였습니다. 파이프라인에 사이클을 끼워 넣는 것은 처음부터 사이클을 갖고 있는 것보다 훨씬 어렵습니다. 본질적으로 아키텍처의 문제입니다.
프론티어와 속도 제한기는 서로 다른 객체여야 한다.
muffet(집합 + 세마포어), LinkChecker(무제한 큐 + 스레드 수), linkinator(p-queue + delayCache), BLC(요청 큐 + maxSockets) 모두 “다음에 할 일”과 “얼마나 빠르게 할지”를 분리해 둡니다. lychee의 초기 시도는 하나의 bounded 채널이 두 역할을 모두 맡게 하려 했고, bounded 채널을 통과하는 사이클은 교착 상태에 빠집니다. 해결책(lychee의 HostPool과 무제한 작업 소스 위의 WaitGroup)은 지금 저희가 지향하는 바로 그 분리입니다.
싱글 스레드 런타임은 중복 제거를 공짜로 얻는다.
두 Node 도구 모두 평범한 Set으로 락 없이 중복을 제거합니다. 이벤트 루프가 접근을 직렬화하기 때문입니다. Go와 Python은 뮤텍스 비용을 치릅니다. Rust는 뮤텍스에 더해 tokio::spawn을 가로지르는 공유 상태의 소유자가 누구인지에 대한 borrow checker와의 싸움까지 치러야 합니다. 제가 지난 글에서 추정한 약 30%의 “Rust 세금”이 바로 이 부분입니다. 알고리즘이 아니라, Send + 'static 제약 아래에서 공유 가능한 가변 프론티어 상태를 표현하는 데 따르는 마찰입니다.
이 중 어느 것도 lychee 설계를 깎아내리는 얘기가 아닙니다. 단방향 스트림은 일반적인 비재귀 경우에는 정답입니다. 그래서 lychee가 빠르고, 시도 2의 30% 채널 회귀가 용납될 수 없었던 이유이기도 합니다. 다른 도구들은 재귀 여부와 관계없이 모든 실행에서 백엣지 비용을 치릅니다. lychee는 그 비용을 치르지 않기로 했고, 바로 그 원칙 때문에 재귀에 5년이 걸렸으며, 재귀가 마침내 들어와도 모두가 실제로 쓰는 경로가 느려지지 않을 것입니다. 저는 두 마리 토끼를 다 잡을 수 있다고 믿습니다. 단발성 파이프라인의 속도를 희생하지 않으면서 재귀를 지원하는 크롤러 아키텍처 말입니다. 다만 “남들이 하는 걸 베끼면 된다”보다 훨씬 어려운 문제입니다. 대부분의 링크 체커가 처음부터 타협 없는 성능을 최우선 목표로 삼지 않았기 때문입니다.
핵심 정리
- 숨겨진 비법은 없습니다. 모든 재귀 체커는 작업 목록(worklist)과 방문 집합, 그리고 정지 상태 감지기로 이루어져 있습니다. “비법”이란 첫 커밋부터 크롤러의 모양을 갖추고 있다는 것뿐입니다.
- 종료 판단은 언제나 같은 아이디어가 옷만 갈아입은 것입니다.
sync.WaitGroup(muffet), join 가능한 큐 카운터(LinkChecker),queue.onIdle()(linkinator), 큐 드레인 이벤트(BLC),WaitGroup(2026년의 lychee). 모두 분산 종료 감지입니다. - 중복 제거는 요청 전인 enqueue 시점에 해야 합니다. 검사 이후에 URL을 방문했다고 표시하는 것(lychee가 네 번의 시도 동안 했던 방식)이 버그였습니다. 다른 모든 도구는 URL이 프론티어에 들어오는 순간 선점합니다.
- 프론티어와 속도 제한기를 분리하십시오. 큐이자 배압 수단인 bounded 채널은 사이클이 생기는 순간 교착 상태에 빠집니다.
- 공짜 점심은 없습니다. Node의 싱글 스레드는 중복 제거를 사소하게 만들지만 성능을 대가로 치릅니다. Go의 고루틴과
WaitGroup은 종료 판단을 사소하게 만들지만 런타임이라는 대가를 치릅니다. Rust는 둘 다 공짜로 주지 않지만, 레이스가 컴파일되지 않게 막아주는 컴파일러를 주고, 정확히 무엇을 하는지 안다면 네트워크 카드를 한계까지 몰아붙일 수 있게 해줍니다.
그러니 누군가 “다른 링크 체커들은 재귀를 어떻게 하냐”고 묻는다면, 진짜 답은 이렇습니다. 그들은 처음부터 재귀를 아키텍처의 일부로 만들었고, (WaitGroup이나 join 가능한 큐, idle 프로미스 같은) 종료 문제를 “분산 종료 감지”를 직접 풀지 않고도 해결해 주는 런타임을 십분 활용했다는 것입니다.
muffet, LinkChecker, linkinator, broken-link-checker의 메인테이너 분들께 감사드립니다. 여러분의 소스를 읽는 것이 크롤러 아키텍처를 배우는 가장 명확한 방법이며, 우리는 모두 서로 다른 트레이드오프를 안고 같은 문제를 함께 풀어가고 있습니다.
글을 무작위로 읽기