페이지 전체를 다시 생성하지 마세요: 구조화된 DOM 연산을 사용하여 LLM으로 HTML 패칭(Patching)하기
요약
LLM을 사용하여 HTML을 수정할 때 전체 코드를 재생성하는 대신, 구조화된 DOM 연산(Patching)을 사용하는 효율적인 방법을 제안합니다. 이를 통해 토큰 비용을 절감하고, 지연 시간을 줄이며, 모델의 생성 오류로 인한 의도치 않은 코드 변경을 방지할 수 있습니다.
핵심 포인트
- 전체 HTML 재생성 대신 JSON 기반의 DOM 연산 명령을 사용해 토큰 비용과 지연 시간 절감
- 모델이 언급하지 않은 영역은 구조적으로 보호되어 코드 무결성 유지 가능
- Symfony의 DomCrawler를 활용한 안정적인 DOM 조작 및 CSS 선택자 지원
- 노드 제거 시 반복문 오류 방지를 위한 실체화된 배열 사용 권장
저는 프롬프트로부터 랜딩 페이지를 생성하는 WordPress 플러그인을 만들었습니다. 첫 번째 버전은 아주 당연한 방식으로 작동했습니다. 사용자가 "히어로 배경을 더 어둡게 만들어줘"라고 입력하면, 저는 페이지 전체 HTML을 모델에 보내고, 모델은 페이지 전체 HTML을 다시 보내주면, 저는 그것을 저장했습니다.
작동은 했습니다. 하지만 끔찍하기도 했습니다.
느립니다. 900줄짜리 페이지는 속성 하나를 변경하는 데 약 12,000개의 출력 토큰(output tokens)을 의미합니다.
비쌉니다. 출력 토큰은 비용이 많이 드는데, 저는 사소한 수정 하나를 할 때마다 전체 재작성 비용을 지불하고 있었습니다.
손실이 발생합니다. 모델은 히어로 섹션을 수정하는 동안 푸터(footer)를 "친절하게도" 재구성하곤 했습니다. 혹은 aria-label을 누락하거나, 클래스(class) 이름을 바꿔서 CSS를 깨뜨리기도 했습니다.
마지막 문제가 진짜 치명적이었습니다. 사용자들은 지연 시간(latency)보다 자신이 바꾸라고 요청하지 않은 부분이 바뀌었을 때 훨씬 더 많이 불평합니다.
제가 이것을 무엇으로 대체했는지 소개합니다.
핵심 아이디어
모델에게 HTML을 요청하지 마세요. 대신 DOM에 대한 연산(operations)을 요청한 다음, 실제 파서(parser)를 사용하여 직접 적용하세요.
json
[
{
"op": "setAttribute",
"selector": "#hero",
"name": "class",
"value": "hero hero--dark"
},
{
"op": "replaceInner",
"selector": "#hero h1",
"html": "Ship faster. Build slower."
},
{
"op": "remove",
"selector": "#hero .badge"
}
]
모델은 12,000개 대신 약 200개의 토큰만을 생성합니다. 그리고 결정적으로, 모델이 언급하지 않은 모든 것은 모델의 '착한 행동' 때문이 아니라 구조적으로 '건드려지지 않은' 상태가 됩니다. 잘못된 생성(generation)이 미치는 영향 범위(blast radius)가 스키마(schema)에 의해 제한됩니다.
연산 적용하기
저는 DOMDocument를 합리적인 API와 CSS 선택자(selector) 지원으로 감싸는 Symfony의 DomCrawler를 사용합니다.
bash
composer require symfony/dom-crawler symfony/css-selector
php
use Symfony\Component\DomCrawler\Crawler;
final class DomPatcher
{
private Crawler $crawler;
private \DOMDocument $doc;
public function __construct(string $html)
{
$this->doc = new \DOMDocument('1.0', 'UTF-8');
...
}
저를 괴롭혔던 몇 가지 사항들:
filter() 결과는 실시간(live-ish)입니다. 만약 수행하려는 작업이 노드를 제거하는 것이라면, 먼저 실체화된 배열(materialised array) — 즉, DOMNodeList에 대해 iterator_to_array()를 사용 — 로 반복문을 돌리세요. 그렇지 않으면 컬렉션이 이동함에 따라 요소를 건너뛰게 됩니다.
LIBXML_HTML_NOIMPLIED 없이 loadHTML를 사용하면 조각(fragment)이 <html> 태그로 감싸집니다. 이 옵션을 사용하면 가공되지 않은 조각을 얻을 수 있지만, 잘못된 형식의 입력이 예상치 못한 트리 구조를 생성할 수 있습니다. 저는 데이터를 영구 저장하기 전에 입력값과 출력 길이를 비교하여 조잡하게나마 무결성 검사(sanity check)를 수행합니다.
HTML5 태그는 libxml 경고를 발생시킵니다.
<main>, <section>, <article>, 커스텀 엘리먼트(custom elements) — libxml의 파서는 HTML4입니다. 경고 자체는 무해하지만, 이를 억제하지 않으면 에러 로그가 가득 차게 됩니다. 진정한 HTML5 파싱이 필요하다면 masterminds/html5가 대안이 될 수 있지만, 실제 성능 비용(performance cost)이 발생합니다.셀렉터(selector) 문제
모델이 노드를 안정적으로 지정할 수 없다면 전체 체계가 무너집니다. .btn:nth-child(3)와 같은 셀렉터는 모델이 페이지의 일부만 볼 수 있는 상황에서는 동전 던지기(coin flip)와 같습니다.
그래서 저는 모델이 셀렉터를 임의로 만들어내지 못하게 합니다. 생성 시점에, 주소 지정이 가능한 모든 요소에 안정적인 ID를 부여합니다:
private function ensureIds(\DOMDocument $doc): void
{
$xpath = new \DOMXPath($doc);
$addressable = $xpath->query(
'//section | //header | //footer | //h1 | //h2 | //h3 '
. '| //p | //img | //a | //button | //ul | //form'
);
foreach ($addressable as $node) {
if (!$node->hasAttribute('data-pid')) {
$node->setAttribute('data-pid', $this->nextId());
...```
모델은 오직 [data-pid="a7f3"] 형태의 셀렉터만 출력합니다. 만약 다른 것을 출력한다면, 추측하기보다는 해당 작업을 거부(reject)합니다. 거부는 비용이 적게 듭니다. 에러 메시지를 대화 내용에 추가하여 한 번 재시도하며, 두 번째 시도는 거의 항상 유효합니다.
2단계 검색(Two-step retrieval)
연산(operations)을 사용하더라도, 저는 여전히 전체 페이지 HTML을 컨텍스트(context)로 보내고 있었습니다. 긴 페이지의 경우 이는 입력 예산(input budget)의 대부분을 차지하며, 어텐션(attention)을 심하게 희석시킵니다.
그래서 다음과 같이 합니다: 두 번의 호출을 사용합니다.
1단계 — 위치 파악 (locate). 스켈레톤 (skeleton)을 전송합니다: 태그 이름, ID, 클래스 목록, 그리고 텍스트 콘텐츠의 처음 약 60자 정도입니다. 인라인 스타일(inline styles), 전체 복사, SVG 경로(paths)는 포함하지 않습니다. 900줄짜리 페이지가 약 40줄로 압축됩니다.
# 더 빠르게 출시하세요. 더 느리게 구축하세요.…
페이지 빌더, 하지만 그렇지 않은…
Get started
…
질문: 이 요청과 관련된 data-pid는 무엇인가요? 응답은 ID의 JSON 배열입니다. 저렴하고, 빠르며, 놀라울 정도로 정확합니다. 위치를 파악하는 것은 편집하는 것보다 훨씬 쉬운 작업입니다.
2단계 — 편집 (edit). 해당 노드들의 전체 outerHTML과 관련 CSS 규칙만을 전송하고, 수행할 연산(operations)을 요청합니다.
전형적인 편집 작업에서 결합된 토큰 비용은 전체 페이지를 다시 생성할 때보다 약 80% 감소했으며, 지연 시간(latency) 개선 효과는 수치 이상입니다. 출력 토큰(output tokens)이 실제 소요 시간(wall-clock time)의 대부분을 차지하기 때문입니다.
주의해야 할 실패 모드: 1단계에서 선택이 부족한 경우입니다. 사용자가 "버튼들을 일관되게 만들어줘"라고 말했는데, 위치 파악 도구가 5개의 버튼 중 2개만 반환하는 상황입니다. 저의 완화 방법은 선택 범위를 확장하는 것입니다. 만약 반환된 노드가 동일한 클래스 시그니처(class signature)를 공유하는 형제 노드(siblings)를 가지고 있다면, 그것들도 포함시킵니다. 우아한 방식은 아니지만, 가장 흔한 불만 사항을 문제 되지 않게 해결해 주었습니다.
WordPress에 저장하기
이것은 WordPress 플러그인이므로, 영속성(persistence)에 대해 한 가지 언급하겠습니다. 왜냐하면 뻔한 선택지는 틀렸기 때문입니다.
저는 생성된 HTML을 post_content에 넣지 않습니다. 대신 다음과 같이 합니다:
- post_meta에는 분리된 html, css, js를 저장하며, 별도의 초안(draft) 및 게시(published) 키를 사용합니다. 편집 작업은 실제 라이브 중인 데이터에 절대 영향을 주지 않습니다.
- post_content에는 마크업(markup)이 제거된 텍스트 복사본(text copy)만 저장합니다.
마지막 결정은 제가 가장 강력하게 옹호할 부분입니다. SEO 플러그인, 검색 인덱싱(search indexing), 발췌(excerpt) 생성, 그리고 REST API는 모두 post_content를 읽습니다. 만약 그곳에 HTML 덩어리가 들어있다면, 이 모든 시스템은 쓰레기 데이터를 받게 됩니다. 그곳에 순수 산문(plain prose)을 유지함으로써, 나머지 WordPress 생태계가 추가 비용 없이 정상적으로 작동하게 만듭니다.
CSS와 JS는 콘텐츠 해시(content-hashed) 이름이 붙은 파일로 작성됩니다:
php
$hash = substr(hash('xxh128', $css), 0, 12);
$file = "page-{$postId}-{$hash}.css";
불변 파일 이름(Immutable filenames)을 사용하면 아주 먼 미래의 Cache-Control을 설정할 수 있으며, 더 이상 무효화(invalidation)에 대해 고민할 필요가 없습니다. 오래된 파일은 예정된 훅(hook)에 따라 가비지 컬렉션(garbage-collected)됩니다.
유사한 것을 구축하고 있다면 제가 해주고 싶은 조언들
모델의 동작이 아니라 출력 형식을 제한하세요. 시스템 프롬프트에 "관련 없는 섹션은 수정하지 마세요"라는 문구를 추가하는 데 보낸 매 시간은 낭비였습니다. 관련 없는 수정을 구조적으로 불가능하게 만드는 데는 오후 한나절이 걸렸지만, 그것이 실제로 작동했습니다.
저장하기 전에 항상 검증하세요. 모델의 JSON을 파싱하고, 스키마를 확인하며, 모든 셀렉터(selector)가 해결되는지 확인하고, 클론(clone)에 적용한 뒤, 노드 수(node count)를 비교(diff)하세요. 망가진 것을 저장하는 것보다 거절하고 재시도하는 것이 훨씬 낫습니다.
위치 찾기(Locating)와 편집(editing)은 서로 다른 작업입니다. 이 둘을 분리한 것이 작업 스키마(operations schema) 자체보다 더 큰 단일 성과였습니다.
libxml은 오래되고 까다롭지만 이미 설치되어 있습니다. 공유 호스팅(shared hosting)에서 실행되어야 하는 플러그인의 경우, 확장이 필요한 의존성(dependencies)이 없는 것이 우아함보다 낫습니다.
결과물을 살펴보고 싶다면 플러그인은 Pagora AI입니다. 무료이며 소스 코드는 GitHub에 있습니다. 저는 주로 누군가가 1단계인 과소 선택(under-selection) 문제에 대해 더 나은 해답을 찾았는지 궁금합니다. 제 방식은 휴리스틱(heuristics)으로 간신히 유지되고 있기 때문입니다.
AI 자동 생성 콘텐츠
본 콘텐츠는 Dev.to AI tag의 원문을 AI가 자동으로 요약·번역·분석한 것입니다. 원 저작권은 원저작자에게 있으며, 정확한 내용은 반드시 원문을 확인해 주세요.
원문 바로가기