본문으로 건너뛰기

© 2026 Molayo

Dev.to헤드라인2026. 05. 31. 13:42

보물찾기 엔진이 47분 만에 70만 개의 깨진 링크를 발견한 날

요약

그래프 순회 엔진에서 발생한 HTTP 410 응답으로 인한 대규모 링크 유실 문제를 다룹니다. 재시도 로직, 사이드카 패턴, 서킷 브레이커 도입 시도와 그 과정에서 발생한 지연 시간 및 데이터 일관성 문제를 분석합니다.

핵심 포인트

  • HTTP 410 응답이 노드 실패로 오인되어 대규모 경로가 삭제되는 문제 발생
  • 재시도 로직 도입 시 꼬리 지연 시간(tail latency) 급증 및 처리량 저하 위험
  • 서킷 브레이커가 상태 코드의 의미를 구분하지 못해 발생하는 데이터 불일치 문제
  • 분산 시스템에서 노이즈 섞인 입력값에 대한 최종 일관성 유지의 중요성

우리가 실제로 해결하고 있었던 문제

화요일 오후 3시 47분, 단 하나의 Slack 알림으로 시작되었습니다. 매일 밤 280만 개의 사용자 생성 경로를 크롤링하는 그래프 순회 서비스 (graph traversal service)인 우리의 사내 보물찾기 엔진이 타겟 URL의 12%에 대해 HTTP 410 Gone 응답을 반환하기 시작했습니다. 이는 심각한 문제였는데, 보물찾기 점수판 (scoreboard)이 해당 링크들이 36시간 동안 유지되는 것에 의존하고 있었기 때문입니다. 더 나쁜 점은, 이러한 실패가 특정 CDN에 집중된 것이 아니라 동일한 리소스 제한 (resource limits)을 가지고 Kubernetes에서 실행 중인 다섯 개의 서로 다른 호스트에 걸쳐 분산되어 있었다는 것입니다. 당직 엔지니어는 서킷 브레이커 (circuit breaker)를 통해 트래픽을 재라우팅하여 에러율이 다시 0%로 급증하는 것을 확인했지만, 이 사건은 잠재적인 실패 모드 (failure mode)를 드러냈습니다. 우리의 엔진은 단 하나의 410 응답을 노드 실패 (node failure)로 취급하여 전체 서브트리 (subtree)를 분리해 버렸고, 이로 인해 한 번에 수백 개의 다운스트림 경로 (downstream routes)가 삭제되었습니다. 그것이 바로 우리가 실제로 해결하고 있었던 문제, 즉 노이즈가 있는 입력값 하에서의 최종 일관성 (eventual consistency) 문제였습니다.

우리가 처음 시도했던 것 (그리고 실패한 이유)

처음 20분 동안 우리는 지수 백오프 (exponential backoff)와 함께 max_retries를 3으로 설정하여 재시도 예산 (retry budget)을 덧붙였습니다. 한 시간 이내에 우리는 또 다른 문제에 직면했습니다. 재시도 경로의 꼬리 지연 시간 (tail latency)이 8.2초로 급증했고, 5초로 설정된 우리의 95퍼센타일 (95th percentile) 마감 시간을 어기기 시작했습니다. 재시도 로직은 최단 경로 점수 (shortest-path scores)를 계산하는 Node.js 워커 (worker) 내에 존재했기 때문에, 비동기 큐 (async queue) 내부에 sleep을 추가하자 처리량 (throughput)이 분당 14,000개 URL에서 3,000개로 급감했습니다. 다음으로 우리는 Envoy의 재시도 정책 (retry policy)을 사용하여 사이드카 (sidecar)로 재시도를 옮기는 것을 시도했지만, 사이드카는 150ms의 추가적인 홉 시간 (hop time)을 유발했고, 업스트림 L7 로드 밸런서 (L7 load balancers)가 압박을 받을 때 엔진은 여전히 마감 시간을 지키지 못했습니다.

그다음 우리는 서킷 브레이커 (circuit breakers)를 시도했습니다. 각 아웃바운드 HTTP 호출을 failure_threshold=5timeout=1.2s 설정의 브레이커로 감쌌습니다. 이론상으로는 합리적으로 보였지만, 브레이커는 410 상태 코드의 의미론적 무게 (semantic weight)를 알지 못했습니다. 브레이커는 이를 단순히 실패로만 카운트했습니다. 그래서 CDN 퍼지 (purge) 이후 도쿄 리전의 URL 70만 개가 연쇄적으로 410을 반환하기 시작했을 때, 모든 브레이커가 작동(trip)했고, 엔진은 훨씬 더 오래된 데이터를 가진 백업 엔드포인트 (backup endpoints)로 전환되었습니다. 10분 후 스코어보드 업데이트가 들어왔을 때, 백업 엔드포인트가 5시간이나 뒤처져 있었기 때문에 경로의 28%가 오래된 점수 (stale scores)를 표시하고 있었습니다. 서킷 브레이커는 한 가지 문제를 해결했지만 또 다른 문제를 만들어냈습니다.

아키텍처 결정 (The Architecture Decision)

우리는 오후 10시 23분에 단 하나의 머지 리퀘스트 (merge request)로 서킷 브레이커와 사이드카 재시도 (sidecar retries)를 제거했습니다. 대신, 우리는 2단계 파이프라인 (two-stage pipeline)을 구축했습니다:

1단계: 사전 검증 (Pre-validation)
모든 URL은 max_timeout=800msstrict_status_filter=[200,301,404]를 적용한 HEAD 요청을 받습니다. 만약 응답이 410이라면, 우리는 즉시 해당 노드를 '사망(dead)' 상태로 표시하고 실패를 전파하지 않고 그래프에서 제거(prune)합니다. 이 단계는 CPU 코어 수의 4배 크기로 설정된 별도의 Go 워커 풀 (Go worker pool)에서 실행되므로, 점수 계산 (score computation)과 절대 충돌하지 않습니다.

2단계: 실시간 조정 (Real-time reconciliation)
별도의 조정 루프 (reconcile loop)가 30초마다 깨어납니다. 이 루프는 데이터베이스를 쿼리하여 마지막 크롤링 사이클 이후에 추가된 모든 사망 노드를 찾습니다. 그런 다음 해당 URL들만 1단계로 다시 큐에 넣되, 천둥 치는 말떼 문제 (thundering-herd)와 같은 재시도를 방지하기 위해 지터 (jittered)가 적용된 지연 시간을 둡니다. 또한 크롤 프런티어 (crawl frontier)에 블룸 필터 (bloom filter)를 추가하여, 1단계에서 이미 거부된 URL이 다시 큐에 들어가지 않도록 했습니다.

이 결정은 비용과 정확성 사이의 문제였습니다. 검증 경로에 더 많은 컴퓨팅 자원을 추가하는 것이 점수 산출 경로에 지연 시간 (latency)을 추가하는 것보다 저렴했습니다. Go 풀은 1,000개 URL당 0.012달러인 스팟 인스턴스 (spot instances)에서 실행됩니다. 반면, 서킷 브레이커가 작동했을 때는 연쇄적인 5xx 오류와 페이저 듀티 (pager duty) 호출로 인해 1,000개 URL당 0.08달러의 비용이 발생했었습니다.

이후의 수치들

2주 후:

  • 사전 검증 (Pre-validation) 오탐률 (false-positive rate): 0.08% (모두 410 상태 코드로 잘못 분류된 일시적인 리다이렉트였음).
  • 95 백분위수 (95th percentile) 1단계 지연 시간 (latency): 415ms.
  • 파이프라인 처리량 (throughput): 분당 22,000개 URL (기존 3,000개에서 증가).
  • 워커 (worker)당 메모리 사용량: 180MB (기존 290MB에서 감소).
  • 스코어보드 최신성 분산 (Scoreboard freshness variance): ±2.4분, 이는 우리의 SLO를 충족함.

또한, 410 상태 코드의 대량 발생 (avalanches)으로 인해 온콜 (on-call) 팀을 깨우는 일을 중단했습니다. 알림 (alerts)은 error_count에서 검증 데이터의 노후화 (validation_staleness)로 전환되었으며, 이는 2.6%의 오탐률 (false-positive rate)을 기록했습니다.

다르게 시도했을 점

저는 다시는 재시도 로직 (retry logic)과 비즈니스 점수 계산 (business-score computation)을 동일한 언어 런타임 (language runtime)에서 혼합하지 않을 것입니다. Node.js 워커들이 비동기 큐 (async queue) 내부에서 대기 (sleep)하도록 요청해서는 안 되었습니다. 만약 첫날부터 두 개의 파이프라인을 분리하여 실행했다면, 3주간의 온콜 시간을 절약할 수 있었을 것이며, 스코어보드가 노후화된 동안 발생했던 14%의 사용자 참여도 (user engagement) 하락도 피할 수 있었을 것입니다.

둘째로, 단순한 HEAD 필터를 URL별 과거 상태 코드 (historical status codes)를 저장하는 경량 피처 스토어 (feature store)로 교체했을 것입니다. 새로운 410 상태 코드가 나타나면, 해당 호스트의 '사망 전 중앙값 시간 (median time-before-death)'을 확인할 수 있습니다. 만약 이 시간이 48시간 미만이라면, 이를 일시적인 삭제 (transient purge)로 간주하고 가지치기 (pruning) 대신 15분 후에 다시 큐에 넣도록 처리할 것입니다. 이렇게 하면 아키텍처를 변경하지 않고도 가지치기 비율 (prune rate)을 12%에서 2.3%로 줄일 수 있습니다.

마지막으로, Grafana 대시보드에 두 파이프라인을 성공/실패 횟수가 아닌 '가지치기 비율 vs 사용자 참여도 변화량 (graph prune rate vs. user engagement delta)' 그래프로 노출했을 것입니다. 가지치기 비율이 1% 상승할 때 일일 활성 사용자 수 (daily active users)가 3% 감소한다는 상관관계를 비즈니스 팀이 확인하게 된다면, 더 많은 검증 컴퓨팅 자원을 확보하기 위한 논쟁은 사소한 문제가 될 것입니다.

AI 자동 생성 콘텐츠

본 콘텐츠는 Dev.to AI tag의 원문을 AI가 자동으로 요약·번역·분석한 것입니다. 원 저작권은 원저작자에게 있으며, 정확한 내용은 반드시 원문을 확인해 주세요.

원문 바로가기
0

댓글

0