마크다운 메시지

마크다운 메시지 (Markdown Messages)

LLM이 생성하는 응답은 대개 마크다운 형식이에요. 제목, 목록, 코드 블록, 표, 인라인 서식까지 — 그런데 이걸 평문으로 그대로 뿌려버리면 모델이 만들어 준 구조가 모두 사라져 버리죠. 이 패턴에서는 에이전트가 실시간으로 스트리밍하는 마크다운을, 주요 프론트엔드 프레임워크에서 어떻게 파싱하고 렌더링하는지 알려드릴게요.

출처: LangChain 공식 문서 — frontend-markdown-messages

마크다운 렌더링이 동작하는 방식

렌더링 파이프라인은 세 단계로 이루어집니다.

  1. 받기(Receive)useStream이 스트리밍된 텍스트를 각 AI 메시지의 msg.text에 누적하고, 새 토큰이 도착할 때마다 반응형으로 업데이트합니다.
  2. 파싱(Parse) — 마크다운 파서가 원시 텍스트를 HTML(또는 React 엘리먼트 트리)로 변환합니다. 이 작업은 매 업데이트마다 실행되지만, 채팅 길이의 콘텐츠(< 5ms for a 5 KB message)에서는 충분히 빨라요.
  3. 렌더링(Render) — 파싱된 결과를 DOM에 렌더링합니다. React는 가상 DOM diffing을 쓰고, Vue와 Svelte는 정제(sanitize)된 HTML로 v-html / {@html}을 사용합니다.

useStream 설정하기

마크다운 패턴은 특별한 설정이 필요 없는 간단한 채팅 에이전트를 사용합니다. 에이전트 URL과 assistant ID로 useStream을 연결하면 돼요.

아래 코드는 타입 안전한 스트림 상태를 위해 useStream<typeof myAgent>를 사용합니다. Python 또는 JavaScript 백엔드의 타입 추론에 대해서는 프론트엔드 개요의 해당 섹션을 참고하세요.

import { useStream } from "@langchain/react";
import { AIMessage, HumanMessage } from "langchain";

const AGENT_URL = "http://localhost:2024";

export function Chat() {
  const stream = useStream<typeof myAgent>({
    apiUrl: AGENT_URL,
    assistantId: "simple_agent",
  });

  return (
    <div>
      {stream.messages.map((msg) => {
        if (AIMessage.isInstance(msg)) {
          return <Markdown key={msg.id}>{msg.text}</Markdown>;
        }
        if (HumanMessage.isInstance(msg)) {
          return <p key={msg.id}>{msg.text}</p>;
        }
      })}
    </div>
  );
}

AI 메시지는 Markdown 컴포넌트로, 사람 메시지는 평범한 <p>로 렌더링하죠. Vue에서는 v-forv-if를, Svelte에서는 {#each}를, Angular에서는 @for를 써서 같은 구조를 만듭니다.

마크다운 라이브러리 고르기

프레임워크 라이브러리 출력 이유
React react-markdown + remark-gfm React 엘리먼트 컴포넌트 기반, 가상 DOM diffing, dangerouslySetInnerHTML 불필요
Svelte marked + dompurify {@html}로 정제된 HTML Vue와 동일하게 일관된 API

HTML 출력 정제하기 (Sanitize)

파싱된 마크다운을 v-html, {@html}, [innerHTML] 같은 방식으로 원시 HTML로 렌더링한다면, 크로스 사이트 스크립팅(XSS)을 막기 위해 반드시 출력을 정제해야 해요. LLM 응답에는 어떤 텍스트든 들어있을 수 있고, 마크다운 파서가 그것을 실행 가능한 HTML로 바꿔버릴 수도 있으니까요. dompurify를 사용해서 위험한 요소를 제거하세요.

스트리밍 고려사항

useStream은 토큰이 도착할 때마다 msg.text를 반응형으로 업데이트하고, 마크다운 컴포넌트는 매 업데이트마다 다시 파싱합니다. 일반적인 채팅 메시지에서는 충분히 성능이 좋아요.

  • marked는 약 1 MB/s로 파싱합니다. 5 KB 메시지는 5ms 미만이 걸려요.
  • react-markdown + remark 파이프라인도 채팅 길이 콘텐츠에서는 비슷하게 빠릅니다.
  • 브라우저의 레이아웃 엔진이 DOM 업데이트를 효율적으로 처리하죠.

아주 긴 응답(> 50 KB)에는 다음 최적화를 고려해 보세요.

  • 렌더링 조절(Throttle)requestAnimationFrame으로 매 토큰마다 다시 그리는 대신 60fps로 업데이트를 배치하세요.
  • 점진적 파싱(Incremental parsing) — 새 콘텐츠만 파싱해서 렌더링 버퍼에 추가하는 방식입니다. 고급 기법이라 채팅 UI에는 보통 필요하지 않아요.

대부분의 채팅 애플리케이션에서는 매 토큰마다 전체 메시지를 다시 파싱하는 단순한 방법으로 충분합니다. 아주 긴 메시지에서 스크롤이 버벅이거나 프레임이 떨어지는 게 관찰될 때만 최적화를 고려하면 돼요.

모범 사례

  • 항상 정제v-html, {@html}, [innerHTML]을 쓸 때는 파싱된 출력을 항상 dompurify에 통과시키세요. LLM 출력으로 만든 마크다운 파서의 원시 HTML을 절대 신뢰하지 마세요.
  • GFM 활성화 — GitHub Flavored Markdown은 표, 취소선, 작업 목록, 자동 링크를 추가합니다. LLM이 자주 쓰는 기능들이죠.
  • 빈 콘텐츠 처리 — 파싱 전에 빈 문자열을 확인해서 빈 컨테이너가 렌더링되지 않게 하세요.
  • breaks: true 사용 — 줄바꿈 변환을 켜서 LLM 출력의 단일 개행이 무시되지 않고 <br>로 렌더링되게 합니다. LLM은 시각적 구분에 단일 개행을 자주 쓰거든요.
  • 채팅 맥락에 맞게 스타일링 — 전폭 기사 레이아웃이 아니라 채팅 버블에 맞는 컴팩트한 여백과 크기를 사용하세요.
  • 풍부한 콘텐츠로 테스트 — 제목, 중첩 목록, 긴 줄의 코드 블록, 넓은 표, 인용구를 렌더링해 보고 오버플로나 레이아웃 문제를 잡으세요.

더 알아보기 (Learn more)