설명
SSE나 Web Streams로 받은 토큰을 도착 즉시 화면에 이어 붙입니다. AI가 일하고 있다는 신호를 사용자가 즉시 받기 때문에, 같은 응답 시간이라도 체감 속도가 달라져요. 단순 텍스트라면 append만 해도 되지만 마크다운이나 코드블록은 미완성 토큰을 그대로 렌더하면 깨진 HTML이 보여요. 그래서 닫히지 않은 마크다운은 한 번 버퍼링한 다음 그려야 합니다.
🖼️ 미니어처
💬 AI에게 이렇게 말하세요
- SSE 스트림으로 받은 토큰을 React useState에 누적하면서 마크다운으로 렌더해 주세요. 코드블록(``` … ```)이 닫히기 전까지는 안의 텍스트를 plain으로만 보여주는 버퍼링 적용.
- Vercel AI SDK의 useChat을 사용해서 스트리밍 챗 UI를 만들어 주세요. 답변 중에는 빈 메시지에 점멸 caret을 표시합니다.
- 토큰 도착 사이에 살짝(20~40ms) 지연을 줘서 흐름을 부드럽게 만들어 주세요. 단, 줄바꿈 직후엔 더 긴 지연(100ms)으로 호흡 추가.
버퍼링이 필요한 이유
LLM은 토큰 단위로 출력해요. 마크다운 코드블록 시작은 보냈는데 닫히는 이 아직 안 온 상태에서 react-markdown에 던지면 페이지 끝까지 코드블록이 펼쳐져요. 표 |---| 도 마찬가지예요. 따라서 incomplete syntax는 버퍼에 보관하고 "확실히 닫힌 마크다운 단위까지"만 렌더하는 streaming-safe parser가 필요합니다.
Vercel AI SDK 골격
// app/api/chat/route.ts
import { streamText } from 'ai'
import { anthropic } from '@ai-sdk/anthropic'
export async function POST(req: Request) {
const { messages } = await req.json()
const result = streamText({
model: anthropic('claude-sonnet-4-6'),
messages,
})
return result.toDataStreamResponse()
}
// app/chat/page.tsx
'use client'
import { useChat } from 'ai/react'
export default function Chat() {
const { messages, input, handleInputChange, handleSubmit } = useChat()
return (
<div>
{messages.map(m => <div key={m.id}>{m.content}</div>)}
<form onSubmit={handleSubmit}>
<input value={input} onChange={handleInputChange} />
</form>
</div>
)
} ⚡ 사용 예 (한 줄)
const { messages } = useChat({ api: '/api/chat' }); /* 토큰 자동 스트리밍 */ 🧩 컴포넌트 매핑
Vercel AI SDK의 useChat / streamText, assistant-ui 라이브러리를 활용해요. react-markdown과 streaming-safe parser(예: marked-incomplete)를 함께 씁니다. caret 점멸은 Tailwind animate-pulse나 @keyframes로 처리해요.
스트림이 끝날 때까지 빈 메시지로 두고 마지막에 한 번에 렌더하면 스트리밍의 가치를 통째로 버리게 됩니다. 마크다운 미완성 토큰을 그대로 react-markdown에 던져 깨진 표·코드블록을 깜빡이게 만드는 것도 같이 피해 주세요.
한국어는 자모 단위 토큰화가 모델마다 달라서 글자가 몇 자씩 점프하듯 나타나기도 해요. 사용자가 글자가 깨졌다고 오해하지 않도록 점멸 caret을 함께 보여 주고, 줄바꿈 직후에는 살짝 지연을 줘서 흐름을 부드럽게 만듭니다. '답변 중…' 같은 텍스트보다 시각적으로 깜빡이는 caret 하나가 한국 사용자에게 더 친숙해요.
스크린리더에 무한 announce가 가지 않게 aria-live="polite"를 적용하고 토큰 청크 단위로 throttle해요(250~500ms 단위로 합쳐서 announce). 코드블록은 완성된 후에만 <pre>에 넣어서 코드 영역 announce 폭주를 방지해요.