LLM 응답을 XML로 스트리밍하고 실시간 렌더링하기
사내 AI 에이전트의 응답을 실시간으로 보여줘야 했습니다. ChatGPT처럼 마크다운 텍스트를 한 글자씩 출력하는 수준이 아니라, 테이블·차트·하이라이트 같은 구조화된 블록을 스트리밍 중간에도 렌더링해야 하는 요구였습니다.
JSON으로 하기엔 스트리밍 중간에 파싱이 안 되고, 마크다운은 구조를 표현하기 어렵습니다. XML을 선택했습니다.
왜 XML인가
LLM 응답이 이런 형태입니다:
<insight-item>
<title>주간 활성 사용자 추이</title>
<desc-item>지난 7일간 DAU가 12% 감소했습니다.</desc-item>
<highlight type="warning">특히 모바일 채널에서 하락폭이 큽니다.</highlight>
<ai-visualization chart-type="line" />
</insight-item>
이게 SSE로 청크 단위로 날아옵니다. 완전한 XML이 아닌 상태에서도 파싱이 되어야 합니다. XML은 SAX 파서로 태그가 열리는 시점부터 처리할 수 있어서 이 요구에 맞았습니다. JSON은 닫히는 중괄호가 와야 유효하니까요.
3계층 구조
1. 전송 — SSE 수신
const response = await fetch(url, { method: "POST", ... });
const reader = response.body.getReader();
const parser = createParser((event) => {
if (event.type === "event") {
handleSSEEvent(event.event, event.data);
}
});
while (true) {
const { done, value } = await reader.read();
if (done) break;
parser.feed(new TextDecoder().decode(value));
}
eventsource-parser로 SSE 이벤트를 디스패치합니다. 이벤트 타입별로 분기하는데, MESSAGE_DELTA일 때 XML 청크가 옵니다.
2. 파싱 — SAX 기반 incremental XML 파싱
htmlparser2를 SAX 모드로 씁니다. blockStack입니다:
const blockStack: ParsedBlock[] = [];
const saxParser = new Parser({
onopentag(name, attrs) {
const block = { tag: name, attrs, children: [], text: "" };
if (blockStack.length > 0) {
const parent = blockStack[blockStack.length - 1];
parent.children.push(block);
} else {
parsedBlocks.value.push(block); // Vue reactive 배열에 push
}
blockStack.push(block);
},
ontext(text) {
if (blockStack.length > 0) {
blockStack[blockStack.length - 1].text += text;
}
},
onclosetag() {
blockStack.pop();
},
});
SSE로 <insight-item><title>주간 활 까지만 와도, insight-item이 열리면서 Vue 리액티브 배열에 들어갑니다. 사용자는 즉시 블록 컨테이너가 렌더링되는 걸 보고, 텍스트가 채워지는 걸 실시간으로 봅니다.
3. 렌더링 — 타이핑 애니메이션
requestAnimationFrame 기반으로 텍스트를 글자 단위로 출력합니다:
class TextAnimator {
private queue: string[] = [];
private charsPerFrame = 3;
feed(text: string) {
this.queue.push(...text.split(""));
if (!this.animating) this.startAnimation();
}
private startAnimation() {
this.animating = true;
const tick = () => {
const chunk = this.queue.splice(0, this.charsPerFrame);
if (chunk.length > 0) {
this.target.text += chunk.join("");
requestAnimationFrame(tick);
} else {
this.animating = false;
}
};
requestAnimationFrame(tick);
}
}
SSE 청크가 한꺼번에 수십 자가 와도, 화면에서는 자연스러운 속도로 타이핑됩니다. 네트워크 jitter를 시각적으로 스무딩하는 효과가 있습니다.
부모-자식 관계 정의
XML 태그끼리 아무렇게나 중첩되면 안 됩니다. 어떤 태그가 어떤 태그의 자식이 될 수 있는지를 미리 정의해놨습니다:
const parentChildMap = {
"insight-item": ["title", "desc-item", "highlight", "ai-visualization"],
"pattern-item": ["title", "desc-item", "highlight"],
"desc-item": ["highlight"],
};
파서가 열리는 태그를 만났을 때, 현재 부모 컨텍스트에서 허용된 자식인지 검증합니다. LLM이 스키마를 벗어난 XML을 생성하면 해당 블록을 무시합니다.
SSE 이벤트 타입 분기
하나의 SSE 연결에서 여러 이벤트 타입이 옵니다:
function handleSSEEvent(eventType: string, data: string) {
switch (eventType) {
case "PROCESS_START":
// 분석 시작, 로딩 UI 표시
break;
case "PROCESS_STREAM":
// 사고 과정 텍스트 스트리밍 (질문 분석 중...)
break;
case "MESSAGE_DELTA":
// XML 청크 → SAX 파서에 feed
saxParser.write(JSON.parse(data).text);
break;
case "MESSAGE_VISUALIZATION":
// 차트 데이터 별도 수신
parsedBlocks.value.push({ tag: "chart", data: JSON.parse(data) });
break;
case "MESSAGE_END":
// 스트리밍 완료
break;
}
}
MESSAGE_DELTA만 XML 파서를 탄다는 게 중요합니다. 나머지 이벤트는 UI 상태만 바꾸거나 차트 데이터를 별도로 넣습니다.
모호성 확인 흐름
LLM이 질문을 분석했는데 조건이 모호하면, XML 대신 구조화된 JSON으로 확인 폼을 보냅니다. 이건 별도 글에서 다룬 적 있는데, 핵심만 말하면:
AMBIGUOUS타입 → 라디오/체크박스 + 커스텀 입력 폼 렌더링- 사용자가 선택 후 재전송 → 이번엔
SUCCESS로 XML 스트리밍 시작
같은 SSE 연결 안에서 응답 타입에 따라 렌더링 경로가 갈리는 구조입니다.
마무리
XML 스트리밍은 일반적인 선택은 아닙니다. 하지만 구조화된 블록을 스트리밍 중간에 렌더링해야 한다면 SAX 파서와의 궁합이 좋습니다. JSON 스트리밍(jsonl 등)도 검토했는데, 중첩 구조 표현과 부분 렌더링에서 XML이 유리했습니다.
htmlparser2가 불완전한 XML도 잘 처리해주는 게 큽니다. 닫히지 않은 태그가 있어도 에러 없이 다음 청크를 기다리거든요.