본문으로 건너뛰기

© 2026 Molayo

Dev.to헤드라인2026. 06. 06. 05:26

데이터 추출을 위해 LLM을 시도하기 전까지, 사이트 스크래핑에 3일을 허비했습니다

요약

HTML 구조 변화로 인한 전통적인 웹 스크래핑의 한계를 LLM을 활용한 의미 기반 데이터 추출 방식으로 해결하는 과정을 다룹니다. CSS 선택자나 정규 표현식 대신 텍ext 콘텐츠를 모델에 입력하여 JSON을 생성하는 방식의 효율성을 강조합니다.

핵심 포인트

  • 전통적인 CSS 선택자 기반 스크래핑은 HTML 구조 변경에 매우 취약함
  • 정규 표현식과 XPath는 복잡하고 가변적인 마크업 대응에 한계가 있음
  • LLM을 활용하면 패턴 매칭이 아닌 의미 이해를 통해 데이터 추출 가능
  • 마크업을 제거한 텍스트를 LLM에 입력하여 JSON 객체로 변환하는 방식 권장

자랑스러운 이야기는 아니지만, 하룻밤 사이에 HTML 클래스(class)가 계속 바뀌는 이커머스(e-commerce) 사이트를 위한 스크래퍼(scraper)를 만드는 데 3일을 보냈습니다. 첫 번째 버전은 CSS 선택자(CSS selectors)와 BeautifulSoup을 사용했습니다. 정확히 4시간 동안만 작동했습니다. 그 후 사이트가 새로운 빌드를 배포했고, 모든 클래스 이름이 해시(hashed) 처리되면서 제가 정성껏 만든 선택자들은 종잇장처럼 쓸모없게 되어버렸습니다. 저는 정규 표현식(regex)으로 임시 방편을 마련했습니다. 하지만 제품 카드 내 필드(field)의 순서가 바뀌기 전까지 딱 하루를 더 버텼을 뿐입니다. 정말 미칠 지경이었습니다.

이 이야기는 특정 사이트에 관한 것도, 단일 도구에 관한 것도 아닙니다. 일관성 없는 마크업(markup)을 이겨내려고 애쓰는 것을 멈추고, 페이지 전체를 언어 모델(language model)이 파싱(parse)할 수 있는 하나의 텍스트 덩어리(blob of text)로 취급하기 시작한 순간에 관한 이야기입니다. 그것은 "패턴 찾기"에서 "의미 이해하기"로의 전환이었습니다. 그것이 모든 것을 바꾸어 놓았습니다.

문제점: 반정형 웹 데이터 (semi-structured web data)

저는 수십 개의 제품 목록 페이지에서 제품명, 가격, 설명, 재고 상태를 추출해야 했습니다. 어떤 페이지는 페이지네이션(pagination)이 있었고, 어떤 페이지는 무한 스크롤(infinite scroll) 방식이었으며, 모두 HTML 구조가 달랐습니다. 데이터는 존재했지만, 컨테이너(container)는 예측할 수 없었습니다. 어떤 페이지는 <div class="price">를 사용했고, 다른 페이지는 <span class="final-amount">를 사용했으며, 세 번째 페이지는 <meta> 태그 안에 가격이 들어있었습니다. 범용 파서(universal parser)를 작성하는 것은 마치 두더지 잡기 게임을 하는 것과 같았습니다.

제가 시도했던 것들 (그리고 왜 힘들었는지)

1. BeautifulSoup + CSS 선택자 (CSS selectors) – 취약합니다. 클래스 이름 하나만 바뀌어도 스크립트 전체가 망가집니다.

2. XPath를 사용한 lxml – 약간 더 견고하지만, 여전히 구조적 가정에 의존합니다. 사이트에 추가적인 래퍼 div(wrapper div)가 생기면 제 XPath 표현식은 더 이상 일치하지 않았습니다.

3. 원시 HTML에 대한 정규 표현식 (Regex over raw HTML) – 알고 있습니다, 저도 압니다. 정말 절박한 선택이었습니다. 몇몇 페이지에서는 작동했지만, 중첩된 속성(nested attributes)이나 자바스크립트 렌더링(JavaScript-rendered) 콘텐츠가 있는 페이지에서는 실패했습니다.

4. 헤드리스 브라우저 (Headless browser, Playwright) – JS 렌더링 문제는 해결했지만, 여전히 각 필드를 추출하기 위한 선택자를 작성해야 했습니다. 동일한 취약성을 가졌으면서, 이제는 페이지당 300ms의 오버헤드(overhead)까지 추가되었습니다.

3일이 지난 후에야 제가 테스트했던 페이지들에서만 정확히 작동하는 스크립트를 완성할 수 있었습니다. 새로운 페이지가 나타날 때마다 수동으로 수정해야 했습니다. 분명 더 나은 방법이 있을 것이라고 생각했습니다.

깨달음: HTML을 파싱하지 말고 모델에게 물어보세요

저는 텍스트 생성(text generation)을 위해 LLM을 사용해 왔지만, 제로샷 개체명 인식 (zero-shot named entity recognition)에 관한 블로그 포스트를 보기 전까지는 이를 추출 (extraction) 용도로 사용할 생각을 전혀 하지 못했습니다. 아이디어는 간단합니다. 페이지의 텍스트 콘텐츠(마크업을 제거한 상태)를 모델에 입력하고, 필요한 필드가 포함된 JSON 객체를 반환하도록 요청하는 것입니다.

선택자(selector)도, 클래스 이름(class name)도, 정규 표현식(regex)도 필요 없습니다. 오직 가공되지 않은 텍스트와 프롬프트(prompt)만 있으면 됩니다.

Python으로 작성한 첫 번째 시도는 다음과 같았습니다:

import requests
from bs4 import BeautifulSoup
from openai import OpenAI
...

이 방식은 첫 시도에 바로 성공했습니다. 모델은 가격 정보가 리뷰 문단 속에 파묻혀 있을 때도 정확하게 식별해냈습니다. 또한 “품절(out of stock)”이 in_stock: false를 의미한다는 것도 이해했습니다. HTML에 대해 어떠한 설명도 해줄 필요가 없었습니다.

프로덕션 환경에 적용하기 (어려운 부분)

초기 스크립트는 마법처럼 느껴졌지만, 마법은 확장성(scale)을 가질 수 없습니다. 저는 세 가지 실질적인 문제에 직면했습니다:

1. 토큰 제한 및 비용

페이지 전체 텍스트는 10,000개 이상의 토큰이 될 수 있습니다. 모든 페이지를 API로 전송하는 비용은 빠르게 누적됩니다. 저의 해결책은 DOM에서 가장 관련성이 높은 부분만 추출하는 것이었습니다. 저는 여전히 BeautifulSoup을 사용하여 공통 선택자(#footer, .sidebar 등)를 찾아 헤더, 푸터, 사이드바를 제거합니다. 이는 정확한 필드를 찾기 위함이 아니라, 단지 노이즈를 줄이기 위한 목적입니다. 이를 통해 토큰 사용량을 60% 절감했습니다.

2. 환각 (Hallucinations) 및 누락된 필드

모델은 때때로 가격이 없을 경우 가격을 지어내거나 무작위 숫자를 반환하기도 했습니다. 저는 “사용 불가(not available)” 표시가 포함된 퓨샷 (few-shot) 예시를 추가하고, try/except 블록 내에서 JSON을 파싱했습니다. 만약 가격 필드가 누락되었거나 날짜처럼 보인다면, 해당 결과를 수동 검토 대상으로 표시했습니다.

import json

def safe_extract(text):
...

3. 속도 및 재시도

API 호출 한 번에 페이지당 13초가 소요됩니다. 1,000페이지를 처리하려면 1530분이 걸립니다. 저는 concurrent.futures.ThreadPoolExecutor를 사용하여 병렬 처리를 수행하고 지수 백오프 (exponential backoff)를 구현했습니다. 그럼에도 불구하고, 만약 귀하의 파이프라인이 1초 미만의 추출 속도를 필요로 한다면 이 방식은 적합하지 않을 것입니다. 저의 사용 사례(야간 배치 작업)에서는 괜찮았습니다.

도구가 아닌 패턴

제가 방금 설명한 모든 것은 OpenAI, Claude, Ollama를 통한 로컬 모델, 또는 전용 추출 API 등 어떤 LLM (Large Language Model)과도 작동합니다. 핵심적인 통찰은 파싱 (parsing)의 부담을 코드에서 언어 이해 (language understanding)로 전환하는 것입니다. HTML을 역공학 (reverse-engineering) 하는 대신, 원하는 것을 설명하고 인간의 언어를 이해하는 모델이 작업을 수행하도록 하는 것입니다.

이 패턴을 익힌 후, 저는 이 분야를 전문으로 하는 몇 가지 호스팅 서비스를 탐색했습니다 (제가 최종적으로 사용한 것은 https://ai.interwestinfo.com/이지만, 엔드포인트와 상관없이 패턴은 동일합니다). 저는 API 키 관리, 재시도 로직, 프롬프트 최적화 (prompt optimisation)를 직접 처리하는 것을 피하기 위해 관리형 서비스 (managed service)를 선택했습니다. 하지만 수십 줄의 코드로 직접 구현하는 것도 충분히 가능합니다.

이 방식을 사용하면 안 되는 경우

  • 고빈도, 저지연 (High-frequency, low-latency) (예: 실시간 가격 업데이트). LLM 추론 (inference)은 100ms 미만의 응답을 제공하기에는 여전히 너무 느립니다. 제어 가능한 데이터에는 전통적인 셀렉터 (selectors)를 사용하세요.
  • 엄격한 예산. 하루에 수천 페이지를 스크래핑한다면, API 비용이 AWS 비용을 초과할 수 있습니다. 저의 500페이지 배치 작업의 경우, 실행당 약 2달러가 소요되었습니다. 비즈니스 도구로서는 수용 가능한 수준이지만, 취미 프로젝트로서는 그렇지 않습니다.
  • 결정론적 요구사항 (Deterministic requirements). 추출 결과가 100% 재현 가능하고 감사 가능해야 한다면, LLM은 변동성을 유발합니다. 정규 표현식 (regex) 기반 파서는 결정론적이지만, LLM은 모델 간에 출력 형식이 약간씩 달라질 수 있습니다.

다음에 한다면 다르게 할 점

선택자(selector)와 싸우며 3일을 보낸 후가 아니라, 첫날부터 LLM 접근 방식을 테스트했어야 했습니다. 너무 비용이 많이 들거나 너무 느릴 것이라고 가정했지만, 개발 속도의 이득만으로도 API 비용을 지불할 가치가 충분했습니다. 다음에는 하이브리드(hybrid) 방식으로 시작할 것입니다. 쉬운 80%의 필드에는 단순한 선택자(selector)를 사용하고, 까다로운 20%에는 LLM을 보조 수단으로 사용하는 방식입니다.

또한, 페이지의 원문 텍스트를 캐싱(cache)하여 다시 다운로드하지 않고도 프롬프트를 재시도할 수 있도록 할 것입니다. 이는 특히 프롬프트를 튜닝(tuning)할 때 매우 유용합니다.

교훈 (Lessons learned)

  • HTML 구조는 일시적이지만, 언어적 의미는 안정적입니다. LLM은 가격 정보가 <span>이나 <div> 또는 일반 텍스트로 감싸져 있더라도 이를 찾아낼 수 있습니다.
  • 프롬프트 엔지니어링 (Prompt engineering)이 새로운 파서 (parser)입니다. 좋은 퓨샷 (few-shot) 예시를 만드는 데 시간을 투자하십시오. 이는 견고한 선택자 (selector)를 작성하는 것과 맞먹는 효과를 내면서도 훨씬 더 유연합니다.
  • 항상 출력을 검증하십시오. 성능이 좋은 모델이라도 쓰레기 값을 내뱉을 수 있습니다. 스키마 검사기 (schema checker)를 구축하십시오.

3일간의 좌절이 프롬프트를 연결하는 2시간의 작업으로 바뀌었습니다. 저는 예전 방식으로 돌아가지 않을 것입니다.

변하지 않는 구조를 거부하는 사이트들을 스크래핑할 때 여러분의 접근 방식은 무엇인가요? 선택자 (selector)를 신뢰하시나요, 아니면 LLM에 의존하기 시작하셨나요?

AI 자동 생성 콘텐츠

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

원문 바로가기
0

댓글

0