내가 정규표현식(Regex) 스크래퍼를 버리고 LLM 파서(Parser)를 선택한 이유 (그리고 선택하지 말아야 할 때)
요약
웹 스크래핑 시 발생하는 HTML 구조 변경 문제를 해결하기 위해 정규표현식 대신 LLM을 파서로 활용한 사례를 공유합니다. HTML 정제 과정을 거쳐 LLM에 데이터를 전달함으로써 유지보수 비용을 줄이고 비정형 데이터 추출 성능을 높이는 방법을 다룹니다.
핵심 포인트
- 정규표현식과 CSS 선택자는 사이트 구조 변경 시 유지보수 비용이 높음
- LLM을 활용하면 비정형 HTML 데이터에서 필요한 정보를 JSON으로 쉽게 추출 가능
- 토큰 절약을 위해 스크립트, 스타일 등 불필요한 HTML 요소를 제거하는 정제 과정이 필수적임
- LLM 파싱은 비용과 속도 측면의 트레이드오프를 고려하여 선택해야 함
지난달에 저는 30개의 서로 다른 이커머스 사이트에서 제품 상세 정보를 스크래핑해야 했습니다. 각 사이트는 자신만의 HTML 구조를 사용하고 있었고, 클래스 이름(class names)은 매주 변경되었으며, 일부는 그냥 일관성이 전혀 없었습니다. 저에게는 두 가지 선택지가 있었습니다. 깨지기 쉬운 CSS 선택자(CSS selectors)를 산더미처럼 작성하거나, 제가 그동안 피해왔던 방식인 LLM(대규모 언어 모델)이 추출을 수행하도록 맡기는 것이었습니다.
실제로 작동했던 코드와, 차라리 BeautifulSoup을 계속 사용했어야 했던 사례들을 포함하여 제가 고생하며 배운 점들을 공유합니다.
내 스크래퍼를 망가뜨린 문제
저는 니치(niche) 아웃도어 장비를 위한 가격 비교 도구를 만들고 있었습니다. 제가 필요로 했던 데이터는 단순했습니다: 제품명, 가격, 재고 여부, 그리고 몇 가지 사양(specs)이었습니다. 하지만 데이터 소스는 거대한 마켓플레이스부터 작은 가족 경영 상점까지 다양했습니다. 사이트가 새로운 템플릿을 배포할 때마다, 제가 정성껏 구축한 정규표현식(regex)이 깨졌습니다. 저는 실제로 데이터를 사용하는 시간보다 스크래퍼를 유지보수하는 데 더 많은 시간을 보냈습니다.
가격 필드를 위한 전형적인 선택자는 다음과 같았습니다:
import re
import requests
from bs4 import BeautifulSoup
...
저는 가격을 분석하는 것보다 선택자를 디버깅하는 데 더 많은 시간을 쓰고 있었습니다. 무언가 변해야만 했습니다.
시도했지만 실패했던 것들
처음에는 퍼지 매칭(fuzzy matching)을 사용한 XPath를 시도했습니다. 그것이 약간의 도움이 되긴 했지만, 여전히 사이트별 규칙이 필요했습니다. 그다음에는 머신러닝(machine learning)을 시도했습니다. HTML 구조를 바탕으로 작은 모델을 학습시키는 방식이었죠. 사이드 프로젝트로 하기에는 과했고, 각 사이트에 대한 라벨링된 데이터(labeled data)도 없었습니다.
상용 스크래핑 서비스들도 살펴보았지만, 너무 비싸거나 제 데이터를 그들의 파이프라인(pipelines)을 통해 전송해야 했습니다. 작은 개인용 도구로서는 정보를 너무 많이 공유하는 것 같다는 느낌이 들었습니다.
그러다 사람들이 원본 HTML이나 심지어 눈에 보이는 텍스트에서 직접 비정형 데이터(unstructured data)를 파싱하기 위해 LLM을 사용한다는 이야기를 들었습니다. 저는 회의적이었습니다. LLM은 느리고, 비싸며, 환각(hallucinate) 현상을 일으키기 때문입니다. 하지만 고통이 실재했기에, 한번 시도해 보기로 했습니다.
결국 성공했던 접근 방식
사이트마다 셀렉터(Selector)를 작성하는 대신, 저는 원본 HTML(또는 다듬어진 버전)을 간단한 지시어와 함께 LLM에 보내기 시작했습니다: “상품명, 가격, 재고 상태를 추출하세요. JSON으로 반환하세요.”
제가 최종적으로 구현한 핵심 함수는 다음과 같습니다:
import json
from openai import OpenAI
import requests
...
이를 사용하기 위해, 저는 단순히 페이지를 가져온 뒤 정제된 스니펫(토큰 수를 낮게 유지하기 위해 스크립트, 스타일, 내비게이션 요소를 제거한 것)을 전달합니다.
import re
def clean_html(raw_html: str) -> str:
...
그 다음 다음과 같이 호출했습니다:
raw = requests.get('https://example.com/product/123').text
snippet = clean_html(raw)
data = extract_product_data(snippet)
...
결과는 놀라울 정도로 좋았습니다. 약 80%의 페이지에서 작동했습니다. LLM은 가격이 테이블 안에 파묻혀 있거나 이상한 span 태그로 포맷팅되어 있어도 찾아낼 수 있었습니다. 정규표현식(Regex)도, 사이트별 로직도 필요 없었습니다.
이 접근 방식을 위해 제가 검토했던 서비스 중 하나는 유사한 추출 API를 제공하는 Interwest AI였습니다. 저는 완전한 제어권을 원했기 때문에 결국 OpenAI를 사용하여 직접 구현했지만, 기술적인 원리는 동일합니다.
배운 점과 트레이드오프 (Trade-offs)
속도 (Speed): 각 추출에는 1~3초가 소요됩니다. 수백 개의 상품에는 괜찮지만, 수백만 개에는 적합하지 않습니다. 캐싱(Caching)이 도움이 됩니다.
비용 (Cost): GPT-4o-mini는 저렴합니다 (입력 토큰 100만 개당 약 $0.15). 4K 토큰 분량의 페이지를 한 번 추출하는 데 약 $0.001이 듭니다. 각 50개의 상품이 있는 30개 사이트의 경우 총 약 $1.50가 들며, 이는 취미 프로젝트로서는 수용 가능한 수준입니다.
정확도 (Accuracy): LLM은 가격이 JavaScript로 렌더링되는 컴포넌트(React 앱 등) 내부에 있는 경우 가끔 놓치기도 했습니다. 그런 경우에는 브라우저 자동화(Browser automation)로 돌아가거나 ScrapingBee와 같은 API를 사용해야 했습니다. 또한, LLM은 환각(Hallucinate)을 일으킬 수 있습니다. 한 번은 그럴듯해 보이지만 실제로는 배송비인 가격을 반환한 적이 있었습니다. 그래서 가격에 통화 기호와 숫자 값이 포함되어 있는지 확인하는 검증 단계를 추가했습니다.
이 접근 방식을 사용하지 말아야 할 때:
- 실시간 추출(하루에 수백만 페이지)이 필요한 경우, 전통적인 스크래핑 (Scraping) 방식을 사용하세요.
- 데이터가 이미 구조화되어 있다면 (예: 페이지에 내장된 JSON-LD), 대신 그것을 파싱 (Parse) 하세요.
- 간헐적인 환각 (Hallucination)을 감당할 수 없는 경우 (예: 금융 데이터), LLM에만 전적으로 의존하지 마세요.
다음에 제가 다르게 시도할 점
저는 두 세계를 결합하겠습니다. 자주 변경되는 사이트에 대해서는 LLM을 폴백 (Fallback)으로 사용하되, 안정적인 페이지를 위해서는 간단한 CSS 선택자 (CSS selector) 캐시를 유지하는 방식입니다. 또한, 특히 수천 페이지를 처리해야 하는 경우, 더 저렴한 온프레미스 (On-premise) 추출을 위해 더 작은 모델 (Llama 변형 모델 등)을 미세 조정 (Fine-tuning) 하는 것도 시도해 볼 것입니다.
또 다른 개선 사항은, 가공되지 않은 HTML을 보내는 대신 trafilatura나 readability-lxml 같은 라이브러리를 사용하여 가시적인 텍스트 블록만 추출하는 것입니다. 이렇게 하면 토큰 (Token) 사용량을 줄이고 정확도를 높일 수 있는데, LLM이 마크업 노이즈 (Markup noise)에 의해 주의가 분산되지 않기 때문입니다.
여러분의 차례
LLM 기반의 스크래핑이 만능 해결책 (Silver bullet)은 아니지만, 지저분하고 반구조화된 (Semi-structured) 데이터에 대해서는 저의 주말을 좌절로 채울 뻔한 상황을 구해주었습니다. 여러분은 스크래핑한 페이지를 AI가 파싱하도록 시도해 본 적이 있나요? 여러분에게는 무엇이 효과적이었고, 무엇이 효과적이지 않았나요?
AI 자동 생성 콘텐츠
본 콘텐츠는 Dev.to AI tag의 원문을 AI가 자동으로 요약·번역·분석한 것입니다. 원 저작권은 원저작자에게 있으며, 정확한 내용은 반드시 원문을 확인해 주세요.
원문 바로가기