CSS 셀렉터가 실패한 이유: 일관성 없는 웹 페이지 스크래핑을 위한 LLM 활용법
요약
일관성 없는 HTML 구조로 인해 발생하는 웹 스크래핑의 한계를 CSS 셀렉터 대신 LLM을 활용하여 해결하는 방법을 제시합니다. Playwright로 텍스트를 추출하고 LLM에 구조화된 데이터 추출을 요청하는 워크플로우를 통해 유지보수 문제를 해결할 수 있습니다.
핵심 포인트
- 기존 CSS 셀렉터와 XPath 방식은 웹 페이지 레이아웃 변경에 취약함
- LLM을 활용하면 복잡한 HTML 구조 없이도 정확한 데이터 추출 가능
- Playwright로 렌더링 후 텍text를 추출하여 LLM에 전달하는 방식 권장
- 구조화된 JSON 출력을 통해 데이터 파싱의 안정성 확보
몇 달 전, 저는 어느 정도 인지도가 있는 이커머스 사이트에서 약 5,000개의 상품 페이지를 스크래핑해야 했습니다. 전에도 해본 일이라 별일 아니라고 생각했죠. 맞나요? 틀렸습니다.
모든 페이지의 HTML 구조가 제각각이었습니다. 어떤 페이지는 가격이 <span class="price">에 있었고, 다른 페이지는 클래스가 없는 <div> 안에 숨겨져 있었으며, 세 번째 페이지는 인라인 스타일이 적용된 <p> 태그를 사용했습니다. 상품 제목은 때로는 <h1>, 때로는 <h2>였고, 가끔은 그냥 meta 태그 안에 있었습니다. 제가 공들여 만든 CSS 셀렉터(CSS selector) 덩어리는 단 열 번째 페이지에서 무너졌습니다.
저는 수년간 웹 스크래퍼(web scraper)를 작성해 왔지만, 이것은 새로운 차원의 혼돈이었습니다. XPath, 정규 표현식(regex), 심지어 몇 가지 휴리스틱 기반 파서(heuristic-based parser)까지 시도해 보았습니다. 하지만 페이지의 80%조차 안정적으로 작동하는 것이 없었습니다. 저는 취약한 셀렉터를 유지보수하는 데 수 시간을 허비하고 있었고, 분명 더 나은 방법이 있을 것이라는 사실을 알고 있었습니다.
시도했지만 실패했던 방법들
처음에는 완전히 고전적인 방식을 택했습니다. 잠재적인 셀렉터 목록과 함께 BeautifulSoup을 사용하는 것이었죠. 여러 패턴을 시도하고 가장 흔한 결과를 선택하는 점수 기반 시스템을 작성했습니다. 한동안은 작동했지만, 사이트가 새로운 레이아웃을 A/B 테스트하자마자 제 점수 체계는 엉망이 되었습니다. 곳곳에서 오탐(False positive)이 발생했습니다.
그다음에는 헤드리스 브라우저(headless browser, Playwright)를 사용하여 JavaScript 렌더링을 기다린 후 DOM 스냅샷을 찍는 방식을 시도했습니다. 이는 동적 콘텐츠(dynamic content) 문제에는 도움이 되었지만, 구조 자체는 여전히 악몽 같았습니다. 결국 저는 500줄에 달하는 try/except 예외 처리 파일로 끝을 맺었습니다. 그것은 깨지기 쉽고 추하며, 다음 주에 또 무너질 것이라는 걸 알고 있었습니다.
심지어 가격 스팬(price span)을 감지하기 위해 간단한 머신러닝(ML) 분류기를 만드는 것도 고민해 보았습니다. 하지만 그러려면 레이블이 지정된 학습 데이터(labeled training data)가 필요했습니다. 새로운 사이트가 생길 때마다 수천 개의 페이지에 주석을 달아야 했습니다. 확장성(scalable)이 없었습니다.
결국 성공한 방법: LLM에게 파싱을 맡기기
돌파구는 제가 DOM과 싸우는 것을 멈추고 구조 자체를 완전히 포기했을 때 찾아왔습니다. 만약 제가 그냥 페이지를 렌더링하고, 보이는 모든 텍스트를 추출한 다음, AI에게 제가 원하는 필드를 찾아달라고 요청한다면 어떨까요?
말도 안 되는 소리처럼 들리시죠? 하지만 현대의 LLM (Large Language Models)은 명확한 지침만 주어진다면 지저분한 텍스트에서 구조화된 데이터 (structured data)를 추출하는 데 놀라울 정도로 뛰어난 성능을 보여줍니다. 기본적인 흐름은 다음과 같습니다:
- Playwright를 사용하여 페이지를 가져오고 렌더링 (rendering)이 완료될 때까지 기다립니다.
- 모든 가시적인 텍스트를 평면 문자열 (flat string, HTML 태그 제외)로 추출합니다.
- LLM에게 특정 필드를 JSON 형식으로 출력하도록 요청하는 프롬프트 (prompt)를 작성합니다.
- 결과를 검증하고 저장합니다.
더 이상 취약한 셀렉터 (selectors)도, HTML 전용 로직도 필요 없습니다. 그저 이렇게만 하면 됩니다: "여기 페이지의 텍스트가 있습니다. 제목, 가격, 설명을 JSON으로 주세요."
코드 예시 (Python + OpenAI)
제가 만든 스크래퍼 (scraper)의 간소화된 버전입니다. 페이지 렌더링에는 Playwright를 사용하고, 추출에는 OpenAI의 GPT-4o-mini를 사용합니다. 이 모델은 저렴하며 대부분의 사용 사례에 충분히 빠릅니다.
import json
from playwright.sync_api import sync_playwright
from openai import OpenAI
...
에러 핸들링 (error handling)을 위해 배치 재시도 루프 (batch retry loop)를 추가했지만, 핵심은 바로 저 두 함수입니다. LLM은 자연어를 이해하기 때문에 모든 기괴한 HTML 변형들을 처리해냅니다. "$29.99"라는 가격이 어디에 나타나든 모델이 찾아냅니다.
배운 점과 트레이드오프 (trade-offs)
이 접근 방식이 만능 해결책 (silver bullet)은 아닙니다. 5,000개의 페이지에 이 방식을 적용한 후 발견한 점은 다음과 같습니다:
- 비용 (Cost): GPT-4o-mini는 입력 토큰 100만 개당 약 0.15달러가 소요됩니다. 약 2,000개의 토큰을 가진 일반적인 페이지의 비용은 약 0.0003달러입니다. 5,000개 페이지의 경우 약 1.50달러가 듭니다. 매우 저렴합니다. 하지만 GPT-4를 사용하면 비용이 급격히 치솟습니다.
- 지연 시간 (Latency): 각 API 호출에는 2
5초가 소요됩니다. 5,000개 페이지를 처리하려면 순차적 호출 시 48시간이 걸립니다.asyncio를 사용하여 병렬화할 수 있지만, 속도 제한 (rate limits)에 주의해야 합니다. - 정확도 (Accuracy): 일반적인 필드(제목, 가격)에 대해서는 약 97%의 정확한 추출 성능을 얻었습니다. 하지만 희귀한 필드(SKU, 사양)의 경우 정확도가 약 85%로 떨어졌습니다. 모델이 때때로 비슷해 보이는 숫자를 혼동하거나 텍스트가 모호할 때 환각 (hallucination) 현상을 일으킵니다.
- 프롬프트 엔지니어링 (Prompt engineering)의 중요성: 모호한 프롬프트는 쓰레기 같은 결과를 줍니다. 신뢰할 수 있는 JSON 출력을 얻기 위해 여러 번 반복 작업을 수행했습니다.
response_format={"type": "json_object"}(OpenAI)를 사용하는 것이 JSON 출력을 강제하는 데 도움이 됩니다. - 토큰 제한 (Token limits): 페이지 전체 텍스트는 매우 클 수 있습니다. 저는 8,000자로 잘라서 사용(truncate)합니다. 때로는 중요한 데이터가 페이지 더 깊은 곳에 있을 수 있습니다. 그래서 절단 전략을 조정해야 했습니다.
또한, 최적화된 모델로 이와 동일한 아이디어를 래핑(wrap)하여 제공하는 전문 API 서비스 사용도 탐색해 보았습니다. 예를 들어, 직접 프롬프트 엔지니어링이나 속도 제한을 관리하고 싶지 않다면, 정확히 이 기능을 수행하는 도구들이 있습니다. 제가 살펴본 것 중 하나는 ai.interwestinfo.com으로, 유사한 추출 엔드포인트 (extraction endpoint)를 제공합니다. 하지만 중요한 것은 기술 자체입니다. 어떤 LLM 제공업체를 사용하더라도 직접 구현할 수 있습니다.
다음에 한다면 다르게 할 점
첫째, 사전에 검증 레이어 (validation layer)를 구축하겠습니다. LLM으로 보내기 전에 알려진 패턴(예: 가격을 위한 "$XX.XX")에 대해 빠른 정규 표현식 (regex) 또는 규칙 기반 체크를 수행할 것입니다. 정규 표현식이 일치하면 이를 직접 사용하고 API 호출을 건너뜁니다. 이는 쉬운 케이스에 대해 비용과 지연 시간을 절약해 줍니다.
둘째, 피드백 루프 (feedback loop)를 만들겠습니다. 사람이 잘못된 추출(예: 잘못된 가격)을 수정할 때, 해당 페이지 텍스트와 수정된 출력을 저장한 다음 이를 작은 모델을 미세 조정 (fine-tune)하는 데 사용하겠습니다. 시간이 지남에 따라 프롬프트 수정 없이도 정확도가 향상될 것입니다.
셋째로, 폴백 전략 (fallback strategy)을 추가하겠습니다. 만약 LLM이 중요한 필드에 대해 null을 반환한다면, 페이지를 다시 가져와서 더 큰 컨텍스트 윈도우 (context window)로 시도하거나, 지금까지 발견된 가장 일반적인 구조를 기반으로 한 전통적인 XPath 셀렉터 (selector)로 전환합니다.
마지막으로, LLM 출력의 신뢰도 (confidence)를 모니터링하겠습니다. 대부분의 제공업체는 로그 확률 (logprobs) 또는 사용 토큰 (usage tokens)을 제공합니다. 이를 통해 간단한 신뢰도 점수를 계산하고, 신뢰도가 낮은 추출 결과는 수동 검토를 위해 플래그 (flag)를 지정할 수 있습니다.
이 접근 방식을 사용하지 말아야 할 때
- 사이트 구조가 안정적이고 단순한 CSS 셀렉터로 해결할 수 있는 경우 – 그렇게 하십시오. LLM은 과합니다.
- 실시간 스크래핑 (1초 미만의 응답 시간)이 필요한 경우 – LLM의 지연 시간 (latency)이 발목을 잡을 것입니다. 학습된 작은 모델이나 전통적인 파싱 (parsing) 방식을 사용하십시오.
- 페이지 텍스트가 의미 없는 문자열이거나 자동 생성된 경우 – 모델이 환각 (hallucination)을 일으킬 것입니다. 사람이 읽을 수 있는 페이지를 대상으로 하십시오.
- 민감한 데이터를 스크래핑하는 경우 – 외부 API로 원문 텍스트를 보내는 것은 개인정보 보호를 위반할 수 있습니다. 대신 로컬 모델 (Llama, Mistral 등)을 고려하십시오.
마치며
저는 DOM의 취약함에 좌절하며 이 프로젝트를 시작했지만, 결국 너무 단순하다고 느껴질 정도의 솔루션을 얻게 되었습니다. 핵심 통찰은 웹 페이지를 구조화된 문서로 취급하는 것을 멈추고, 자연어 텍스트 (natural language texts)로 취급하기 시작한 것이었습니다. LLM은 지저분한 텍스트를 읽고 필요한 정보를 추출하는 데 탁월합니다.
물론, 데이터가 깨끗할 때는 예전 방식의 전통적인 스크래핑을 대체할 수는 없습니다. 하지만 모든 페이지가 제각각인 악몽 같은 사이트들의 경우, LLM 기반 파서 (parser) 덕분에 몇 주간의 유지보수 시간을 아낄 수 있었습니다. 다른 분들은 이 문제를 어떻게 다루고 계신지 궁금합니다. 여전히 DOM과 싸우고 계신가요, 아니면 여러분도 AI를 도입하기 시작하셨나요?
AI 자동 생성 콘텐츠
본 콘텐츠는 Dev.to AI tag의 원문을 AI가 자동으로 요약·번역·분석한 것입니다. 원 저작권은 원저작자에게 있으며, 정확한 내용은 반드시 원문을 확인해 주세요.
원문 바로가기