Articlefirecrawl.dev·2026년 7월 6일·0

Handling 300k requests per day: an adventure in scaling

Quick Summary

Firecrawl 팀은 사용자 증가로 API와 크롤링 작업이 불안정해지자 큐 락 설정, 스크레이프 처리 구조, 크롤 작업 단위, 큐 라이브러리, Redis 인프라를 단계적으로 바꾸며 하루 30만 요청 규모에 대응했다.

Handling 300k requests per day: an adventure in scaling 관련 대표 이미지

🖼️ 인포그래픽

Handling 300k requests per day: an adventure in scaling 내용을 설명하는 본문 이미지

🖼️ 4컷 인포그래픽

Handling 300k requests per day: an adventure in scaling 내용을 설명하는 본문 이미지

💡 한 줄 요약

Firecrawl 팀은 사용자 증가로 API와 크롤링 작업이 불안정해지자 큐 락 설정, 스크레이프 처리 구조, 크롤 작업 단위, 큐 라이브러리, Redis 인프라를 단계적으로 바꾸며 하루 30만 요청 규모에 대응했다.

📌 핵심 요약

  • 초기 Firecrawl은 Fly.io 위에서 Express 기반 app 프로세스와 Bull 큐 기반 worker 프로세스를 나누어 운영했고, 인증과 데이터 저장은 Supabase/Postgres, 큐 저장소는 Railway의 Redis를 사용했다.
  • 서비스 성장 이후 재배포 때마다 크롤 작업이 몇 시간씩 멈추는 문제가 반복됐고, 처음에는 종료 시점의 락 해제 문제로 보였지만 실제 원인은 Bull의 lockDuration과 lockRenewTime 설정을 잘못 이해한 데 있었다.
  • 스크레이프 요청을 API app 프로세스에서 직접 수행하던 구조는 관측, 재시도, 확장 기준, 안정성 측면에서 한계가 커서, app은 작업을 큐에 넣고 worker가 처리한 뒤 결과를 기다려 반환하는 방식으로 바뀌었다.
  • 큰 크롤 하나를 오래 실행하는 대신 URL별 scrape 작업으로 잘게 나누고, Redis 키와 set을 이용해 crawlId별 상태와 방문 URL 중복을 관리하면서 여러 머신에 병렬로 분산할 수 있게 됐다.
  • Bull에서 BullMQ로 전환한 뒤 작업 완료 대기 API, Sentry 계측, CPU/RAM 기반 작업 수용 같은 개선을 얻었지만, Redis 트래픽 증가로 Railway egress 비용이 급증해 Fly.io 내부 Redis로 옮기는 과정에서 ioredis의 IPv6 조회 옵션 문제까지 겪었다.

🧩 주요 포인트

  1. 초기 Firecrawl은 Fly.io 위에서 Express 기반 app 프로세스와 Bull 큐 기반 worker 프로세스를 나누어 운영했고, 인증과 데이터 저장은 Supabase/Postgres, 큐 저장소는 Railway의 Redis를 사용했다.
  2. 서비스 성장 이후 재배포 때마다 크롤 작업이 몇 시간씩 멈추는 문제가 반복됐고, 처음에는 종료 시점의 락 해제 문제로 보였지만 실제 원인은 Bull의 lockDuration과 lockRenewTime 설정을 잘못 이해한 데 있었다.
  3. 스크레이프 요청을 API app 프로세스에서 직접 수행하던 구조는 관측, 재시도, 확장 기준, 안정성 측면에서 한계가 커서, app은 작업을 큐에 넣고 worker가 처리한 뒤 결과를 기다려 반환하는 방식으로 바뀌었다.
  4. 큰 크롤 하나를 오래 실행하는 대신 URL별 scrape 작업으로 잘게 나누고, Redis 키와 set을 이용해 crawlId별 상태와 방문 URL 중복을 관리하면서 여러 머신에 병렬로 분산할 수 있게 됐다.
  5. Bull에서 BullMQ로 전환한 뒤 작업 완료 대기 API, Sentry 계측, CPU/RAM 기반 작업 수용 같은 개선을 얻었지만, Redis 트래픽 증가로 Railway egress 비용이 급증해 Fly.io 내부 Redis로 옮기는 과정에서 ioredis의 IPv6 조회 옵션 문제까지 겪었다.

🧠 상세 정리

1. 성장 이전의 평온한 개발 단계

글은 작성자가 7월 초 Firecrawl 팀에 합류했을 때의 상황에서 시작한다. 당시 팀은 주로 새 기능과 작은 버그 수정에 집중할 수 있었고, 아키텍처나 서버 부하를 심각하게 고민하지 않아도 되는 상태였다. 그러나 사용자가 늘어나면서 제품을 좋아하는 사람들이 몰려드는 이른바 “hug of death”가 반복되기 시작했고, 매일 새로운 장애성 문제가 발생했다. 팀은 새 기능을 계속 내는 것만으로는 개발자 경험과 서비스 안정성 악화를 만회할 수 없다고 판단했고, 구조를 빠르게 바꿔야 한다는 문제의식에 도달했다.

2. 초기 아키텍처: Fly.io, app, worker, Supabase, Redis

Firecrawl의 API 서비스는 Fly.io에 배포되어 Docker 컨테이너로 실행됐고, Fly.io는 로드밸런싱, 로그 수집, 무중단 배포 전략, VPC 관리 같은 운영 기능을 제공했다. 핵심 프로세스는 두 종류였다. app 프로세스는 Express로 메인 API를 제공하고 상대적으로 짧은 scrape 요청을 처리하며, 오래 걸리는 crawl은 Bull 작업 큐를 통해 worker로 넘겼다. worker 프로세스는 큐의 워커로 등록되어 긴 크롤 작업을 수행했고, 두 프로세스 모두 Supabase를 통해 인증과 Postgres 데이터 저장을 처리했다. Bull은 Redis 위에서 동작했으며, 당시 Redis는 사용하기 쉬운 Railway에 배포되어 있었다.

3. 재배포와 크롤 정지 문제를 락 문제로 오해한 과정

사용자가 늘자 크롤이 몇 시간 동안 진행되지 않고 멈춰 있는 이상한 문제가 보고되기 시작했다. 작성자가 해당 크롤들의 시간을 차트로 그려 보니, 문제가 재배포가 일어날 때마다 발생한다는 패턴이 보였다. 당시 팀은 여러 메모리 누수 문제 때문에 GitHub Actions로 2시간마다 전체 서비스를 재배포해 모든 머신을 사실상 재시작하고 있었다. 이 과정에서 크롤 작업의 락을 잡고 있던 worker들이 죽었고, 작성자는 락이 죽은 워커에 고착된다고 생각해 종료 시 현재 워커의 락을 해제하는 복잡한 코드를 추가하려 했다. 하지만 이 접근은 SIGTERM 처리 경쟁, Fly.io의 조기 SIGKILL, Bull API 한계, 종료 직전 워커가 다시 작업을 집어 드는 문제, 롤오버 배포 중 마지막 워커에 작업이 몰리는 문제 때문에 점점 통제하기 어려워졌다.

4. 진짜 원인: Bull 락 설정의 의미를 잘못 이해함

작성자가 복잡한 종료 정리 코드에 빠져드는 동안, Fire-Engine을 주로 담당하던 Thomas가 큐 락 옵션이 심각하게 잘못 설정되어 있음을 발견했다. 기존 설정은 lockDuration을 2시간, lockRenewTime을 30분으로 두고 있었는데, 이는 lockDuration을 작업이 걸릴 수 있는 최대 시간으로 이해한 결과였다. 실제로 lockDuration은 워커가 lockRenewTime마다 락을 갱신하지 못할 때, 그 작업을 stalled 상태로 보고 다른 워커에게 넘기기 전까지 기다리는 시간이었다. 따라서 워커가 죽어도 작업은 2시간 동안 잠긴 것처럼 남아 있었고, 고객이 보고한 “몇 시간 정지” 현상과 맞아떨어졌다. 해결은 복잡한 정리 코드가 아니라 lockDuration을 2분, lockRenewTime을 15초로 낮추는 간단한 설정 변경이었다.

5. 스크레이프 요청을 app에서 worker로 옮긴 결정

초기 구조에서는 scrape 요청이 API를 제공하는 app 프로세스에서 직접 실행됐다. /v0/scrape 엔드포인트 핸들러가 scrape를 시작하고 결과를 바로 반환하는 방식은 만들기에는 단순했지만, 운영 관점에서는 지속 가능하지 않았다. 팀은 scrape 요청이 언제 얼마나 실행되는지 알기 어려웠고, 실패한 scrape를 재시도할 방법도 없었으며, app 프로세스를 확장할 데이터 기준도 거의 없었다. 결국 scrape도 worker 프로세스로 옮기기로 했고, crawl 작업과 같은 큐에 scrape 작업을 추가하는 방식을 선택했다. app은 작업을 제출하고, worker는 이를 처리하며, app은 작업 완료를 기다렸다가 데이터를 반환하는 구조가 되었는데, 이 선택은 “작업 완료를 기다리지 말라”는 일반 조언을 알고도 엔드포인트의 단순성을 유지하기 위해 조심스럽게 택한 절충이었다.

6. 작업 단위를 더 작게 쪼개 크롤을 병렬화

재배포 중 크롤이 잡히면 큰 크롤 하나의 실행 시간이 사실상 두 배로 늘어날 수 있다는 점은 팀을 불안하게 했다. 일부 worker는 큰 크롤을 처리하다가 OOM 오류로 죽기도 했다. 그래서 팀은 큰 크롤을 하나의 긴 작업으로 유지하지 않고, 개별 scrape 작업으로 쪼개며 새 URL을 발견하면 새로운 작업을 생성하는 방식으로 전환했다. 모든 큐 작업은 scrape 타입을 갖게 되었고, 크롤에 속한 scrape 작업은 crawlId라는 메타데이터로 해당 크롤과 연결됐다. crawlId는 Redis 키들을 가리키며, 원본 URL, 요청 팀, robots.txt, 취소 여부, 생성 시각 같은 크롤의 기본 데이터를 저장하고 조정하는 데 사용됐다.

7. Redis set을 이용한 URL 중복 방지와 분산 크롤 조정

새 구조에서 Redis는 단순한 큐 저장소를 넘어 크롤 조정의 핵심 상태 저장소로 사용됐다. 팀은 이미 방문한 URL을 판단하기 위해 Redis set을 적극 활용했다. Redis의 SADD 명령은 set에 새 원소를 추가하고, 이미 존재하던 값이면 추가하지 않으므로 반환값으로 해당 URL을 처음 발견했는지 판단할 수 있다. Firecrawl은 이 성질을 락 메커니즘처럼 사용해 두 워커가 같은 URL을 동시에 발견하더라도 중복 작업을 만들지 않도록 했다. 이 변경으로 하나의 크롤 안에서도 여러 scrape 요청을 동시에 수행할 수 있게 되었고, 기존 크롤러에 없던 scrape 병렬성이 생기면서 여러 머신 전체에 크롤을 분산해 비용 대비 성능을 높일 수 있었다.

8. BullMQ 전환으로 얻은 개선과 여전히 남은 API 주의점

팀은 Bull을 만날 때마다 BullMQ가 더 나은 API, 새로운 기능, 활발한 유지보수를 제공한다는 점을 체감했다. 그래서 먼저 Fire-Engine에서, 이후 Firecrawl에서 BullMQ로 전환하는 작업을 진행했다. 이 전환으로 작업 완료를 기다리기 위해 작성했던 불편한 코드들을 job.waitUntilFinished()로 대체할 수 있었다. 또한 worker를 커스터마이즈해 Sentry 계측을 추가하고, 단순한 최대 동시성 상수가 아니라 CPU와 RAM 사용량을 기준으로 작업을 받을 수 있게 되었다. 다만 BullMQ에도 API의 함정은 남아 있었고, 예를 들어 Job.moveToCompleted나 Job.moveToFailed의 세 번째 인자를 false로 두지 않으면 의도치 않게 다른 작업을 체크아웃하고 잠그는 문제가 생길 수 있다고 설명한다.

9. Redis egress 비용 폭증과 Fly.io Redis 이전 중 만난 IPv6 문제

구조 변경으로 확장성은 크게 좋아졌지만, 더 많은 트래픽이 Redis를 거치게 되면서 새로운 비용 문제가 나타났다. 팀은 8월에 Railway에서 1만 5천 달러의 청구서를 받았고, 대부분은 Redis egress 비용이었다. 이 비용 구조는 지속 가능하지 않았기 때문에 빠르게 이전해야 했고, Upstash에 실망하고 Dragonfly에서도 문제를 겪은 뒤 Fly.io에 Redis를 직접 배포하는 방향을 찾았다. 처음에는 public IP로 redis-cli 접속이 되지 않아 혼란이 있었지만, Fly의 Private Networking을 사용해 worker 머신에서 접속하자 안정적인 Redis 인스턴스에 연결할 수 있었다. 그러나 애플리케이션을 새 Redis로 배포하자 ioredis가 연결하지 못했고, 원인은 ioredis가 기본적으로 IPv4만 조회하며 IPv6 주소를 쓰려면 연결 문자열에 ?family=6을 명시해야 한다는, 문서화가 부족한 동작이었다.

🧾 핵심 주장 / 시사점

  • 장애의 겉모습이 “종료 시 락 정리 실패”처럼 보여도, 실제 원인은 라이브러리 옵션의 의미를 잘못 이해한 단순 설정일 수 있으므로 복잡한 보상 로직을 만들기 전에 기본 동작을 먼저 검증해야 한다.
  • API 프로세스가 직접 긴 작업을 수행하면 관측, 재시도, 확장 기준, 장애 격리가 어려워지므로, 단순한 엔드포인트 경험을 유지하더라도 내부적으로는 큐와 worker 중심 구조가 안정성에 유리하다.
  • 확장성 개선은 성능 문제를 해결하는 동시에 새로운 비용 병목을 만들 수 있으며, Firecrawl 사례에서는 Redis 중심 아키텍처가 처리량을 높였지만 egress 비용과 네트워크 연결 세부사항까지 운영 이슈로 끌어냈다.

✅ 액션 아이템

  • lockDuration과 lockRenewTime를 큐 락 정책으로 명확히 정리하고 배포 전후 크롤 중단 구간을 점검한다.
  • API app의 스크레이프 직수행을 폐기하고 큐 등록-워커 처리-결과 대기 흐름으로 전환해 재시도와 관측 지표를 보강한다.
  • BullMQ와 URL 단위 분할 처리를 적용해 CPU/RAM 기반 수용 기준, crawlId 상태 키와 중복 제거 규칙을 다중 머신 기준으로 운영한다.

❓ 열린 질문

  • 일일 30만 요청 환경에서 lockDuration·lockRenewTime의 임계값은 어떤 구간에서 안정적으로 유지될 수 있는가?
  • URL 단위 작업 분해 시 crawlId 상태 키와 Redis set의 만료·정리 정책은 어느 주기로 두는 것이 중복·누락을 최소화할 수 있는가?
  • Railway Redis에서 Fly 내부 Redis로 옮길 때 ioredis IPv6 조회 옵션은 어떤 설정이 가장 안전하게 연결 실패 없이 동작하는가?

관련 문서

공통 태그와 주제 흐름을 기준으로 같이 보면 좋은 문서를 이어서 제안합니다.