주말 10달러로 메이커를 위한 50만 도메인 검색 엔진 만들기
거대 기업의 SEO와 문서에 묻히지 않고, 개인 프로젝트와 창작물만 찾아주는 맞춤형 검색 엔진을 주말 단기로 구축한 사례입니다. 로컬 소형 언어 모델을 활용해 웹페이지를 요약하고 분류하며, 약 10달러의 GPU 서버 비용으로 50만 개 이상의 사이트 인덱스를 구축할 수 있었습니다. 개인 개발자가 AI를 활용해 기존 검색 엔진의 한계를 뛰어넘는 효율적인 로컬 도구를 만드는 데 훌륭한 영감을 제공합니다.
marlin (글쓴이): 주말, 단 10달러로 메이커를 위한 50만 도메인 검색 엔진을 구축한 방법
일요일, 새벽 2시. 잠이 오지 않았고 또다시 검색 엔진들 때문에 짜증이 났습니다. 내가 정말로 관심 있는 포트폴리오, 개인 잡지, 조금 독특한 예술 프로젝트, 1인 개발자 소프트웨어 같은 검색어들은 늘 거대 기업의 문서와 SEO(검색 엔진 최적화) 쓰레기 더미 아래에 파묻혀 버리곤 했죠. 그래서 새벽 2시에 흔히들 하듯이 그 짓을 했습니다. 터미널을 켜고 계획을 타이핑했죠.
"나만을 위한 검색 엔진을 만들고 싶다. 약 4천만 개의 도메인이 있다. 각 도메인에 대해 약간의 메타데이터를 저장할 수 있다. 1KB씩만 잡아도 40GB인데, 이 정도면 충분히 가능하다."
수요일 점심쯤에는 560,183개의 홈페이지를 분류해 두었습니다. 다음에 가져올 가치 있는 크롤링 대기열은 비어 있었고, 여기서 멈추기로 결정했습니다. 이 글은 바로 그 주말에 있었던 이야기입니다. 무엇을 만들었고, 무엇이 망가졌고, 비용은 얼마나 들었으며, 여러분이 직접 하나 만들고 싶다면 해줄 수 있는 조언들은 무엇인지에 대해 다룹니다.
만약 딱 한 문단만 읽으신다면 핵심은 이것입니다. 약 10달러의 밤샘 GPU 대여 비용과, 시스템이 돌아가는 동안 이리저리 방향을 잡아준 몇 시간의 시간이면 디스크상 1GB 미만의 용량으로 수십만 개 사이트의 개인 검색 인덱스를 가질 수 있습니다. 이게 제가 말씀드리고 싶은 전부입니다. 아래에 적힌 내용들은 내가 그곳에 어떻게 도달했고, 그 과정에서 어떤 까다로운 문제들이 있었는지를 설명합니다. (자세한 기술적 세부 사항은 별도로 제공됩니다.)
내가 실제로 만들려고 했던 것
'웹 전체를 인덱싱(Indexing)하는 것'이 아니었습니다. 그저 물건을 만드는 사람들, 예술, 코드, 하드웨어, 시, 소극장 등을 하는 사람들을 찾고 싶었을 뿐이고, docs.company.com 같은 기업 문서들에 파묻히고 싶지 않았습니다. 오직 개인이 쓰는 단일 사용자 용이며, 계정 같은 건 없습니다.
크롤러는 오직 홈페이지만을 바라봅니다. 각 페이지를 읽고 이름, 두세 문장의 요약, 카테고리, 그리고 몇 가지 태그를 작성해 주는 작은 로컬 언어 모델을 사용했습니다. 그 위에는 단어의 절반만 타이핑하더라도 올바른 사이트를 찾을 수 있도록 퍼지 매칭(fuzzy matching) 기능이 적용된 작은 검색 UI를 올렸습니다. 저는 제가 명시적으로 만들지 '않는' 것들을 적어두었는데, 이는 주로 저를 돕는 AI 에이전트들이 이를 조용히 더 거대한 무언가로 '단순화(확장)'시키는 것을 막기 위함이었습니다. IP 스캐닝은 없고, Redis도 없으며, 페이지의 전체 HTML을 저장하지도 않고, 재크롤링 스케줄러도 없으며, 멀티 테넌트(다중 사용자) 시스템도 아닙니다. '제외'는 단지 카테고리의 체크박스 하나로 처리되어 검색 시에만 적용되었습니다. 크롤러는 여전히 이커머스 사이트도 요약하겠지만, 내가 굳이 그것들을 볼 필요가 없도록 한 것입니다.
간단히 계산해 보면 수천만 개의 도메인에 약 1KB씩 메타데이터를 할당하는 건 Postgres 박스 하나에겐 정말 아무것도 아닙니다. 페이지의 텍스트 자체는 말뭉치(Corpus)로 쓰려던 게 아니라, 모델이 읽고 나면 버려지는 임시 스크래치 버퍼(메모리) 역할만 하도록 설계했습니다.
머신 구성
총 4개의 프로세스가 동작했습니다. 그중 3개는 제 PC에서 로컬로 돌렸고, 1개는 데이터베이스를 직접 건드리지 않는 대여한 GPU 서버였습니다.
• 패처(Fetcher): 대기 중인 도메인을 하나씩 가져와서 HTTPS, 그리고 HTTP 순서로 시도합니다. JavaScript는 실행하지 않고 간단한 HTML 파서로 제목, 본문 텍스트, 외부 링크만 추출합니다. 그런 다음 해당 데이터베이스 행(Row)을 '준비 완료' 상태로 바꿉니다.
• 워커(Worker): '준비 완료'된 도메인을 가져옵니다. 페이지가 비어 있거나, 주차되어 있거나(Parked), 봇 인증(Bot-challenge) 차단벽이라면 모델을 아예 거치지 않고 건너뜁니다. 그렇지 않은 경우, 소형 로컬 모델(Gemma, 4B 파라미터 모델)에 구조화된 요청을 한 번 보내 이름, 요약, 카테고리, 태그를 받아옵니다. 임시 텍스트는 지워버리고, 추출해 왔던 외부 링크들은 그 링크가 나온 페이지의 종류에 따라 우선순위를 매겨 다시 대기열(Queue)에 넣습니다.
• 스튜어드(Steward): 이 프로세스는 메인 대기열을 전혀 건드리지 않습니다. 의심스러울 정도로 너무 많은 페이지를 생성해 내는 호스트(Host)에서 도메인을 표본 추출(Sampling)하여 모델에게 "차단, 유지, 불확실" 중 하나를 묻습니다. 그리고 조용히 차단 목록(Blocklist)을 관리합니다. 왜 이것이 필요했는지는 나중에 더 설명하겠습니다.
• API와 작은 웹 UI: 필터가 있는 검색, 숨길 카테고리를 토글하는 페이지, 그리고 로그를 찌푸리며 볼 필요 없이 내 공장(시스템)이 실제로 무슨 짓을 하고 있는지 볼 수 있는 대시보드입니다.
패처(Fetcher)가 가져온 모든 데이터는 '진행 중'인 동안 해당 도메인 데이터베이스 행(Row)에 직접 저장됩니다. 모델 처리가 끝나면 해당 텍스트는 즉시 지워집니다. 이는 생각보다 훨씬 중요합니다. 왜냐하면 실제 규모로 갔을 때 원시 페이지 텍스트가 영원히 쌓이게 둘 수 없기 때문입니다. 4천만 개의 행에 각각 몇 킬로바이트씩 곱해지면 용량이 엄청나게 빠르게 증가합니다. 저는 그 텍스트가 필요했던 이유가 단지 모델이 읽는 데 걸리는 몇 초 동안뿐이었습니다.
사실 웹의 90%는 기업입니다
첫 번째 버전은 몇 시간 만에 작동했습니다. 샘플 도메인을 향해 실행시키고, 항목들이 요약되는 것을 지켜보고, 검색해 보았습니다. 정말 좋았습니다. 그러다 일요일 오후,