LLM 채팅 UI 마크다운 렌더링
프론트엔드LLM 채팅 UI 마크다운 렌더링 — 실전 적용 구조와 코드 예시
언제 쓰나 · LLM(Claude/GPT/Gemini) 이 생성하는 응답은 **거의 항상 마크다운 포함**: · 테이블 (요약/비교) · 목록, 체크박스 · 코드 블록
#frontend#LLM#마크다운#렌더링
---
title: LLM 채팅 UI 마크다운 렌더링 (다크 테마 + Tailwind)
tags: [frontend, react, llm, markdown, chat-ui, tailwind]
created: 2026-04-23
---
# LLM 채팅 UI 마크다운 렌더링
## 문제
LLM(Claude/GPT/Gemini) 이 생성하는 응답은 **거의 항상 마크다운 포함**:
- 테이블 (요약/비교)
- 목록, 체크박스
- 코드 블록
- 굵은 글씨, 링크
그런데 React 채팅 UI 에서 기본으로 `whitespace-pre-wrap` 만 쓰면 **원문 그대로 표시**:
```
**등록 크롤러 목록:**
| ID | 이름 | 상태 |
|----|------|------|
| 1 | naver_place | ✅ 활성 |
```
파이프 문자 그대로 보임 → 못생김 + 읽기 어려움.
## 해결
`react-markdown` + `remark-gfm` + Tailwind 커스텀 컴포넌트.
### 의존성 (npm)
```bash
npm install react-markdown remark-gfm
```
- `react-markdown` (~50KB gzip): 마크다운 → React 엘리먼트 트리
- `remark-gfm` (~30KB gzip): GitHub Flavored Markdown — **테이블, 체크박스, 취소선 필수**
⚠️ 합계 약 150KB 번들 추가. SPA 채팅 UI엔 허용 수준. 일반 웹사이트에 부담되면 route-level code split.
### 컴포넌트 — `src/components/Markdown.tsx`
```tsx
import ReactMarkdown from "react-markdown";
import remarkGfm from "remark-gfm";
/**
* LLM 채팅 응답용 마크다운 렌더러. 다크 테마 + Tailwind 스타일 내장.
* 지원: GFM 테이블, 체크박스, 취소선, 인라인/블록 코드, 목록, 링크, blockquote.
*/
export default function Markdown({ children }: { children: string }) {
return (
<div className="markdown-body text-sm leading-relaxed">
<ReactMarkdown
remarkPlugins={[remarkGfm]}
components={{
// Tables — overflow-x-auto 로 모바일 대응
table: ({ node, ...props }) => (
<div className="overflow-x-auto my-2 rounded border border-slate-800">
<table className="min-w-full text-xs border-collapse" {...props} />
</div>
),
thead: ({ node, ...props }) => (
<thead className="bg-slate-950 border-b border-slate-700" {...props} />
),
tr: ({ node, ...props }) => <tr className="even:bg-slate-950/40" {...props} />,
th: ({ node, ...props }) => (
<th className="px-2.5 py-1.5 text-left text-slate-200 font-medium" {...props} />
),
td: ({ node, ...props }) => (
<td className="px-2.5 py-1.5 border-t border-slate-800 align-top" {...props} />
),
// Code — inline vs block 구분 (react-markdown 10+ 에서 inline prop 은 any 필요)
code: (props) => {
const { inline, className, children, ...rest } =
props as { inline?: boolean; className?: string; children?: React.ReactNode };
if (inline) {
return (
<code
className="bg-slate-950 px-1 py-0.5 rounded text-[0.9em] font-mono text-emerald-300"
{...rest}
>
{children}
</code>
);
}
return (
<code className={`block font-mono text-xs ${className ?? ""}`} {...rest}>
{children}
</code>
);
},
pre: ({ node, ...props }) => (
<pre
className="bg-slate-950 border border-slate-800 p-3 rounded overflow-x-auto my-2 text-xs"
{...props}
/>
),
// Headings — 작은 크기로 (챗 버블 안에 있으므로)
h1: ({ node, ...props }) => <h1 className="text-lg font-semibold mt-3 mb-2 text-slate-100" {...props} />,
h2: ({ node, ...props }) => <h2 className="text-base font-semibold mt-3 mb-1.5 text-slate-100" {...props} />,
h3: ({ node, ...props }) => <h3 className="text-sm font-semibold mt-2 mb-1 text-slate-100" {...props} />,
// Lists
ul: ({ node, ...props }) => <ul className="list-disc ml-5 my-1.5 space-y-0.5" {...props} />,
ol: ({ node, ...props }) => <ol className="list-decimal ml-5 my-1.5 space-y-0.5" {...props} />,
li: ({ node, ...props }) => <li className="text-slate-200" {...props} />,
// Inline — 다크 테마에 맞는 색상 대비
p: ({ node, ...props }) => <p className="my-1.5 text-slate-200" {...props} />,
strong: ({ node, ...props }) => <strong className="font-semibold text-slate-100" {...props} />,
em: ({ node, ...props }) => <em className="italic text-slate-300" {...props} />,
a: ({ node, ...props }) => (
<a
className="text-emerald-400 hover:text-emerald-300 underline"
target="_blank"
rel="noopener noreferrer"
{...props}
/>
),
blockquote: ({ node, ...props }) => (
<blockquote className="border-l-2 border-slate-700 pl-3 my-2 text-slate-400" {...props} />
),
hr: () => <hr className="my-3 border-slate-800" />,
}}
>
{children}
</ReactMarkdown>
</div>
);
}
```
### 사용 예
```tsx
import Markdown from "./components/Markdown";
function ChatBubble({ message }: { message: string }) {
return (
<div className="bg-slate-800 border border-slate-700 rounded-lg px-3.5 py-2.5">
<Markdown>{message}</Markdown>
</div>
);
}
```
## 주요 포인트
### ✅ 왜 custom components?
- Tailwind 유틸리티는 기본 HTML 요소에만 붙음 — `<table>`, `<th>` 등에 global CSS 없이 스타일 주려면 컴포넌트 재정의 필요
- `@tailwindcss/typography` (`prose` 클래스) 대안도 있지만 **다크 테마 세부 조정에 한계** + 번들 ~30KB 추가
### ✅ `react-markdown` 10+ API 주의
- `code` 컴포넌트에 `inline` prop 이 타입에서 빠진 경우 있음 → `props as { inline?: boolean; ... }` 캐스팅
- props 에서 `node` destructure 후 `{...props}` 하면 `node` 가 DOM 으로 안 새나감
### ✅ Tailwind 이스케이프 이슈
- `even:bg-slate-950/40` 같은 조건부 스타일 적극 사용 — 테이블 가독성 크게 올림
- `overflow-x-auto` 래퍼는 **필수** — 긴 테이블이 모바일에서 넘치지 않게
### ⚠ 보안 — XSS
- `react-markdown` 은 **기본 HTML sanitize** (허용된 엘리먼트만 통과)
- 사용자가 직접 HTML 태그 넣어도 raw 로 렌더 안 함
- 단, `rehype-raw` 같은 HTML 플러그인 추가 시 `rehype-sanitize` 반드시 병행
### ⚠ 스트리밍 중 리렌더
- LLM 토큰 스트리밍 UI 의 경우 `<Markdown>{partial}</Markdown>` 이 매 토큰마다 전체 파싱 → 성능 이슈 가능
- 해결:
- 부분 완성된 블록만 파싱 (예: `\n\n` 단위로 chunked)
- `useMemo` + 토큰 N개마다 업데이트 (debounce)
## 함께 쓰면 좋은 기능 (추후)
- **`rehype-highlight`**: 코드 블록 syntax highlighting (~50KB, 언어별 lazy load 가능)
- **`react-markdown/lib/ast-to-react`** 커스텀 컴포넌트로 tool-call JSON 을 expandable 카드로 렌더
- **LaTeX 수식**: `rehype-katex` + `remark-math`
## 실제 적용 결과 (crawl-manager_260422)
사용자 입력 → Haiku 응답:
```
**등록 크롤러 목록:**
| ID | 이름 | 상태 | 일일 한도 |
|---|---|---|---|
| 1 | naver_place | ✅ 활성 | 400건 |
| 5 | used_market | ✅ 활성 | 100건 |
```
**적용 전**: 파이프·별표 그대로 표시, 한 줄 뭉침.
**적용 후**: 제대로 된 HTML `<table>`, even row striping, overflow-x-auto 로 모바일 대응.
번들 크기 약 190KB → 350KB (gzip 기준 57KB → 108KB) 증가. SPA 채팅 앱에는 수용 가능.
## 체크리스트 (새 프로젝트 적용 시)
- [ ] `npm install react-markdown remark-gfm`
- [ ] `src/components/Markdown.tsx` 위 코드 복사
- [ ] Tailwind 다크 색상 프로젝트 팔레트에 맞게 `slate-*` → 변경
- [ ] 채팅 버블 컴포넌트에서 `<whitespace-pre-wrap>{content}</>` → `<Markdown>{content}</Markdown>`
- [ ] 테이블 모바일 뷰 확인 (overflow-x-auto 동작)
- [ ] 링크 `target="_blank" rel="noopener noreferrer"` 들어갔는지 확인jt · v1 · CC0-1.0 · 복사 0