본문으로 건너뛰기

© 2026 Molayo

Dev.to헤드라인2026. 06. 29. 08:02

우리는 스크레이퍼를 에이전트화(Agentic)했다. 그리고 더 느려졌다.

요약

채용 정보 통합 스크레이퍼를 구축하며 겪은 에이전트 방식 도입의 시행착오를 다룹니다. 수동 코딩의 유지보수 문제와 LLM 기반 에이전트 도입 시 발생하는 속도 저하 및 비용 문제를 솔직하게 분석합니다.

핵심 포인트

  • 수동 스크레이퍼 방식은 사이트 구조 변경 시 유지보수 비용이 매우 높음
  • LLM 에이전트 도입은 자율성은 높으나 실행 속도가 느려지는 트레이드오프 존재
  • 다양한 상용 AI 스크레이퍼와 브라우저 에이전트의 성능 비교 분석
  • 효율적인 데이터 추출을 위한 엔지니어링적 접근 방식의 중요성

우리는 사이드 프로젝트로 채용 정보 통합 스크레이퍼(Scraper)를 구축했는데, 이것이 예상보다 훨씬 더 어려운 엔지니어링 문제로 변했습니다. 목표를 말하기는 지루하지만 해결하기는 잔혹합니다. 구조가 제각각인 수백 개의 기업 채용 페이지를 가져와서, 깨끗하고 중복이 제거되며 지속적으로 새로고침되는 실시간 채용 공고 피드로 만드는 것입니다. 현재 이 빌드는 3,000개 이상의 채용 정보를 가져옵니다. 이 단계에 도달하기까지 우리는 정답을 찾기 전까지 반복적으로, 그리고 값비싼 대가를 치르며 틀린 길을 걸어야 했습니다.

이 글은 그 과정에 대한 솔직한 이야기입니다. 작동하지 않았던 접근 방식, 작동했던 방식, 그리고 각 단계에서의 지표를 다룹니다. 만약 데모가 마법처럼 보여서 스크레이퍼에 LLM을 연결하려고 한다면, 이 글은 우리가 가장 먼저 읽었기를 바랐던 바로 그 글입니다.


자세히 살펴보기 전에 전체적인 흐름은 다음과 같습니다:

제1막: 뻔한 정답들, 그리고 그것들이 실패한 이유

각 사이트를 수동으로 코딩하기 (Hand-coding)

수십 개의 스크레이퍼를 만들 때쯤, 우리는 새로운 스크레이퍼를 작성하는 시간보다 기존의 것들을 수리하는 데 더 많은 시간을 쓰고 있었습니다.

첫 번째 버전은 전혀 AI가 아니었습니다. 사이트당 하나의 스크레이퍼를 해당 사이트의 마크업(Markup)에 맞춰 수동으로 작성했습니다. JavaScript 비중이 높은 페이지에는 Selenium을 사용했고, 서버 측에서 데이터를 제공하는 페이지에는 일반적인 requests를 사용했습니다. 각 스크립트는 ATS(채용 관리 시스템)가 채용 목록을 렌더링하는 컨테이너가 정확히 어디인지 알고 있었고, 각 목록을 따라 채용 상세 페이지로 이동하여 필드를 추출했습니다. 어떤 경우에는 목록에 도달하기 위해 iframe으로 전환해야 하기도 했습니다. 원칙은 항상 같았습니다. 이 특정 사이트의 구조를 수동으로 인코딩하는 것이었습니다.

각 스크레이퍼는 특정 사이트에서만 작동했습니다. 그 사이트의 HTML 구조가 변경될 때까지 말입니다. 그리고 저희는 두 명으로 구성된 팀입니다. 다양한 ATS(Workday, Greenhouse, Lever, iCIMS, Taleo 등)를 사용하는 수백 개의 고용주들이 있으며, 이 모든 것이 별도의 스크레이퍼를 작성하고, 더 나쁜 것은 리디자인될 때마다 _수리_해야 하는 상황이었습니다. 문제는 작성 자체가 아니라 유지보수 부담이었습니다. 그래서 저희는 AI에 손을 댔습니다.

상용 AI 스크레이퍼

저희는 인기 있는 옵션들을 시도했고, 비교를 공정하게 하기 위해 모두 같은 목표물—BlackBerry의 Workday 채용 페이지—을 가리켰습니다.

**브라우저 사용(Browser-use)**은 꿈의 극대화된 버전입니다. 자율 에이전트에게 작업(

접근 방식 (Approach)시간 (Time)작업 수 (Jobs)토큰 (Tokens)규칙 없음 (Rule-free?)상태 유지 (Stateful?)결과 (Outcome)
Browser-use (자율 에이전트)35분 이상, 134–143 단계0중단됨, 완료되지 않음
...
우리가 깨닫는 데 너무 오래 걸렸던 관점의 전환은 다음과 같습니다: 이것은 "AI 스크레이퍼는 나쁘다"라는 뜻이 아닙니다. 각 도구는 각자의 장점이 있었습니다. Browser-use는 규칙이 필요 없고, crawl4ai-with-rules는 매우 빠르며, crawl4ai-with-LLM은 규칙 없이도 확장 가능합니다. 하지만 그 어떤 것도 이 세 가지를 동시에 제공하지는 못했습니다: 규칙이 없고(rule-free), 상태를 유지하며(stateful), 저렴한(cheap) 것. 그 간극이 바로 우리가 우리만의 도구를 직접 만든 이유 전체입니다.

제2막: 우리만의 도구 만들기 — 영리함에서 지루함으로

아무도 말해주지 않는 부분이 있습니다. 우리가 직접 만들었을 때, 첫 번째 버전은 우리가 방금 거부했던 기성 제품(off-the-shelf) 도구들보다 더 나빴습니다. 왜냐하면 우리도 자율 에이전트들이 저지른 것과 똑같은 실수, 즉 영리해지려고 노력했기 때문입니다.

영리했던 버전 (우리가 삭제한 버전)

우리의 첫 번째 커스텀 빌드는 설계 단계부터 에이전트 방식(agentic)이었으며, 우리가 자랑스러워했던 두 가지 아이디어를 담고 있었지만 둘 다 틀렸습니다.

첫째, 매 단계마다 LLM에게 메타 질문을 던졌습니다: "다음에 무엇을 해야 할까요?" 모델은 메뉴에서 하나를 선택했고, 우리는 그 선택에 따라 행동했습니다. 말 그대로, 모든 페이지 전환은 다음과 같은 왕복 과정을 거쳤습니다:

# 매 단계마다, 다음 동작을 결정하기 위해 LLM 호출 수행:
action = ai.determine_next_action(page_state, memory)

...

둘째, 저 retry_with_screenshot 분기를 보십시오. 텍스트 추출이 약해 보일 때, 우리는 "더 나은 결과"를 위해 **스크린샷으로 에스컬레이션(escalate)**했습니다. 페이지를 스크롤하고, 이미지를 캡처하여 모델이 볼 수 있도록 전송하는 방식이었습니다.

결과는 겸허해질 수밖에 없었습니다. 이 방식의 초기 Workday 전용 버전은 29개의 작업을 가져오는 데 20~30분이 소요되었습니다. 작업당 약 45초가 걸린 셈입니다. 위의 표와 비교해 보십시오: crawl4ai-with-LLM은 동일한 종류의 작업을 약 6초 만에 수행했습니다. 우리의 영리한 커스텀 스크레이퍼는 우리가 무시했던 기성 도구보다 더 느렸습니다. 병목 현상은 설계 자체에 있었습니다. 즉, 느린 LLM 호출이 끊임없이 이어졌고, 스크린샷 페이로드(payload)로 인해 상황은 더 악화되었습니다.

우리의 두 가지 "영리한" 아이디어는 모두 함정이었습니다:

  • 결정마다 LLM을 호출하는 것은 매 단계마다 지연 시간(latency)과 비용이라는 세금을 부과하는 것과 같습니다. "다음 버튼을 클릭해야 할까?"라는 결정은 언어 모델(language model)과의 왕복 통신(round-trip)이 필요하지 않습니다. 이는 페이지 상태(page state)를 기반으로 5줄짜리 함수가 답할 수 있는 문제입니다. 우리는 이미 이해하고 있는 제어 흐름(control flow)을 다시 도출하기 위해 모델의 지연 시간(latency) 비용을 지불하고 있었습니다.
  • 스크린샷은 페이지를 읽는 가장 비싼 방법입니다. Gemini는 이미지를 타일링(tiling) 방식으로 토큰화합니다. 즉, 768×768 타일당 258개의 토큰이 소모되므로, 전체 높이의 채용 페이지는 스크린샷 하나당 수천 개의 토큰이 소모되며, 스크롤이 필요한 페이지는 여러 장의 스크린샷이 필요합니다. 반면 텍스트는 약 0.75단어/토큰 비율로 토큰화됩니다. 따라서 약 2,000단어 분량의 페이지 전체 원본 HTML은 정제 전 약 2,700개의 토큰이 됩니다. 수치를 비교하면 다음과 같습니다:
전체 페이지 스크린샷텍스트로서의 페이지 (HTML)
계산 방식768×768 타일당 258 토큰; 긴 페이지 = 많은 타일약 1.33 토큰/단어
.........

우리는 더 많은 비용을 지불하고, 더 오래 기다렸으며(OCR), 더 나쁜 구조를 얻었습니다. 스크린샷이 승리한 측면은 단 하나도 없습니다.

주목할 만한 대칭성은, 저렴한 경로가 실패했을 때 단계를 격상(escalate)시키려는 본능 자체는 옳았다는 점입니다. 단지 우리가 잘못된 것으로 단계를 격상시켰을 뿐입니다. 이전 버전은 스크린샷(비싸고, 느리고, 정확도가 낮음)으로 격상했고, 출시된 버전은 더 강력한 텍스트 모델(저렴하고, 빠르고, 정확함)로 격상합니다. 아이디어는 같지만, 비용은 정반대입니다.

그래서 우리는 영리한 부분들을 삭제하고 지루해지기로 했습니다.

지루한 버전 (우리가 출시한 버전)

프로덕션 디자인은 몇 가지 매력적이지 않은 결정들에 기반하고 있으며, 각 결정은 영리한 대안보다 더 빠르거나 더 신뢰할 수 있다는 점을 통해 그 자리를 확보했습니다.

픽셀이 아닌 텍스트. 우리는 BeautifulSoup를 사용하여 페이지를 해체합니다. script, style, noscript, svg, meta, link를 제거하고 주석을 삭제한 뒤, 토큰 예산에 맞춰 잘라낸 *정제된 HTML (cleaned HTML)*을 모델에 보냅니다. 스크린샷은 사용하지 않습니다. 위의 수치에서 보여주듯, 모델은 이미지보다 텍스트로서 구조를 더 잘 읽으며, 비용은 훨씬 적게 듭니다.

기본적으로 스텔스(Stealth) 모드 적용. 우리는 일반적인 Playwright를 그대로 사용하지 않습니다. 대신 playwright-stealth를 사용하여 브라우저 핑거프린트(browser fingerprint, 채용 사이트가 자동화 탐지에 사용하는 navigator.webdriver 신호, headless-Chrome의 특이점 등)를 패치하여 실행합니다. 이것이 완벽한 방패는 아니지만, 깨끗한 HTML을 받아내느냐 아니면 상당수의 사이트에서 챌린지 페이지(challenge page)를 마주하느냐를 결정짓는 차이를 만듭니다.

모델에게 직접 제어(drive)를 맡기지 마세요. LLM은 정확히 두 가지의 좁은 역할만 수행합니다. 목록 페이지에서는 "여기 채용 공고 링크와 페이지네이션(pagination) 액션이 있습니다"라고 알려주고, 상세 페이지에서는 "여기 구조화된 데이터(structured data)가 있습니다"라고 알려주는 것입니다. 제어 흐름(Control flow) — 페이지를 넘길지 말지, 작업이 완료되었는지, 탐색(navigation)이 실패했는지 등 — 은 단순히 상태(state)를 읽는 일반적인 Python 코드가 담당합니다. 모델은 주석(annotate)을 달고, 코드가 결정합니다. (이 구분은 매우 중요하여 현재 진행 중인 재작성 작업인 Act 3의 조직 원칙이 되었습니다.)

Headless 우선, 증거가 있을 때만 Headed 사용. 우리는 더 빠르고 가볍기 때문에 기본적으로 Playwright를 headless 모드로 실행합니다. 모델은 봇 차단벽(bot wall)이나 JS 전용 렌더링(JS-only rendering)이 의심될 때 플래그(flag)를 표시하지만, 우리는 그 플래그를 권고(advisory) 사항으로 취급하며 절대 직접적으로 행동에 옮기지 않습니다. 에이전트는 항상 headless 모드로 먼저 시도하며, 오직 관찰된(observed) 실패(채용 공고가 0개이거나, 페이지네이션 오류, 사이클 중단 발생, 또는 예상 수치 미달 등)가 발생했을 때만 headed 모드로 재시도합니다. 우리의 철학은 예측에 따라 headless를 포기하는 것이 아니라, headless가 작동하지 않음을 증명하는 것입니다. 어떤 시도든 더 많은 채용 공고를 찾아내는 쪽이 승리합니다.

회사별로 모든 것을 기록. 우리가 스크레이핑하는 각 회사는 자체적인 콘솔 로그(console log)와 해당 실행에서 추출한 데이터 덤프(dump)를 기록합니다. 사이트가 조용히 변경되어 추출 품질이 떨어질 때, 이러한 회사별 기록은 한 시간 내에 문제를 파악하느냐, 아니면 몇 주 뒤 피드(feed)의 공백을 보고서야 알게 되느냐를 결정짓는 차이를 만듭니다.

모든 작업을 원자적(atomic)으로 작성하세요. 절반만 작성된 작업 행(row)은 행이 아예 없는 것보다 더 나쁩니다. 피드(feed)를 깨끗하게 유지하는 패턴은 두 단계로 이루어집니다. 데이터베이스에 접근하기 전에 모든 작업을 검증(validate) 및 정제(sanitize)하여(그래야 잘못된 형식의 행 하나가 배치(batch) 전체를 망가뜨리지 않습니다), 그 다음 깨끗한 세트를 완전히 커밋(commit)하거나 완전히 롤백(rollback)하는 단일 트랜잭션(transaction) 내에서 삽입하는 것입니다:

# 1. 먼저 검증합니다 — 필수 필드가 누락된 행은 버리고, 나머지는 강제 변환합니다.
valid = [sanitize(j) for j in jobs if has_required_fields(j)]

...

따라서 데이터베이스에 있는 것은 항상 완전한 작업이며, 결코 파편이 아닙니다. 즉, 잘못된 스크레이핑(scrape)이 피드를 절반만 업데이트된 상태로 남겨둘 수 없습니다.

목록(listing)과 상세 정보(detail)를 분리하세요. 이것은 가장 중요한 구조적 결정입니다. 왜냐하면 스크레이핑은 서로 다른 형태를 가진 두 가지 문제이기 때문입니다:

  1. *목록 탐색(Listing discovery)*은 순차적이며 페이지네이션(pagination)에 종속됩니다. 페이지를 로드하고, 작업을 찾고, "다음" 컨트롤을 찾고, 이를 반복합니다. 하나의 브라우저가 페이지를 훑으며 지나가는 방식입니다.
  2. *상세 정보 추출(Detail extraction)*은 대단히 병렬적(embarrassingly parallel)입니다. N개의 작업 URL이 주어지면 N개의 페이지를 가져와서 파싱(parse)합니다. 이들은 서로 의존하지 않습니다.

이 두 가지를 구분되지 않은 하나의 루프(loop)로 취급한 것이 영리한 버전이 느렸던 이유 중 하나입니다. 이들을 분리한 것이 우리가 빠르게 동작할 수 있게 해준 핵심입니다.

병렬성(Parallelism) — 하지만 주의해서 사용하세요. 상세 정보 추출이 별도의 단계가 되면, 이를 확장(fan out)할 수 있습니다. 즉, 많은 작업 URL을 동시에 가져오고 파싱할 수 있습니다. 우리는 asyncio.Semaphore(5)를 사용하여 동시성(concurrency)을 제한합니다. 숫자 5는 튜닝된 값이 아니라 실용적인 선택입니다. 이보다 적으면 작업을 충분히 활용하지 못했고, 이보다 많으면 속도 제한(rate-limiting)에 걸리거나 대상 사이트(그리고 우리의 서버; 이 작업은 소박한 AWS t3.medium에서 실행됩니다)에 과부하를 줄 위험이 있었습니다. 이는 마법의 숫자가 아니라 합리적인 상한선입니다. 하지만 어떻게 확장하느냐가 상한선보다 더 중요하며, 우리는 처음에 이를 잘못 수행했습니다.

병렬성의 함정

우리가 처음 동시성 (Concurrency)을 도입했을 때, 그것은 도움이 되지 않았습니다. 오히려 해가 되었습니다. 동일한 워크로드에 대해 연속으로 두 번 실행한 결과는 다음과 같습니다:

병렬 (Parallel):    0:08:09
순차 (Sequential):  0:07:27

병렬 버전이 더 느렸습니다. 그리고 그 이유가 바로 이번 교훈의 핵심입니다: 우리는 잘못된 작업 단위 (Unit of work)를 병렬화하고 있었습니다. 우리의 첫 번째 동시성 버전은 모든 작업마다 새로운 브라우저를 콜드 스타트 (Cold-start)했습니다. 콜드 상태에서 헤드리스 브라우저 (Headless browser)를 실행하는 것은 파이프라인에서 가장 무거운 작업이기에, 우리는 그 시작 비용 (Startup tax)을 N번 지불하면서 그 비용들을 병렬로 실행하고 있었던 것입니다. 우리가 실제로 병렬화한 것은 오직 오버헤드 (Overhead)뿐이었습니다.

해결책은 다음과 같습니다: 하나의 브라우저를 실행하여 전체 배치 (Batch) 동안 유지하고, 각 동시 작업에 대해 해당 브라우저 내의 가벼운 자식 탭 (Child tab)을 할당하는 것입니다. 탭을 여는 것은 브라우저를 부팅하는 비용의 아주 작은 일부에 불과합니다. 따라서 시작 비용을 N번이 아닌 단 한 번만 지불하게 되면서, 기존에 30분 정도 걸리던 워크로드는 대략 3분 만에 50개의 작업 (~작업당 약 3.6초)을 처리하는 수준으로 단축되었습니다. 이는 crawl4ai-with-LLM보다 작업당 속도가 더 빠를 뿐만 아니라, 상태 유지 (Stateful)가 가능하며, 토큰 비용도 훨씬 저렴합니다. 이는 기존의 기성 도구(Off-the-shelf tools) 중 그 어느 것도 제공하지 못했던 조합입니다.

AI 자동 생성 콘텐츠

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

원문 바로가기
0

댓글

0